This commit is contained in:
Felix Klement committed 2026-09-18 18:28:28 +02:00
1 parent d97f541738
commit 0c2b4e7df2
16 files changed
+687 -170

No files matched your search

+27 -8
View File
@@ -25,8 +25,9 @@ npm run check # Astro + TypeScript check
| `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Stages (with `GridObject`), Sectors, Proof, Quote, Insights, Contact. |
| `src/components/home/GridObject.astro` | The four wireframe objects on the stage cards (sphere, cube, plane, wave). Geometry is computed at build time; single stroke in `--ink`. |
| `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. |
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (tabs, one schematic per group), Cards, Facts, Outro. |
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. |
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. |
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
| `src/scripts/reveal.ts` | Marks `[data-animate]` drawings as drawn once they scroll into view. The drawings' own CSS turns that into the plotter-style reveal, wide screens only, off under `prefers-reduced-motion`. |
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
@@ -90,12 +91,30 @@ by git). Everything on the page was read from that repository on 18 September 20
are the `cipherverifier` Django app; the installer is `cvm`.
When the product changes, update `ui.verifier` (copy and diagram labels) and, if a stage or tool is
added, the layouts in `diagrams.ts`. Run `node scripts/tabs-shots.mjs <url> <prefix> '[data-how]'`
to render every schematic for review.
added, the layouts in `diagrams.ts`. All five schematics are on the page; `scroll-shots.mjs` renders
them for review.
## Interaction
## Interaction and motion
Two islands of script: the mobile navigation toggle in `Header.astro` and the tab lists in
`Sectors.astro` and `HowItWorks.astro`, which share `src/scripts/tabs.ts` (ARIA tablist, arrow
keys, Home and End). Without JavaScript every sector panel
Three islands of script: the mobile navigation toggle in `Header.astro`, the sectors tabs in
`Sectors.astro` (`src/scripts/tabs.ts`: ARIA tablist, arrow keys, Home and End), and the reveal
observer used by the product page's drawings.
Motion on the product page, all of it on wide screens: the hero's seventeen lines draw in on load
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
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.
+21
View File
@@ -0,0 +1,21 @@
import { chromium } from 'playwright';
/* Jumps to card 3 via its dot, then back to card 1 via its dot, and reports where the page rests. */
const [,, url, 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 snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
const dots = page.locator('[data-deck-dots] a');
await page.evaluate((y) => window.scrollTo(0, y), snaps[2]);
await page.waitForTimeout(800);
const before = await page.evaluate(() => Math.round(window.scrollY));
await dots.nth(0).click();
await page.waitForTimeout(1500);
const back = await page.evaluate(() => Math.round(window.scrollY));
await dots.nth(3).click();
await page.waitForTimeout(1500);
const fwd = await page.evaluate(() => Math.round(window.scrollY));
const active = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-dots] a')).map((a) => a.getAttribute('aria-current') === 'true'));
const hint = await page.evaluate(() => { const h = document.querySelector('[data-deck-hint]'); return { href: h?.getAttribute('href'), last: h?.hasAttribute('data-last') }; });
console.log({ snaps, before, backTo1: back, expected1: snaps[0], fwdTo4: fwd, expected4: snaps[3], active, hint });
await browser.close();
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from 'playwright';
/* Reports where the chevron hint sits at the first snap and whether anything covers it. */
const [,, url, 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 snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
await page.evaluate((y) => window.scrollTo(0, y), snaps[0]);
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
const h = document.querySelector('[data-deck-hint]');
const r = h.getBoundingClientRect();
const cs = getComputedStyle(h);
const svg = h.querySelector('svg').getBoundingClientRect();
const atPoint = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 10);
const next = document.querySelectorAll('[data-deck-card]')[1].getBoundingClientRect();
return { hint: { top: r.top, bottom: r.bottom, h: r.height }, svg: { top: svg.top, bottom: svg.bottom }, position: cs.position, display: cs.display, bottom: cs.bottom, zIndex: cs.zIndex, opacity: cs.opacity, atPoint: atPoint && (atPoint.tagName + '.' + atPoint.className), nextCardTop: next.top, innerHeight: window.innerHeight };
});
console.log(JSON.stringify(info, null, 1));
await browser.close();
+14
View File
@@ -0,0 +1,14 @@
import { chromium } from 'playwright';
/* Hovers one node group of a schematic and captures the drawing. */
const [,, url, out, selector, group] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(url, { waitUntil: 'networkidle' });
const svg = page.locator(selector).first();
await svg.scrollIntoViewIfNeeded();
await page.waitForTimeout(3500);
await svg.locator(`[data-group="${group}"] .s-ring`).first().hover({ force: true, position: { x: 6, y: 36 } });
await page.waitForTimeout(400);
await svg.screenshot({ path: out });
await browser.close();
console.log('hovered', group);
+16
View File
@@ -0,0 +1,16 @@
import { chromium } from 'playwright';
/* Scrolls a target into view and captures it at several moments, to see a draw-in happen. */
const [,, url, prefix, selector, width = '1440', times = '150,700,1600,3200'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 } });
await page.goto(url, { waitUntil: 'networkidle' });
const el = page.locator(selector).first();
await el.scrollIntoViewIfNeeded();
const t0 = Date.now();
for (const t of times.split(',').map(Number)) {
const wait = t - (Date.now() - t0);
if (wait > 0) await page.waitForTimeout(wait);
await el.screenshot({ path: `${prefix}-${t}.png` });
}
await browser.close();
console.log('captured', selector);
+28
View File
@@ -0,0 +1,28 @@
import { chromium } from 'playwright';
/* Scrolls to positions around each deck card's snap point and reports where the page comes to rest. */
const [,, url, 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 info = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('[data-deck-snap]'));
const cs = getComputedStyle(document.documentElement);
return cards.map((c, i) => {
const st = getComputedStyle(c);
return { i, naturalTop: c.offsetTop + (c.offsetParent?.getBoundingClientRect().top ?? 0) + window.scrollY, marginTop: st.scrollMarginTop, snapAlign: st.scrollSnapAlign, snapStop: st.scrollSnapStop, htmlSnap: cs.scrollSnapType };
});
});
console.log(JSON.stringify(info, null, 1));
// expected snap points: naturalTop - scrollMarginTop
const results = [];
for (const c of info) {
const expected = Math.round(c.naturalTop - parseFloat(c.marginTop));
for (const off of [-220, 180, 320, 352]) {
await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'auto' }), expected + off);
await page.waitForTimeout(1200);
const y = await page.evaluate(() => Math.round(window.scrollY));
results.push({ card: c.i, expected, offset: off, landed: y, snapped: Math.abs(y - expected) < 2 });
}
}
console.table(results);
await browser.close();
+13
View File
@@ -0,0 +1,13 @@
import { chromium } from 'playwright';
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = 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 start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 3000 : 1800);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
+13
View File
@@ -0,0 +1,13 @@
import { chromium } from 'playwright';
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = 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 start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 2500 : 1500);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
+14
View File
@@ -0,0 +1,14 @@
import { chromium } from 'playwright';
/* Scrolls from an anchor downwards in steps, capturing the viewport each time. */
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = 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 start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 3000 : 1800);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
+61 -8
View File
@@ -4,9 +4,11 @@
* Single stroke in currentColor, even width, no fills. The geometry is computed
* at build time so the markup stays small and the stroke stays exact.
* Kinds: sphere (a model seen from every side), cube (a built structure),
* plane (one element lifted out for inspection), wave (a signal over time).
* plane (one element lifted out for inspection), wave (a signal over time),
* steps (a ranked row of columns), nested (a structure inside its own walls),
* stack (layers kept one on top of the other).
*/
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave'; class?: string }
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave' | 'steps' | 'nested' | 'stack'; class?: string }
const { kind, class: className } = Astro.props;
const S = 400;
@@ -15,8 +17,29 @@ const poly = (pts: [number, number][]) => pts.map(([x, y]) => `${f(x)},${f(y)}`)
/* Isometric projection of a 3D point (x right, y up, z towards the viewer's left). */
const ISO = { cx: S / 2, cy: S / 2, c30: Math.cos(Math.PI / 6), s30: Math.sin(Math.PI / 6) };
const iso = (x: number, y: number, z: number, cy = ISO.cy): [number, number] =>
[ISO.cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y];
const iso = (x: number, y: number, z: number, cy = ISO.cy, cx = ISO.cx): [number, number] =>
[cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y];
/* The twelve edges of a cuboid with its near-bottom corner at (x0, y0, z0). */
function cuboid(x0: number, y0: number, z0: number, L: number, H: number, D: number, cy: number, cx = ISO.cx): string[] {
const p = (x: number, y: number, z: number) => iso(x, y, z, cy, cx);
const c = [
p(x0, y0, z0), p(x0 + L, y0, z0), p(x0 + L, y0, z0 + D), p(x0, y0, z0 + D),
p(x0, y0 + H, z0), p(x0 + L, y0 + H, z0), p(x0 + L, y0 + H, z0 + D), p(x0, y0 + H, z0 + D),
];
const e: [number, number][] = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
return e.map(([a, b]) => poly([c[a], c[b]]));
}
/* A square grid plane of n cells at height y. */
function plane(n: number, u: number, y: number, cy: number, cx = ISO.cx): string[] {
const L = n * u, out: string[] = [];
for (let i = 0; i <= n; i++) {
const t = i * u;
out.push(poly([iso(t, y, 0, cy, cx), iso(t, y, L, cy, cx)]));
out.push(poly([iso(0, y, t, cy, cx), iso(L, y, t, cy, cx)]));
}
return out;
}
const lines: string[] = [];
@@ -29,7 +52,6 @@ if (kind === 'sphere') {
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
return [ISO.cx + x, ISO.cy - y2];
};
// Parallels every 20 degrees, meridians every 22.5 degrees (8 great circles).
for (let d = -80; d <= 80; d += 20) {
const lat = (d * Math.PI) / 180;
const pts: [number, number][] = [];
@@ -49,13 +71,10 @@ if (kind === 'cube') {
const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
for (let i = 0; i <= n; i++) {
const t = i * u;
// top face (y = L)
lines.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
lines.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
// front-right face (z = 0): x by y
lines.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
// front-left face (x = 0): z by y
lines.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)]));
}
@@ -89,6 +108,40 @@ if (kind === 'wave') {
lines.push(poly(pts));
}
}
if (kind === 'steps') {
/* Five columns in a row, tallest first: a ranked list seen as a skyline. */
const heights = [168, 128, 96, 68, 44], w = 40, gap = 14, depth = 40, cy = ISO.cy + 78;
const total = heights.length * w + (heights.length - 1) * gap;
heights.forEach((h, i) => {
const x0 = -total / 2 + i * (w + gap);
cuboid(x0, 0, -depth / 2, w, h, depth, cy).forEach((l) => lines.push(l));
});
const L = total + 48;
lines.push(poly([iso(-L / 2, 0, -depth / 2 - 24, cy), iso(L / 2, 0, -depth / 2 - 24, cy)]));
}
if (kind === 'nested') {
/* A structure inside its own walls: a cube inside a cube, corners tied together. */
const O = 200, I = 96, cy = ISO.cy + 84;
cuboid(-O / 2, 0, -O / 2, O, O, O, cy).forEach((l) => lines.push(l));
const off = (O - I) / 2;
cuboid(-I / 2, off, -I / 2, I, I, I, cy).forEach((l) => lines.push(l));
const outer = [[-O / 2, 0, -O / 2], [O / 2, 0, -O / 2], [O / 2, 0, O / 2], [-O / 2, 0, O / 2], [-O / 2, O, -O / 2], [O / 2, O, -O / 2], [O / 2, O, O / 2], [-O / 2, O, O / 2]];
const inner = [[-I / 2, off, -I / 2], [I / 2, off, -I / 2], [I / 2, off, I / 2], [-I / 2, off, I / 2], [-I / 2, off + I, -I / 2], [I / 2, off + I, -I / 2], [I / 2, off + I, I / 2], [-I / 2, off + I, I / 2]];
outer.forEach(([x, y, z], k) => lines.push(poly([iso(x, y, z, cy), iso(inner[k][0], inner[k][1], inner[k][2], cy)])));
}
if (kind === 'stack') {
/* Layers one on top of the other, each a record kept whole. */
const n = 4, u = 38, L = n * u, gapY = 46, layers = 4, cy = ISO.cy + 60;
for (let k = 0; k < layers; k++) plane(n, u, k * gapY, cy).forEach((l) => lines.push(l));
const corners: [number, number][] = [[0, 0], [L, 0], [L, L], [0, L]];
corners.forEach(([x, z]) => lines.push(poly([iso(x, 0, z, cy), iso(x, (layers - 1) * gapY, z, cy)])));
// shift the whole drawing so the isometric footprint sits centred
const dx = -((L - L) * ISO.c30) / 2;
void dx;
}
---
<svg class:list={['grid-object', className]} viewBox={`0 0 ${S} ${S}`} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
+1 -1
View File
@@ -4,7 +4,7 @@ import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const objects = ['plane', 'cube', 'wave'] as const;
const objects = ['steps', 'nested', 'stack'] as const;
---
<section class="section vcards" aria-label={lang === 'de' ? 'Eigenschaften' : 'Properties'}>
<div class="container">
+29 -3
View File
@@ -19,12 +19,12 @@ const ins = Array.from({ length: n }, (_, i) => {
<svg class="vhero__burst" viewBox={`0 0 ${W} ${H}`} role="img" aria-label={v.burstDesc} xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke-width="1" vector-effect="non-scaling-stroke">
<g class="vhero__in">
{ins.map((p) => <line x1={p.split(',')[0]} y1={p.split(',')[1]} x2={F[0]} y2={F[1]} />)}
{ins.map((p, i) => <line x1={p.split(',')[0]} y1={p.split(',')[1]} x2={F[0]} y2={F[1]} pathLength="1" style={`--i: ${i}`} />)}
</g>
<line class="vhero__out" x1={F[0]} y1={F[1]} x2={W} y2={F[1]} stroke-width="1.25" />
<line class="vhero__out" x1={F[0]} y1={F[1]} x2={W} y2={F[1]} stroke-width="1.25" pathLength="1" />
<circle class="vhero__ring" cx={F[0]} cy={F[1]} r="40" stroke-dasharray="2 5" stroke-width="1.25" />
</g>
<circle cx={F[0]} cy={F[1]} r="5" fill="currentColor" />
<circle class="vhero__dot" cx={F[0]} cy={F[1]} r="5" fill="currentColor" />
</svg>
<div class="vhero__text">
@@ -58,6 +58,32 @@ const ins = Array.from({ length: n }, (_, i) => {
}
.vhero__in { stroke: var(--ink-muted); opacity: 0.55; }
.vhero__out, .vhero__ring { stroke: currentColor; }
/* The one orchestrated moment: the lines arrive, meet, and one line leaves. */
.vhero__in line {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: vhero-draw 1100ms var(--ease) forwards;
animation-delay: calc(200ms + var(--i) * 40ms);
}
.vhero__ring, .vhero__dot {
opacity: 0;
animation: vhero-appear 500ms var(--ease) forwards;
animation-delay: 1250ms;
}
.vhero__dot { transform-box: fill-box; transform-origin: center; }
.vhero__out {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: vhero-draw 900ms var(--ease) forwards;
animation-delay: 1500ms;
}
@keyframes vhero-draw { to { stroke-dashoffset: 0; } }
@keyframes vhero-appear { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.vhero__in line, .vhero__out { animation: none; stroke-dashoffset: 0; }
.vhero__ring, .vhero__dot { animation: none; opacity: 1; }
}
.vhero__text {
display: grid;
gap: var(--space-5);
+265 -83
View File
@@ -10,111 +10,293 @@ const pairs = [builders[0](v.groups[0]), builders[1](v.groups[1]), builders[2](v
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__grid" data-how>
<div class="how__text">
<h2 id="how-title" class="h2">{v.title}</h2>
<div class="how__tabs" role="tablist" aria-orientation="vertical" 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) => (
<div class="how__tab">
<h3 class="how__heading">
<button class="how__button" type="button" role="tab" id={`${g.id}-tab`} aria-controls={`${g.id}-panel`} aria-selected={i === 0 ? 'true' : 'false'} tabindex={i === 0 ? undefined : -1}>{g.title}</button>
</h3>
<div class="how__panel" role="tabpanel" id={`${g.id}-panel`} aria-labelledby={`${g.id}-tab`} tabindex="0">
<div class="how__panel-inner">
<p class="muted">{g.body}</p>
<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>
</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>
<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>
</div>
</div>
</li>
))}
</div>
</div>
<div class="how__stage">
{groups.map((g, i) => (
<div class="how__figure" data-stage-item data-active={i === 0 ? '' : undefined}>
<Schematic layout={g.pair.wide} title={g.title} desc={g.desc} variant="wide" id={`${g.id}-wide`} />
</div>
))}
</div>
</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>
.how__grid {
.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);
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-8);
}
.how__text {
grid-column: 1 / -1;
display: grid;
gap: var(--space-8);
}
.how__tab + .how__tab { margin-top: var(--space-5); }
.how__heading { font-size: inherit; font-weight: inherit; }
.how__button {
display: block;
text-align: left;
font-size: var(--step-h3);
line-height: var(--leading-h3);
letter-spacing: var(--track-h3);
font-weight: var(--weight-medium);
color: var(--ink);
transition: color var(--dur-fast) var(--ease);
}
.how__button[aria-selected='false'] { color: var(--ink-muted); }
.how__button[aria-selected='false']:hover { color: var(--ink); }
.how__panel-inner { display: grid; gap: var(--space-6); padding-top: var(--space-3); }
.how__panel p { max-width: 30em; }
.how__tall { max-width: 26rem; }
/* The stage carries the wide drawings for wide screens; the panels carry the tall ones. */
.how__stage { display: none; }
:global(html.js) .how__panel {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows var(--dur-slow) var(--ease);
}
:global(html.js) .how__panel-inner { min-height: 0; }
:global(html.js) .how__panel[data-open] { grid-template-rows: 1fr; }
@media (min-width: 64rem) {
/* Title left, the list right; the drawing takes the full width underneath. */
.how__text {
grid-column: 1 / -1;
grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--gap);
row-gap: var(--space-4);
align-items: start;
}
.how__text > h2 { grid-column: 1 / span 5; }
.how__tabs { grid-column: 6 / span 7; }
.how__stage {
grid-column: 1 / -1;
display: grid;
border: var(--hairline) solid var(--line);
padding: var(--space-7) var(--space-7);
.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__tall { max-width: 26rem; }
@media (min-width: 48rem) {
.how__heading { grid-column: 1 / span 5; }
.how__body { grid-column: 6 / span 7; }
}
.how__figure {
grid-area: 1 / 1;
align-self: center;
opacity: 0;
transition: opacity var(--dur-slow) var(--ease);
/* 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) {
.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__figure[data-active] { opacity: 1; }
.how__wide { display: block; }
.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 {
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));
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%;
}
}
/* Short viewports (small laptops): a slimmer bar and a tighter card head leave more height for the drawing. */
@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); }
}
@media (prefers-reduced-motion: reduce) {
.how__panel, .how__figure { transition: none; }
.how__group::after { opacity: 0; }
.how__dot-mark { transition: none; }
}
</style>
<script>
import { initTabs } from '../../scripts/tabs';
const root = document.querySelector<HTMLElement>('[data-how]');
if (root) initTabs(root);
/* 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);
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;
});
dots.forEach((d, i) => {
if (i === active) d.setAttribute('aria-current', 'true'); else d.removeAttribute('aria-current');
});
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>
+112 -22
View File
@@ -3,8 +3,12 @@
* A spec-sheet schematic rendered from a layout: nodes (main with dotted ring, or small
* satellites), edges (polylines, optional direction tick), and free text. One drawing per
* variant; the caller decides which variant shows where.
*
* Elements that belong to one node (its ring, satellites, branches and labels) render in one
* group, so a hover can single it out. The per-element delays (--d) are kept in the markup for
* a future draw-in; nothing animates by default.
*/
import type { Layout } from './diagrams';
import type { Edge, Layout, Node, Text } from './diagrams';
interface Props { layout: Layout; title: string; desc: string; variant: 'wide' | 'tall'; id: string }
const { layout, title, desc, variant, id } = Astro.props;
const stroke = 1.25;
@@ -12,48 +16,124 @@ const tick = (x: number, y: number, dx: number, dy: number, s = 7) => {
const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; const px = -uy, py = ux;
return `${x - ux * s + px * s},${y - uy * s + py * s} ${x},${y} ${x - ux * s - px * s},${y - uy * s - py * s}`;
};
/* A tick sits at the midpoint of the edge's last segment, pointing along it. */
const edgeTick = (pts: [number, number][]) => {
const [a, b] = [pts[pts.length - 2], pts[pts.length - 1]];
return tick((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, b[0] - a[0], b[1] - a[1]);
};
const lines = (t: string | string[]) => (Array.isArray(t) ? t : [t]);
const pts = (e: Edge) => e.points.map(([x, y]) => `${x},${y}`).join(' ');
/* A label ready to render: position, anchor, lines, delay. */
interface L { x: number; y: number; anchor: string; lines: string[]; d: number; strong?: boolean }
const nodeLabel = (n: Node, d: number): L | null => n.label ? {
x: n.x + (n.dx ?? (n.dot === 'sub' ? 12 : 0)),
y: n.y + (n.dy ?? (n.dot === 'sub' ? 5 : 6)),
anchor: n.anchor ?? 'start', lines: lines(n.label), d,
} : null;
const textLabel = (t: Text, d: number): L => ({ x: t.x, y: t.y, anchor: t.anchor ?? 'start', lines: lines(t.text), d, strong: t.strong });
const compact = (xs: (L | null)[]): L[] => xs.filter((x): x is L => x !== null);
/* Group order: the order in which a group first appears among nodes, edges and texts. */
const order: string[] = [];
const seen = (g?: string) => { if (g && !order.includes(g)) order.push(g); };
layout.nodes.forEach((n) => seen(n.group));
layout.edges.forEach((e) => seen(e.group));
layout.texts?.forEach((t) => seen(t.group));
const freeEdges = layout.edges.filter((e) => !e.group).map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: i * 150 }));
const groups = order.map((g, gi) => {
const start = 320 + gi * 200;
const nodes = layout.nodes.filter((n) => n.group === g);
const edges = layout.edges.filter((e) => e.group === g);
const texts = (layout.texts ?? []).filter((t) => t.group === g);
return {
key: g,
rings: nodes.filter((n) => n.ring).map((n) => ({ x: n.x, y: n.y, r: n.ring!, d: start })),
edges: edges.map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: start + 80 + i * 45 })),
dots: nodes.filter((n) => n.dot !== 'none').map((n, i) => ({ x: n.x, y: n.y, sub: n.dot === 'sub', d: start + 120 + i * 45 })),
mains: compact(nodes.filter((n) => n.dot !== 'sub').map((n) => nodeLabel(n, start + 260))),
subs: compact(nodes.filter((n) => n.dot === 'sub').map((n, i) => nodeLabel(n, start + 200 + i * 45))),
texts: texts.map((t) => textLabel(t, start + 320)),
};
});
const freeStart = 320 + groups.length * 200;
const freeNodes = layout.nodes.filter((n) => !n.group);
const free = {
rings: freeNodes.filter((n) => n.ring).map((n, i) => ({ x: n.x, y: n.y, r: n.ring!, d: freeStart + i * 60 })),
dots: freeNodes.filter((n) => n.dot !== 'none').map((n, i) => ({ x: n.x, y: n.y, sub: n.dot === 'sub', d: freeStart + i * 60 })),
mains: compact(freeNodes.filter((n) => n.dot !== 'sub').map((n, i) => nodeLabel(n, freeStart + 100 + i * 60))),
subs: compact(freeNodes.filter((n) => n.dot === 'sub').map((n, i) => nodeLabel(n, freeStart + 120 + i * 40))),
texts: (layout.texts ?? []).filter((t) => !t.group).map((t, i) => textLabel(t, freeStart + 160 + i * 60)),
};
const fontMain = layout.fontMain ?? 17;
const fontSub = layout.fontSub ?? 15;
---
<svg class:list={['schematic', `schematic--${variant}`]} viewBox={`0 0 ${layout.w} ${layout.h}`} role="img" aria-labelledby={`${id}-title ${id}-desc`} xmlns="http://www.w3.org/2000/svg">
<svg class:list={['schematic', `schematic--${variant}`]} viewBox={`0 0 ${layout.w} ${layout.h}`} role="img" aria-labelledby={`${id}-title ${id}-desc`} xmlns="http://www.w3.org/2000/svg" font-size={fontSub}>
<title id={`${id}-title`}>{title}</title>
<desc id={`${id}-desc`}>{desc}</desc>
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" stroke-linejoin="round" vector-effect="non-scaling-stroke">
{layout.edges.map((e) => (
<g class:list={{ 'schematic__faint': e.faint }}>
<polyline points={e.points.map(([x, y]) => `${x},${y}`).join(' ')} stroke-dasharray={e.dashed ? '2 5' : undefined} />
{e.tick && <polyline points={edgeTick(e.points)} />}
<g class="schematic__edges" fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
{freeEdges.map((f) => (
<g class:list={{ 'schematic__faint': f.e.faint }}>
<polyline class="s-edge" pathLength="1" points={f.points} stroke-dasharray={f.e.dashed ? '2 5' : undefined} style={`--d: ${f.d}ms`} />
{f.tick && <polyline class="s-edge" pathLength="1" points={f.tick} style={`--d: ${f.d + 500}ms`} />}
</g>
))}
</g>
{groups.map((g) => (
<g class="schematic__group" data-group={g.key}>
<g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
{g.rings.map((r) => <circle class="s-ring" cx={r.x} cy={r.y} r={r.r} stroke-dasharray="2 5" style={`--d: ${r.d}ms`} />)}
{g.edges.map((f) => (
<g class:list={{ 'schematic__faint': f.e.faint }}>
<polyline class="s-edge" pathLength="1" points={f.points} style={`--d: ${f.d}ms`} />
{f.tick && <polyline class="s-edge" pathLength="1" points={f.tick} style={`--d: ${f.d + 420}ms`} />}
</g>
))}
{layout.nodes.filter((n) => n.ring).map((n) => <circle cx={n.x} cy={n.y} r={n.ring} stroke-dasharray="2 5" />)}
{layout.boxes?.map((b) => <rect x={b.x} y={b.y} width={b.w} height={b.h} stroke-dasharray={b.dashed ? '2 5' : undefined} />)}
</g>
<g fill="currentColor">
{layout.nodes.map((n) => n.dot === 'none' ? null : <circle cx={n.x} cy={n.y} r={n.dot === 'sub' ? 3 : 5} class:list={{ 'schematic__dot--sub': n.dot === 'sub' }} />)}
{g.dots.map((d) => <circle class:list={['s-dot', { 'schematic__dot--sub': d.sub }]} cx={d.x} cy={d.y} r={d.sub ? 3 : 5} style={`--d: ${d.d}ms`} />)}
</g>
<g class="schematic__labels" font-size={layout.fontMain ?? 17} font-weight="500">
{layout.nodes.filter((n) => n.label && n.dot !== 'sub').map((n) => (
<text x={n.x + (n.dx ?? 0)} y={n.y + (n.dy ?? 6)} text-anchor={n.anchor ?? 'start'}>
{lines(n.label!).map((l, i) => <tspan x={n.x + (n.dx ?? 0)} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)}
<g class="schematic__labels" font-size={fontMain} font-weight="500">
{g.mains.map((l) => (
<text class="s-label" x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
</text>
))}
</g>
<g class="schematic__meta" font-size={layout.fontSub ?? 15}>
{layout.nodes.filter((n) => n.label && n.dot === 'sub').map((n) => (
<text x={n.x + (n.dx ?? 12)} y={n.y + (n.dy ?? 5)} text-anchor={n.anchor ?? 'start'}>
{lines(n.label!).map((l, i) => <tspan x={n.x + (n.dx ?? 12)} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)}
<g class="schematic__meta">
{[...g.subs, ...g.texts].map((l) => (
<text class:list={['s-label', { 'schematic__strong': l.strong }]} x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
</text>
))}
{layout.texts?.map((t) => (
<text x={t.x} y={t.y} text-anchor={t.anchor ?? 'start'} class:list={{ 'schematic__strong': t.strong }}>
{lines(t.text).map((l, i) => <tspan x={t.x} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)}
</g>
</g>
))}
<g class="schematic__free">
<g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
{free.rings.map((r) => <circle class="s-ring" cx={r.x} cy={r.y} r={r.r} stroke-dasharray="2 5" style={`--d: ${r.d}ms`} />)}
</g>
<g fill="currentColor">
{free.dots.map((d) => <circle class:list={['s-dot', { 'schematic__dot--sub': d.sub }]} cx={d.x} cy={d.y} r={d.sub ? 3 : 5} style={`--d: ${d.d}ms`} />)}
</g>
<g class="schematic__labels" font-size={fontMain} font-weight="500">
{free.mains.map((l) => (
<text class="s-label" x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
</text>
))}
</g>
<g class="schematic__meta">
{[...free.subs, ...free.texts].map((l) => (
<text class:list={['s-label', { 'schematic__strong': l.strong }]} x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
{l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
</text>
))}
</g>
</g>
</svg>
<style>
@@ -68,4 +148,14 @@ const lines = (t: string | string[]) => (Array.isArray(t) ? t : [t]);
.schematic__strong { fill: var(--ink); font-weight: var(--weight-medium); }
.schematic__faint { stroke: var(--line); }
.schematic__dot--sub { fill: var(--ink-muted); }
.s-ring { pointer-events: all; }
.s-dot { transform-box: fill-box; transform-origin: center; }
/* Hover singles out one node and what belongs to it. */
@media (hover: hover) and (min-width: 64rem) {
.schematic__group, .schematic__edges, .schematic__free { transition: opacity var(--dur) var(--ease); }
.schematic:has(.schematic__group:hover) .schematic__group:not(:hover) { opacity: 0.28; }
.schematic:has(.schematic__group:hover) .schematic__edges,
.schematic:has(.schematic__group:hover) .schematic__free { opacity: 0.45; }
}
</style>
+47 -45
View File
@@ -9,10 +9,10 @@ type Groups = HowGroups;
type Pt = [number, number];
type Label = string | string[];
export interface Node { x: number; y: number; label?: Label; dot?: 'main' | 'sub' | 'none'; ring?: number; anchor?: 'start' | 'end' | 'middle'; dx?: number; dy?: number }
export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean }
export interface Node { x: number; y: number; label?: Label; dot?: 'main' | 'sub' | 'none'; ring?: number; anchor?: 'start' | 'end' | 'middle'; dx?: number; dy?: number; group?: string }
export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean; group?: string }
export interface Box { x: number; y: number; w: number; h: number; dashed?: boolean }
export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean }
export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean; group?: string }
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number }
export interface Pair { wide: Layout; tall: Layout }
@@ -20,20 +20,20 @@ const RING = 36;
const LINE = 21; // one line of a 17px label, in viewBox units
/* A ringed main node with its label centred below it and an optional muted line under that. */
function main(x: number, y: number, label: Label, sub?: Label, ring = RING): { nodes: Node[]; texts: Text[] } {
function main(x: number, y: number, label: Label, sub?: Label, ring = RING, group?: string): { nodes: Node[]; texts: Text[] } {
const lines = Array.isArray(label) ? label.length : 1;
const nodes: Node[] = [{ x, y, label, dot: 'main', ring, anchor: 'middle', dx: 0, dy: ring + 22 }];
const texts: Text[] = sub ? [{ x, y: y + ring + 22 + lines * LINE + 2, text: sub, anchor: 'middle' }] : [];
const nodes: Node[] = [{ x, y, label, dot: 'main', ring, anchor: 'middle', dx: 0, dy: ring + 22, group }];
const texts: Text[] = sub ? [{ x, y: y + ring + 22 + lines * LINE + 2, text: sub, anchor: 'middle', group }] : [];
return { nodes, texts };
}
/* A plain dot with its label centred below. */
function dot(x: number, y: number, label: Label): Node {
return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28 };
function dot(x: number, y: number, label: Label, group?: string): Node {
return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28, group };
}
/* Satellites fanning from a node to a column of dots. */
function fan(from: Pt, xs: number, ys: number[], labels: string[], anchor: 'start' | 'end'): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = ys.map((y, i) => ({ x: xs, y, label: labels[i], dot: 'sub', anchor, dx: anchor === 'end' ? -12 : 12 }));
const edges: Edge[] = ys.map((y) => ({ points: [from, [xs, y]], faint: true }));
function fan(from: Pt, xs: number, ys: number[], labels: string[], anchor: 'start' | 'end', group?: string): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = ys.map((y, i) => ({ x: xs, y, label: labels[i], dot: 'sub', anchor, dx: anchor === 'end' ? -12 : 12, group }));
const edges: Edge[] = ys.map((y) => ({ points: [from, [xs, y]], faint: true, group }));
return { nodes, edges };
}
const chain = (pts: Pt[], tick = true): Edge[] => pts.slice(1).map((p, i) => ({ points: [pts[i], p], tick }));
@@ -67,12 +67,12 @@ function spine(groups: { title: Label; items?: string[]; note?: string }[]): Lay
export function overview(g: Groups[0]): Pair {
const L = g.labels;
const y1 = 110, y2 = 330, ym = 220, fork = 190, join = 1050;
const sbom = main(340, y1, L.sbom, L.sbomSub), dt = main(640, y1, L.dt, L.dtSub);
const sast = main(340, y2, L.sast, L.sastSub), poly = main(640, y2, L.poly, L.polySub), prism = main(900, y2, L.prism, L.prismSub);
const y1 = 90, y2 = 300, ym = 195, fork = 190, join = 1050;
const sbom = main(340, y1, L.sbom, L.sbomSub, RING, 'sbom'), dt = main(640, y1, L.dt, L.dtSub, RING, 'dt');
const sast = main(340, y2, L.sast, L.sastSub, RING, 'sast'), poly = main(640, y2, L.poly, L.polySub, RING, 'poly'), prism = main(900, y2, L.prism, L.prismSub, RING, 'prism');
const wide: Layout = {
w: 1200, h: 470,
nodes: [dot(80, ym, L.repo), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report)],
w: 1200, h: 430,
nodes: [dot(80, ym, L.repo, 'repo'), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report, 'report')],
edges: [
{ points: [[80, ym], [fork, ym], [fork, y1], [340, y1]], tick: true },
{ points: [[fork, ym], [fork, y2], [340, y2]], tick: true },
@@ -94,14 +94,15 @@ export function overview(g: Groups[0]): Pair {
export function scanners(g: Groups[1]): Pair {
const L = g.labels;
const focal: Pt = [500, 280];
const ys = g.tools.map((_, i) => 60 + i * 27.5);
const tools = fan(focal, 210, ys, g.tools, 'end');
const one = main(500, 280, L.one, L.oneSub), cwe = main(700, 280, L.cwe, L.cweSub), merge = main(900, 280, L.merge, L.mergeSub), resolve = main(1090, 280, L.resolve, L.resolveSub);
const cy = 232;
const focal: Pt = [500, cy];
const ys = g.tools.map((_, i) => 40 + i * 24);
const tools = fan(focal, 210, ys, g.tools, 'end', 'tools');
const one = main(500, cy, L.one, L.oneSub, RING, 'one'), cwe = main(700, cy, L.cwe, L.cweSub, RING, 'cwe'), merge = main(900, cy, L.merge, L.mergeSub, RING, 'merge'), resolve = main(1090, cy, L.resolve, L.resolveSub, RING, 'resolve');
const wide: Layout = {
w: 1200, h: 560,
w: 1200, h: 450,
nodes: [...tools.nodes, ...one.nodes, ...cwe.nodes, ...merge.nodes, ...resolve.nodes],
edges: [...tools.edges, ...chain([[500, 280], [700, 280], [900, 280], [1090, 280]])],
edges: [...tools.edges, ...chain([[500, cy], [700, cy], [900, cy], [1090, cy]])],
texts: [...one.texts, ...cwe.texts, ...merge.texts, ...resolve.texts],
};
const tall = spine([
@@ -113,14 +114,14 @@ export function scanners(g: Groups[1]): Pair {
export function verdict(g: Groups[2]): Pair {
const L = g.labels;
const y = 300, top = 120;
const ctx = fan([380, y], 230, [100, 140, 180, 220], L.ctx, 'end');
const tools = fan([920, y], 1040, [150, 190, 230, 270], L.tools, 'start');
const outcomes = fan([1220, y], 1330, [240, 280, 320], L.outcomes, 'start');
const context = main(380, y, L.context), s1 = main(640, y, L.stage1), s2 = main(920, y, L.stage2), v = main(1220, y, L.verdict, L.carries);
const y = 260, top = 80;
const ctx = fan([380, y], 230, [60, 100, 140, 180], L.ctx, 'end', 'context');
const tools = fan([920, y], 1040, [110, 150, 190, 230], L.tools, 'start', 'escalation');
const outcomes = fan([1220, y], 1330, [200, 240, 280], L.outcomes, 'start', 'verdict');
const context = main(380, y, L.context, undefined, RING, 'context'), s1 = main(640, y, L.stage1, undefined, RING, 'static'), s2 = main(920, y, L.stage2, undefined, RING, 'escalation'), v = main(1220, y, L.verdict, L.carries, RING, 'verdict');
const wide: Layout = {
w: 1460, h: 420,
nodes: [dot(140, y, L.finding), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
w: 1460, h: 380,
nodes: [dot(140, y, L.finding, 'finding'), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
edges: [
...ctx.edges, ...tools.edges, ...outcomes.edges,
...chain([[140, y], [380, y], [640, y], [920, y], [1220, y]]),
@@ -141,14 +142,14 @@ export function verdict(g: Groups[2]): Pair {
export function dependencies(g: Groups[3]): Pair {
const L = g.labels;
const y = 220, ya = 130, yb = 310, mergeX = 300;
const sbom = main(430, y, L.sbom, L.sbomSub), dt = main(700, y, L.dt, L.dtSub), vdr = main(960, y, L.vdr, L.vdrSub);
const y = 170, ya = 80, yb = 260, mergeX = 300;
const sbom = main(430, y, L.sbom, L.sbomSub, RING, 'sbom'), dt = main(700, y, L.dt, L.dtSub, RING, 'dt'), vdr = main(960, y, L.vdr, L.vdrSub, RING, 'vdr');
const wide: Layout = {
w: 1200, h: 400,
w: 1200, h: 320,
nodes: [
{ x: 150, y: ya, label: L.syft, dot: 'main', anchor: 'end', dx: -16, dy: 6 },
{ x: 150, y: yb, label: L.cdxgen, dot: 'main', anchor: 'end', dx: -16, dy: 6 },
...sbom.nodes, ...dt.nodes, ...vdr.nodes, dot(1130, y, L.report),
{ x: 150, y: ya, label: L.syft, dot: 'main', anchor: 'end', dx: -16, dy: 6, group: 'generators' },
{ x: 150, y: yb, label: L.cdxgen, dot: 'main', anchor: 'end', dx: -16, dy: 6, group: 'generators' },
...sbom.nodes, ...dt.nodes, ...vdr.nodes, dot(1130, y, L.report, 'report'),
],
edges: [
{ points: [[150, ya], [mergeX, ya], [mergeX, y]] },
@@ -168,23 +169,24 @@ export function dependencies(g: Groups[3]): Pair {
export function operate(g: Groups[4]): Pair {
const L = g.labels;
const hub: Pt = [560, 220];
const ys = [100, 160, 220, 280, 340];
const sources = fan(hub, 200, ys, L.sources, 'end');
const outputs = fan(hub, 920, ys, L.outputs, 'start');
const hub: Pt = [560, 200];
const ys = [100, 150, 200, 250, 300];
const sources = fan(hub, 200, ys, L.sources, 'end', 'sources');
const outputs = fan(hub, 920, ys, L.outputs, 'start', 'outputs');
const sy = 410;
const wide: Layout = {
w: 1200, h: 540,
w: 1200, h: 470,
nodes: [
{ x: hub[0], y: hub[1], label: L.hub, dot: 'main', ring: 44, anchor: 'middle', dx: 0, dy: -58 },
{ x: hub[0], y: hub[1], label: L.hub, dot: 'main', ring: 44, anchor: 'middle', dx: 0, dy: -58, group: 'hub' },
...sources.nodes, ...outputs.nodes,
dot(420, 460, L.scopes[0]), dot(560, 460, L.scopes[1]), dot(700, 460, L.scopes[2]),
dot(420, sy, L.scopes[0], 'scopes'), dot(560, sy, L.scopes[1], 'scopes'), dot(700, sy, L.scopes[2], 'scopes'),
],
edges: [
...sources.edges, ...outputs.edges,
{ points: [[560, 264], [560, 460]], faint: true },
...chain([[420, 460], [560, 460], [700, 460]]),
{ points: [[560, 244], [560, sy]], faint: true, group: 'scopes' },
...chain([[420, sy], [560, sy], [700, sy]]).map((e) => ({ ...e, group: 'scopes' })),
],
texts: [{ x: 540, y: 425, text: L.scopesTitle, anchor: 'end' }],
texts: [{ x: 540, y: sy - 35, text: L.scopesTitle, anchor: 'end', group: 'scopes' }],
};
const tall = spine([
{ title: L.hub, items: L.sources },
+6
View File
@@ -162,6 +162,9 @@ 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.',
@@ -380,6 +383,9 @@ 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.',