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
+332 -269

No files matched your search

+216 -252
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">
<h2 id="how-title" class="h2 how__title">{v.title}</h2>
<ol class="how__dots" role="list" aria-label={v.position} data-deck-dots>
{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>
))}
</ol>
<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__steps" role="list">
{groups.map((g, i) => (
<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>
</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>)}
<!-- 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>
<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>
</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');
const setActive = (index: number) => {
steps.forEach((step, i) => {
if (i === index) step.setAttribute('data-active', ''); else step.removeAttribute('data-active');
});
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';
});
};
/* 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;
/* 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);
});
dots.forEach((d, i) => {
if (i === active) d.setAttribute('aria-current', 'true'); else d.removeAttribute('aria-current');
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' });
});
});
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();
}
</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.',