diff --git a/.agents/skills/beat-model/SKILL.md b/.agents/skills/beat-model/SKILL.md index f530dcd..19d15f3 100644 --- a/.agents/skills/beat-model/SKILL.md +++ b/.agents/skills/beat-model/SKILL.md @@ -25,6 +25,8 @@ Every `StoryBeat` carries these base fields, plus the story's own entity arrays | `focus` | keys to spotlight: entity ids and `owner.slot` keys | | `phase?` | optional story-specific stage (e.g. GC `mark` / `sweep`) | +Stack-and-heap stories take `Slot`, `StackFrame`, `HeapObject`, `Snapshot` and the reference helpers (`slotKey`, `getReferences`, `getReachable`) from `src/shared/snapshot`, because a Playground trace step holds the same snapshot ([ADR 0004](../../../docs/adr/0004-playground-traces-share-the-snapshot.md)). The story narrows them: `Placed>` makes the authored `cell` required. Other domains (e.g. Git commits and refs) keep their own typed snapshots. + Rules that make the model render cleanly: - **Full snapshot per beat.** Every beat lists every entity present at that moment; stepping backwards is just rendering an earlier beat. diff --git a/.claude/skills/beat-model/SKILL.md b/.claude/skills/beat-model/SKILL.md index 688ef6b..2255de1 100644 --- a/.claude/skills/beat-model/SKILL.md +++ b/.claude/skills/beat-model/SKILL.md @@ -27,6 +27,8 @@ Every `StoryBeat` carries these base fields, plus the story's own entity arrays | `focus` | keys to spotlight: entity ids and `owner.slot` keys | | `phase?` | optional story-specific stage (e.g. GC `mark` / `sweep`) | +Stack-and-heap stories take `Slot`, `StackFrame`, `HeapObject`, `Snapshot` and the reference helpers (`slotKey`, `getReferences`, `getReachable`) from `src/shared/snapshot`, because a Playground trace step holds the same snapshot ([ADR 0004](../../../docs/adr/0004-playground-traces-share-the-snapshot.md)). The story narrows them: `Placed>` makes the authored `cell` required. Other domains (e.g. Git commits and refs) keep their own typed snapshots. + Rules that make the model render cleanly: - **Full snapshot per beat.** Every beat lists every entity present at that moment; stepping backwards is just rendering an earlier beat. diff --git a/src/features/javascript/components/visualizations/2d/StackHeap2D.tsx b/src/features/javascript/components/visualizations/2d/StackHeap2D.tsx index 14972a0..0c0a181 100644 --- a/src/features/javascript/components/visualizations/2d/StackHeap2D.tsx +++ b/src/features/javascript/components/visualizations/2d/StackHeap2D.tsx @@ -9,6 +9,7 @@ import { type StackFrame, type StoryBeat, } from '../../../utils/stackHeapStory'; +import { vizPalette } from '../../../../../shared/viz/palette'; interface StackHeap2DProps { beat: StoryBeat; @@ -30,19 +31,8 @@ const CELL_Y0 = 78; const CELL_DY = 190; const OBJ_W = 180; -const COLORS = { - frame: '#e0e7ff', - frameStroke: '#6366f1', - object: '#d1fae5', - objectStroke: '#10b981', - array: '#e0f2fe', - arrayStroke: '#0ea5e9', - ref: '#64748b', - focus: '#f59e0b', - root: '#f59e0b', - unmarked: '#ffe4e6', - unmarkedStroke: '#f43f5e', -}; +/** Light only for now; the theme becomes a prop when the view is generalised. */ +const P = vizPalette.light; interface Box { x: number; @@ -149,7 +139,7 @@ const StackHeap2D: React.FC = ({ beat }) => { markerHeight="7" orient="auto-start-reverse" > - + = ({ beat }) => { markerHeight="7" orient="auto-start-reverse" > - + @@ -171,8 +161,8 @@ const StackHeap2D: React.FC = ({ beat }) => { width={STACK_W} height={H - 36} rx={14} - fill="#f8fafc" - stroke="#cbd5e1" + fill={P.region.fill} + stroke={P.region.stroke} /> = ({ beat }) => { width={W - HEAP_X - 16} height={H - 36} rx={14} - fill="#f8fafc" - stroke="#cbd5e1" + fill={P.region.fill} + stroke={P.region.stroke} /> = ({ beat }) => { width={b.w} height={b.h} rx={8} - fill={COLORS.frame} - stroke={marking || focused ? COLORS.root : COLORS.frameStroke} + fill={P.frame.fill} + stroke={marking || focused ? P.root : P.frame.stroke} strokeWidth={marking || focused ? 3 : top ? 2 : 1} strokeDasharray={marking ? '6 4' : undefined} /> @@ -240,7 +230,7 @@ const StackHeap2D: React.FC = ({ beat }) => { fontSize={10} fontWeight={700} textAnchor="end" - fill={COLORS.root} + fill={P.root} > ROOT @@ -275,7 +265,7 @@ const StackHeap2D: React.FC = ({ beat }) => { className="sh-ref sh-enter" d={d} fill="none" - stroke={focused ? COLORS.focus : COLORS.ref} + stroke={focused ? P.focus : P.reference.stroke} strokeWidth={focused ? 3 : 1.6} markerEnd={`url(#${focused ? 'sh-arrow-focus' : 'sh-arrow'})`} /> @@ -304,15 +294,15 @@ const StackHeap2D: React.FC = ({ beat }) => { width={b.w} height={b.h} rx={isArray ? 4 : 10} - fill={unmarked ? COLORS.unmarked : isArray ? COLORS.array : COLORS.object} + fill={unmarked ? P.unreachable.fill : isArray ? P.array.fill : P.object.fill} stroke={ focused - ? COLORS.focus + ? P.focus : unmarked - ? COLORS.unmarkedStroke + ? P.unreachable.stroke : isArray - ? COLORS.arrayStroke - : COLORS.objectStroke + ? P.array.stroke + : P.object.stroke } strokeWidth={focused ? 3 : 1.5} strokeDasharray={collected ? '5 4' : undefined} @@ -337,7 +327,7 @@ const StackHeap2D: React.FC = ({ beat }) => { fontSize={10} fontWeight={700} textAnchor="end" - fill={COLORS.unmarkedStroke} + fill={P.unreachable.stroke} > {collected ? 'RECLAIMED' : 'UNMARKED'} diff --git a/src/features/javascript/components/visualizations/3d/StackHeap3D.tsx b/src/features/javascript/components/visualizations/3d/StackHeap3D.tsx index 824851d..087604a 100644 --- a/src/features/javascript/components/visualizations/3d/StackHeap3D.tsx +++ b/src/features/javascript/components/visualizations/3d/StackHeap3D.tsx @@ -21,6 +21,7 @@ import { } from '../../../utils/stackHeapStory'; import type { CameraState } from '../../../../../shared/components/viz/viewerCamera'; import { LabelFramer, StoryCamera } from '../../../../../shared/viz3d/camera'; +import { vizPalette } from '../../../../../shared/viz/palette'; import { boundsOf, type Box3, type Shot, type V3 } from '../../../../../shared/viz3d/frameShot'; /** @@ -37,20 +38,8 @@ export interface StackHeap3DProps { instant?: boolean; } -const PALETTE = { - ground: '#e2e8f0', - stackBase: '#cbd5e1', - heapFloor: '#f1f5f9', - heapEdge: '#cbd5e1', - frame: '#818cf8', - frameTop: '#6366f1', - object: '#34d399', - array: '#38bdf8', - unmarked: '#fb7185', - ref: '#475569', - focus: '#f59e0b', - root: '#f59e0b', -}; +/** Light only for now; the theme becomes a prop when the view is generalised. */ +const P = vizPalette.light; // ---- Layout (world units) ---------------------------------------------------------- @@ -156,7 +145,7 @@ const FrameSlab: React.FC<{ - + )} @@ -230,10 +219,10 @@ const HeapBlock: React.FC<{ () => new THREE.Color( object.state === 'unmarked' || object.state === 'collected' - ? PALETTE.unmarked + ? P.unreachable.solid : object.kind === 'array' - ? PALETTE.array - : PALETTE.object + ? P.array.solid + : P.object.solid ), [object.state, object.kind] ); @@ -253,7 +242,7 @@ const HeapBlock: React.FC<{ {focused && !collected && ( - + )} @@ -269,7 +258,7 @@ const HeapBlock: React.FC<{ [1, 2, 3].map((i) => ( - + ))} @@ -384,7 +373,7 @@ const RefArc: React.FC<{ start: V3; end: V3; mid: V3; focused: boolean; instant: instant, }) => { const line = useRef(null); - const color = focused ? PALETTE.focus : PALETTE.ref; + const color = focused ? P.focus : P.reference.solid; const cone = useMemo(() => { const e = new THREE.Vector3(...end); @@ -441,7 +430,7 @@ const RootPulse: React.FC = () => { {[0, 1, 2].map((i) => ( void (rings.current[i] = m)}> - + ))} @@ -465,21 +454,21 @@ const Scene: React.FC = ({ beat, camera, instant = false }) => return ( <> - - + + {/* Stack plinth + heap floor */} - + - + diff --git a/src/features/javascript/utils/stackHeapStory.ts b/src/features/javascript/utils/stackHeapStory.ts index 9f3dbae..b72c9c7 100644 --- a/src/features/javascript/utils/stackHeapStory.ts +++ b/src/features/javascript/utils/stackHeapStory.ts @@ -5,62 +5,45 @@ * mid-story keeps the learner on the same beat. */ -export type ShotId = 'overview' | 'stack' | 'bridge' | 'heap' | 'gc'; +import type { + HeapObject as SharedHeapObject, + HeapState, + Placed, + Reference, + Slot, + Snapshot, + StackFrame, +} from '../../../shared/snapshot'; -/** A slot holds either a primitive `value` or a reference (`refId`) to a heap object. */ -export interface Slot { - name: string; - value?: string; - refId?: string; -} +// The snapshot shape and its reference helpers are shared with Playground traces (ADR 0004). +export type { Reference, Slot, StackFrame } from '../../../shared/snapshot'; +export { getReachable, getReferences, slotKey } from '../../../shared/snapshot'; -export interface StackFrame { - id: string; - name: string; - slots: Slot[]; -} +export type ShotId = 'overview' | 'stack' | 'bridge' | 'heap' | 'gc'; +/** This story allocates only plain objects and arrays. */ export type HeapObjectKind = 'object' | 'array'; -/** `unmarked` = GC mark phase found no path from a root; `collected` = swept. */ -export type HeapObjectState = 'live' | 'marked' | 'unmarked' | 'collected'; +export type HeapObjectState = HeapState; -export interface HeapObject { - id: string; - label: string; - kind: HeapObjectKind; - slots: Slot[]; - state: HeapObjectState; - /** Stable grid position on the heap floor, shared by both views. */ - cell: { col: number; row: number }; -} +/** A story authors every object's cell, so here it is required. */ +export type HeapObject = Placed>; export type GcPhase = 'mark' | 'sweep'; -export interface StoryBeat { +/** A snapshot plus what the author wrote. */ +export interface StoryBeat extends Snapshot { id: string; title: string; caption: string; /** 1-based line in STORY_CODE. */ line: number; shot: ShotId; - /** Bottom of the stack first. */ - frames: StackFrame[]; - objects: HeapObject[]; /** Slot keys (`owner.slot`) and object ids to spotlight on this beat. */ focus: string[]; gcPhase?: GcPhase; } -export interface Reference { - id: string; - /** Slot key: `${frameId|objectId}.${slotName}` */ - from: string; - fromOwner: string; - fromKind: 'frame' | 'object'; - to: string; -} - export const STORY_CODE = `function makeUser(name) { const user = { name, friends: [] }; return user; @@ -76,8 +59,6 @@ function main() { main();`; -export const slotKey = (owner: string, slot: string) => `${owner}.${slot}`; - const globalFrame: StackFrame = { id: 'global', name: 'global', @@ -285,43 +266,6 @@ export const STORY_BEATS: StoryBeat[] = [ }, ]; -/** Every reference edge in a beat, from stack slots and from heap object slots. */ -export function getReferences(beat: StoryBeat): Reference[] { - const refs: Reference[] = []; - for (const frame of beat.frames) { - for (const slot of frame.slots) { - if (slot.refId) { - const from = slotKey(frame.id, slot.name); - refs.push({ id: from, from, fromOwner: frame.id, fromKind: 'frame', to: slot.refId }); - } - } - } - for (const object of beat.objects) { - if (object.state === 'collected') continue; - for (const slot of object.slots) { - if (slot.refId) { - const from = slotKey(object.id, slot.name); - refs.push({ id: from, from, fromOwner: object.id, fromKind: 'object', to: slot.refId }); - } - } - } - return refs; -} - -/** Object ids reachable from the roots (every stack frame), following references. */ -export function getReachable(beat: StoryBeat): Set { - const refs = getReferences(beat); - const reached = new Set(); - const queue = refs.filter((r) => r.fromKind === 'frame').map((r) => r.to); - while (queue.length) { - const id = queue.shift()!; - if (reached.has(id)) continue; - reached.add(id); - for (const r of refs) if (r.fromOwner === id) queue.push(r.to); - } - return reached; -} - export const isFocused = (beat: StoryBeat, key: string) => beat.focus.includes(key); /** An edge is spotlighted when its source slot is in focus. */ diff --git a/src/shared/snapshot/index.ts b/src/shared/snapshot/index.ts new file mode 100644 index 0000000..6789691 --- /dev/null +++ b/src/shared/snapshot/index.ts @@ -0,0 +1,12 @@ +export type { + Cell, + HeapKind, + HeapObject, + HeapState, + Placed, + Reference, + Slot, + Snapshot, + StackFrame, +} from './types'; +export { getReachable, getReferences, slotKey } from './references'; diff --git a/src/shared/snapshot/references.test.ts b/src/shared/snapshot/references.test.ts new file mode 100644 index 0000000..b05c63d --- /dev/null +++ b/src/shared/snapshot/references.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { getReachable, getReferences, slotKey } from './index'; +import type { HeapObject, HeapState, Slot, Snapshot, StackFrame } from './index'; + +const frame = (id: string, slots: Slot[]): StackFrame => ({ id, name: id, slots }); +const object = (id: string, slots: Slot[] = [], state: HeapState = 'live'): HeapObject => ({ + id, + label: id, + kind: 'object', + slots, + state, +}); + +describe('slotKey', () => { + it('joins owner and slot name', () => { + expect(slotKey('main', 'ada')).toBe('main.ada'); + }); +}); + +describe('getReferences', () => { + it('reports a stack slot pointing at a heap object', () => { + const snapshot: Snapshot = { + frames: [ + frame('main', [ + { name: 'n', value: '1' }, + { name: 'a', refId: 'a' }, + ]), + ], + objects: [object('a')], + }; + expect(getReferences(snapshot)).toEqual([ + { id: 'main.a', from: 'main.a', fromOwner: 'main', fromKind: 'frame', to: 'a' }, + ]); + }); + + it('reports a heap slot pointing at another heap object, after the stack references', () => { + const snapshot: Snapshot = { + frames: [frame('main', [{ name: 'a', refId: 'a' }])], + objects: [object('a', [{ name: 'next', refId: 'b' }]), object('b')], + }; + expect(getReferences(snapshot)).toEqual([ + { id: 'main.a', from: 'main.a', fromOwner: 'main', fromKind: 'frame', to: 'a' }, + { id: 'a.next', from: 'a.next', fromOwner: 'a', fromKind: 'object', to: 'b' }, + ]); + }); + + it('takes no references from a collected object', () => { + const snapshot: Snapshot = { + frames: [frame('main', [])], + objects: [object('a', [{ name: 'next', refId: 'b' }], 'collected'), object('b')], + }; + expect(getReferences(snapshot)).toEqual([]); + }); + + it('reports a reference to a frame id like any other', () => { + const snapshot: Snapshot = { + frames: [frame('outer', [{ name: 'fn', refId: 'fn' }])], + objects: [{ ...object('fn', [{ name: '[[Scope]]', refId: 'outer' }]), kind: 'function' }], + }; + expect(getReferences(snapshot)).toContainEqual({ + id: 'fn.[[Scope]]', + from: 'fn.[[Scope]]', + fromOwner: 'fn', + fromKind: 'object', + to: 'outer', + }); + }); + + it('reports a dangling refId as it is written', () => { + const snapshot: Snapshot = { + frames: [frame('main', [{ name: 'ghost', refId: 'nowhere' }])], + objects: [], + }; + expect(getReferences(snapshot).map((r) => r.to)).toEqual(['nowhere']); + }); +}); + +describe('getReachable', () => { + it('follows stack → heap → heap', () => { + const snapshot: Snapshot = { + frames: [frame('main', [{ name: 'a', refId: 'a' }])], + objects: [object('a', [{ name: 'next', refId: 'b' }]), object('b'), object('c')], + }; + expect(getReachable(snapshot)).toEqual(new Set(['a', 'b'])); + }); + + it('does not reach a cycle that no root points at', () => { + const snapshot: Snapshot = { + frames: [frame('main', [{ name: 'a', value: 'null' }])], + objects: [object('a', [{ name: 'b', refId: 'b' }]), object('b', [{ name: 'a', refId: 'a' }])], + }; + expect(getReachable(snapshot)).toEqual(new Set()); + }); + + it('terminates on a reachable cycle', () => { + const snapshot: Snapshot = { + frames: [frame('main', [{ name: 'a', refId: 'a' }])], + objects: [object('a', [{ name: 'b', refId: 'b' }]), object('b', [{ name: 'a', refId: 'a' }])], + }; + expect(getReachable(snapshot)).toEqual(new Set(['a', 'b'])); + }); + + it('does not follow references out of a collected object', () => { + const snapshot: Snapshot = { + frames: [frame('main', [{ name: 'a', refId: 'a' }])], + objects: [object('a', [{ name: 'next', refId: 'b' }], 'collected'), object('b')], + }; + expect(getReachable(snapshot)).toEqual(new Set(['a'])); + }); + + it('returns only heap object ids when a slot references a frame', () => { + const snapshot: Snapshot = { + frames: [ + frame('global', [{ name: 'x', refId: 'x' }]), + frame('outer', [ + { name: 'fn', refId: 'fn' }, + { name: 'self', refId: 'outer' }, + { name: 'parent', refId: 'global' }, + ]), + ], + objects: [ + { ...object('fn', [{ name: '[[Scope]]', refId: 'outer' }]), kind: 'function' }, + object('x'), + ], + }; + expect(getReachable(snapshot)).toEqual(new Set(['fn', 'x'])); + }); + + it('ignores a refId that matches nothing', () => { + const snapshot: Snapshot = { + frames: [ + frame('main', [ + { name: 'ghost', refId: 'nowhere' }, + { name: 'a', refId: 'a' }, + ]), + ], + objects: [object('a', [{ name: 'lost', refId: 'gone' }])], + }; + expect(getReachable(snapshot)).toEqual(new Set(['a'])); + }); +}); diff --git a/src/shared/snapshot/references.ts b/src/shared/snapshot/references.ts new file mode 100644 index 0000000..13e67bc --- /dev/null +++ b/src/shared/snapshot/references.ts @@ -0,0 +1,49 @@ +import type { Reference, Snapshot } from './types'; + +export const slotKey = (owner: string, slot: string) => `${owner}.${slot}`; + +/** + * Every reference edge in a snapshot, from stack slots and from heap object slots. + * A collected object no longer exists, so its slots contribute no references. + * The target may be a heap object, a frame, or nothing at all (a dangling `refId`). + */ +export function getReferences(snapshot: Snapshot): Reference[] { + const refs: Reference[] = []; + for (const frame of snapshot.frames) { + for (const slot of frame.slots) { + if (slot.refId) { + const from = slotKey(frame.id, slot.name); + refs.push({ id: from, from, fromOwner: frame.id, fromKind: 'frame', to: slot.refId }); + } + } + } + for (const object of snapshot.objects) { + if (object.state === 'collected') continue; + for (const slot of object.slots) { + if (slot.refId) { + const from = slotKey(object.id, slot.name); + refs.push({ id: from, from, fromOwner: object.id, fromKind: 'object', to: slot.refId }); + } + } + } + return refs; +} + +/** + * Heap object ids reachable from the roots (every stack frame), following references. + * Frame ids and ids that match nothing are never in the result: a frame is a root already, + * so its own slots are followed whether or not something points at it. + */ +export function getReachable(snapshot: Snapshot): Set { + const objectIds = new Set(snapshot.objects.map((o) => o.id)); + const refs = getReferences(snapshot); + const reached = new Set(); + const queue = refs.filter((r) => r.fromKind === 'frame').map((r) => r.to); + while (queue.length) { + const id = queue.shift()!; + if (!objectIds.has(id) || reached.has(id)) continue; + reached.add(id); + for (const r of refs) if (r.fromKind === 'object' && r.fromOwner === id) queue.push(r.to); + } + return reached; +} diff --git a/src/shared/snapshot/types.ts b/src/shared/snapshot/types.ts new file mode 100644 index 0000000..0a24f9f --- /dev/null +++ b/src/shared/snapshot/types.ts @@ -0,0 +1,69 @@ +/** + * The stack-and-heap snapshot: the state of everything at one moment. + * + * A story beat and a Playground trace step hold this same shape (ADR 0004), so one set of + * views can draw either. Pure data: no React, no three.js, no DOM. + */ + +/** A fixed grid position inside a region, shared by the 2D and 3D views. */ +export interface Cell { + col: number; + row: number; +} + +/** + * A slot holds either a primitive `value` or a reference (`refId`) to another entity: + * a heap object, or a frame whose scope is still on the stack. + */ +export interface Slot { + name: string; + value?: string; + refId?: string; +} + +export interface StackFrame { + id: string; + name: string; + slots: Slot[]; +} + +/** + * `function` is a closure and `scope` is the popped frame it keeps alive; traces need both, + * no story uses them yet. + */ +export type HeapKind = 'object' | 'array' | 'function' | 'scope'; + +/** + * `unmarked` = GC mark phase found no path from a root; `collected` = swept. + * Reachability is not a state: derive it with `getReachable`. + */ +export type HeapState = 'live' | 'marked' | 'unmarked' | 'collected'; + +export interface HeapObject { + id: string; + label: string; + kind: K; + slots: Slot[]; + state: HeapState; + /** Stable grid position on the heap floor. Authored in a story; absent in a trace until placed. */ + cell?: Cell; +} + +/** An entity whose cell is known, e.g. `Placed`. */ +export type Placed = T & { cell: Cell }; + +export interface Snapshot { + /** Bottom of the stack first. */ + frames: StackFrame[]; + objects: O[]; +} + +/** A link from a slot to the entity it points at. Derived by `getReferences`, never stored. */ +export interface Reference { + id: string; + /** Slot key: `${frameId|objectId}.${slotName}` */ + from: string; + fromOwner: string; + fromKind: 'frame' | 'object'; + to: string; +} diff --git a/src/shared/viz/palette.test.ts b/src/shared/viz/palette.test.ts new file mode 100644 index 0000000..33119c0 --- /dev/null +++ b/src/shared/viz/palette.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { ENTITY_ROLES, vizPalette, type EntityRole, type VizTheme } from './palette'; + +const THEMES: VizTheme[] = ['light', 'dark']; + +/** Every leaf of a palette as `path -> value`. */ +function leaves(node: unknown, path = ''): Map { + const out = new Map(); + if (typeof node === 'object' && node !== null) { + for (const [key, value] of Object.entries(node)) { + for (const [p, v] of leaves(value, path ? `${path}.${key}` : key)) out.set(p, v); + } + } else { + out.set(path, node); + } + return out; +} + +/** WCAG 2.x relative luminance of a 6-digit hex. */ +function luminance(hex: string): number { + const channel = (i: number) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} + +/** WCAG 2.x contrast ratio between two hex colours. */ +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const TEXT_MIN = 4.5; +const GRAPHIC_MIN = 3; + +/** + * Light reproduces the colours the Stack & Heap views shipped with, and three of those fall + * short of 3:1 against the pale surface. They are kept as they are (this palette must not + * change how the stories look), so light asserts today's measured value for them instead. + * Do not add to this list: a new colour meets the threshold. + */ +const LIGHT_STROKE_FLOOR: Partial> = { + object: 2.42, // #10b981 on #f8fafc + array: 2.64, // #0ea5e9 on #f8fafc +}; +const LIGHT_FOCUS_FLOOR = 2.05; // #f59e0b on #f8fafc + +describe('vizPalette', () => { + it('has exactly the same keys in both variants', () => { + expect([...leaves(vizPalette.dark).keys()].sort()).toEqual( + [...leaves(vizPalette.light).keys()].sort() + ); + }); + + describe.each(THEMES)('%s', (theme) => { + const p = vizPalette[theme]; + + it('uses only 6-digit hex colours', () => { + for (const [path, value] of leaves(p)) { + expect(value, path).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it('keeps primary text readable on every entity fill and on the label background', () => { + for (const role of ENTITY_ROLES) { + expect(contrast(p.text.primary, p[role].fill), role).toBeGreaterThanOrEqual(TEXT_MIN); + } + expect(contrast(p.text.primary, p.label.bg), 'label.bg').toBeGreaterThanOrEqual(TEXT_MIN); + }); + + it('keeps every entity stroke visible against the surface', () => { + for (const role of ENTITY_ROLES) { + const min = (theme === 'light' ? LIGHT_STROKE_FLOOR[role] : undefined) ?? GRAPHIC_MIN; + expect(contrast(p[role].stroke, p.surface), role).toBeGreaterThanOrEqual(min); + } + }); + + it('keeps focus visible against the surface', () => { + const min = theme === 'light' ? LIGHT_FOCUS_FLOOR : GRAPHIC_MIN; + expect(contrast(p.focus, p.surface)).toBeGreaterThanOrEqual(min); + }); + + it('gives every entity role its own fill and its own solid', () => { + for (const key of ['fill', 'solid'] as const) { + const values = ENTITY_ROLES.map((role) => p[role][key]); + expect(new Set(values).size, key).toBe(values.length); + } + }); + }); + + it('pins only light colours that really are below the threshold', () => { + const p = vizPalette.light; + for (const role of ENTITY_ROLES) { + if (!LIGHT_STROKE_FLOOR[role]) continue; + expect(contrast(p[role].stroke, p.surface), role).toBeLessThan(GRAPHIC_MIN); + } + expect(contrast(p.focus, p.surface)).toBeLessThan(GRAPHIC_MIN); + }); +}); diff --git a/src/shared/viz/palette.ts b/src/shared/viz/palette.ts new file mode 100644 index 0000000..e4d226d --- /dev/null +++ b/src/shared/viz/palette.ts @@ -0,0 +1,133 @@ +/** + * The one palette for the stack-and-heap views (2D SVG and 3D), organised by role. + * + * Each colour means one role (docs/3D-Visualization-Standard.md, "Style"), and each role keeps + * its hue family in both variants so a story met in light mode is recognisable in the dark + * Playground (docs/adr/0004-playground-traces-share-the-snapshot.md, decision 6). + * + * Pure data: no React, no three.js, no DOM. Every value is a 6-digit hex. + */ + +export type VizTheme = 'light' | 'dark'; + +/** One kind of thing a view draws. 2D draws a pale `fill` with a `stroke`; 3D draws a `solid`. */ +export interface EntityColors { + fill: string; + stroke: string; + solid: string; +} + +export interface VizPalette { + /** The viewer background behind everything. */ + surface: string; + /** The stack and heap areas. */ + region: { + /** 2D panel fill. */ + fill: string; + /** 2D panel outline and the 3D heap grid. */ + stroke: string; + /** "CALL STACK" / "HEAP" captions. */ + label: string; + /** 3D ground plane. */ + ground: string; + /** 3D heap floor. */ + floor: string; + /** 3D plinth under the stack. */ + plinth: string; + }; + /** 3D hemisphere light. Lighting, not a role: it only keeps the solids readable. */ + lighting: { sky: string; ground: string }; + /** A stack frame. */ + frame: EntityColors; + /** The top frame: the one that is running. */ + frameActive: EntityColors; + /** A plain heap object. */ + object: EntityColors; + /** A heap array; `divider` marks its element boundaries in 3D. */ + array: EntityColors & { divider: string }; + /** A function object (a closure). */ + function: EntityColors; + /** A retained scope: a frame that left the stack but is kept alive by a closure. */ + scope: EntityColors; + /** A heap object the collector could not reach (unmarked, then reclaimed). */ + unreachable: EntityColors; + /** A reference arrow: `stroke` in 2D, `solid` in 3D. */ + reference: { stroke: string; solid: string }; + /** What the current beat is about. */ + focus: string; + /** A garbage-collection root. */ + root: string; + text: { + primary: string; + secondary: string; + muted: string; + /** Text of a focused slot. */ + onFocus: string; + }; + /** The DOM labels that float over the 3D scene. */ + label: { bg: string; border: string }; +} + +/** The entity roles, for code that treats them uniformly (and for the palette tests). */ +export const ENTITY_ROLES = [ + 'frame', + 'frameActive', + 'object', + 'array', + 'function', + 'scope', + 'unreachable', +] as const satisfies readonly (keyof VizPalette)[]; + +export type EntityRole = (typeof ENTITY_ROLES)[number]; + +export const vizPalette: Record = { + light: { + surface: '#f8fafc', + region: { + fill: '#f8fafc', + stroke: '#cbd5e1', + label: '#64748b', + ground: '#e2e8f0', + floor: '#f1f5f9', + plinth: '#cbd5e1', + }, + lighting: { sky: '#ffffff', ground: '#cbd5e1' }, + frame: { fill: '#e0e7ff', stroke: '#6366f1', solid: '#818cf8' }, + frameActive: { fill: '#c7d2fe', stroke: '#4f46e5', solid: '#6366f1' }, + object: { fill: '#d1fae5', stroke: '#10b981', solid: '#34d399' }, + array: { fill: '#e0f2fe', stroke: '#0ea5e9', solid: '#38bdf8', divider: '#0369a1' }, + function: { fill: '#f3e8ff', stroke: '#a855f7', solid: '#c084fc' }, + scope: { fill: '#e8eaf6', stroke: '#5c6399', solid: '#9aa0c9' }, + unreachable: { fill: '#ffe4e6', stroke: '#f43f5e', solid: '#fb7185' }, + reference: { stroke: '#64748b', solid: '#475569' }, + focus: '#f59e0b', + root: '#f59e0b', + text: { primary: '#1e293b', secondary: '#475569', muted: '#94a3b8', onFocus: '#b45309' }, + label: { bg: '#ffffff', border: '#e2e8f0' }, + }, + dark: { + surface: '#0a0a1a', + region: { + fill: '#12122a', + stroke: '#2a2a4e', + label: '#a0a0cc', + ground: '#0f0f2e', + floor: '#12122a', + plinth: '#2a2a4e', + }, + lighting: { sky: '#ffffff', ground: '#3a3a6e' }, + frame: { fill: '#1e1b4b', stroke: '#818cf8', solid: '#6366f1' }, + frameActive: { fill: '#312e81', stroke: '#a5b4fc', solid: '#818cf8' }, + object: { fill: '#064e3b', stroke: '#34d399', solid: '#10b981' }, + array: { fill: '#0c4a6e', stroke: '#38bdf8', solid: '#0ea5e9', divider: '#bae6fd' }, + function: { fill: '#3b0764', stroke: '#c084fc', solid: '#a855f7' }, + scope: { fill: '#262a45', stroke: '#8b93c9', solid: '#5c6399' }, + unreachable: { fill: '#4c0519', stroke: '#fb7185', solid: '#f43f5e' }, + reference: { stroke: '#94a3b8', solid: '#94a3b8' }, + focus: '#fbbf24', + root: '#fbbf24', + text: { primary: '#e0e0ff', secondary: '#a0a0cc', muted: '#6b6b99', onFocus: '#fcd34d' }, + label: { bg: '#12122a', border: '#2a2a4e' }, + }, +};