product page

This commit is contained in:
Felix Klement committed 2026-09-19 11:18:31 +02:00
1 parent e652b54319
commit c4513588b1
6 files changed
+325 -262

No files matched your search

+15 -11
View File
@@ -127,17 +127,21 @@ Motion on the product page, all of it on wide screens: the hero's seventeen line
and meet in one point (off under `prefers-reduced-motion`); hovering a node in a schematic dims
everything that does not belong to it. The drawings themselves do not animate.
The "How CipherVerifier works" cards are `position: sticky` with a growing top offset, so they
stack into a deck under the pinned heading. Every card has the same height, bound to the viewport
and capped at 37rem, and the drawing fills what the text leaves, so a card always fits and fully
covers the one beneath it. The page snaps to each card (`scroll-snap-type: y proximity` on the
root while this component is on the page, `scroll-snap-stop: always`) via invisible anchors placed
at each card's natural position: snapping to the sticky cards themselves would create phantom snap
points at the current offset in Chrome. The pinned bar carries one dot per card; the filled one is
the card currently on top, and each dot links to its card. A small script sets `--covered` on each
card as the next one slides over it (the card darkens under an opaque veil) and moves the filled
dot. The heading is pinned inside a track that ends one card before the deck does, so heading and
deck leave the screen together. Phones get the same drawings in a plain flow without any of this. This goes
"How CipherVerifier works" follows the pattern of arcos.eu/technology. On wide screens the
title and a bordered panel hold still while the page scrolls; the step list under the title
advances, one step open at a time, and the panel crossfades to that step's drawing. The section is
as tall as one pitch per step plus one panel height, so the panel stays pinned until the last step
has been read. Which step is open is decided by invisible triggers tiling that height: whichever
crosses the middle of the viewport wins, and the triggers start half a viewport down so the first
step turns over exactly when the panel pins. A step's title is a button that scrolls to it.
The panel is tied to the text, not to the pinned frame: it is positioned against
`.how__text-inner`, so it starts at the title and its width is stated as 200% of the text column
(4 of 12 against 8 of 12). Its height is measured once by script as the collapsed list plus the
tallest step's text, so it never resizes as steps change while each step's text still takes only
the room it needs. Without script the first step stays open, the panel takes the text's own
height, and the section reads as a plain sequence. Phones get that plain sequence in any case:
every step open, the upright drawing under its text. This goes
beyond the home page's single load moment on purpose, at the client's request; the home page is
unchanged. Without JavaScript every sector panel
and its image is shown in full; the `html.js` class set in `Base.astro` switches to one at a time.
+37
View File
@@ -0,0 +1,37 @@
import { chromium } from 'playwright';
/* Does the panel start at the title and end with the list, and hold that box across steps? */
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
const rows = [];
for (let i = 0; i < count; i++) {
await page.evaluate((i) => {
const t = document.querySelectorAll('[data-trigger]')[i];
const r = t.getBoundingClientRect();
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
}, i);
await page.waitForTimeout(900);
rows.push(await page.evaluate(() => {
const title = document.querySelector('.how__title').getBoundingClientRect();
const steps = Array.from(document.querySelectorAll('[data-step]'));
const last = steps[steps.length - 1].getBoundingClientRect();
const fig = document.querySelector('[data-figure][data-active]').getBoundingClientRect();
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
const container = document.querySelector('.how__sticky');
const cs = getComputedStyle(container);
const rightEdge = sticky.right - parseFloat(cs.paddingRight);
return {
active: steps.findIndex((s) => s.hasAttribute('data-active')),
panel: `${Math.round(fig.width)}x${Math.round(fig.height)}`,
topVsTitle: Math.round(fig.top - title.top),
bottomVsList: Math.round(fig.bottom - last.bottom),
rightVsEdge: Math.round(fig.right - rightEdge),
listFits: Math.round(sticky.bottom - last.bottom),
};
}));
await page.screenshot({ path: `${prefix}-${i}.png` });
}
console.table(rows);
await browser.close();
+40
View File
@@ -0,0 +1,40 @@
import { chromium } from 'playwright';
/* Walks the pinned section step by step: which step is active, does the text column fit
inside the pinned area, and is the panel where it should be. */
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
const rows = [];
for (let i = 0; i < count; i++) {
await page.evaluate((i) => {
const t = document.querySelectorAll('[data-trigger]')[i];
const r = t.getBoundingClientRect();
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
}, i);
await page.waitForTimeout(900);
const info = await page.evaluate(() => {
const steps = Array.from(document.querySelectorAll('[data-step]'));
const active = steps.findIndex((s) => s.hasAttribute('data-active'));
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
const text = document.querySelector('.how__text');
const first = text.getBoundingClientRect();
const last = steps[steps.length - 1].getBoundingClientRect();
const fig = document.querySelector('[data-figure][data-active]');
const f = fig ? fig.getBoundingClientRect() : null;
const svg = fig ? fig.querySelector('svg').getBoundingClientRect() : null;
return {
active,
textTop: Math.round(first.top - sticky.top),
spaceBelowList: Math.round(sticky.bottom - last.bottom),
panel: f ? `${Math.round(f.width)}x${Math.round(f.height)}` : null,
drawing: svg ? `${Math.round(svg.width)}x${Math.round(svg.height)}` : null,
panelInside: f ? (f.top >= sticky.top - 1 && f.bottom <= sticky.bottom + 1) : null,
};
});
rows.push({ step: i, ...info });
await page.screenshot({ path: `${prefix}-${i}.png` });
}
console.table(rows);
await browser.close();
+24
View File
@@ -0,0 +1,24 @@
import { chromium } from 'playwright';
/* Does the pinned panel hold from the first step to the last, and let go cleanly after? */
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const geo = await page.evaluate(() => {
const sc = document.querySelector('.how__scroller').getBoundingClientRect();
return { top: Math.round(sc.top + window.scrollY), height: Math.round(sc.height) };
});
const marks = { 'enter': geo.top, 'mid': geo.top + geo.height / 2, 'lastStep': geo.top + geo.height - (window?.innerHeight ?? 900), 'exit': geo.top + geo.height - 200 };
for (const [name, y] of Object.entries(marks)) {
await page.evaluate((y) => window.scrollTo(0, y), y);
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
const st = document.querySelector('.how__sticky').getBoundingClientRect();
const active = Array.from(document.querySelectorAll('[data-step]')).findIndex((s) => s.hasAttribute('data-active'));
return { stickyTop: Math.round(st.top), stickyBottom: Math.round(st.bottom), active };
});
console.log(name.padEnd(9), JSON.stringify(info));
await page.screenshot({ path: `${prefix}-${name}.png` });
}
console.log('scroller', JSON.stringify(geo));
await browser.close();
+209 -245
View File
@@ -9,294 +9,258 @@ const v = ui.verifier.how;
const pairs = [builders[0](v.groups[0]), builders[1](v.groups[1]), builders[2](v.groups[2]), builders[3](v.groups[3]), builders[4](v.groups[4])];
const groups = v.groups.map((g, i) => ({ title: g.title, body: g.body, desc: g.desc, id: `how-${i + 1}`, pair: pairs[i] }));
---
<section class="section night how" id="how" aria-labelledby="how-title">
<div class="container how__deck">
<div class="how__track">
<div class="how__bar">
<section class="night how" id="how" aria-labelledby="how-title">
<div class="how__scroller" style={`--how-steps: ${groups.length}`} data-how>
<div class="how__sticky container">
<div class="how__layout">
<div class="how__text">
<div class="how__text-inner">
<h2 id="how-title" class="h2 how__title">{v.title}</h2>
<ol class="how__dots" role="list" aria-label={v.position} data-deck-dots>
<ol class="how__steps" role="list">
{groups.map((g, i) => (
<li>
<a class="how__dot" href={`#how-snap-${i + 1}`} aria-label={`${v.of(i + 1, groups.length)}: ${g.title}`} aria-current={i === 0 ? 'true' : undefined}><span class="how__dot-mark"></span></a>
<li class="how__step" id={g.id} data-step data-active={i === 0 ? '' : undefined}>
<h3 class="how__step-heading">
<button class="how__step-button" type="button" data-step-to={i} aria-current={i === 0 ? 'step' : undefined}>{g.title}</button>
</h3>
<div class="how__step-body">
<div class="how__step-inner">
<p class="muted">{g.body}</p>
<div class="how__tall">
<Schematic layout={g.pair.tall} title={g.title} desc={g.desc} variant="tall" id={`${g.id}-tall`} />
</div>
</div>
</div>
<!-- On wide screens this sits in the panel on the right, one drawing per step. -->
<div class="how__figure" data-figure data-active={i === 0 ? '' : undefined}>
<Schematic layout={g.pair.wide} title={g.title} desc={g.desc} variant="wide" id={`${g.id}-wide`} />
</div>
</li>
))}
</ol>
</div>
</div>
<div class="how__anchors" aria-hidden="true">
{groups.map((_, i) => <span class="how__snap" id={`how-snap-${i + 1}`} style={`--i: ${i}`} data-deck-snap></span>)}
</div>
</div>
<ul class="how__list" role="list">
{groups.map((g, i) => (
<li class="how__group" id={g.id} style={`--i: ${i}`} data-deck-card>
<div class="how__head">
<h3 class="h3 how__heading">{g.title}</h3>
<p class="muted how__body">{g.body}</p>
<!-- One trigger per step, tiling the scroller: whichever crosses the middle of the
viewport is the step being read. -->
<div class="how__triggers" aria-hidden="true">
{groups.map(() => <div class="how__trigger" data-trigger></div>)}
</div>
<div class="how__figure">
<div class="how__wide">
<Schematic layout={g.pair.wide} title={g.title} desc={g.desc} variant="wide" id={`${g.id}-wide`} />
</div>
<div class="how__tall">
<Schematic layout={g.pair.tall} title={g.title} desc={g.desc} variant="tall" id={`${g.id}-tall`} />
</div>
</div>
</li>
))}
</ul>
<a class="how__hint" href="#how-snap-2" aria-label={v.next} data-deck-hint>
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><polyline points="5,9 12,16 19,9" fill="none" stroke="currentColor" stroke-width="1.25" /></svg>
</a>
</div>
</section>
<style>
/* Narrow screens: a plain sequence, every step open, its drawing under its text. */
.how { padding-block: var(--section); }
.how__title { max-width: 12em; }
.how__list { margin-top: var(--space-8); }
.how__anchors { display: none; }
.how__dots { display: none; }
.how__hint { display: none; }
.how__group {
padding-block: var(--space-8) var(--space-9);
.how__steps { margin-top: var(--space-8); }
.how__step {
padding-block: var(--space-7) var(--space-8);
border-top: var(--hairline) solid var(--line);
scroll-margin-top: var(--header-height);
}
.how__head {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--gap);
row-gap: var(--space-4);
align-items: start;
.how__step-heading {
font-size: var(--step-h3);
line-height: var(--leading-h3);
letter-spacing: var(--track-h3);
font-weight: var(--weight-medium);
}
.how__heading { grid-column: 1 / -1; }
.how__body { grid-column: 1 / -1; max-width: 34em; }
.how__figure { margin-top: var(--space-7); }
/* The wide drawing needs the full container; below that width the tall spine takes over. */
.how__wide { display: none; }
.how__step-button {
display: block;
text-align: left;
font: inherit;
letter-spacing: inherit;
color: inherit;
cursor: default;
}
.how__step-inner { display: grid; gap: var(--space-6); padding-top: var(--space-4); }
.how__step-inner p { max-width: 34em; }
.how__tall { max-width: 26rem; }
.how__figure { display: none; }
.how__triggers { display: none; }
@media (min-width: 48rem) {
.how__heading { grid-column: 1 / span 5; }
.how__body { grid-column: 6 / span 7; }
}
/* Wide screens: the heading stays put and the cards stack beneath it as a deck, each one a
step lower than the one before. Every card has the same height, bound to the viewport, so
a card always fits and fully covers the one beneath it; the drawing fills whatever height
the text leaves and scales to fit. The heading is pinned inside a track that ends one card
before the deck does, so heading and deck let go of the screen together. */
/* Wide screens: the title and the panel hold still, the list advances as the page scrolls. */
@media (min-width: 64rem) {
.how {
--how-bar-h: 5rem;
--how-step: 0.75rem;
--how-hint-h: var(--space-6);
/* The height under the pinned bar is one card pitch, so at a snap the next card starts
exactly at the bottom edge and only rises when you scroll. */
--how-pitch: calc(100svh - var(--header-height) - var(--how-bar-h));
/* As tall as the viewport allows under the deck lips, but no taller than the drawings need. */
--how-card-h: min(calc(var(--how-pitch) - 4 * var(--how-step) - var(--space-6)), 37rem);
padding-bottom: var(--space-9);
--how-pitch: 70svh;
--how-inset: var(--space-7);
--how-panel-h: calc(100svh - var(--header-height));
padding-block: 0;
}
.how__wide { display: block; }
/* One pitch of scrolling per step, plus one panel height so the panel stays pinned until
the last step has been read rather than letting go early. */
.how__scroller {
position: relative;
height: calc(var(--how-steps) * var(--how-pitch) + var(--how-panel-h));
}
.how__sticky {
position: sticky;
top: var(--header-height);
height: var(--how-panel-h);
}
.how__layout {
display: grid;
grid-template-columns: 4fr 8fr;
column-gap: var(--gap);
height: 100%;
padding-block: var(--how-inset);
}
.how__text { grid-column: 1; }
/* The box on the right is tied to this, so it ends where the text ends. */
.how__text-inner { position: relative; }
.how__title { max-width: 14em; }
.how__steps { margin-top: clamp(1.5rem, 9vh, 5rem); }
.how__step {
padding-block: var(--space-3);
border-top: 0;
scroll-margin-top: 0;
}
.how__step-button {
color: var(--ink-muted);
cursor: pointer;
transition: color var(--dur) var(--ease);
}
.how__step-button:hover { color: var(--ink); }
.how__step[data-active] .how__step-button { color: var(--ink); }
/* Only the step being read shows its text. */
.how__step-body {
height: 0;
overflow: hidden;
transition: height var(--dur-slow) var(--ease);
}
.how__step-inner {
min-height: 0;
gap: 0;
padding-top: var(--space-3);
opacity: 0;
transition: opacity var(--dur) var(--ease);
}
.how__step-inner p {
font-size: var(--step-small);
line-height: var(--leading-small);
max-width: 32em;
}
.how__step[data-active] .how__step-body { height: auto; }
.how__step[data-active] .how__step-inner { opacity: 1; }
.how__tall { display: none; }
.how__anchors { display: block; }
/* The page snaps to a card when the scroll ends near one, and every card is a stop on the way.
The snap points are invisible anchors at each card's natural position: a stuck card would
report its shifted position and drag the page back to where it already is. */
:global(html) { scroll-snap-type: y proximity; }
.how__deck { position: relative; }
.how__anchors { position: absolute; inset: 0; pointer-events: none; }
.how__snap {
/* The panel: one box on the right, the drawings crossfading inside it. */
.how__figure {
display: grid;
place-items: center;
position: absolute;
top: 0;
height: var(--how-text-h, 100%);
/* The text column is 4 of 12 and the panel 8, so the panel is twice its width and sits
one gap to its right. Stated against the text column, since that is what it is tied to. */
right: calc(-1 * (var(--gap) + 200%));
width: 200%;
padding: var(--space-6);
border: var(--hairline) solid var(--line);
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-slow) var(--ease);
}
.how__figure[data-active] { opacity: 1; }
.how__figure :global(.schematic) { max-height: 100%; }
/* The triggers start half a viewport down, so the first step turns over exactly when the
panel pins and the last one when it lets go. */
.how__triggers {
display: block;
position: absolute;
left: 0;
right: 0;
top: calc(var(--how-bar-h) + var(--space-7) + var(--i) * var(--how-pitch));
height: 1px;
scroll-snap-align: start;
scroll-snap-stop: always;
scroll-margin-top: calc(var(--header-height) + var(--how-bar-h) + var(--i) * var(--how-step));
}
.how__track {
position: absolute;
top: 0;
left: var(--gutter);
right: var(--gutter);
height: calc(100% - var(--how-card-h) - 4 * var(--how-step) - var(--how-hint-h));
top: 50svh;
pointer-events: none;
}
.how__bar {
position: sticky;
top: var(--header-height);
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
height: var(--how-bar-h);
background: var(--night);
border-bottom: var(--hairline) solid var(--line);
pointer-events: auto;
}
/* Where you are in the deck: one dot per card, the current one filled. */
.how__dots {
display: flex;
align-items: center;
gap: var(--space-3);
}
.how__dot {
display: grid;
place-items: center;
width: var(--space-5);
height: var(--space-5);
}
.how__dot-mark {
display: block;
width: var(--space-2);
height: var(--space-2);
border-radius: 50%;
border: var(--hairline) solid var(--ink-muted);
transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
}
.how__dot:hover .how__dot-mark { border-color: var(--ink); }
.how__dot[aria-current='true'] .how__dot-mark {
background: var(--ink);
border-color: var(--ink);
transform: scale(1.5);
}
.how__title {
font-size: var(--step-title);
line-height: var(--leading-title);
letter-spacing: var(--track-h2);
max-width: none;
}
.how__list {
display: grid;
row-gap: calc(var(--how-pitch) - var(--how-card-h));
margin-top: 0;
padding-top: calc(var(--how-bar-h) + var(--space-7));
}
/* A chevron at the bottom edge points to the next card and takes you there. Gone on the last. */
.how__hint {
position: sticky;
bottom: var(--space-2);
z-index: 2;
display: grid;
place-items: center;
width: 100%;
height: var(--how-hint-h);
color: var(--ink-muted);
transition: opacity var(--dur) var(--ease), color var(--dur-fast) var(--ease);
}
.how__hint svg { display: block; }
.how__hint:hover { color: var(--ink); }
.how__hint[data-last] { opacity: 0; pointer-events: none; }
.how__group {
position: sticky;
top: calc(var(--header-height) + var(--how-bar-h) + var(--i) * var(--how-step));
display: flex;
flex-direction: column;
height: var(--how-card-h);
overflow: hidden;
border: var(--hairline) solid var(--line);
background: var(--night);
padding: var(--space-5) var(--space-6) var(--space-5);
scroll-margin-top: calc(var(--header-height) + var(--how-bar-h) + var(--i) * var(--how-step));
}
/* --covered goes 0 to 1 as the next card slides over this one (set by the script): the card
darkens under an opaque veil. It never turns transparent, so nothing beneath shows through. */
.how__group::after {
content: '';
position: absolute;
inset: 0;
background: var(--night);
opacity: var(--covered, 0);
pointer-events: none;
}
.how__head { flex: none; }
.how__heading { grid-column: 1 / span 4; }
.how__body { grid-column: 5 / span 8; max-width: none; }
.how__figure {
position: relative;
flex: 1 1 auto;
min-height: 0;
margin-top: var(--space-4);
}
.how__wide { position: absolute; inset: 0; }
.how__wide :global(.schematic) {
display: block;
width: 100%;
height: 100%;
}
.how__trigger { height: var(--how-pitch); }
}
/* Short viewports (small laptops): a slimmer bar and a tighter card head leave more height for the drawing. */
/* Short screens: less air, smaller body, so the list always fits beside the panel. */
@media (min-width: 64rem) and (max-height: 52rem) {
.how { --how-bar-h: 4.5rem; }
.how__group { padding: var(--space-5) var(--space-6) var(--space-5); }
.how__body { font-size: var(--step-small); line-height: var(--leading-small); }
.how__figure { margin-top: var(--space-4); }
.how { --how-inset: var(--space-5); }
.how__steps { margin-top: var(--space-5); }
.how__step { padding-block: var(--space-2); }
.how__step-inner p { font-size: var(--step-small); line-height: var(--leading-small); }
}
@media (prefers-reduced-motion: reduce) {
.how__group::after { opacity: 0; }
.how__dot-mark { transition: none; }
.how__step-body, .how__step-inner, .how__figure, .how__step-button { transition: none; }
}
</style>
<script>
/* Two enhancements, both only on wide screens. Without them the deck still stacks and snaps.
1. Each card learns how far the next one has slid over it (--covered), so it can darken.
2. The dots in the bar follow the card that currently sits on top. */
const cards = Array.from(document.querySelectorAll<HTMLElement>('[data-deck-card]'));
const anchors = Array.from(document.querySelectorAll<HTMLElement>('[data-deck-snap]'));
const dots = Array.from(document.querySelectorAll<HTMLElement>('[data-deck-dots] a'));
const hint = document.querySelector<HTMLAnchorElement>('[data-deck-hint]');
const section = document.querySelector<HTMLElement>('.how');
const mq = window.matchMedia('(min-width: 64rem)');
let ticking = false;
const px = (raw: string, fallback: number) => {
const n = parseFloat(raw);
if (Number.isNaN(n)) return fallback;
return raw.trim().endsWith('rem') ? n * parseFloat(getComputedStyle(document.documentElement).fontSize) : n;
};
const stepPx = () => px(section ? getComputedStyle(section).getPropertyValue('--how-step') : '', 12);
/* The step being read is the one whose trigger crosses the middle of the viewport. Without
this the first step simply stays open, which is a readable section on its own. */
const root = document.querySelector<HTMLElement>('[data-how]');
if (root) {
const steps = Array.from(root.querySelectorAll<HTMLElement>('[data-step]'));
const figures = Array.from(root.querySelectorAll<HTMLElement>('[data-figure]'));
const triggers = Array.from(root.querySelectorAll<HTMLElement>('[data-trigger]'));
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-step-to]'));
const update = () => {
ticking = false;
if (!mq.matches) { cards.forEach((c) => c.style.removeProperty('--covered')); return; }
const step = stepPx();
for (let i = 0; i < cards.length - 1; i++) {
const a = cards[i], b = cards[i + 1];
const aTop = a.getBoundingClientRect().top;
const bTop = b.getBoundingClientRect().top;
const total = Math.max(1, a.offsetHeight - step);
const covered = Math.min(1, Math.max(0, (aTop + a.offsetHeight - bTop) / total));
a.style.setProperty('--covered', covered.toFixed(3));
}
cards[cards.length - 1]?.style.setProperty('--covered', '0');
/* The current card is the last one whose anchor has reached its snap line, give or take a step. */
let active = 0;
anchors.forEach((a, i) => {
const margin = px(getComputedStyle(a).scrollMarginTop, 0);
if (a.getBoundingClientRect().top - margin <= step * 2) active = i;
const setActive = (index: number) => {
steps.forEach((step, i) => {
if (i === index) step.setAttribute('data-active', ''); else step.removeAttribute('data-active');
});
dots.forEach((d, i) => {
if (i === active) d.setAttribute('aria-current', 'true'); else d.removeAttribute('aria-current');
figures.forEach((figure, i) => {
if (i === index) figure.setAttribute('data-active', ''); else figure.removeAttribute('data-active');
});
buttons.forEach((button, i) => {
if (i === index) button.setAttribute('aria-current', 'step'); else button.removeAttribute('aria-current');
});
bodies.forEach((body, i) => {
body.style.height = i === index ? `${inners[i].offsetHeight}px` : '0px';
});
if (hint) {
const last = active >= anchors.length - 1;
if (last) hint.setAttribute('data-last', ''); else hint.removeAttribute('data-last');
hint.href = `#how-snap-${Math.min(active + 2, anchors.length)}`;
}
};
const schedule = () => { if (!ticking) { ticking = true; requestAnimationFrame(update); } };
if (cards.length) {
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
mq.addEventListener('change', schedule);
update();
/* The box on the right takes the height of the longest step, so it holds still while the
text below each title grows and shrinks. */
const inners = Array.from(root.querySelectorAll<HTMLElement>('.how__step-inner'));
const bodies = Array.from(root.querySelectorAll<HTMLElement>('.how__step-body'));
const textInner = root.querySelector<HTMLElement>('.how__text-inner');
const activeIndex = () => steps.findIndex((step) => step.hasAttribute('data-active'));
const measure = () => {
if (!textInner) return;
const open = inners[activeIndex()]?.offsetHeight ?? 0;
const base = textInner.offsetHeight - open;
const tallest = inners.reduce((max, el) => Math.max(max, el.offsetHeight), 0);
if (tallest > 0) root.style.setProperty('--how-text-h', `${Math.ceil(base + tallest)}px`);
};
measure();
let resizeTimer = 0;
window.addEventListener('resize', () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(measure, 150);
});
if (document.fonts?.ready) document.fonts.ready.then(measure).catch(() => {});
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const index = triggers.indexOf(entry.target as HTMLElement);
if (index >= 0) setActive(index);
}
},
/* A line across the middle of the viewport: exactly one trigger spans it at a time. */
{ rootMargin: '-50% 0px -50% 0px' },
);
triggers.forEach((trigger) => observer.observe(trigger));
/* A step's title takes you to it, so the list works as a table of contents too. */
buttons.forEach((button, i) => {
button.addEventListener('click', () => {
const trigger = triggers[i];
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const top = window.scrollY + rect.top + rect.height / 2 - window.innerHeight / 2;
const smooth = window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
window.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
});
});
}
</script>
-6
View File
@@ -162,9 +162,6 @@ const en = {
imageAlt: 'A software engineer at a desk in the evening, source code reflected in the dark window beside the monitors.',
how: {
title: 'How CipherVerifier works.',
position: 'Position in the sequence',
of: (n: number, total: number) => `${n} of ${total}`,
next: 'Next card',
groups: [
{
title: 'One run, five stages.',
@@ -383,9 +380,6 @@ const de: typeof en = {
imageAlt: 'Ein Software-Engineer am Schreibtisch am Abend, der Quellcode spiegelt sich im dunklen Fenster neben den Monitoren.',
how: {
title: 'So arbeitet CipherVerifier.',
position: 'Position in der Abfolge',
of: (n: number, total: number) => `${n} von ${total}`,
next: 'Nächste Karte',
groups: [
{
title: 'Ein Lauf, fünf Stufen.',