diff --git a/README.md b/README.md index 8416265..82ff926 100644 --- a/README.md +++ b/README.md @@ -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 '[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. diff --git a/scripts/dots-test.mjs b/scripts/dots-test.mjs new file mode 100644 index 0000000..d84b9d1 --- /dev/null +++ b/scripts/dots-test.mjs @@ -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(); diff --git a/scripts/hint-probe.mjs b/scripts/hint-probe.mjs new file mode 100644 index 0000000..558e0a5 --- /dev/null +++ b/scripts/hint-probe.mjs @@ -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(); diff --git a/scripts/hover-shot.mjs b/scripts/hover-shot.mjs new file mode 100644 index 0000000..c73073d --- /dev/null +++ b/scripts/hover-shot.mjs @@ -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); diff --git a/scripts/motion-shots.mjs b/scripts/motion-shots.mjs new file mode 100644 index 0000000..74268e5 --- /dev/null +++ b/scripts/motion-shots.mjs @@ -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); diff --git a/scripts/snap-test.mjs b/scripts/snap-test.mjs new file mode 100644 index 0000000..6cf957b --- /dev/null +++ b/scripts/snap-test.mjs @@ -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(); diff --git a/scripts/stack-shots-b.mjs b/scripts/stack-shots-b.mjs new file mode 100644 index 0000000..03bf418 --- /dev/null +++ b/scripts/stack-shots-b.mjs @@ -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); diff --git a/scripts/stack-shots-c.mjs b/scripts/stack-shots-c.mjs new file mode 100644 index 0000000..d55abb0 --- /dev/null +++ b/scripts/stack-shots-c.mjs @@ -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); diff --git a/scripts/stack-shots.mjs b/scripts/stack-shots.mjs new file mode 100644 index 0000000..b24b67e --- /dev/null +++ b/scripts/stack-shots.mjs @@ -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); diff --git a/src/components/home/GridObject.astro b/src/components/home/GridObject.astro index c9c6d7b..744d018 100644 --- a/src/components/home/GridObject.astro +++ b/src/components/home/GridObject.astro @@ -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; +} ---
diff --git a/src/components/verifier/Hero.astro b/src/components/verifier/Hero.astro index 2c643f4..4909c10 100644 --- a/src/components/verifier/Hero.astro +++ b/src/components/verifier/Hero.astro @@ -19,12 +19,12 @@ const ins = Array.from({ length: n }, (_, i) => { - {ins.map((p) => )} + {ins.map((p, i) => )} - + - +
@@ -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); diff --git a/src/components/verifier/HowItWorks.astro b/src/components/verifier/HowItWorks.astro index 924d081..5f2ab90 100644 --- a/src/components/verifier/HowItWorks.astro +++ b/src/components/verifier/HowItWorks.astro @@ -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] })); ---
-
-
-

{v.title}

-
- {groups.map((g, i) => ( -
-

- -

-
-
-

{g.body}

-
- -
-
-
-
- ))} +
+
+
+

{v.title}

+
    + {groups.map((g, i) => ( +
  1. + +
  2. + ))} +
-
- {groups.map((g, i) => ( -
- -
- ))} + + +
    + {groups.map((g, i) => ( +
  • +
    +

    {g.title}

    +

    {g.body}

    +
    +
    +
    + +
    +
    + +
    +
    +
  • + ))} +
+ + + +
diff --git a/src/components/verifier/Schematic.astro b/src/components/verifier/Schematic.astro index 0e6c3f4..cfa8897 100644 --- a/src/components/verifier/Schematic.astro +++ b/src/components/verifier/Schematic.astro @@ -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,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; 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; --- - + {title} {desc} - - {layout.edges.map((e) => ( - - `${x},${y}`).join(' ')} stroke-dasharray={e.dashed ? '2 5' : undefined} /> - {e.tick && } + + + {freeEdges.map((f) => ( + + + {f.tick && } ))} - {layout.nodes.filter((n) => n.ring).map((n) => )} - {layout.boxes?.map((b) => )} - - {layout.nodes.map((n) => n.dot === 'none' ? null : )} - - - {layout.nodes.filter((n) => n.label && n.dot !== 'sub').map((n) => ( - - {lines(n.label!).map((l, i) => {l})} - - ))} - - - {layout.nodes.filter((n) => n.label && n.dot === 'sub').map((n) => ( - - {lines(n.label!).map((l, i) => {l})} - - ))} - {layout.texts?.map((t) => ( - - {lines(t.text).map((l, i) => {l})} - - ))} + + {groups.map((g) => ( + + + {g.rings.map((r) => )} + {g.edges.map((f) => ( + + + {f.tick && } + + ))} + + + {g.dots.map((d) => )} + + + {g.mains.map((l) => ( + + {l.lines.map((s, i) => {s})} + + ))} + + + {[...g.subs, ...g.texts].map((l) => ( + + {l.lines.map((s, i) => {s})} + + ))} + + + ))} + + + + {free.rings.map((r) => )} + + + {free.dots.map((d) => )} + + + {free.mains.map((l) => ( + + {l.lines.map((s, i) => {s})} + + ))} + + + {[...free.subs, ...free.texts].map((l) => ( + + {l.lines.map((s, i) => {s})} + + ))} + @@ -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; } + } diff --git a/src/components/verifier/diagrams.ts b/src/components/verifier/diagrams.ts index 1222bc6..995c9fd 100644 --- a/src/components/verifier/diagrams.ts +++ b/src/components/verifier/diagrams.ts @@ -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 }, diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 1eba4d3..95f0679 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -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.',