From 3ed0382066ffbe8f61d3fe2296e915a837adef48 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 22:56:07 +0800 Subject: [PATCH 1/7] feat(viewer): let an outside clock drive the flow layer --- packages/viewer/src/canvas/FlowLayer.test.tsx | 43 ++++++++++++++ packages/viewer/src/canvas/FlowLayer.tsx | 39 ++++++++----- packages/viewer/src/motion/flowOf.ts | 53 ++++++++++++++++++ packages/viewer/src/motion/useFlow.ts | 56 ++----------------- 4 files changed, 126 insertions(+), 65 deletions(-) create mode 100644 packages/viewer/src/canvas/FlowLayer.test.tsx create mode 100644 packages/viewer/src/motion/flowOf.ts diff --git a/packages/viewer/src/canvas/FlowLayer.test.tsx b/packages/viewer/src/canvas/FlowLayer.test.tsx new file mode 100644 index 0000000..efab184 --- /dev/null +++ b/packages/viewer/src/canvas/FlowLayer.test.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { Flow } from '../motion/flow'; +import { FlowLayer, type FlowClock } from './FlowLayer'; + +const flow: Flow = { + period: 2000, + pulses: [ + { + id: 'ab', + from: 'a', + to: 'b', + kind: 'sync', + tint: 'service', + path: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + length: 100, + delay: 0, + travel: 500, + glow: null, + }, + ], +}; + +describe('FlowLayer', () => { + it('draws the frame an outside clock asks for, and nothing when it asks for none', () => { + let draw: (ms: number | null) => void = () => {}; + const clock: FlowClock = (d) => { + draw = d; + return () => {}; + }; + const { container } = render(); + const pulse = container.querySelector('[data-pulse="ab"]')!; + const head = container.querySelector('[data-part="head"]')!; + draw(250); + expect(pulse.style.visibility).toBe(''); + expect(head.getAttribute('transform')).toMatch(/^translate\(75 0\)$/); + draw(null); + expect(pulse.style.visibility).toBe('hidden'); + }); +}); diff --git a/packages/viewer/src/canvas/FlowLayer.tsx b/packages/viewer/src/canvas/FlowLayer.tsx index d9f8c23..24a409a 100644 --- a/packages/viewer/src/canvas/FlowLayer.tsx +++ b/packages/viewer/src/canvas/FlowLayer.tsx @@ -6,14 +6,29 @@ const accent = (p: FlowPulse) => `var(--sm-${p.tint}-accent)`; const polyline = (p: FlowPulse) => `M${p.path.map((q) => `${q.x} ${q.y}`).join('L')}`; const PACKETS = [0, 1, 2]; +/** Drives the layer: calls `draw` with ms into playback, or null to hide every pulse; returns a stop. */ +export type FlowClock = (draw: (ms: number | null) => void) => () => void; + +const playing: FlowClock = (draw) => { + let began: number | null = null; + let frame = 0; + const tick = (now: number) => { + began ??= now; + draw(now - began); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); +}; + /** * Flow playback on the canvas, drawing `pulseFrame` for every pulse: a landing glow behind the target, a * tapering trail lighting the wire, the port flash, and the head (a dot; a train of packets on an async edge; * a hollow ring on a reply). Sits above the edges and under the cards, so glows bloom out from behind a card * and a pulse slides out of one card into the next. One rAF loop writes attributes; React renders only when - * the flow changes. Exports skip it (`data-flow`). + * the flow changes; a `clock` (a scroll scrub) can drive it instead. Exports skip it (`data-flow`). */ -export function FlowLayer({ flow, width, height }: { flow: Flow; width: number; height: number }) { +export function FlowLayer({ flow, width, height, clock }: { flow: Flow; width: number; height: number; clock?: FlowClock }) { const root = useRef(null); useEffect(() => { const groups = [...(root.current?.children ?? [])] as SVGGElement[]; @@ -31,13 +46,12 @@ export function FlowLayer({ flow, width, height }: { flow: Flow; width: number; if (el.style.visibility !== v) el.style.visibility = v; return on; }; - let began: number | null = null; - let frame = 0; - const tick = (now: number) => { - began ??= now; + // Only the viewer's own playback says which edges are in flight; a borrowed clock is someone else's. + const tracked = !clock; + const draw = (ms: number | null) => { const flying: string[] = []; flow.pulses.forEach((p, i) => { - const f = pulseFrame(flow, p, now - began!); + const f = ms === null ? null : pulseFrame(flow, p, ms); const el = parts[i]!; if (!show(groups[i]!, !!f) || !f) return; if (f.head !== null) flying.push(p.id); @@ -74,15 +88,14 @@ export function FlowLayer({ flow, width, height }: { flow: Flow; width: number; h.setAttribute('transform', `translate(${at.x} ${at.y})`); }); }); - setInFlight(flying); - frame = requestAnimationFrame(tick); + if (tracked) setInFlight(flying); }; - frame = requestAnimationFrame(tick); + const stop = (clock ?? playing)(draw); return () => { - cancelAnimationFrame(frame); - setInFlight([]); + stop(); + if (tracked) setInFlight([]); }; - }, [flow]); + }, [flow, clock]); return (
{ if (e.key !== 'Escape' || !lensOpen) return; diff --git a/packages/viewer/src/chrome/ViewerShell.tsx b/packages/viewer/src/chrome/ViewerShell.tsx index 7a01ab9..a198e65 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -140,10 +140,10 @@ export function ViewerShell({ }, [reloaded]); return ( -
+
{!presenting && ( -
-
+
+
{draft.title} )} {!presenting && } -
+
- - + + {/* Narrow, the header already names the diagram: the toolbar gets the room. */} +
+ +
present(true)} />
From 2dd8091ab23ebfacfcf2156bd55a8cb6f0338488 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 22:56:07 +0800 Subject: [PATCH 3/7] docs(site): play each kind's flow through its hold on the landing --- site/components/landing/KindFlow.tsx | 27 +++++++++++++++++ site/components/landing/KindsStage.tsx | 7 +++-- site/components/landing/kinds-geometry.ts | 37 +++++++++++++++++++++++ site/components/landing/mobile/Kinds.tsx | 4 ++- site/components/landing/site.css | 6 ++++ site/tests/kinds-geometry.test.ts | 33 +++++++++++++++++++- 6 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 site/components/landing/KindFlow.tsx diff --git a/site/components/landing/KindFlow.tsx b/site/components/landing/KindFlow.tsx new file mode 100644 index 0000000..f1b8b8d --- /dev/null +++ b/site/components/landing/KindFlow.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useCallback, useRef } from 'react'; +import { FlowLayer, type FlowClock } from '@stackmap/viewer/src/canvas/FlowLayer'; +import { holdTime, type KindFlow as Data } from './kinds-geometry'; +import { s } from './style'; + +/** Kind k's pulses over its layer, playing one loop through its hold as the scene scrubs; hidden at rest. */ +export function KindFlow({ data, k }: { data: Data; k: number }) { + const box = useRef(null); + const { flow, origin, width, height } = data; + const clock = useCallback( + (draw) => { + const scene = box.current?.closest('[data-scene]'); + if (!scene) return () => {}; + const onScene = (e: Event) => draw(holdTime((e as CustomEvent).detail, k, flow.period)); + scene.addEventListener('scene', onScene); + return () => scene.removeEventListener('scene', onScene); + }, + [k, flow.period], + ); + return ( +
+ +
+ ); +} diff --git a/site/components/landing/KindsStage.tsx b/site/components/landing/KindsStage.tsx index 0b37dd5..979db59 100644 --- a/site/components/landing/KindsStage.tsx +++ b/site/components/landing/KindsStage.tsx @@ -3,14 +3,16 @@ import { NodeCard } from '@stackmap/viewer/src/card/NodeCard'; import { StepCard } from '@stackmap/viewer/src/card/StepCard'; import { Still } from '@/components/diagram/Still'; import { stillOf } from '@/lib/diagrams'; -import { kindsGeometry, kindsTweens, type Area } from './kinds-geometry'; +import { KindFlow } from './KindFlow'; +import { kindsGeometry, kindsTweens, restingFlow, type Area } from './kinds-geometry'; import { s } from './style'; const px = (n: number) => `${Math.round(n * 10) / 10}px`; /** * One checkout, five ways: each kind's layer (its lanes, phases and connections) plus the card slots that - * glide between kinds. At rest the scene's data-kind picks what shows; scrolling plays the tweens it carries. + * glide between kinds. At rest the scene's data-kind picks what shows; scrolling plays the tweens it carries, + * and each kind's flow in its hold. */ export function KindsStage({ checkout, area, root }: { checkout: Record; area: Area; root: string }) { const g = kindsGeometry(checkout, area); @@ -35,6 +37,7 @@ export function KindsStage({ checkout, area, root }: { checkout: Record (
+
))} {g.slots.map((faces, i) => ( diff --git a/site/components/landing/kinds-geometry.ts b/site/components/landing/kinds-geometry.ts index ae3d0c6..df26a58 100644 --- a/site/components/landing/kinds-geometry.ts +++ b/site/components/landing/kinds-geometry.ts @@ -1,5 +1,9 @@ import { DIAGRAM_KINDS, type DiagramKind, type LaidOutDiagram, type Rect } from '@stackmap/core'; import { toScene, type SceneCard } from '@stackmap/viewer/src/canvas/scene'; +import { buildGraph } from '@stackmap/viewer/src/explore/graph'; +import { INITIAL } from '@stackmap/viewer/src/explore/state'; +import type { Flow } from '@stackmap/viewer/src/motion/flow'; +import { flowOf } from '@stackmap/viewer/src/motion/flowOf'; import { SLOT_COUNT, SLOTS } from '@/lib/checkout'; import type { Tween } from './timeline'; @@ -133,3 +137,36 @@ export function kindsTweens(g: KindsGeometry): Tween[] { out.push({ target: '.kcam', frames: holds((k) => ({ scale: String(Math.round(g.layers[k]!.cam * 1000) / 1000) })) }); return out; } + +export interface KindFlow { + flow: Flow; + /** the drawn content's corner in diagram px, which the layer's still is cut to */ + origin: { x: number; y: number }; + width: number; + height: number; +} + +/** + * The flow the viewer plays on a diagram left alone. `glows: false` lands each pulse on a ripple: where the + * layer draws its own cards, a glow would sit over them rather than behind. + */ +export function restingFlow(diagram: LaidOutDiagram, { glows = true } = {}): KindFlow { + const scene = toScene(diagram); + const { draft } = diagram; + const graph = buildGraph(draft.nodes.map((n) => n.id), draft.edges, { timed: draft.kind === 'sequence' }); + const flow = flowOf(scene, graph, INITIAL, { active: false, nodes: new Map(), edges: new Map() }); + return { + flow: glows ? flow : { ...flow, pulses: flow.pulses.map((p) => ({ ...p, glow: null })) }, + origin: { x: scene.content.x, y: scene.content.y }, + width: scene.bounds.width, + height: scene.bounds.height, + }; +} + +/** "Each hold scrubs one loop of that kind's flow": ms into kind k's loop at scene progress p, or null outside its hold. */ +export function holdTime(p: number, k: number, period: number): number | null { + const [a, b] = HOLDS[k]!; + const pct = p * 100; + if (p <= 0 || pct < a || pct >= b) return null; + return ((pct - a) / (b - a)) * period; +} diff --git a/site/components/landing/mobile/Kinds.tsx b/site/components/landing/mobile/Kinds.tsx index 481e2ba..4060b54 100644 --- a/site/components/landing/mobile/Kinds.tsx +++ b/site/components/landing/mobile/Kinds.tsx @@ -3,7 +3,8 @@ import { toScene } from '@stackmap/viewer/src/canvas/scene'; import { Still } from '@/components/diagram/Still'; import { stillOf } from '@/lib/diagrams'; import { KindCount } from '../KindCount'; -import { crossfadeTweens } from '../kinds-geometry'; +import { KindFlow } from '../KindFlow'; +import { crossfadeTweens, restingFlow } from '../kinds-geometry'; import { s } from '../style'; const REST = DIAGRAM_KINDS.indexOf('workflow'); @@ -73,6 +74,7 @@ export function Kinds({ checkout }: { checkout: Record } {layers.map((l, k) => (
+
))}
diff --git a/site/components/landing/site.css b/site/components/landing/site.css index d02bfce..b6db2b9 100644 --- a/site/components/landing/site.css +++ b/site/components/landing/site.css @@ -98,6 +98,12 @@ @media (prefers-reduced-motion: reduce) { .lp .kslot, .lp .klayer, +.lp .kflow { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} .lp .kface, .lp .kcam, .lp .kn-col, diff --git a/site/tests/kinds-geometry.test.ts b/site/tests/kinds-geometry.test.ts index 3250447..fde4210 100644 --- a/site/tests/kinds-geometry.test.ts +++ b/site/tests/kinds-geometry.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { DIAGRAM_KINDS, type LaidOutDiagram } from '@stackmap/core'; -import { kindsGeometry, kindsTweens, type KindsGeometry } from '../components/landing/kinds-geometry'; +import { HOLDS, holdTime, kindsGeometry, kindsTweens, restingFlow, type KindsGeometry } from '../components/landing/kinds-geometry'; import { layoutSite } from '../lib/data/diagrams'; const AREA = { cx: 720, cy: 640, width: 1248, height: 522 }; @@ -58,3 +58,34 @@ describe('kinds geometry', () => { expect(slot0.frames['16%']!.left).toBe(slot0.frames['0%']!.left); }); }); + +describe('kind flows', () => { + it('gives every kind a flow along its own drawn connections', () => { + for (const kind of DIAGRAM_KINDS) { + const { flow, width, height } = restingFlow(checkout[kind]!); + expect(flow.pulses.length).toBeGreaterThan(0); + expect(width).toBeGreaterThan(0); + expect(height).toBeGreaterThan(0); + } + }); + + it('lands on a ripple instead of a glow when asked to', () => { + const { flow } = restingFlow(checkout.workflow!, { glows: false }); + expect(flow.pulses.every((p) => p.glow === null)).toBe(true); + }); + + it('scrubs one loop of a kind\'s flow through its hold, and none outside it', () => { + const [a, b] = HOLDS[1]!; + expect(holdTime(a / 100, 1, 3000)).toBe(0); + expect(holdTime((a + b) / 200, 1, 3000)).toBeCloseTo(1500, 5); + // The loop's end is its start again: the hold's last instant already belongs outside it. + expect(holdTime(b / 100, 1, 3000)).toBeNull(); + expect(holdTime((b - 0.13) / 100, 1, 3000)).toBeCloseTo(2970, 5); + expect(holdTime((a - 1) / 100, 1, 3000)).toBeNull(); + expect(holdTime((b + 1) / 100, 1, 3000)).toBeNull(); + }); + + it('shows nothing before the scene starts scrubbing', () => { + expect(holdTime(0, 0, 3000)).toBeNull(); + }); +}); From ecec71a5f11f510279255dfebc1777f8485bd8b5 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 22:56:13 +0800 Subject: [PATCH 4/7] docs(site): add a menu to the phone nav --- site/components/site/Nav.tsx | 59 ++++++++++++++++++++++++++++++++++-- site/components/ui/icons.tsx | 8 +++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/site/components/site/Nav.tsx b/site/components/site/Nav.tsx index a0049a7..07a285d 100644 --- a/site/components/site/Nav.tsx +++ b/site/components/site/Nav.tsx @@ -2,8 +2,9 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; import type { ThemeName } from '@stackmap/core'; -import { GitHubIcon } from '@/components/ui/icons'; +import { GitHubIcon, MenuIcon } from '@/components/ui/icons'; import { LINKS } from './links'; import { Lockup } from './Lockup'; @@ -16,8 +17,27 @@ export function Nav() { const path = usePathname(); // The landing opens on a dark scene; its scroll driver retunes the nav per scene from there. const theme: ThemeName = path === '/' ? 'dark' : 'light'; + const [open, setOpen] = useState(false); + const header = useRef(null); + const toggle = useRef(null); + useEffect(() => setOpen(false), [path]); + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + setOpen(false); + toggle.current?.focus(); + }; + const onDown = (e: PointerEvent) => header.current?.contains(e.target as Node) || setOpen(false); + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onDown); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onDown); + }; + }, [open]); return ( -
+
@@ -49,6 +69,41 @@ export function Nav() { > {LINKS.install.label} + + {/* Links to a section of the page it's on don't change the path, so a click closes the menu too. */} +
); } diff --git a/site/components/ui/icons.tsx b/site/components/ui/icons.tsx index 9a358a6..9722b77 100644 --- a/site/components/ui/icons.tsx +++ b/site/components/ui/icons.tsx @@ -21,3 +21,11 @@ export function ArrowIcon({ back = false }: { back?: boolean }) { ); } + +export function MenuIcon({ open }: { open: boolean }) { + return ( + + ); +} From e33c042c9bb5e346862dc8eb7bed053bdc97d8e6 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 22:56:13 +0800 Subject: [PATCH 5/7] docs(site): link the README's film from the hero --- site/components/landing/desktop/Hero.tsx | 7 +++++++ site/components/landing/mobile/Hero.tsx | 7 +++++++ site/components/site/links.ts | 2 ++ 3 files changed, 16 insertions(+) diff --git a/site/components/landing/desktop/Hero.tsx b/site/components/landing/desktop/Hero.tsx index 975c49a..4f6dffd 100644 --- a/site/components/landing/desktop/Hero.tsx +++ b/site/components/landing/desktop/Hero.tsx @@ -1,6 +1,7 @@ import { SITE } from '@/lib/site-data'; import { BoardCopy } from '../BoardCopy'; import { s } from '../style'; +import { LINKS } from '@/components/site/links'; export function Hero() { return ( @@ -156,6 +157,12 @@ export function Hero() { {SITE.install.skill}
+ + + {LINKS.film.label} +
+ + + {LINKS.film.label} +
diff --git a/site/components/landing/mobile.css b/site/components/landing/mobile.css index 5018cfb..ec85355 100644 --- a/site/components/landing/mobile.css +++ b/site/components/landing/mobile.css @@ -195,6 +195,8 @@ .vx {position:absolute;left:195px;top:420px;width:0;height:0} .vring {position:absolute;left:0;top:0} .vchip {position:absolute;left:0;top:0;white-space:nowrap;padding:6px 10px;border-radius:8px;background:var(--sm-panel);box-shadow:inset 0 0 0 1px var(--sm-panel-border);font-family:var(--font-mono);line-height:1.5;color:var(--sm-text)} + /* Decorative texture: the names are drawn, not text, so they carry no contrast duty. */ + .vchip[data-label]::before {content:attr(data-label)} .kline-wrap {position:absolute;inset:0;transform-origin:195px 420px} .kl {position:absolute;left:24px;margin:0;font-size:44px;line-height:1;font-weight:600;letter-spacing:-0.045em;color:var(--sm-text)} .kl-l {display:block;white-space:nowrap} diff --git a/site/components/landing/mobile/Unmapped.tsx b/site/components/landing/mobile/Unmapped.tsx index d853d81..ac1c092 100644 --- a/site/components/landing/mobile/Unmapped.tsx +++ b/site/components/landing/mobile/Unmapped.tsx @@ -21,55 +21,25 @@ export function Unmapped() {
diff --git a/site/components/landing/site.css b/site/components/landing/site.css index b6db2b9..8e797aa 100644 --- a/site/components/landing/site.css +++ b/site/components/landing/site.css @@ -21,12 +21,15 @@ .lp *::after { box-sizing: border-box; } -:where(.lp) button { - font: inherit; - color: inherit; -} -:where(.lp) a { - color: inherit; +/* Element resets sit in the base layer: unlayered, they'd beat the viewer's utility classes (its Export label). */ +@layer base { + :where(.lp) button { + font: inherit; + color: inherit; + } + :where(.lp) a { + color: inherit; + } } .lp-m { display: none; @@ -95,15 +98,15 @@ .lp .kface-in { transform-origin: 0 0; } -@media (prefers-reduced-motion: reduce) { - .lp .kslot, - .lp .klayer, .lp .kflow { position: absolute; top: 0; left: 0; pointer-events: none; } +@media (prefers-reduced-motion: reduce) { + .lp .kslot, + .lp .klayer, .lp .kface, .lp .kcam, .lp .kn-col, diff --git a/site/components/landing/timeline.ts b/site/components/landing/timeline.ts index d53db0c..1736fd2 100644 --- a/site/components/landing/timeline.ts +++ b/site/components/landing/timeline.ts @@ -30,7 +30,8 @@ const IDENTITY = { x: 0, y: 0, z: 0, scale: 1, rotation: 0, rotationX: 0, rotati /** * A CSS transform string as GSAP's own transform properties, which it animates on HTML and SVG alike * (it writes SVG transforms as attributes, where a CSS string doesn't parse). GSAP composes them as - * translate, rotate, scale, the order nearly every board transform is already written in. + * translate, rotate, scale, the order nearly every board transform is already written in; a translate + * the board writes after a rotateX moves in the tilted frame, so it's turned into the same move in world space. */ export function transformVars(css: string): gsap.TweenVars { if (css.trim() === 'none') return { ...IDENTITY }; @@ -40,7 +41,12 @@ export function transformVars(css: string): gsap.TweenVars { const n = (i: number) => parseFloat(v[i] ?? '0'); // A percentage moves by the element's own size: GSAP's xPercent and yPercent. const axis = (i: number, name: 'x' | 'y') => ((v[i] ?? '').endsWith('%') ? { [`${name}Percent`]: n(i) } : { [name]: n(i) }); - if (fn === 'translate') Object.assign(out, axis(0, 'x'), axis(1, 'y')); + const tilt = ((Number(out.rotationX) || 0) * Math.PI) / 180; + if (tilt && (fn === 'translateY' || fn === 'translateZ')) { + const [y, z] = fn === 'translateY' ? [n(0), 0] : [0, n(0)]; + out.y = (Number(out.y) || 0) + y * Math.cos(tilt) - z * Math.sin(tilt); + out.z = (Number(out.z) || 0) + y * Math.sin(tilt) + z * Math.cos(tilt); + } else if (fn === 'translate') Object.assign(out, axis(0, 'x'), axis(1, 'y')); else if (fn === 'translateX') Object.assign(out, axis(0, 'x')); else if (fn === 'translateY') Object.assign(out, axis(0, 'y')); else if (fn === 'translateZ') out.z = n(0); diff --git a/site/tests/timeline.test.ts b/site/tests/timeline.test.ts index 8fda7f7..9eb7698 100644 --- a/site/tests/timeline.test.ts +++ b/site/tests/timeline.test.ts @@ -5,7 +5,6 @@ describe('board transforms as GSAP properties', () => { it('reads the transform functions the boards use', () => { expect(transformVars('translateY(-90px)')).toEqual({ y: -90 }); expect(transformVars('translate(-50%, 12px) scale(0.92)')).toEqual({ xPercent: -50, y: 12, scale: 0.92 }); - expect(transformVars('rotateX(58deg) translateY(-20px) scale(0.92)')).toEqual({ rotationX: 58, y: -20, scale: 0.92 }); expect(transformVars('translateZ(560px)')).toEqual({ z: 560 }); expect(transformVars('scale(1, 0.5)')).toEqual({ scaleX: 1, scaleY: 0.5 }); }); @@ -13,4 +12,13 @@ describe('board transforms as GSAP properties', () => { it('turns none into the identity, so a frame can return to rest', () => { expect(transformVars('none')).toEqual({ x: 0, y: 0, z: 0, scale: 1, rotation: 0, rotationX: 0, rotationY: 0 }); }); + + it('keeps a translate written after a tilt inside the tilted frame', () => { + // GSAP translates before it rotates, so the board's order becomes the same move in world space. + const v = transformVars('rotateX(58deg) translateY(-20px) scale(0.92)'); + expect(v.rotationX).toBe(58); + expect(v.y as number).toBeCloseTo(-20 * Math.cos((58 * Math.PI) / 180), 5); + expect(v.z as number).toBeCloseTo(-20 * Math.sin((58 * Math.PI) / 180), 5); + expect(v.scale).toBe(0.92); + }); }); From c743821bace88d4832677636d8c5566dab23a652 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 22:56:13 +0800 Subject: [PATCH 7/7] docs(site): smoke-test the phone menu, the kinds' flow and the viewer's colours --- site/e2e/smoke.spec.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/site/e2e/smoke.spec.ts b/site/e2e/smoke.spec.ts index 5933466..21cbc74 100644 --- a/site/e2e/smoke.spec.ts +++ b/site/e2e/smoke.spec.ts @@ -64,3 +64,53 @@ test('the docs copy button confirms', async ({ page, context }) => { await expect(page.getByRole('button', { name: 'Copied' })).toBeVisible(); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('npx skills add hyamero/stackmap'); }); + +test('on a phone the nav menu opens the site links and closes on Escape', async ({ browser }) => { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + await page.goto('/docs'); + const menu = page.getByRole('button', { name: 'Menu' }); + await expect(menu).toHaveAttribute('aria-expanded', 'false'); + await menu.click(); + await expect(menu).toHaveAttribute('aria-expanded', 'true'); + const links = page.getByRole('navigation', { name: 'Site menu' }); + await expect(links.getByRole('link', { name: 'Examples' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(menu).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).toBeFocused(); + await menu.click(); + await links.getByRole('link', { name: 'Examples' }).click(); + await expect(page).toHaveURL(/\/examples$/); + await expect(page.getByRole('button', { name: 'Menu' })).toHaveAttribute('aria-expanded', 'false'); + await page.close(); +}); + +test('each kind plays its flow while it holds', async ({ browser }) => { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'no-preference' }); + await page.goto('/'); + await expect(page.locator('.lp-d')).toHaveClass(/live/); + const visible = page.locator('.lp-d #kinds .kl0 [data-pulse]:not([style*="hidden"])'); + expect(await visible.count()).toBe(0); + // A little way into the first kind's hold, its opening pulses are on the wire. + await page.evaluate(() => { + const spacer = document.querySelector('.lp-d [data-scene="kinds"]')!.closest('.pin-spacer') as HTMLElement; + const top = spacer.getBoundingClientRect().top + scrollY; + scrollTo(0, top + (spacer.offsetHeight - innerHeight) * 0.02); + }); + await expect.poll(() => visible.count()).toBeGreaterThan(0); + await page.close(); +}); + +test('the landing viewer keeps its own button colours', async ({ browser }) => { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'no-preference' }); + await page.goto('/'); + await expect(page.locator('.lp-d')).toHaveClass(/live/); + await page.evaluate(() => { + const spacer = document.querySelector('.lp-d [data-scene="viewer"]')!.closest('.pin-spacer') as HTMLElement; + scrollTo(0, spacer.getBoundingClientRect().top + scrollY + (spacer.offsetHeight - innerHeight) * 0.9); + }); + const exportButton = page.locator('.lp-d .vreal button[aria-haspopup="menu"]'); + await expect(exportButton).toBeVisible(); + const [fg, bg] = await exportButton.evaluate((el) => [getComputedStyle(el).color, getComputedStyle(el).backgroundColor]); + expect(fg).not.toBe(bg); + await page.close(); +});