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

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/*` | 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/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/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/*` | 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`. | | `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`. | | `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/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. | | `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`. 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 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]'` added, the layouts in `diagrams.ts`. All five schematics are on the page; `scroll-shots.mjs` renders
to render every schematic for review. them for review.
## Interaction ## Interaction and motion
Two islands of script: the mobile navigation toggle in `Header.astro` and the tab lists in Three islands of script: the mobile navigation toggle in `Header.astro`, the sectors tabs in
`Sectors.astro` and `HowItWorks.astro`, which share `src/scripts/tabs.ts` (ARIA tablist, arrow `Sectors.astro` (`src/scripts/tabs.ts`: ARIA tablist, arrow keys, Home and End), and the reveal
keys, Home and End). Without JavaScript every sector panel 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. 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 * 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. * 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), * 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 { kind, class: className } = Astro.props;
const S = 400; 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). */ /* 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 = { 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] => const iso = (x: number, y: number, z: number, cy = ISO.cy, cx = ISO.cx): [number, number] =>
[ISO.cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y]; [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[] = []; const lines: string[] = [];
@@ -29,7 +52,6 @@ if (kind === 'sphere') {
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt); const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
return [ISO.cx + x, ISO.cy - y2]; 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) { for (let d = -80; d <= 80; d += 20) {
const lat = (d * Math.PI) / 180; const lat = (d * Math.PI) / 180;
const pts: [number, number][] = []; 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; const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
for (let i = 0; i <= n; i++) { for (let i = 0; i <= n; i++) {
const t = i * u; 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(t, L, 0, cy), iso(t, L, L, cy)]));
lines.push(poly([iso(0, L, t, cy), iso(L, L, t, 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(t, 0, 0, cy), iso(t, L, 0, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(L, t, 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, 0, t, cy), iso(0, L, t, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(0, t, L, 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)); 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"> <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"> <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 } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); 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'}> <section class="section vcards" aria-label={lang === 'de' ? 'Eigenschaften' : 'Properties'}>
<div class="container"> <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"> <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 fill="none" stroke-width="1" vector-effect="non-scaling-stroke">
<g class="vhero__in"> <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> </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" /> <circle class="vhero__ring" cx={F[0]} cy={F[1]} r="40" stroke-dasharray="2 5" stroke-width="1.25" />
</g> </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> </svg>
<div class="vhero__text"> <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__in { stroke: var(--ink-muted); opacity: 0.55; }
.vhero__out, .vhero__ring { stroke: currentColor; } .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 { .vhero__text {
display: grid; display: grid;
gap: var(--space-5); 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] })); 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="section night how" id="how" aria-labelledby="how-title">
<div class="container how__grid" data-how> <div class="container how__deck">
<div class="how__text"> <div class="how__track">
<h2 id="how-title" class="h2">{v.title}</h2> <div class="how__bar">
<div class="how__tabs" role="tablist" aria-orientation="vertical" aria-labelledby="how-title"> <h2 id="how-title" class="h2 how__title">{v.title}</h2>
{groups.map((g, i) => ( <ol class="how__dots" role="list" aria-label={v.position} data-deck-dots>
<div class="how__tab"> {groups.map((g, i) => (
<h3 class="how__heading"> <li>
<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> <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> </li>
<div class="how__panel" role="tabpanel" id={`${g.id}-panel`} aria-labelledby={`${g.id}-tab`} tabindex="0"> ))}
<div class="how__panel-inner"> </ol>
<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>
</div>
))}
</div> </div>
</div> </div>
<div class="how__stage"> <div class="how__anchors" aria-hidden="true">
{groups.map((g, i) => ( {groups.map((_, i) => <span class="how__snap" id={`how-snap-${i + 1}`} style={`--i: ${i}`} data-deck-snap></span>)}
<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> </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> </div>
</section> </section>
<style> <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; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--gap); column-gap: var(--gap);
row-gap: var(--space-8); row-gap: var(--space-4);
align-items: start;
} }
.how__text { .how__heading { grid-column: 1 / -1; }
grid-column: 1 / -1; .how__body { grid-column: 1 / -1; max-width: 34em; }
display: grid; .how__figure { margin-top: var(--space-7); }
gap: var(--space-8); /* The wide drawing needs the full container; below that width the tall spine takes over. */
} .how__wide { display: none; }
.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; } .how__tall { max-width: 26rem; }
/* The stage carries the wide drawings for wide screens; the panels carry the tall ones. */ @media (min-width: 48rem) {
.how__stage { display: none; } .how__heading { grid-column: 1 / span 5; }
.how__body { grid-column: 6 / span 7; }
: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; }
/* 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) {
/* Title left, the list right; the drawing takes the full width underneath. */ .how {
.how__text { --how-bar-h: 5rem;
grid-column: 1 / -1; --how-step: 0.75rem;
grid-template-columns: repeat(12, minmax(0, 1fr)); --how-hint-h: var(--space-6);
column-gap: var(--gap); /* The height under the pinned bar is one card pitch, so at a snap the next card starts
align-items: start; 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__text > h2 { grid-column: 1 / span 5; } .how__wide { display: block; }
.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__figure {
grid-area: 1 / 1;
align-self: center;
opacity: 0;
transition: opacity var(--dur-slow) var(--ease);
}
.how__figure[data-active] { 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 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) { @media (prefers-reduced-motion: reduce) {
.how__panel, .how__figure { transition: none; } .how__group::after { opacity: 0; }
.how__dot-mark { transition: none; }
} }
</style> </style>
<script> <script>
import { initTabs } from '../../scripts/tabs'; /* Two enhancements, both only on wide screens. Without them the deck still stacks and snaps.
const root = document.querySelector<HTMLElement>('[data-how]'); 1. Each card learns how far the next one has slid over it (--covered), so it can darken.
if (root) initTabs(root); 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> </script>
+121 -31
View File
@@ -3,8 +3,12 @@
* A spec-sheet schematic rendered from a layout: nodes (main with dotted ring, or small * 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 * satellites), edges (polylines, optional direction tick), and free text. One drawing per
* variant; the caller decides which variant shows where. * 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 } interface Props { layout: Layout; title: string; desc: string; variant: 'wide' | 'tall'; id: string }
const { layout, title, desc, variant, id } = Astro.props; const { layout, title, desc, variant, id } = Astro.props;
const stroke = 1.25; const stroke = 1.25;
@@ -12,47 +16,123 @@ 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; 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}`; 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 edgeTick = (pts: [number, number][]) => {
const [a, b] = [pts[pts.length - 2], pts[pts.length - 1]]; 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]); 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 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> <title id={`${id}-title`}>{title}</title>
<desc id={`${id}-desc`}>{desc}</desc> <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="schematic__edges" fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
<g class:list={{ 'schematic__faint': e.faint }}> {freeEdges.map((f) => (
<polyline points={e.points.map(([x, y]) => `${x},${y}`).join(' ')} stroke-dasharray={e.dashed ? '2 5' : undefined} /> <g class:list={{ 'schematic__faint': f.e.faint }}>
{e.tick && <polyline points={edgeTick(e.points)} />} <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>
))} ))}
{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>
<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' }} />)} {groups.map((g) => (
</g> <g class="schematic__group" data-group={g.key}>
<g class="schematic__labels" font-size={layout.fontMain ?? 17} font-weight="500"> <g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
{layout.nodes.filter((n) => n.label && n.dot !== 'sub').map((n) => ( {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`} />)}
<text x={n.x + (n.dx ?? 0)} y={n.y + (n.dy ?? 6)} text-anchor={n.anchor ?? 'start'}> {g.edges.map((f) => (
{lines(n.label!).map((l, i) => <tspan x={n.x + (n.dx ?? 0)} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)} <g class:list={{ 'schematic__faint': f.e.faint }}>
</text> <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> </g>
<g class="schematic__meta" font-size={layout.fontSub ?? 15}> ))}
{layout.nodes.filter((n) => n.label && n.dot === 'sub').map((n) => ( </g>
<text x={n.x + (n.dx ?? 12)} y={n.y + (n.dy ?? 5)} text-anchor={n.anchor ?? 'start'}> <g fill="currentColor">
{lines(n.label!).map((l, i) => <tspan x={n.x + (n.dx ?? 12)} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)} {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`} />)}
</text> </g>
))} <g class="schematic__labels" font-size={fontMain} font-weight="500">
{layout.texts?.map((t) => ( {g.mains.map((l) => (
<text x={t.x} y={t.y} text-anchor={t.anchor ?? 'start'} class:list={{ 'schematic__strong': t.strong }}> <text class="s-label" x={l.x} y={l.y} text-anchor={l.anchor} style={`--d: ${l.d}ms`}>
{lines(t.text).map((l, i) => <tspan x={t.x} dy={i === 0 ? 0 : '1.25em'}>{l}</tspan>)} {l.lines.map((s, i) => <tspan x={l.x} dy={i === 0 ? 0 : '1.25em'}>{s}</tspan>)}
</text> </text>
))} ))}
</g>
<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>
))}
</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> </g>
</svg> </svg>
@@ -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__strong { fill: var(--ink); font-weight: var(--weight-medium); }
.schematic__faint { stroke: var(--line); } .schematic__faint { stroke: var(--line); }
.schematic__dot--sub { fill: var(--ink-muted); } .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> </style>
+47 -45
View File
@@ -9,10 +9,10 @@ type Groups = HowGroups;
type Pt = [number, number]; type Pt = [number, number];
type Label = string | string[]; 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 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 } 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 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 Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number }
export interface Pair { wide: Layout; tall: Layout } 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 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. */ /* 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 lines = Array.isArray(label) ? label.length : 1;
const nodes: Node[] = [{ x, y, label, dot: 'main', ring, anchor: 'middle', dx: 0, dy: ring + 22 }]; 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' }] : []; const texts: Text[] = sub ? [{ x, y: y + ring + 22 + lines * LINE + 2, text: sub, anchor: 'middle', group }] : [];
return { nodes, texts }; return { nodes, texts };
} }
/* A plain dot with its label centred below. */ /* A plain dot with its label centred below. */
function dot(x: number, y: number, label: Label): Node { function dot(x: number, y: number, label: Label, group?: string): Node {
return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28 }; return { x, y, label, dot: 'main', anchor: 'middle', dx: 0, dy: 28, group };
} }
/* Satellites fanning from a node to a column of dots. */ /* 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[] } { 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 })); 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 })); const edges: Edge[] = ys.map((y) => ({ points: [from, [xs, y]], faint: true, group }));
return { nodes, edges }; return { nodes, edges };
} }
const chain = (pts: Pt[], tick = true): Edge[] => pts.slice(1).map((p, i) => ({ points: [pts[i], p], tick })); 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 { export function overview(g: Groups[0]): Pair {
const L = g.labels; const L = g.labels;
const y1 = 110, y2 = 330, ym = 220, fork = 190, join = 1050; const y1 = 90, y2 = 300, ym = 195, fork = 190, join = 1050;
const sbom = main(340, y1, L.sbom, L.sbomSub), dt = main(640, y1, L.dt, L.dtSub); 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), poly = main(640, y2, L.poly, L.polySub), prism = main(900, y2, L.prism, L.prismSub); 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 = { const wide: Layout = {
w: 1200, h: 470, w: 1200, h: 430,
nodes: [dot(80, ym, L.repo), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report)], nodes: [dot(80, ym, L.repo, 'repo'), ...sbom.nodes, ...dt.nodes, ...sast.nodes, ...poly.nodes, ...prism.nodes, dot(1120, ym, L.report, 'report')],
edges: [ edges: [
{ points: [[80, ym], [fork, ym], [fork, y1], [340, y1]], tick: true }, { points: [[80, ym], [fork, ym], [fork, y1], [340, y1]], tick: true },
{ points: [[fork, ym], [fork, y2], [340, y2]], 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 { export function scanners(g: Groups[1]): Pair {
const L = g.labels; const L = g.labels;
const focal: Pt = [500, 280]; const cy = 232;
const ys = g.tools.map((_, i) => 60 + i * 27.5); const focal: Pt = [500, cy];
const tools = fan(focal, 210, ys, g.tools, 'end'); const ys = g.tools.map((_, i) => 40 + i * 24);
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 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 = { const wide: Layout = {
w: 1200, h: 560, w: 1200, h: 450,
nodes: [...tools.nodes, ...one.nodes, ...cwe.nodes, ...merge.nodes, ...resolve.nodes], 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], texts: [...one.texts, ...cwe.texts, ...merge.texts, ...resolve.texts],
}; };
const tall = spine([ const tall = spine([
@@ -113,14 +114,14 @@ export function scanners(g: Groups[1]): Pair {
export function verdict(g: Groups[2]): Pair { export function verdict(g: Groups[2]): Pair {
const L = g.labels; const L = g.labels;
const y = 300, top = 120; const y = 260, top = 80;
const ctx = fan([380, y], 230, [100, 140, 180, 220], L.ctx, 'end'); const ctx = fan([380, y], 230, [60, 100, 140, 180], L.ctx, 'end', 'context');
const tools = fan([920, y], 1040, [150, 190, 230, 270], L.tools, 'start'); const tools = fan([920, y], 1040, [110, 150, 190, 230], L.tools, 'start', 'escalation');
const outcomes = fan([1220, y], 1330, [240, 280, 320], L.outcomes, 'start'); const outcomes = fan([1220, y], 1330, [200, 240, 280], L.outcomes, 'start', 'verdict');
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 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 = { const wide: Layout = {
w: 1460, h: 420, w: 1460, h: 380,
nodes: [dot(140, y, L.finding), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes], nodes: [dot(140, y, L.finding, 'finding'), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
edges: [ edges: [
...ctx.edges, ...tools.edges, ...outcomes.edges, ...ctx.edges, ...tools.edges, ...outcomes.edges,
...chain([[140, y], [380, y], [640, y], [920, y], [1220, y]]), ...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 { export function dependencies(g: Groups[3]): Pair {
const L = g.labels; const L = g.labels;
const y = 220, ya = 130, yb = 310, mergeX = 300; const y = 170, ya = 80, yb = 260, 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 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 = { const wide: Layout = {
w: 1200, h: 400, w: 1200, h: 320,
nodes: [ nodes: [
{ x: 150, y: ya, label: L.syft, dot: 'main', anchor: 'end', dx: -16, dy: 6 }, { 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 }, { 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), ...sbom.nodes, ...dt.nodes, ...vdr.nodes, dot(1130, y, L.report, 'report'),
], ],
edges: [ edges: [
{ points: [[150, ya], [mergeX, ya], [mergeX, y]] }, { 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 { export function operate(g: Groups[4]): Pair {
const L = g.labels; const L = g.labels;
const hub: Pt = [560, 220]; const hub: Pt = [560, 200];
const ys = [100, 160, 220, 280, 340]; const ys = [100, 150, 200, 250, 300];
const sources = fan(hub, 200, ys, L.sources, 'end'); const sources = fan(hub, 200, ys, L.sources, 'end', 'sources');
const outputs = fan(hub, 920, ys, L.outputs, 'start'); const outputs = fan(hub, 920, ys, L.outputs, 'start', 'outputs');
const sy = 410;
const wide: Layout = { const wide: Layout = {
w: 1200, h: 540, w: 1200, h: 470,
nodes: [ 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, ...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: [ edges: [
...sources.edges, ...outputs.edges, ...sources.edges, ...outputs.edges,
{ points: [[560, 264], [560, 460]], faint: true }, { points: [[560, 244], [560, sy]], faint: true, group: 'scopes' },
...chain([[420, 460], [560, 460], [700, 460]]), ...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([ const tall = spine([
{ title: L.hub, items: L.sources }, { 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.', 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.',
@@ -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.', 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.',