diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..04ffa28 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index c36b85e..eb51cb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -265,4 +265,9 @@ Node ids are scoped as `` `${name}\u0000${occurrence}` `` where occurrence counts blocks mentioning that name, in document order. `node.name` stays the raw name and is what gets rendered — ids are internal only. Changing this scheme requires bumping `POSITIONS_VERSION` in `storage.ts`, since stored -drag positions are keyed by id. \ No newline at end of file +drag positions are keyed by id. + +### Diffs Playground +Ported from `~/Documents/diffs`. Compares texts visually by tokenizing, stemming (Porter2 via `wink-porter2-stemmer`), and grouping shared vocabulary into a central Shared Words column. Pure model architecture (`buildModel`) with frame-driven progressive reveal (`useProgressiveReveal`), highlight mode (`selectedStem`), animated arcs, and camera auto-pan tween (`TWEEN`). Storage versioning uses `STORAGE_KEY = 'diffs-state-v1'`. + +Text in `StatLine` and `Legend` must set `baseline={TEXT_BASELINE}` (`'baseline'` → `dominant-baseline: alphabetic`). The original asked for `'top'`, which its older two.js emitted verbatim — an invalid CSS value browsers resolve to `auto`, i.e. alphabetic. The `y` offsets (`SIZE * 0.33` for the word, `SIZE * 0.25` for the tally) were tuned against that, so two.js's `middle` default renders the text ~5.6px low. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-09-zui-hook.md b/docs/superpowers/plans/2026-08-09-zui-hook.md new file mode 100644 index 0000000..1017075 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-zui-hook.md @@ -0,0 +1,1825 @@ +# ZUI Hook + Wiremarks Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a production-ready `useZUI` React hook that adds zoom/pan to any react-two.js `Group`, and wire it into the Wiremarks playground so background-drag pans, wheel/pinch zooms, and node-dragging stays pixel-accurate at any zoom level. + +**Architecture:** `useZUI` wraps Two.js's `ZUI` extra, binds Pointer Events to a target DOM element, and keeps all zoom/pan state in a ref (rAF-coalesced notifications) so high-frequency wheel events never re-render the scene. Panning is gated on a new `hitTestPoint(clientX, clientY)` method exposed through `TwoCoreContext`: if the pointer landed on a registered shape, ZUI stands down and the shape's own drag handler wins. + +**Tech Stack:** TypeScript (strict), React 18.3+, Two.js >= v0.8.23 (`two.js/extras/jsm/zui.js`), Vitest + React Testing Library + jsdom. + +## Global Constraints + +- Branch from `feat-playgrounds`. **Do NOT merge or cherry-pick the `7-zui` branch** — it forks from `e077c5e`, predates the split-context refactor, and is superseded by this plan. Read it for reference only. +- TypeScript strict mode. No `any` without an `// eslint-disable-next-line @typescript-eslint/no-explicit-any` comment matching existing codebase style. +- **Never publish an ambient `declare module 'two.js/extras/jsm/zui.js'`.** Types stay local and non-ambient (Task 1). +- Library code lives in `lib/`. Tests live in `tests/`. Demo code lives in `src/`. +- Run tests with `npx vitest run ` (bare `npm test` starts watch mode). +- Two.js `contains()` and `getBoundingClientRect(false)` already operate in **world space** and already account for a group's `worldMatrix`. Hit testing therefore works under zoom with **no changes to `lib/Events.ts` hit-test logic.** Do not "fix" it. +- The ZUI-managed `` must never receive `x`, `y`, or `scale` props — `lib/Group.tsx:77-92` re-applies props on every render and would fight ZUI. + +## Verified Preconditions + +These were checked against the installed `two.js@0.8.23-r.2` and this repo's jsdom before the plan was written. Trust them; do not re-litigate. + +| Assumption | Status | +|---|---| +| `two.js/extras/jsm/zui.js` imports and runs under Vitest | ✅ verified | +| `addLimits(0.25, 8)` clamps `zui.scale` at both ends | ✅ verified | +| `zoomBy(Math.LN2)` yields `scale === 2` and writes through to `group.scale` | ✅ verified | +| `translateSurface(50, 30)` writes to `surfaceMatrix.elements[2]/[5]` and `group.translation` | ✅ verified | +| At scale 2, a 100px client move is a 50-unit `clientToSurface` delta (the Task 6 invariant) | ✅ verified | +| `requestAnimationFrame` exists in jsdom | ✅ verified | +| `PointerEvent` exists in jsdom | ❌ **absent** — polyfill required, Task 4 Step 1 | +| Two.js `contains()` / `getBoundingClientRect(false)` use `worldMatrix`, so hit testing already works under zoom | ✅ verified by source inspection | + +--- + +## File Structure + +**Create:** +- `lib/zuiTypes.ts` — non-ambient type surface for the untyped Two.js ZUI extra +- `lib/zuiMath.ts` — pure, framework-free gesture math +- `lib/ZUI.ts` — `useZUI` + `useZUIState` +- `tests/zuiMath.test.ts` — pure math tests +- `tests/zui.test.tsx` — hook + gesture integration tests + +**Modify:** +- `lib/Events.ts` — extract `clientToWorldPoint` +- `lib/Context.ts` — add `hitTestPoint` to `TwoCoreContextValue` +- `lib/Provider.tsx` — implement `hitTestPoint`, add to `coreValue` +- `lib/Group.tsx:110-117`, `lib/SVG.tsx:232-239` — forward `hitTestPoint` +- `lib/main.ts` — export the hook +- `vite.config.lib.ts` — externalize deep `two.js/*` imports +- `src/playgrounds/wiremarks/components/WiremarkEntity.tsx` — emit client coords +- `src/playgrounds/wiremarks/components/WiremarksScene.tsx` — relay new callback signature +- `src/playgrounds/wiremarks/WiremarkCanvas.tsx` — mount ZUI, convert drag to surface space +- `src/playgrounds/wiremarks/WiremarksPlayground.tsx` — zoom UI + cursor + +**Explicitly out of scope:** `fitTo`/fit-to-content, minimap, keyboard panning, fixing the pre-existing broken SVG export in `WiremarksPlayground.handleDownload` (it calls `querySelector('svg')` while the Canvas renders with `Two.Types.canvas`). + +--- + +### Task 1: ZUI types + build externalization + +**Files:** +- Create: `lib/zuiTypes.ts` +- Modify: `vite.config.lib.ts:19` + +**Interfaces:** +- Produces: `ZUIInstance` interface, `ZUIConstructor` type. + +`vite.config.lib.ts` currently has `external: ['react', 'react/jsx-runtime', 'two.js']` — an exact-match array. `two.js/extras/jsm/zui.js` would not match and would get **bundled into the published library**. Fix that here. + +- [ ] **Step 1: Create the local type surface** + +`lib/zuiTypes.ts`: + +```ts +/** + * Local, non-ambient types for `two.js/extras/jsm/zui.js`, which ships no + * declarations. Declaring this as an ambient module would leak into every + * consumer of react-two.js, so we type it structurally and cast at the + * single import site in `lib/ZUI.ts`. + */ +import type { Group } from 'two.js/src/group'; +import type { Matrix } from 'two.js/src/matrix'; + +export interface ZUIVector { + x: number; + y: number; + z: number; +} + +export interface ZUIInstance { + zoom: number; + scale: number; + surfaceMatrix: Matrix; + viewport: HTMLElement; + limits: { + scale: { min: number; max: number }; + x: { min: number; max: number }; + y: { min: number; max: number }; + }; + addLimits(min?: number, max?: number): ZUIInstance; + zoomBy(byF: number, clientX: number, clientY: number): ZUIInstance; + zoomSet(zoom: number, clientX: number, clientY: number): ZUIInstance; + translateSurface(x: number, y: number): ZUIInstance; + clientToSurface(x: number, y: number, z?: number): ZUIVector; + surfaceToClient(x: number, y: number, z?: number): ZUIVector; + updateOffset(): ZUIInstance; + updateSurface(): ZUIInstance; + reset(): ZUIInstance; +} + +export type ZUIConstructor = new ( + group: Group, + domElement?: HTMLElement +) => ZUIInstance; +``` + +- [ ] **Step 2: Externalize deep two.js imports** + +In `vite.config.lib.ts`, replace the `external` line: + +```ts + external: [/^react$/, /^react\/jsx-runtime$/, /^two\.js(\/.*)?$/], +``` + +- [ ] **Step 3: Verify typecheck passes** + +Run: `npx tsc --noEmit -p ./tsconfig.build.json` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add lib/zuiTypes.ts vite.config.lib.ts +git commit -m "feat(zui): add local ZUI types and externalize deep two.js imports" +``` + +--- + +### Task 2: Gesture math (pure functions) + +**Files:** +- Create: `lib/zuiMath.ts` +- Test: `tests/zuiMath.test.ts` + +**Interfaces:** +- Produces: `normalizeWheelDelta`, `wheelZoomDelta`, `pinchWheelZoomDelta`, `pinchZoomDelta`, `distance`, `centroid`, `clamp`, and the `PanBounds` type. + +Zoom in Two.js's ZUI is **logarithmic**: `scale = Math.exp(zoom)`, and `zoomBy(byF)` adds `byF` to the log-space position. Every function below returns log-space deltas. + +- [ ] **Step 1: Write the failing tests** + +`tests/zuiMath.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { + normalizeWheelDelta, + wheelZoomDelta, + pinchWheelZoomDelta, + pinchZoomDelta, + distance, + centroid, + clamp, +} from '../lib/zuiMath'; + +describe('normalizeWheelDelta', () => { + it('passes pixel mode through unchanged', () => { + expect(normalizeWheelDelta(100, 0, 800)).toBe(100); + }); + + it('scales line mode by 16px per line', () => { + expect(normalizeWheelDelta(3, 1, 800)).toBe(48); + }); + + it('scales page mode by the viewport height', () => { + expect(normalizeWheelDelta(1, 2, 800)).toBe(800); + }); +}); + +describe('wheelZoomDelta', () => { + it('returns a negative log delta when scrolling down (zoom out)', () => { + expect(wheelZoomDelta(100, 0, 0.05, 800)).toBeCloseTo(-0.05, 10); + }); + + it('returns a positive log delta when scrolling up (zoom in)', () => { + expect(wheelZoomDelta(-100, 0, 0.05, 800)).toBeCloseTo(0.05, 10); + }); + + it('is symmetric so a scroll down then up is a round trip', () => { + const down = wheelZoomDelta(100, 0, 0.05, 800); + const up = wheelZoomDelta(-100, 0, 0.05, 800); + expect(down + up).toBeCloseTo(0, 10); + }); +}); + +describe('pinchWheelZoomDelta', () => { + it('inverts a ctrl-wheel trackpad pinch', () => { + expect(pinchWheelZoomDelta(-10)).toBeCloseTo(0.1, 10); + }); +}); + +describe('pinchZoomDelta', () => { + it('returns zero when the distance is unchanged', () => { + expect(pinchZoomDelta(100, 100)).toBe(0); + }); + + it('returns a log ratio so doubling the spread is scale-independent', () => { + expect(pinchZoomDelta(100, 200)).toBeCloseTo(Math.LN2, 10); + expect(pinchZoomDelta(400, 800)).toBeCloseTo(Math.LN2, 10); + }); + + it('guards against zero and negative distances', () => { + expect(pinchZoomDelta(0, 100)).toBe(0); + expect(pinchZoomDelta(100, 0)).toBe(0); + }); +}); + +describe('distance and centroid', () => { + it('computes euclidean distance', () => { + expect(distance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5); + }); + + it('computes the midpoint of two points', () => { + expect(centroid([{ x: 0, y: 0 }, { x: 10, y: 20 }])).toEqual({ x: 5, y: 10 }); + }); + + it('returns the origin for an empty list', () => { + expect(centroid([])).toEqual({ x: 0, y: 0 }); + }); +}); + +describe('clamp', () => { + it('clamps to both bounds', () => { + expect(clamp(5, 0, 10)).toBe(5); + expect(clamp(-1, 0, 10)).toBe(0); + expect(clamp(11, 0, 10)).toBe(10); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run tests/zuiMath.test.ts` +Expected: FAIL — cannot resolve `../lib/zuiMath`. + +- [ ] **Step 3: Implement** + +`lib/zuiMath.ts`: + +```ts +/** + * Pure gesture math for the ZUI hook. No React, no DOM — everything here is + * directly unit-testable. + * + * Two.js's ZUI treats zoom logarithmically (`scale = Math.exp(zoom)`), so all + * "delta" helpers below return log-space amounts suitable for `zui.zoomBy()`. + */ + +/** Approximate pixel height of one wheel "line" in DOM_DELTA_LINE mode. */ +export const LINE_HEIGHT_PX = 16; + +/** Log-space units applied per unit of ctrl+wheel (trackpad pinch) delta. */ +export const PINCH_WHEEL_FACTOR = 0.01; + +/** A single wheel notch in DOM_DELTA_PIXEL mode, used to normalise `speed`. */ +export const WHEEL_NOTCH_PX = 100; + +export interface Point { + x: number; + y: number; +} + +export interface PanBounds { + x?: [number, number]; + y?: [number, number]; +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Convert a `WheelEvent.deltaY` into pixels, accounting for `deltaMode`. + * Firefox reports lines (mode 1); some environments report pages (mode 2). + */ +export function normalizeWheelDelta( + deltaY: number, + deltaMode: number, + viewportHeight: number +): number { + switch (deltaMode) { + case 1: + return deltaY * LINE_HEIGHT_PX; + case 2: + return deltaY * viewportHeight; + default: + return deltaY; + } +} + +/** + * Log-space zoom delta for a standard scroll wheel. `speed` is expressed in + * log units per notch, so the default of 0.05 means one notch changes scale + * by a factor of e^0.05 (~5%). + */ +export function wheelZoomDelta( + deltaY: number, + deltaMode: number, + speed: number, + viewportHeight: number +): number { + const pixels = normalizeWheelDelta(deltaY, deltaMode, viewportHeight); + return (-pixels / WHEEL_NOTCH_PX) * speed; +} + +/** Log-space zoom delta for a trackpad pinch, which arrives as ctrl+wheel. */ +export function pinchWheelZoomDelta(deltaY: number): number { + return -deltaY * PINCH_WHEEL_FACTOR; +} + +/** + * Log-space zoom delta for a two-finger pinch. Using the log of the distance + * ratio makes the gesture feel identical at every zoom level — the WIP branch + * used a linear pixel difference, which did not. + */ +export function pinchZoomDelta( + prevDistance: number, + nextDistance: number +): number { + if (prevDistance <= 0 || nextDistance <= 0) { + return 0; + } + return Math.log(nextDistance / prevDistance); +} + +export function distance(a: Point, b: Point): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.sqrt(dx * dx + dy * dy); +} + +export function centroid(points: Point[]): Point { + if (points.length === 0) { + return { x: 0, y: 0 }; + } + let x = 0; + let y = 0; + for (const point of points) { + x += point.x; + y += point.y; + } + return { x: x / points.length, y: y / points.length }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run tests/zuiMath.test.ts` +Expected: PASS, 13 tests. + +- [ ] **Step 5: Commit** + +```bash +git add lib/zuiMath.ts tests/zuiMath.test.ts +git commit -m "feat(zui): add pure gesture math helpers" +``` + +--- + +### Task 3: `hitTestPoint` on the core context + +**Files:** +- Modify: `lib/Events.ts:101-112`, `lib/Context.ts:7-30`, `lib/Provider.tsx:186-192` and `:560-567`, `lib/Group.tsx:110-117`, `lib/SVG.tsx:232-239` +- Test: `tests/events.test.tsx` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `clientToWorldPoint(clientX: number, clientY: number, canvas: HTMLElement): { x: number; y: number }` from `lib/Events.ts`; `hitTestPoint(clientX: number, clientY: number): boolean` on `TwoCoreContextValue`. + +This is the coordination channel that lets `useZUI` know whether a pointerdown was already claimed by a shape. It queries the exact same registry, in the exact same coordinate space, as the Provider's own dispatch — so there is no listener-ordering dependency. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/events.test.tsx`, inside the top-level `describe('react-two.js Event System', ...)` block: + +```tsx + describe('hitTestPoint', () => { + it('reports true over a registered shape and false over empty canvas', () => { + const seen: Array<{ label: string; hit: boolean }> = []; + + function Probe() { + const { hitTestPoint } = useTwo(); + useEffect(() => { + seen.push({ label: 'inside', hit: hitTestPoint(400, 300) }); + seen.push({ label: 'outside', hit: hitTestPoint(10, 10) }); + }, [hitTestPoint]); + return null; + } + + render( + + + {}} + /> + + + , + ); + + expect(seen.find((s) => s.label === 'inside')?.hit).toBe(true); + expect(seen.find((s) => s.label === 'outside')?.hit).toBe(false); + }); + }); +``` + +Add `useEffect` to the `react` import at the top of the file and `useTwo` to the `react-two.js` import. + +> Note on coordinates: jsdom returns an all-zero `getBoundingClientRect()` for the canvas, so client coords and canvas-relative coords coincide in tests. The shape sits at world (400, 300) with a 100x60 box, so client (400, 300) is inside and (10, 10) is outside. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/events.test.tsx -t hitTestPoint` +Expected: FAIL — `hitTestPoint is not a function`. + +- [ ] **Step 3: Extract the shared coordinate helper** + +In `lib/Events.ts`, replace the body of `getWorldCoordinates` (lines 101-112) with a delegating version and add the new export directly above it: + +```ts +/** + * Convert raw client coordinates to world-space coordinates for hit testing. + * World space uses a top-left origin, relative to the canvas element. + */ +export function clientToWorldPoint( + clientX: number, + clientY: number, + canvas: HTMLElement +): { x: number; y: number } { + const rect = canvas.getBoundingClientRect(); + + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; +} + +/** + * Convert DOM event coordinates to world-space coordinates for hit testing + * World-space uses top-left origin (same as DOM but relative to canvas) + */ +export function getWorldCoordinates( + nativeEvent: PointerEvent | MouseEvent, + canvas: HTMLElement +): { x: number; y: number } { + return clientToWorldPoint(nativeEvent.clientX, nativeEvent.clientY, canvas); +} +``` + +- [ ] **Step 4: Add `hitTestPoint` to the context type** + +In `lib/Context.ts`, add the member to `TwoCoreContextValue` (after `unregisterEventShape`): + +```ts + hitTestPoint: (clientX: number, clientY: number) => boolean; +``` + +and to the `createContext` default: + +```ts +export const TwoCoreContext = createContext({ + two: null, + registerEventShape: () => {}, + unregisterEventShape: () => {}, + hitTestPoint: () => false, +}); +``` + +- [ ] **Step 5: Implement it in the Provider** + +In `lib/Provider.tsx`, add `clientToWorldPoint` to the existing import from `./Events`, then add this callback immediately after the `unregisterEventShape` definition (around line 192): + +```tsx + /** + * Returns true if any registered event shape sits under the given client + * coordinates. Used by `useZUI` to leave pointerdowns that landed on a + * shape alone, so shape drags and canvas panning never fight. + */ + const hitTestPoint = useCallback( + (clientX: number, clientY: number): boolean => { + const canvas = twoState?.renderer.domElement; + if (!canvas) return false; + + const point = clientToWorldPoint(clientX, clientY, canvas); + return ( + getShapesAtPoint(eventShapes.current, point.x, point.y, twoState) + .length > 0 + ); + }, + [twoState], + ); +``` + +Then add it to `coreValue` (around line 560): + +```tsx + const coreValue = useMemo( + () => ({ + two: twoState, + registerEventShape, + unregisterEventShape, + hitTestPoint, + }), + [twoState, registerEventShape, unregisterEventShape, hitTestPoint], + ); +``` + +- [ ] **Step 6: Forward it through Group and SVG** + +`lib/Group.tsx` — add `hitTestPoint` to the `useTwo()` destructure (line 35-42) and to `coreValue`: + +```tsx + const coreValue = useMemo( + () => ({ + two, + registerEventShape, + unregisterEventShape, + hitTestPoint, + }), + [two, registerEventShape, unregisterEventShape, hitTestPoint] + ); +``` + +`lib/SVG.tsx` — make the identical change to its `useTwo()` destructure and its `coreValue` at lines 232-239. + +- [ ] **Step 7: Run the full test suite** + +Run: `npx vitest run tests/` +Expected: PASS, including the new `hitTestPoint` test. No regressions in `events.test.tsx`. + +- [ ] **Step 8: Lint and commit** + +```bash +npm run lint +git add lib/Events.ts lib/Context.ts lib/Provider.tsx lib/Group.tsx lib/SVG.tsx tests/events.test.tsx +git commit -m "feat(events): expose hitTestPoint through the core context" +``` + +--- + +### Task 4: The `useZUI` hook + +**Files:** +- Create: `lib/ZUI.ts` +- Modify: `lib/main.ts`, `src/test-setup.ts` +- Test: `tests/zui.test.tsx` + +**Interfaces:** +- Consumes: `ZUIConstructor` / `ZUIInstance` (Task 1); all of `lib/zuiMath.ts` (Task 2); `hitTestPoint` from `useTwo()` (Task 3). +- Produces: `useZUI(target, options): ZUIControls`, `useZUIState(controls): ZUIState`, and the `UseZUIOptions` / `ZUIControls` / `ZUIState` / `ReadonlyRef` types. + +Key correctness requirements, each of which fixes a defect in the `7-zui` WIP: +1. `instance` is a **ref**, never read during render. +2. Zoom/pan state lives in a ref; listeners are notified on `requestAnimationFrame`, so a wheel gesture cannot re-render the scene per event. +3. One Pointer Event code path handles mouse, touch, and pen. There is no separate touch branch. +4. `lostpointercapture` and `pointercancel` both end a pan, so a drag can never get stuck. +5. `panBounds` is enforced by us — Two.js's `ZUI` declares `limits.x`/`limits.y` but never reads them. + +- [ ] **Step 1: Polyfill `PointerEvent` for jsdom (required — do this first)** + +This project's jsdom does **not** implement `PointerEvent` (verified: `typeof PointerEvent === 'undefined'`). Without this, every gesture test throws `PointerEvent is not defined`. + +Append to `src/test-setup.ts`: + +```ts +/** + * jsdom does not implement PointerEvent. The ZUI hook binds pointer events + * exclusively, so tests need a minimal stand-in. MouseEvent already carries + * clientX/clientY/button/buttons, so only the pointer-specific fields are added. + */ +interface PointerEventInitLike extends MouseEventInit { + pointerId?: number; + pointerType?: string; + isPrimary?: boolean; +} + +if (typeof globalThis.PointerEvent === 'undefined') { + class PointerEventPolyfill extends MouseEvent { + readonly pointerId: number; + readonly pointerType: string; + readonly isPrimary: boolean; + + constructor(type: string, params: PointerEventInitLike = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 0; + this.pointerType = params.pointerType ?? 'mouse'; + this.isPrimary = params.isPrimary ?? true; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = PointerEventPolyfill; +} +``` + +Verify it loads: `npx vitest run tests/events.test.tsx` +Expected: PASS, no regressions. + +- [ ] **Step 2: Write the failing tests** + +`tests/zui.test.tsx`: + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { useEffect, useRef } from 'react'; +import Two from 'two.js'; +import { Canvas, Group, Circle, useZUI, type RefGroup, type ZUIControls } from '../lib/main'; + +beforeEach(() => { + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ + fillRect: vi.fn(), clearRect: vi.fn(), getImageData: vi.fn().mockReturnValue({ data: [] }), + putImageData: vi.fn(), createImageData: vi.fn().mockReturnValue([]), setTransform: vi.fn(), + drawImage: vi.fn(), save: vi.fn(), fillText: vi.fn(), restore: vi.fn(), beginPath: vi.fn(), + moveTo: vi.fn(), lineTo: vi.fn(), closePath: vi.fn(), stroke: vi.fn(), translate: vi.fn(), + scale: vi.fn(), rotate: vi.fn(), arc: vi.fn(), fill: vi.fn(), + measureText: vi.fn().mockReturnValue({ width: 0 }), transform: vi.fn(), rect: vi.fn(), clip: vi.fn(), + }); +}); + +/** Renders a ZUI-managed group and hands the controls back to the test. */ +function Harness({ + onReady, + withShape = false, +}: { + onReady: (controls: ZUIControls, canvas: HTMLElement) => void; + withShape?: boolean; +}) { + const groupRef = useRef(null); + const controls = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 }); + + useEffect(() => { + const canvas = document.querySelector('canvas'); + if (canvas) onReady(controls, canvas); + }, [controls, onReady]); + + return ( + + {withShape ? ( + {}} /> + ) : ( + + )} + + ); +} + +function renderHarness(withShape = false) { + const captured: { controls?: ZUIControls; canvas?: HTMLElement } = {}; + render( + + { + captured.controls = controls; + captured.canvas = canvas; + }} + /> + , + ); + return captured as { controls: ZUIControls; canvas: HTMLElement }; +} + +function pointer(type: string, init: Partial) { + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + isPrimary: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + ...init, + }); +} + +describe('useZUI', () => { + it('starts at scale 1 with an identity surface', () => { + const { controls } = renderHarness(); + expect(controls.state.current.scale).toBe(1); + expect(controls.state.current.zoom).toBe(0); + expect(controls.state.current.x).toBe(0); + expect(controls.state.current.y).toBe(0); + }); + + it('exposes the ZUI instance via a ref, not a render-time value', () => { + const { controls } = renderHarness(); + expect(controls.instance.current).not.toBeNull(); + }); + + it('applies the zoom to the target group scale', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(Math.LN2, 400, 300); + }); + expect(controls.state.current.scale).toBeCloseTo(2, 5); + }); + + it('clamps zoom to maxZoom', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(100, 400, 300); + }); + expect(controls.state.current.scale).toBeCloseTo(8, 5); + }); + + it('clamps zoom to minZoom', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(-100, 400, 300); + }); + expect(controls.state.current.scale).toBeCloseTo(0.25, 5); + }); + + it('pans on a background drag', () => { + const { controls, canvas } = renderHarness(false); + act(() => { + canvas.dispatchEvent(pointer('pointerdown', { clientX: 10, clientY: 10 })); + window.dispatchEvent(pointer('pointermove', { clientX: 60, clientY: 40 })); + window.dispatchEvent(pointer('pointerup', { clientX: 60, clientY: 40 })); + }); + expect(controls.state.current.x).toBeCloseTo(50, 5); + expect(controls.state.current.y).toBeCloseTo(30, 5); + }); + + it('does NOT pan when the pointerdown lands on a registered shape', () => { + const { controls, canvas } = renderHarness(true); + act(() => { + canvas.dispatchEvent(pointer('pointerdown', { clientX: 400, clientY: 300 })); + window.dispatchEvent(pointer('pointermove', { clientX: 450, clientY: 330 })); + window.dispatchEvent(pointer('pointerup', { clientX: 450, clientY: 330 })); + }); + expect(controls.state.current.x).toBe(0); + expect(controls.state.current.y).toBe(0); + }); + + it('ends a pan on pointercancel', () => { + const { controls, canvas } = renderHarness(false); + act(() => { + canvas.dispatchEvent(pointer('pointerdown', { clientX: 10, clientY: 10 })); + window.dispatchEvent(pointer('pointercancel', { clientX: 10, clientY: 10 })); + window.dispatchEvent(pointer('pointermove', { clientX: 200, clientY: 200 })); + }); + expect(controls.state.current.x).toBe(0); + }); + + it('zooms on wheel toward the cursor', () => { + const { controls, canvas } = renderHarness(); + act(() => { + canvas.dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: -100, deltaMode: 0, clientX: 400, clientY: 300 }), + ); + }); + expect(controls.state.current.scale).toBeGreaterThan(1); + }); + + it('resets back to the identity surface', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(1, 400, 300); + controls.panBy(25, 25); + controls.reset(); + }); + expect(controls.state.current.scale).toBe(1); + expect(controls.state.current.x).toBe(0); + expect(controls.state.current.y).toBe(0); + }); + + it('round-trips client and surface coordinates', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(0.5, 400, 300); + controls.panBy(40, -20); + }); + const surface = controls.clientToSurface(250, 175); + const client = controls.surfaceToClient(surface.x, surface.y); + expect(client.x).toBeCloseTo(250, 4); + expect(client.y).toBeCloseTo(175, 4); + }); + + it('honours panBounds', () => { + const captured: { controls?: ZUIControls } = {}; + function Bounded() { + const groupRef = useRef(null); + captured.controls = useZUI(groupRef, { panBounds: { x: [-50, 50], y: [-50, 50] } }); + return ; + } + render( + + + , + ); + act(() => { + captured.controls!.panBy(500, 500); + }); + expect(captured.controls!.state.current.x).toBe(50); + expect(captured.controls!.state.current.y).toBe(50); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run tests/zui.test.tsx` +Expected: FAIL — `useZUI` is not exported from `../lib/main`. + +- [ ] **Step 4: Implement the hook** + +`lib/ZUI.ts`: + +```ts +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type RefObject, +} from 'react'; +import { useTwo } from './Context'; +import type { RefGroup } from './Group'; +import type { ZUIConstructor, ZUIInstance } from './zuiTypes'; +import { + centroid, + clamp, + distance, + pinchWheelZoomDelta, + pinchZoomDelta, + wheelZoomDelta, + type PanBounds, + type Point, +} from './zuiMath'; + +// two.js ships no declarations for its extras; `lib/zuiTypes.ts` types it +// structurally so we never leak an ambient module into consumers. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error - untyped two.js extra +import { ZUI as ZUIImpl } from 'two.js/extras/jsm/zui.js'; + +const ZUIClass = ZUIImpl as unknown as ZUIConstructor; + +/** + * A ref whose `current` is always present. React 18's own `RefObject` types + * `current` as `T | null`, which would force a null check on every read of + * `zui.state.current`. + */ +export interface ReadonlyRef { + readonly current: T; +} + +/** Immutable snapshot of the current zoom/pan state. */ +export interface ZUIState { + /** Logarithmic zoom position. `scale === Math.exp(zoom)`. */ + zoom: number; + /** Linear scale factor. */ + scale: number; + /** Surface translation on the x axis, in client pixels. */ + x: number; + /** Surface translation on the y axis, in client pixels. */ + y: number; +} + +export interface UseZUIOptions { + /** Minimum scale factor (default: 0.25) */ + minZoom?: number; + /** Maximum scale factor (default: 8) */ + maxZoom?: number; + /** Log-space zoom units per wheel notch (default: 0.05) */ + wheelZoomSpeed?: number; + /** Optional clamp on surface translation, in client pixels */ + panBounds?: PanBounds; + /** + * `'background'` (default) pans only when the pointer misses every + * registered shape, so shape drag handlers win. `'always'` pans on any + * drag. `false` disables pointer panning entirely. + */ + pan?: 'background' | 'always' | false; + /** `'wheel'` (default) enables wheel and trackpad-pinch zoom; `false` disables it. */ + zoom?: 'wheel' | false; + /** Override the element that listeners attach to (default: the Two.js renderer element) */ + domElement?: HTMLElement | null; + /** Called at most once per animation frame while zoom or pan changes. */ + onChange?: (state: ZUIState) => void; +} + +export interface ZUIControls { + /** Live, always-current state. Reading this never triggers a re-render. */ + state: ReadonlyRef; + /** True while a pointer pan is in progress. */ + isPanning: ReadonlyRef; + /** The underlying Two.js ZUI instance, for advanced use. */ + instance: ReadonlyRef; + /** Zoom by a log-space amount, anchored at the given client point. */ + zoomBy: (byF: number, clientX: number, clientY: number) => void; + /** Zoom to an absolute scale factor, anchored at the given client point. */ + zoomTo: (scale: number, clientX: number, clientY: number) => void; + /** Pan by a delta in client pixels. */ + panBy: (dx: number, dy: number) => void; + /** Pan to an absolute surface translation in client pixels. */ + panTo: (x: number, y: number) => void; + /** Restore scale 1 and zero translation. */ + reset: () => void; + /** Convert client coordinates into the ZUI group's local space. */ + clientToSurface: (x: number, y: number) => Point; + /** Convert the ZUI group's local space back into client coordinates. */ + surfaceToClient: (x: number, y: number) => Point; + /** Subscribe to coalesced state changes. Used by `useZUIState`. */ + subscribe: (listener: () => void) => () => void; + /** Read the current immutable snapshot. Used by `useZUIState`. */ + getSnapshot: () => ZUIState; +} + +const IDENTITY_STATE: ZUIState = { zoom: 0, scale: 1, x: 0, y: 0 }; + +/** + * Add zoom and pan to a react-two.js `Group`. + * + * The target group's `x`, `y`, and `scale` are owned by this hook — do not + * also pass those props to it, or the two will fight each other. + * + * @example + * ```tsx + * function Scene() { + * const groupRef = useRef(null); + * const zui = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 }); + * + * return ( + * + * + * + * ); + * } + * ``` + */ +export function useZUI( + target: RefObject, + options: UseZUIOptions = {} +): ZUIControls { + const { two, hitTestPoint } = useTwo(); + + const instance = useRef(null); + const state = useRef(IDENTITY_STATE); + const isPanning = useRef(false); + const listeners = useRef(new Set<() => void>()); + const frame = useRef(null); + + // Latest options, read inside event handlers so they never need rebinding. + const optionsRef = useRef(options); + useEffect(() => { + optionsRef.current = options; + }); + + const { + minZoom = 0.25, + maxZoom = 8, + domElement: domElementOption, + } = options; + + const element = domElementOption ?? two?.renderer.domElement ?? null; + + /** + * Publish a fresh immutable snapshot. The snapshot updates synchronously so + * drag math stays exact, but subscribers are notified on the next animation + * frame so a wheel gesture cannot re-render the scene per event. + */ + const flush = useCallback(() => { + const zui = instance.current; + if (!zui) return; + + const elements = zui.surfaceMatrix.elements; + state.current = { + zoom: zui.zoom, + scale: zui.scale, + x: elements[2], + y: elements[5], + }; + + if (frame.current !== null) return; + + frame.current = requestAnimationFrame(() => { + frame.current = null; + optionsRef.current.onChange?.(state.current); + for (const listener of listeners.current) { + listener(); + } + }); + }, []); + + /** Two.js's ZUI declares limits.x / limits.y but never applies them. */ + const applyPanBounds = useCallback(() => { + const zui = instance.current; + const bounds = optionsRef.current.panBounds; + if (!zui || !bounds) return; + + const elements = zui.surfaceMatrix.elements; + const x = clamp( + elements[2], + bounds.x?.[0] ?? -Infinity, + bounds.x?.[1] ?? Infinity + ); + const y = clamp( + elements[5], + bounds.y?.[0] ?? -Infinity, + bounds.y?.[1] ?? Infinity + ); + + if (x !== elements[2] || y !== elements[5]) { + zui.translateSurface(x - elements[2], y - elements[5]); + } + }, []); + + // Create the ZUI instance once the group and element both exist. + useEffect(() => { + const group = target.current; + if (!group || !element) return; + + const zui = new ZUIClass(group, element); + zui.addLimits(minZoom, maxZoom); + instance.current = zui; + flush(); + + return () => { + instance.current = null; + if (frame.current !== null) { + cancelAnimationFrame(frame.current); + frame.current = null; + } + }; + }, [target, element, minZoom, maxZoom, flush]); + + const zoomBy = useCallback( + (byF: number, clientX: number, clientY: number) => { + const zui = instance.current; + if (!zui) return; + zui.zoomBy(byF, clientX, clientY); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const zoomTo = useCallback( + (scale: number, clientX: number, clientY: number) => { + const zui = instance.current; + if (!zui) return; + zui.zoomSet(scale, clientX, clientY); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const panBy = useCallback( + (dx: number, dy: number) => { + const zui = instance.current; + if (!zui) return; + zui.translateSurface(dx, dy); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const panTo = useCallback( + (x: number, y: number) => { + const zui = instance.current; + if (!zui) return; + const elements = zui.surfaceMatrix.elements; + zui.translateSurface(x - elements[2], y - elements[5]); + applyPanBounds(); + flush(); + }, + [applyPanBounds, flush] + ); + + const reset = useCallback(() => { + const zui = instance.current; + if (!zui) return; + zui.reset(); + flush(); + }, [flush]); + + const clientToSurface = useCallback((x: number, y: number): Point => { + const zui = instance.current; + if (!zui) return { x, y }; + const result = zui.clientToSurface(x, y); + return { x: result.x, y: result.y }; + }, []); + + const surfaceToClient = useCallback((x: number, y: number): Point => { + const zui = instance.current; + if (!zui) return { x, y }; + const result = zui.surfaceToClient(x, y); + return { x: result.x, y: result.y }; + }, []); + + const subscribe = useCallback((listener: () => void) => { + listeners.current.add(listener); + return () => { + listeners.current.delete(listener); + }; + }, []); + + const getSnapshot = useCallback(() => state.current, []); + + // Pointer panning and pinch zoom. One code path covers mouse, touch and pen. + useEffect(() => { + if (!element) return; + + const active = new Map(); + let lastCentroid: Point | null = null; + let lastDistance = 0; + + const points = () => Array.from(active.values()); + + const handlePointerDown = (event: PointerEvent) => { + const mode = optionsRef.current.pan ?? 'background'; + if (mode === false) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + if (mode === 'background' && hitTestPoint(event.clientX, event.clientY)) { + return; + } + + active.set(event.pointerId, { x: event.clientX, y: event.clientY }); + isPanning.current = true; + lastCentroid = centroid(points()); + lastDistance = active.size === 2 ? distance(points()[0], points()[1]) : 0; + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!active.has(event.pointerId)) return; + + active.set(event.pointerId, { x: event.clientX, y: event.clientY }); + const current = points(); + const nextCentroid = centroid(current); + + if (lastCentroid) { + panBy(nextCentroid.x - lastCentroid.x, nextCentroid.y - lastCentroid.y); + } + lastCentroid = nextCentroid; + + if (current.length === 2) { + const nextDistance = distance(current[0], current[1]); + if (lastDistance > 0) { + zoomBy( + pinchZoomDelta(lastDistance, nextDistance), + nextCentroid.x, + nextCentroid.y + ); + } + lastDistance = nextDistance; + } + }; + + const endPointer = (event: PointerEvent) => { + if (!active.delete(event.pointerId)) return; + + const remaining = points(); + lastCentroid = remaining.length > 0 ? centroid(remaining) : null; + lastDistance = + remaining.length === 2 ? distance(remaining[0], remaining[1]) : 0; + isPanning.current = active.size > 0; + }; + + element.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', endPointer); + window.addEventListener('pointercancel', endPointer); + window.addEventListener('lostpointercapture', endPointer); + + return () => { + element.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', endPointer); + window.removeEventListener('pointercancel', endPointer); + window.removeEventListener('lostpointercapture', endPointer); + }; + }, [element, hitTestPoint, panBy, zoomBy]); + + // Wheel zoom, including ctrl+wheel trackpad pinch. + useEffect(() => { + if (!element) return; + + const handleWheel = (event: WheelEvent) => { + if ((optionsRef.current.zoom ?? 'wheel') === false) return; + + event.preventDefault(); + + const delta = event.ctrlKey + ? pinchWheelZoomDelta(event.deltaY) + : wheelZoomDelta( + event.deltaY, + event.deltaMode, + optionsRef.current.wheelZoomSpeed ?? 0.05, + element.clientHeight || window.innerHeight + ); + + zoomBy(delta, event.clientX, event.clientY); + }; + + element.addEventListener('wheel', handleWheel, { passive: false }); + return () => element.removeEventListener('wheel', handleWheel); + }, [element, zoomBy]); + + return useMemo( + () => ({ + state, + isPanning, + instance, + zoomBy, + zoomTo, + panBy, + panTo, + reset, + clientToSurface, + surfaceToClient, + subscribe, + getSnapshot, + }), + [ + zoomBy, + zoomTo, + panBy, + panTo, + reset, + clientToSurface, + surfaceToClient, + subscribe, + getSnapshot, + ] + ); +} + +/** + * Opt into re-rendering when zoom or pan changes. Only use this in components + * that actually display the value — `useZUI` alone never re-renders. + */ +export function useZUIState(controls: ZUIControls): ZUIState { + return useSyncExternalStore( + controls.subscribe, + controls.getSnapshot, + controls.getSnapshot + ); +} +``` + +- [ ] **Step 5: Export from the library entry point** + +In `lib/main.ts`, add below the `Group` export: + +```ts +export { + useZUI, + useZUIState, + type UseZUIOptions, + type ZUIControls, + type ZUIState, + type ReadonlyRef, +} from './ZUI'; +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx vitest run tests/zui.test.tsx` +Expected: PASS, 12 tests. + +Debugging notes if they do not: +- **`state.current` is stale** — the snapshot updates synchronously inside `flush()`. If it lags, something is reading before `flush()` runs; this is not a `requestAnimationFrame` timing problem. +- **The "does NOT pan on a shape" test pans anyway** — `hitTestPoint` returned false. Two.js `contains()` calls `_update(true)` and works headlessly, but a shape is only registered for hit testing if it has at least one event handler prop. Confirm the harness `Circle` has `onPointerDown`. +- **The "pans on a background drag" test does not pan** — jsdom's `getBoundingClientRect()` returns all zeros, so client and canvas coordinates coincide. A hit at (10, 10) means a shape is registered near the origin; check the harness is passing `withShape={false}`. + +- [ ] **Step 7: Verify no regressions, then lint and commit** + +```bash +npx vitest run tests/ +npm run lint +npx tsc --noEmit -p ./tsconfig.build.json +git add lib/ZUI.ts lib/main.ts src/test-setup.ts tests/zui.test.tsx +git commit -m "feat(zui): add useZUI and useZUIState hooks" +``` + +--- + +### Task 5: Wiremarks — client-space drag callbacks + +**Files:** +- Modify: `src/playgrounds/wiremarks/components/WiremarkEntity.tsx:9-48` +- Modify: `src/playgrounds/wiremarks/components/WiremarksScene.tsx:13-15` + +**Interfaces:** +- Produces: the new drag callback contract used by Task 6 — + `onDragStart?: (nodeId: string, clientX: number, clientY: number) => void`, + `onDrag?: (nodeId: string, clientX: number, clientY: number) => void`, + `onDragEnd?: (nodeId: string) => void`. + +The entity currently computes deltas in **screen pixels** and hands them straight to `updateNodePosition`, which writes **world** coordinates. That is already an implicit assumption that scale is 1, and it breaks the moment ZUI lands. The fix is to stop computing deltas here at all: emit raw client coordinates and let the component that owns the ZUI controls do the conversion. `WiremarksScene` stays presentational and ZUI-unaware. + +This task also swaps the `mousemove`/`mouseup` listeners for pointer events, and adds `pointercancel` so a drag interrupted by the OS cannot get stuck. + +- [ ] **Step 1: Update the entity's props and handler** + +In `src/playgrounds/wiremarks/components/WiremarkEntity.tsx`, replace the props interface and `handlePointerDown`: + +```tsx +interface WiremarkEntityProps { + node: WiremarkNode; + isDragging?: boolean; + onDragStart?: (nodeId: string, clientX: number, clientY: number) => void; + onDrag?: (nodeId: string, clientX: number, clientY: number) => void; + onDragEnd?: (nodeId: string) => void; +} +``` + +```tsx + const handlePointerDown = useCallback( + (e: TwoEvent) => { + // Keeps useZUI from also treating this as a background pan. + e.stopPropagation(); + + const native = e.nativeEvent as PointerEvent; + onDragStart?.(node.id, native.clientX, native.clientY); + + const handlePointerMove = (moveEvt: PointerEvent) => { + onDrag?.(node.id, moveEvt.clientX, moveEvt.clientY); + }; + + const handlePointerUp = () => { + onDragEnd?.(node.id); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + }, + [node.id, onDragStart, onDrag, onDragEnd], + ); +``` + +Keep the `TwoEvent` import in this file — the handler signature still uses it. + +- [ ] **Step 2: Relay the new signature through the scene** + +In `src/playgrounds/wiremarks/components/WiremarksScene.tsx`, update the props interface (lines 13-15) to match: + +```tsx + onDragStart?: (nodeId: string, clientX: number, clientY: number) => void; + onDrag?: (nodeId: string, clientX: number, clientY: number) => void; + onDragEnd?: (nodeId: string) => void; +``` + +The `TwoEvent` import at line 2 is now unused — remove it, leaving `import { Group } from 'react-two.js';`. The JSX below needs no changes; it already forwards the callbacks by reference. + +- [ ] **Step 3: Verify the typecheck fails in exactly one expected place** + +Run: `npx tsc --noEmit -p ./tsconfig.app.json` +Expected: errors only in `src/playgrounds/wiremarks/WiremarkCanvas.tsx` — its `handleDragStart`/`handleDrag` still take `(nodeId, dx, dy)`. Task 6 fixes them. + +- [ ] **Step 4: Commit** + +```bash +git add src/playgrounds/wiremarks/components/WiremarkEntity.tsx src/playgrounds/wiremarks/components/WiremarksScene.tsx +git commit -m "refactor(wiremarks): emit client coordinates from entity drags" +``` + +--- + +### Task 6: Wiremarks — mount ZUI and convert drags to surface space + +**Files:** +- Modify: `src/playgrounds/wiremarks/WiremarkCanvas.tsx` +- Test: `tests/zui.test.tsx` + +**Interfaces:** +- Consumes: `useZUI`, `ZUIControls` (Task 4); the drag callback contract (Task 5). +- Produces: `WiremarkCanvasProps` gains `controlsRef?: MutableRefObject` and `onZoomChange?: (scale: number) => void`, both consumed by Task 7. + +`sceneGroupRef` already exists here and is currently unused — it is the ZUI mount point. + +Drag positions are computed by converting **both** the drag origin and the current pointer into surface space and taking the difference. That is exact, and unlike dividing by `scale` it stays correct if the user zooms or pans mid-drag. + +- [ ] **Step 1: Write the failing test** + +This tests the exact invariant `WiremarkCanvas` relies on: the difference between two `clientToSurface` results is the true world-space distance the pointer travelled, at any zoom or pan. + +Append to `tests/zui.test.tsx`, inside the top-level `describe('useZUI', ...)` block: + +```tsx + describe('surface-space drag deltas', () => { + it('maps screen pixels one-for-one at scale 1', () => { + const { controls } = renderHarness(); + const a = controls.clientToSurface(100, 100); + const b = controls.clientToSurface(200, 160); + expect(b.x - a.x).toBeCloseTo(100, 5); + expect(b.y - a.y).toBeCloseTo(60, 5); + }); + + it('halves the surface delta at scale 2', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(Math.LN2, 400, 300); + }); + expect(controls.state.current.scale).toBeCloseTo(2, 5); + + const a = controls.clientToSurface(100, 100); + const b = controls.clientToSurface(200, 100); + expect(b.x - a.x).toBeCloseTo(50, 5); + }); + + it('doubles the surface delta at scale 0.5', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(-Math.LN2, 400, 300); + }); + expect(controls.state.current.scale).toBeCloseTo(0.5, 5); + + const a = controls.clientToSurface(100, 100); + const b = controls.clientToSurface(200, 100); + expect(b.x - a.x).toBeCloseTo(200, 5); + }); + + it('is unaffected by panning', () => { + const { controls } = renderHarness(); + act(() => { + controls.zoomBy(Math.LN2, 400, 300); + controls.panBy(123, -45); + }); + const a = controls.clientToSurface(100, 100); + const b = controls.clientToSurface(200, 100); + expect(b.x - a.x).toBeCloseTo(50, 5); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it passes against Task 4's hook** + +Run: `npx vitest run tests/zui.test.tsx -t "surface-space drag deltas"` +Expected: PASS, 4 tests. These characterise `useZUI` from Task 4 and are the guard rail for Step 3 — if any of them fail, the bug is in `clientToSurface`, not in `WiremarkCanvas`. Fix Task 4 before continuing. + +- [ ] **Step 3: Rewrite WiremarkCanvas** + +Replace `src/playgrounds/wiremarks/WiremarkCanvas.tsx` entirely: + +```tsx +import { + useCallback, + useEffect, + useRef, + useState, + type MutableRefObject, +} from 'react'; +import { useFrame, useZUI, Group, RefGroup, type ZUIControls } from 'react-two.js'; +import { useWiremarksGraph } from './hooks/useWiremarksGraph'; +import { WiremarksScene } from './components/WiremarksScene'; + +interface WiremarkCanvasProps { + instructions: string; + /** Receives the ZUI controls so DOM chrome outside can drive zoom. */ + controlsRef?: MutableRefObject; + /** Called at most once per frame while the zoom level changes. */ + onZoomChange?: (scale: number) => void; +} + +export function WiremarkCanvas({ + instructions, + controlsRef, + onZoomChange, +}: WiremarkCanvasProps) { + const sceneGroupRef = useRef(null); + + const [dashOffset, setDashOffset] = useState(0); + const [draggingNodeId, setDraggingNodeId] = useState(null); + const dragOriginRef = useRef<{ + pointer: { x: number; y: number }; + node: { x: number; y: number }; + } | null>(null); + + const { nodes, edges, nodesMap, updateNodePosition } = + useWiremarksGraph(instructions); + + const handleZoomChange = useCallback( + (state: { scale: number }) => onZoomChange?.(state.scale), + [onZoomChange], + ); + + // `pan: 'background'` leaves pointerdowns that landed on an entity alone, so + // dragging a node never also pans the canvas. + const zui = useZUI(sceneGroupRef, { + minZoom: 0.25, + maxZoom: 8, + pan: 'background', + onChange: handleZoomChange, + }); + + useEffect(() => { + if (controlsRef) { + controlsRef.current = zui; + } + }, [controlsRef, zui]); + + // Smooth 60fps dash offset animation loop + useFrame((_, frameDelta) => { + setDashOffset((prev) => prev - frameDelta / 10); + }); + + const handleDragStart = useCallback( + (nodeId: string, clientX: number, clientY: number) => { + setDraggingNodeId(nodeId); + const node = nodesMap.get(nodeId); + if (!node) return; + + const pointer = zui.clientToSurface(clientX, clientY); + dragOriginRef.current = { + pointer, + node: { x: node.x, y: node.y }, + }; + }, + [nodesMap, zui], + ); + + const handleDrag = useCallback( + (nodeId: string, clientX: number, clientY: number) => { + const origin = dragOriginRef.current; + if (!origin) return; + + // Diffing two surface-space points stays exact even if the view zooms + // or pans partway through the drag. + const pointer = zui.clientToSurface(clientX, clientY); + updateNodePosition( + nodeId, + origin.node.x + (pointer.x - origin.pointer.x), + origin.node.y + (pointer.y - origin.pointer.y), + ); + }, + [updateNodePosition, zui], + ); + + const handleDragEnd = useCallback(() => { + setDraggingNodeId(null); + dragOriginRef.current = null; + }, []); + + return ( + // NOTE: this Group's translation and scale are owned by useZUI. + // Do not add x, y, or scale props to it. + + + + ); +} +``` + +- [ ] **Step 4: Verify tests and typecheck pass** + +```bash +npx vitest run tests/ +npx tsc --noEmit -p ./tsconfig.app.json +npm run lint +``` +Expected: all PASS, no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/playgrounds/wiremarks/WiremarkCanvas.tsx tests/zui.test.tsx +git commit -m "feat(wiremarks): add zoom/pan and make node drags zoom-accurate" +``` + +--- + +### Task 7: Wiremarks playground — zoom chrome + +**Files:** +- Modify: `src/playgrounds/wiremarks/WiremarksPlayground.tsx` + +**Interfaces:** +- Consumes: `WiremarkCanvasProps.controlsRef` and `.onZoomChange` (Task 6); `ZUIControls` (Task 4). + +The zoom buttons are DOM elements, so they must live outside `` (per the Canvas children restriction in `CLAUDE.md`). That is why this uses the `controlsRef` + `onZoomChange` pair rather than `useZUIState` — `useZUIState` is for components rendered *inside* the Canvas. + +- [ ] **Step 1: Wire up the controls ref and zoom readout** + +In `src/playgrounds/wiremarks/WiremarksPlayground.tsx`, add to the imports: + +```tsx +import { type ZUIControls } from 'react-two.js'; +import { MagnifyingGlassPlusIcon, MagnifyingGlassMinusIcon } from '@heroicons/react/20/solid'; +``` + +Add inside the component, next to the existing refs: + +```tsx + const zuiRef = useRef(null); + const [scale, setScale] = useState(1); + + const zoomAtCenter = useCallback((delta: number) => { + const controls = zuiRef.current; + const canvas = containerRef.current?.querySelector('canvas'); + if (!controls || !canvas) return; + + const rect = canvas.getBoundingClientRect(); + controls.zoomBy(delta, rect.left + rect.width / 2, rect.top + rect.height / 2); + }, []); + + const handleResetView = useCallback(() => { + zuiRef.current?.reset(); + setScale(1); + }, []); +``` + +Add `useCallback` to the `react` import. + +- [ ] **Step 2: Pass the props to the canvas** + +Replace the `` call: + +```tsx + +``` + +- [ ] **Step 3: Add the cursor affordance** + +On the `` element, replace the two commented-out lines (currently at lines 99-100) with: + +```tsx + className="w-full h-full cursor-grab active:cursor-grabbing" + style={{ userSelect: 'none', touchAction: 'none' }} +``` + +`touchAction: 'none'` is required — without it the browser claims touch drags for scrolling and pinch-zoom before pointer events fire. + +- [ ] **Step 4: Add the zoom control cluster** + +Insert this block immediately after the closing `` of the "Floating Action Controls" section: + +```tsx + {/* Zoom Controls */} + {!isOpen && ( +
+ + + {Math.round(scale * 100)}% + + + +
+ )} +``` + +- [ ] **Step 5: Verify** + +```bash +npx tsc --noEmit -p ./tsconfig.app.json +npm run lint +npx vitest run tests/ +``` +Expected: all PASS. + +- [ ] **Step 6: Manual smoke test** + +Run `npm run dev`, open the Wiremarks playground, close the instructions overlay, then confirm each of: +1. Scroll wheel zooms toward the cursor; the percentage readout updates. +2. Dragging empty background pans; the cursor shows grab/grabbing. +3. Dragging an entity moves **only** that entity — the canvas must not pan at the same time. +4. Zoom to ~400%, then drag an entity: it tracks the cursor exactly, with no drift or lag. +5. Zoom to ~30% and repeat step 4. +6. Zoom clamps at 25% and 800%. +7. Reset returns to 100% and the original position. +8. On a trackpad, two-finger pinch zooms rather than scrolling the page. + +- [ ] **Step 7: Commit** + +```bash +git add src/playgrounds/wiremarks/WiremarksPlayground.tsx +git commit -m "feat(wiremarks): add zoom controls and pan cursor affordances" +``` + +--- + +### Task 8: Documentation + +**Files:** +- Modify: `README.md`, `CLAUDE.md` + +- [ ] **Step 1: Document the hook in README.md** + +Add a `### useZUI()` subsection alongside the existing `useTwo()` / `useFrame()` documentation: + +````markdown +### useZUI() + +Adds zoom and pan to a `Group`. + +```tsx +function Scene() { + const groupRef = useRef(null); + const zui = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 }); + + return ( + + + + ); +} +``` + +By default `pan: 'background'` means a drag only pans when it does not land on +a shape that has event handlers, so shape drag handlers keep working. Use +`pan: 'always'` to pan unconditionally, or `pan: false` to disable it. + +Zoom and pan state lives in `zui.state.current` and updating it does **not** +re-render. To display the zoom level from a component inside ``, use +`useZUIState(zui)`. For DOM chrome outside ``, pass an `onChange` +callback instead. + +To convert coordinates between screen and scene space — which you need for any +drag interaction on a zoomable scene — use `zui.clientToSurface(x, y)`. + +> The target ``'s `x`, `y`, and `scale` are owned by the hook. Do not +> also pass those props to it. +```` + +- [ ] **Step 2: Add the ZUI section to CLAUDE.md** + +Under "Context System", after the `useFrame()` block: + +````markdown +### useZUI() Hook +```typescript +const zui = useZUI(groupRef, { minZoom: 0.25, maxZoom: 8 }); +``` +Zoom/pan for a `Group`. State lives in `zui.state.current` (a ref) and does not +re-render; use `useZUIState(zui)` to opt into re-rendering. + +**Panning vs. shape drags:** `useZUI` calls `hitTestPoint()` from `TwoCoreContext` +on pointerdown and skips panning if a registered shape was hit. Any component +with its own drag behavior gets it for free. + +**Coordinate conversion:** drag handlers on a zoomable scene must convert client +coordinates with `zui.clientToSurface()`. Diff two converted points rather than +dividing a pixel delta by `scale` — the former stays correct if the view moves +mid-drag. See `src/playgrounds/wiremarks/WiremarkCanvas.tsx`. + +**Constraint:** never pass `x`, `y`, or `scale` to a ZUI-managed `Group` — +`Group.tsx` re-applies props on every render and would overwrite the transform. +```` + +Also add to the "Common Issues & Solutions" section: + +````markdown +### Zoom/Pan Fighting Shape Drags +- **Problem**: dragging a shape also pans the canvas +- **Cause**: `pan: 'always'`, or the shape has no registered event handlers so + `hitTestPoint()` does not see it +- **Solution**: keep the default `pan: 'background'` and make sure the shape has + at least one event handler prop, which is what registers it for hit testing +```` + +- [ ] **Step 3: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "docs: document useZUI and the pan/drag interaction model" +``` + +--- + +## Verification Checklist + +Before opening a PR, all of these must pass: + +```bash +npx vitest run tests/ +npm run lint +npx tsc --noEmit -p ./tsconfig.build.json +npx tsc --noEmit -p ./tsconfig.app.json +npm run build:lib +``` + +Then confirm the library build did not swallow the extra: + +```bash +grep -c "class ZUI" dist/*.js +``` +Expected: `0` — `two.js/extras/jsm/zui.js` must stay external (Task 1, Step 2). + +Finally, re-run the eight manual checks in Task 7 Step 6. diff --git a/docs/superpowers/specs/2026-08-12-diffs-playground-design.md b/docs/superpowers/specs/2026-08-12-diffs-playground-design.md new file mode 100644 index 0000000..ab7c51b --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-diffs-playground-design.md @@ -0,0 +1,333 @@ +# Diffs Playground — Design + +Port `~/Documents/diffs` into `react-two.js` as a playground alongside Wiremarks, +rebuilt on the library's declarative components instead of imperative Two.js +scene manipulation. + +## Source Material + +`~/Documents/diffs` is a React 17 + esbuild app (two.js 0.8) that visually +compares texts by shared vocabulary. Its live code is ~1,900 lines across +`app.js`, `results/index.js`, `results/stat-line.js`, `results/graph-line.js`, +`results/arc.js`, `legend.js`, `keyword.js`, `registry.js`, and `utils/`. + +`src/visualization/` (matter-js physics) is dead code — nothing imports it. It +is not ported. + +What the app does: + +- N text panes, each with a title and a body +- Words are tokenized, stripped of contractions/punctuation, and stemmed + (Porter2, via `wink-porter2-stemmer`) +- Each text renders a column of "stat lines": a rounded rectangle in the text's + color, the word, and a tally count. Repeat occurrences within a text collapse + into the first line and bump its tally. +- Words appearing in two or more texts move to a shared "Shared Words" column + and are hidden from their owning columns +- A "graph line" per column draws a vertical polyline with a point per row +- A legend maps colors to text names +- Pan/zoom over the whole scene +- Highlight mode: clicking a word (in text or on canvas) tints every matching + stat line, marks matching points on the graph lines, draws staggered arcs from + the visible occurrence to each hidden one, and pans the camera to the match +- State persists to `localStorage`; the scene exports as SVG + +## Goals + +1. Feature parity with the original. +2. The scene is a pure function of state, rendered with `react-two.js` + components. No imperative scene-graph construction, no mutable registry, no + frame-driven cursors into half-built state. +3. Follow the structural conventions Wiremarks already established in this repo. + +## Non-Goals + +- No changes to `lib/`. `useZUI` already provides pointer pan, two-pointer + pinch, wheel/trackpad zoom, `pan: 'background'` gating, and the `panTo` / + `clientToSurface` / `surfaceToClient` conversions this design needs. +- No port of `src/visualization/` (matter-js). +- No new DSL, no new file format. Input is plain text, as before. + +## Architecture + +``` +src/playgrounds/diffs/ +├── DiffsPlayground.tsx # DOM chrome; owns texts, mode, selection, toggles +├── DiffsCanvas.tsx # child: useZUI, TWEEN tick, camera pan +├── components/ +│ ├── DiffsScene.tsx # arcs / graph lines / columns groups +│ ├── StatLine.tsx # RoundedRectangle + word Text + tally Text +│ ├── GraphLine.tsx # Line + row Points + highlight Points +│ ├── Arc.tsx # Path with computed arc vertices +│ ├── Legend.tsx # swatch + name per text, plus Shared Words +│ └── TextColumn.tsx # DOM pane: textarea, or tokenized highlight view +├── hooks/ +│ ├── useDiffsModel.ts # texts -> deferred model + reveal progress +│ ├── useProgressiveReveal.ts +│ └── useTweenTick.ts # one useFrame -> TWEEN.update() +├── model/ +│ ├── tokenize.ts # split, strip contractions/non-words +│ ├── stem.ts # Porter2 wrapper with a memo cache +│ ├── analyze.ts # per-text stems, counts, sorting +│ ├── merge.ts # cross-text shared-word resolution +│ └── layout.ts # row/column coordinates, graph-line extents +├── stopwords.ts # ported from the original utils/string.js +├── storage.ts # versioned localStorage load/save +├── constants.ts # leading, size, characterWidth, column pitch +└── types.ts +``` + +Registered in `src/playgrounds/registry.ts`: + +```ts +{ + id: 'diffs', + name: 'Diffs', + description: 'Visually compare texts by shared vocabulary', + component: DiffsPlayground, +} +``` + +## State + +User-owned, persisted: + +```ts +interface TextDoc { + id: string; + name: string; + color: string; // rgb(...) string, assigned on creation + body: string; +} +``` + +Plus `mode: SortMode` (`'chronologic' | 'frequency' | 'alphabetic'`), +`selectedStem: string | null`, and three booleans for the Text / Visuals / +Highlight toggles. + +Everything else is derived. + +## The Model + +One pure function, `buildModel(texts: TextDoc[], mode: SortMode): DiffsModel`: + +1. **Tokenize** each body: split on whitespace, strip trailing contractions + (`/['’]\w*$/`) and non-word characters (`/[^\w\-_]+/g`), drop empties. +2. **Stem** each token with Porter2. A module-level `Map` cache + keeps repeated words cheap; it is a pure memo, not shared state. +3. **Fold per text**: group tokens by stem. The first occurrence becomes the + visible line; `count` is the number of occurrences. Stopword stems are marked + not visible (the original's `regex.restricted`). +4. **Merge across texts**: any stem present in two or more texts moves into the + shared column with a summed count, and is marked hidden in every owning + column. +5. **Sort** each column's lines by mode: `chronologic` by first token index, + `frequency` by count descending, `alphabetic` by stem. +6. **Lay out**: visible lines get `y = row * leading * 1.15`; column `n` sits at + `x = (n + 1) * 250`; the shared column sits at `x = 40`. Each column's graph + line spans its first to last visible row and carries a point per row. Line + width is `(word.length + 1 + String(count).length) * characterWidth`, matching + the original — a character-count estimate, so the model stays pure and needs + no DOM measurement. + +Output: + +```ts +interface StatLineDatum { + key: string; // `${textId}:${stem}` + word: string; // display form (first occurrence) + stem: string; + count: number; + x: number; + y: number; + width: number; +} + +interface Column { + id: string; // a TextDoc id, or the literal 'shared' + color: string; + x: number; + lines: StatLineDatum[]; + graph: { top: number; bottom: number; points: { x: number; y: number }[] }; +} + +interface DiffsModel { + columns: Column[]; // one per text + shared: Column; + byStem: Map; // for highlight + arc lookup + totalLines: number; + totalChars: number; +} +``` + +This replaces the original's mutable `Registry` class, its `needsUpdate` flags, +and the `layout` / `reconcile` / `merge` frame machinery with its +`index` / `yid` / `mergeId` cursors. Same output, one function, directly +testable without a canvas. + +## Progressive Reveal + +The original builds the scene incrementally — `MAX_ITERATIONS` lines per frame, +with a spinning indicator while it works. That build-up is preserved, but as +state rather than as partially-constructed scene graph. + +`useDiffsModel(texts, mode)`: + +- `const deferred = useDeferredValue(texts)` so typing stays responsive +- `const model = useMemo(() => buildModel(deferred, mode), [deferred, mode])` +- `const revealed = useProgressiveReveal(model)` — a `useFrame`-driven counter + that grows by `clamp(floor(model.totalChars / 100), 1, 250)` per frame, the + original's `MAX_ITERATIONS` formula. It resets to 0 whenever `model` identity + changes and stops once it reaches `model.totalLines`. +- `processing = revealed < model.totalLines || deferred !== texts` + +Reveal is allocated across columns in order, so `DiffsScene` renders +`column.lines.slice(0, revealedForColumn)`. + +**Known risk.** Growing a state counter each frame re-renders the scene during +build-up, and React reconciliation is heavier than the original's direct object +creation. `StatLine` is wrapped in `memo` and keyed by `StatLineDatum.key`, so +settled lines neither re-render nor remount and only newly revealed lines mount. +The chunk size already scales with input length. This is the riskiest part of +the port; measure it with a large paired text during phase 4 and, if it stalls, +raise the chunk floor before reaching for a worker. + +## Rendering + +`DiffsCanvas` mounts inside ``, holds the +`useZUI(sceneGroupRef, { minZoom: 0.06, maxZoom: 8 })` (the original's limits), +and runs one `useFrame` that calls `TWEEN.update()`. + +`DiffsScene` renders, in z-order: + +```tsx + {/* arcs */} + {/* graph lines */} + {/* columns of stat lines */} +``` + +- **StatLine** — `` containing `` + (fill = column color, stroke = yellow when highlighted), a left-aligned + `` for the word, and a right-aligned `` for the tally. The + pointer handler only reports a selection when highlight mode is on. +- **GraphLine** — `` from `graph.top` to `graph.bottom`, a `` for row ticks, and a `` whose + vertices are the rows matching `selectedStem`. +- **Arc** — `` whose vertices are computed from the + source and target points in a `useMemo`. The original's custom `Two.Path` + subclass is unnecessary: `Path` already exposes `beginning`, `ending`, + `curved`, and `vertices`. +- **Legend** — rendered by `DiffsCanvas` as a sibling of the ZUI group, not + inside `DiffsScene`, so it stays fixed to the viewport while the scene pans. + This matches the original, which added the legend to `two` rather than to the + panned stage. One row per text plus a Shared Words row, each a `` + swatch and a ``. + +## Highlight Mode + +`selectedStem` is the single source of truth. Both entry points — clicking a +word span in a text pane, and clicking a stat line on the canvas — call the same +setter, which toggles off if the stem is already selected. + +Derived from it: + +- `line.stem === selectedStem` decides a stat line's highlight styling +- graph-line highlight points are a `useMemo` over the model +- arcs are a `useMemo` from the visible occurrence (shared column, or the first + column containing it) to each hidden occurrence + +This removes `stage.getByClassName()`, the +`className.replace(/(sl|highlight)/ig, '')` string parsing, the +`document.querySelectorAll('svg g.sl')` listener add/remove toggle, and the +`requestAnimationFrame` tick loops in `addHighlightsToGraphLines` and the +`show`/`hide` text highlighters. + +## Text Panes + +`TextColumn` renders a controlled `` for the title and, depending on +mode: + +- **Editing** — a controlled `