product page
This commit is contained in:
1 parent
e652b54319
commit
c4513588b1
6 files changed
+325
-262
No files matched your search
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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.',
|
||||
|
||||
Reference in new issue
Block a user