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 ( diff --git a/packages/viewer/src/chrome/Toolbar.tsx b/packages/viewer/src/chrome/Toolbar.tsx index ce65ce8..779d03e 100644 --- a/packages/viewer/src/chrome/Toolbar.tsx +++ b/packages/viewer/src/chrome/Toolbar.tsx @@ -76,7 +76,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic 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)} /> diff --git a/packages/viewer/src/motion/flowOf.ts b/packages/viewer/src/motion/flowOf.ts new file mode 100644 index 0000000..d432738 --- /dev/null +++ b/packages/viewer/src/motion/flowOf.ts @@ -0,0 +1,53 @@ +import { CARD, COMPACT } from '@stackmap/core'; +import { routeBetween, type Graph } from '../explore/graph'; +import type { Emphasis } from '../explore/emphasis'; +import type { ExploreState } from '../explore/state'; +import { EDGE_RADIUS, type Scene } from '../canvas/scene'; +import { hopFlow, hopRanks, NO_FLOW, roundedPolyline, waveFlow, type Flow, type FlowEdge } from './flow'; + +/** What a pulse needs from the scene: its drawn route, its tint, and what lights up when it lands. */ +function flowEdges(scene: Scene): Map { + const cards = new Map(scene.cards.map((c) => [c.node.id, c])); + const radius = scene.compact ? COMPACT.radius : CARD.radius; + return new Map( + scene.edges.map((e) => { + const end = e.points.at(-1); + // A sequence lands on the activation bar the message opens or feeds (the innermost), else on the lifeline. + const bar = + scene.kind === 'sequence' && end + ? scene.activations.filter((a) => a.node === e.to && end.y >= a.rect.y - 2 && end.y <= a.rect.y + a.rect.height + 2).sort((a, b) => b.depth - a.depth)[0] + : undefined; + const target = cards.get(e.to); + const glow = scene.kind === 'sequence' ? (bar ? { rect: bar.rect, radius: 2, tint: bar.type } : null) : target ? { rect: target.rect, radius, tint: target.node.type } : null; + const tint = e.tone === 'error' ? 'failure' : (cards.get(e.from)?.node.type ?? 'external'); + return [e.id, { id: e.id, from: e.from, to: e.to, kind: e.kind, tint, path: roundedPolyline(e.points, EDGE_RADIUS), glow }]; + }), + ); +} + +/** + * The flow that plays is whatever the explorer is showing: a route runs hop by hop from its start, a selection + * (traced or not) runs through its lit connections, and otherwise everything not dimmed (by a view, the lens or + * a search) plays: a wave in reading order, or a sequence's messages replayed one after another. + */ +export function flowOf(scene: Scene, graph: Graph, state: Pick, em: Emphasis): Flow { + const edges = flowEdges(scene); + const pick = (ids: string[]) => ids.flatMap((id) => edges.get(id) ?? []); + const lit = scene.edges.filter((e) => em.edges.get(e.id)?.tint).map((e) => e.id); + const serial = graph.timed; + if (state.route) { + const route = routeBetween(graph, state.route.from, state.route.to); + if (!route) return NO_FLOW; + // A reversed route's `from` is already the end it really runs from. + return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { start: route.from, fallback: route.from }), { serial }); + } + if (state.selected) return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { fallback: state.selected }), { serial }); + const shown = scene.edges.filter((e) => !em.edges.get(e.id)?.dim).map((e) => e.id); + if (serial) return hopFlow(pick(shown), hopRanks(graph, new Set(shown), { fallback: '' }), { serial }); + const rect = new Map(scene.cards.map((c) => [c.node.id, c.rect])); + const at = (id: string) => { + const r = rect.get(id); + return r ? (scene.direction === 'RIGHT' ? r.x : r.y) : 0; + }; + return waveFlow(pick(shown).map((e) => ({ ...e, at: at(e.from) }))); +} diff --git a/packages/viewer/src/motion/useFlow.ts b/packages/viewer/src/motion/useFlow.ts index 8dd2334..06df042 100644 --- a/packages/viewer/src/motion/useFlow.ts +++ b/packages/viewer/src/motion/useFlow.ts @@ -1,58 +1,10 @@ import { useMemo } from 'react'; -import { CARD, COMPACT } from '@stackmap/core'; import { useExplore } from '../explore/ExploreContext'; -import { routeBetween, type Graph } from '../explore/graph'; -import type { Emphasis } from '../explore/emphasis'; -import type { ExploreState } from '../explore/state'; -import { EDGE_RADIUS, type Scene } from '../canvas/scene'; -import { hopFlow, hopRanks, NO_FLOW, roundedPolyline, waveFlow, type Flow, type FlowEdge } from './flow'; +import type { Scene } from '../canvas/scene'; +import type { Flow } from './flow'; +import { flowOf } from './flowOf'; -/** What a pulse needs from the scene: its drawn route, its tint, and what lights up when it lands. */ -function flowEdges(scene: Scene): Map { - const cards = new Map(scene.cards.map((c) => [c.node.id, c])); - const radius = scene.compact ? COMPACT.radius : CARD.radius; - return new Map( - scene.edges.map((e) => { - const end = e.points.at(-1); - // A sequence lands on the activation bar the message opens or feeds (the innermost), else on the lifeline. - const bar = - scene.kind === 'sequence' && end - ? scene.activations.filter((a) => a.node === e.to && end.y >= a.rect.y - 2 && end.y <= a.rect.y + a.rect.height + 2).sort((a, b) => b.depth - a.depth)[0] - : undefined; - const target = cards.get(e.to); - const glow = scene.kind === 'sequence' ? (bar ? { rect: bar.rect, radius: 2, tint: bar.type } : null) : target ? { rect: target.rect, radius, tint: target.node.type } : null; - const tint = e.tone === 'error' ? 'failure' : (cards.get(e.from)?.node.type ?? 'external'); - return [e.id, { id: e.id, from: e.from, to: e.to, kind: e.kind, tint, path: roundedPolyline(e.points, EDGE_RADIUS), glow }]; - }), - ); -} - -/** - * The flow that plays is whatever the explorer is showing: a route runs hop by hop from its start, a selection - * (traced or not) runs through its lit connections, and otherwise everything not dimmed (by a view, the lens or - * a search) plays: a wave in reading order, or a sequence's messages replayed one after another. - */ -export function flowOf(scene: Scene, graph: Graph, state: Pick, em: Emphasis): Flow { - const edges = flowEdges(scene); - const pick = (ids: string[]) => ids.flatMap((id) => edges.get(id) ?? []); - const lit = scene.edges.filter((e) => em.edges.get(e.id)?.tint).map((e) => e.id); - const serial = graph.timed; - if (state.route) { - const route = routeBetween(graph, state.route.from, state.route.to); - if (!route) return NO_FLOW; - // A reversed route's `from` is already the end it really runs from. - return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { start: route.from, fallback: route.from }), { serial }); - } - if (state.selected) return hopFlow(pick(lit), hopRanks(graph, new Set(lit), { fallback: state.selected }), { serial }); - const shown = scene.edges.filter((e) => !em.edges.get(e.id)?.dim).map((e) => e.id); - if (serial) return hopFlow(pick(shown), hopRanks(graph, new Set(shown), { fallback: '' }), { serial }); - const rect = new Map(scene.cards.map((c) => [c.node.id, c.rect])); - const at = (id: string) => { - const r = rect.get(id); - return r ? (scene.direction === 'RIGHT' ? r.x : r.y) : 0; - }; - return waveFlow(pick(shown).map((e) => ({ ...e, at: at(e.from) }))); -} +export { flowOf }; export function useFlow(scene: Scene): Flow { const { graph, state, emphasis } = useExplore(); 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/desktop.css b/site/components/landing/desktop.css index d552400..fc1b990 100644 --- a/site/components/landing/desktop.css +++ b/site/components/landing/desktop.css @@ -198,6 +198,8 @@ .vx {position:absolute;left:720px;top:450px;width:0;height:0} .vring {position:absolute;left:0;top:0} .vchip {position:absolute;left:0;top:0;white-space:nowrap;padding:8px 14px;border-radius:9px;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)} .vghost {padding:0;background:none;box-shadow:none} .kline-wrap {position:absolute;inset:0;transform-origin:720px 450px} .kl {position:absolute;left:96px;margin:0;font-size:96px;line-height:1;font-weight:600;letter-spacing:-0.045em;color:var(--sm-text);white-space:nowrap;transition:color 300ms ease} 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} + diff --git a/site/components/landing/desktop/Unmapped.tsx b/site/components/landing/desktop/Unmapped.tsx index 9240296..b28742d 100644 --- a/site/components/landing/desktop/Unmapped.tsx +++ b/site/components/landing/desktop/Unmapped.tsx @@ -21,9 +21,7 @@ export function Unmapped() { - - .github/workflows/deploy.yml - + @@ -33,9 +31,7 @@ export function Unmapped() { - - packages/ui/Button.tsx - + @@ -45,9 +41,7 @@ export function Unmapped() { - - infra/queue/order-events.tf - + @@ -57,9 +51,7 @@ export function Unmapped() { - - apps/storefront/next.config.js - + @@ -71,41 +63,19 @@ export function Unmapped() { - - apps/storefront/app/checkout/page.tsx - - - services/api/src/routes/orders.ts - - - infra/cache/redis.tf - - - services/api/src/payments/stripe.ts - - - infra/storage/receipts.tf - - - docker-compose.yml - + + + + + + - - server.ts - - - postgres.tf - - - App.tsx - - - session.ts - - - consume.ts - + + + + + 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.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/Hero.tsx b/site/components/landing/mobile/Hero.tsx index 2fe3798..a3b8221 100644 --- a/site/components/landing/mobile/Hero.tsx +++ b/site/components/landing/mobile/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 ( @@ -126,6 +127,12 @@ export function Hero() { + + + + + {LINKS.film.label} + 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/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() { - - .github/workflows/deploy.yml - - - packages/ui/Button.tsx - - - infra/queue/order-events.tf - - - apps/storefront/next.config.js - - - infra/storage/receipts.tf - - - docker-compose.yml - + + + + + + - - checkout/page.tsx - - - routes/orders.ts - - - redis.tf - - - payments/stripe.ts - - - receipts.tf - + + + + + - - server.ts - - - postgres.tf - - - App.tsx - - - session.ts - + + + + diff --git a/site/components/landing/site.css b/site/components/landing/site.css index d02bfce..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,6 +98,12 @@ .lp .kface-in { transform-origin: 0 0; } +.lp .kflow { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} @media (prefers-reduced-motion: reduce) { .lp .kslot, .lp .klayer, 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/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} + setOpen((o) => !o)} + className={`${panel} sm-press grid size-11 place-items-center text-fg-muted hover:text-fg md:hidden`} + > + + + {/* Links to a section of the page it's on don't change the path, so a click closes the menu too. */} + (e.target as HTMLElement).closest('a') && setOpen(false)} + className={`${panel} invisible absolute top-full right-4 mt-2 flex w-56 origin-top-right scale-[0.97] flex-col p-1.5 opacity-0 transition-[opacity,scale,visibility] duration-180 ease-[cubic-bezier(0.23,1,0.32,1)] data-open:visible data-open:scale-100 data-open:opacity-100 motion-reduce:transition-none md:hidden`} + > + {[...ITEMS, LINKS.github].map((l) => { + const external = l.href.startsWith('http'); + return ( + + {external && } + {l.label} + + ); + })} + ); } diff --git a/site/components/site/links.ts b/site/components/site/links.ts index 8dd3560..4b2f098 100644 --- a/site/components/site/links.ts +++ b/site/components/site/links.ts @@ -10,6 +10,8 @@ export const LINKS = { schema: { label: 'Schema', href: '/docs/schema' }, cli: { label: 'CLI', href: '/docs#cli' }, authoring: { label: 'Authoring contract', href: `${repo}/blob/main/skill/references/authoring-contract.md` }, + // The film is the video in the README, hosted by GitHub. + film: { label: 'Watch the film', href: `${repo}#readme` }, github: { label: 'GitHub', href: repo }, npm: { label: 'npm', href: 'https://www.npmjs.com/package/@hyamero/stackmap' }, contributing: { label: 'Contributing', href: `${repo}/blob/main/CONTRIBUTING.md` }, 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 ( + + + + ); +} 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(); +}); 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(); + }); +}); 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); + }); });
{SITE.install.skill}