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
|
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.
|
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
|
"How CipherVerifier works" follows the pattern of arcos.eu/technology. On wide screens the
|
||||||
stack into a deck under the pinned heading. Every card has the same height, bound to the viewport
|
title and a bordered panel hold still while the page scrolls; the step list under the title
|
||||||
and capped at 37rem, and the drawing fills what the text leaves, so a card always fits and fully
|
advances, one step open at a time, and the panel crossfades to that step's drawing. The section is
|
||||||
covers the one beneath it. The page snaps to each card (`scroll-snap-type: y proximity` on the
|
as tall as one pitch per step plus one panel height, so the panel stays pinned until the last step
|
||||||
root while this component is on the page, `scroll-snap-stop: always`) via invisible anchors placed
|
has been read. Which step is open is decided by invisible triggers tiling that height: whichever
|
||||||
at each card's natural position: snapping to the sticky cards themselves would create phantom snap
|
crosses the middle of the viewport wins, and the triggers start half a viewport down so the first
|
||||||
points at the current offset in Chrome. The pinned bar carries one dot per card; the filled one is
|
step turns over exactly when the panel pins. A step's title is a button that scrolls to it.
|
||||||
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
|
The panel is tied to the text, not to the pinned frame: it is positioned against
|
||||||
dot. The heading is pinned inside a track that ends one card before the deck does, so heading and
|
`.how__text-inner`, so it starts at the title and its width is stated as 200% of the text column
|
||||||
deck leave the screen together. Phones get the same drawings in a plain flow without any of this. This goes
|
(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
|
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
|
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.
|
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 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] }));
|
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">
|
<section class="night how" id="how" aria-labelledby="how-title">
|
||||||
<div class="container how__deck">
|
<div class="how__scroller" style={`--how-steps: ${groups.length}`} data-how>
|
||||||
<div class="how__track">
|
<div class="how__sticky container">
|
||||||
<div class="how__bar">
|
<div class="how__layout">
|
||||||
|
<div class="how__text">
|
||||||
|
<div class="how__text-inner">
|
||||||
<h2 id="how-title" class="h2 how__title">{v.title}</h2>
|
<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) => (
|
{groups.map((g, i) => (
|
||||||
<li>
|
<li class="how__step" id={g.id} data-step data-active={i === 0 ? '' : undefined}>
|
||||||
<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>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="how__anchors" aria-hidden="true">
|
</div>
|
||||||
{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">
|
<!-- One trigger per step, tiling the scroller: whichever crosses the middle of the
|
||||||
{groups.map((g, i) => (
|
viewport is the step being read. -->
|
||||||
<li class="how__group" id={g.id} style={`--i: ${i}`} data-deck-card>
|
<div class="how__triggers" aria-hidden="true">
|
||||||
<div class="how__head">
|
{groups.map(() => <div class="how__trigger" data-trigger></div>)}
|
||||||
<h3 class="h3 how__heading">{g.title}</h3>
|
|
||||||
<p class="muted how__body">{g.body}</p>
|
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<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__title { max-width: 12em; }
|
||||||
.how__list { margin-top: var(--space-8); }
|
.how__steps { margin-top: var(--space-8); }
|
||||||
.how__anchors { display: none; }
|
.how__step {
|
||||||
.how__dots { display: none; }
|
padding-block: var(--space-7) var(--space-8);
|
||||||
.how__hint { display: none; }
|
|
||||||
.how__group {
|
|
||||||
padding-block: var(--space-8) var(--space-9);
|
|
||||||
border-top: var(--hairline) solid var(--line);
|
border-top: var(--hairline) solid var(--line);
|
||||||
scroll-margin-top: var(--header-height);
|
scroll-margin-top: var(--header-height);
|
||||||
}
|
}
|
||||||
.how__head {
|
.how__step-heading {
|
||||||
display: grid;
|
font-size: var(--step-h3);
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
line-height: var(--leading-h3);
|
||||||
column-gap: var(--gap);
|
letter-spacing: var(--track-h3);
|
||||||
row-gap: var(--space-4);
|
font-weight: var(--weight-medium);
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
.how__heading { grid-column: 1 / -1; }
|
.how__step-button {
|
||||||
.how__body { grid-column: 1 / -1; max-width: 34em; }
|
display: block;
|
||||||
.how__figure { margin-top: var(--space-7); }
|
text-align: left;
|
||||||
/* The wide drawing needs the full container; below that width the tall spine takes over. */
|
font: inherit;
|
||||||
.how__wide { display: none; }
|
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__tall { max-width: 26rem; }
|
||||||
|
.how__figure { display: none; }
|
||||||
|
.how__triggers { display: none; }
|
||||||
|
|
||||||
@media (min-width: 48rem) {
|
/* Wide screens: the title and the panel hold still, the list advances as the page scrolls. */
|
||||||
.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. */
|
|
||||||
@media (min-width: 64rem) {
|
@media (min-width: 64rem) {
|
||||||
.how {
|
.how {
|
||||||
--how-bar-h: 5rem;
|
--how-pitch: 70svh;
|
||||||
--how-step: 0.75rem;
|
--how-inset: var(--space-7);
|
||||||
--how-hint-h: var(--space-6);
|
--how-panel-h: calc(100svh - var(--header-height));
|
||||||
/* The height under the pinned bar is one card pitch, so at a snap the next card starts
|
padding-block: 0;
|
||||||
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__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__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 panel: one box on the right, the drawings crossfading inside it. */
|
||||||
The snap points are invisible anchors at each card's natural position: a stuck card would
|
.how__figure {
|
||||||
report its shifted position and drag the page back to where it already is. */
|
display: grid;
|
||||||
:global(html) { scroll-snap-type: y proximity; }
|
place-items: center;
|
||||||
.how__deck { position: relative; }
|
position: absolute;
|
||||||
.how__anchors { position: absolute; inset: 0; pointer-events: none; }
|
top: 0;
|
||||||
.how__snap {
|
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;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: calc(var(--how-bar-h) + var(--space-7) + var(--i) * var(--how-pitch));
|
top: 50svh;
|
||||||
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));
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.how__bar {
|
.how__trigger { height: var(--how-pitch); }
|
||||||
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%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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) {
|
@media (min-width: 64rem) and (max-height: 52rem) {
|
||||||
.how { --how-bar-h: 4.5rem; }
|
.how { --how-inset: var(--space-5); }
|
||||||
.how__group { padding: var(--space-5) var(--space-6) var(--space-5); }
|
.how__steps { margin-top: var(--space-5); }
|
||||||
.how__body { font-size: var(--step-small); line-height: var(--leading-small); }
|
.how__step { padding-block: var(--space-2); }
|
||||||
.how__figure { margin-top: var(--space-4); }
|
.how__step-inner p { font-size: var(--step-small); line-height: var(--leading-small); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.how__group::after { opacity: 0; }
|
.how__step-body, .how__step-inner, .how__figure, .how__step-button { transition: none; }
|
||||||
.how__dot-mark { transition: none; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* Two enhancements, both only on wide screens. Without them the deck still stacks and snaps.
|
/* The step being read is the one whose trigger crosses the middle of the viewport. Without
|
||||||
1. Each card learns how far the next one has slid over it (--covered), so it can darken.
|
this the first step simply stays open, which is a readable section on its own. */
|
||||||
2. The dots in the bar follow the card that currently sits on top. */
|
const root = document.querySelector<HTMLElement>('[data-how]');
|
||||||
const cards = Array.from(document.querySelectorAll<HTMLElement>('[data-deck-card]'));
|
if (root) {
|
||||||
const anchors = Array.from(document.querySelectorAll<HTMLElement>('[data-deck-snap]'));
|
const steps = Array.from(root.querySelectorAll<HTMLElement>('[data-step]'));
|
||||||
const dots = Array.from(document.querySelectorAll<HTMLElement>('[data-deck-dots] a'));
|
const figures = Array.from(root.querySelectorAll<HTMLElement>('[data-figure]'));
|
||||||
const hint = document.querySelector<HTMLAnchorElement>('[data-deck-hint]');
|
const triggers = Array.from(root.querySelectorAll<HTMLElement>('[data-trigger]'));
|
||||||
const section = document.querySelector<HTMLElement>('.how');
|
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-step-to]'));
|
||||||
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);
|
|
||||||
|
|
||||||
const update = () => {
|
const setActive = (index: number) => {
|
||||||
ticking = false;
|
steps.forEach((step, i) => {
|
||||||
if (!mq.matches) { cards.forEach((c) => c.style.removeProperty('--covered')); return; }
|
if (i === index) step.setAttribute('data-active', ''); else step.removeAttribute('data-active');
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
dots.forEach((d, i) => {
|
figures.forEach((figure, i) => {
|
||||||
if (i === active) d.setAttribute('aria-current', 'true'); else d.removeAttribute('aria-current');
|
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) {
|
/* The box on the right takes the height of the longest step, so it holds still while the
|
||||||
window.addEventListener('scroll', schedule, { passive: true });
|
text below each title grows and shrinks. */
|
||||||
window.addEventListener('resize', schedule);
|
const inners = Array.from(root.querySelectorAll<HTMLElement>('.how__step-inner'));
|
||||||
mq.addEventListener('change', schedule);
|
const bodies = Array.from(root.querySelectorAll<HTMLElement>('.how__step-body'));
|
||||||
update();
|
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>
|
</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.',
|
imageAlt: 'A software engineer at a desk in the evening, source code reflected in the dark window beside the monitors.',
|
||||||
how: {
|
how: {
|
||||||
title: 'How CipherVerifier works.',
|
title: 'How CipherVerifier works.',
|
||||||
position: 'Position in the sequence',
|
|
||||||
of: (n: number, total: number) => `${n} of ${total}`,
|
|
||||||
next: 'Next card',
|
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
title: 'One run, five stages.',
|
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.',
|
imageAlt: 'Ein Software-Engineer am Schreibtisch am Abend, der Quellcode spiegelt sich im dunklen Fenster neben den Monitoren.',
|
||||||
how: {
|
how: {
|
||||||
title: 'So arbeitet CipherVerifier.',
|
title: 'So arbeitet CipherVerifier.',
|
||||||
position: 'Position in der Abfolge',
|
|
||||||
of: (n: number, total: number) => `${n} von ${total}`,
|
|
||||||
next: 'Nächste Karte',
|
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
title: 'Ein Lauf, fünf Stufen.',
|
title: 'Ein Lauf, fünf Stufen.',
|
||||||
|
|||||||
Reference in new issue
Block a user