Add PosterBoard: FigJam-style pan/zoom board card (v1 step 1)#666
Add PosterBoard: FigJam-style pan/zoom board card (v1 step 1)#666burieberry wants to merge 3 commits into
Conversation
Staging Submissions PreviewThis PR's content is pushed to the staging submissions realm: https://realms-staging.stack.cards/submissions/ Changed folders:
Updated at 2026-07-17 20:41:44 UTC for commit |
zoomCentered/zoomAtPoint now clear momentum loops and the pending momentum-start timeout, so a pinch followed quickly by a zoom button press can no longer drift the camera off its target when the stale wheel velocity resumes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Reviewed as the first slice of the board subsystem: the rig engine's math and lifecycle, the input wiring (wheel / pointer / keyboard), and whether the tests pin real semantics — since this file pair is the foundation the later board steps build on.
Bottom line: the architecture is right and the port is clean — no structural issues. One functional bug (the keyboard shortcuts) is worth fixing before merge, plus two small input-robustness fixes recommended while here; everything else is follow-up material.
What lands right
- The rig is genuinely DOM-free, and the zoom-at-cursor math is correct. With the plane composited as
scale(m) translate(w), keeping a local pointLfixed across a magnify change requiresw' = w + L/next − L/current, which is exactly what both the wheel branch andzoomAtPointapply. I traced the transform composition end to end; the invariant holds. - Cleanup discipline is complete.
destroy()cancels both rAF loops and the pending momentum-start timeout, pointer capture release is guarded for thepointercancelpath, and the window keydown listener is removed inwillDestroy. I found no leak path. - The programmatic-zoom contract is stated, implemented, and tested. Explicit camera commands stop the momentum loops and the pending momentum-start timeout (
stopAll()inzoomAtPoint/zoomCentered), and the momentum-regression test pins it with real timers. That's the right way to guard a race. - Tests assert semantics, not just shape. The "Untitled Poster Board" assertion pins the real
cardTitlecompute — verified against the base card-api, which falls back toUntitled <displayName>whencardInfo.nameis blank. The zoom-button assertions pin the actual 1.2× step and the reset behavior. - Styling follows the house rules: zero hex literals, semantic tokens with surface/foreground pairing,
--pb-*locals for every metric, and the HUD hit-test keys off adata-attribute rather than a styling class.
Recommendations
- Fix the keyboard matching — match
event.codeand bail on Ctrl/Meta/Alt; two of three shortcuts are dead on a US layout and the working one hijacks browser zoom-in. See thehandleKeyDowncomment. This is the one I'd fix before merge. - Filter non-primary buttons in
handlePointerDown(one-liner) and scope the pan session to a pointer id. See the pointer-session comment. - Normalize
deltaModein the wheel pan path the way the zoom path in the same function already does. See the rig wheel comment. - Follow-ups, no action here: the momentum loops' unbounded
dtacross a background-tab pause and the skipped finalonChange(rig hardening comment), and the finite dot grid whose edge is visible at min zoom (grid comment).
Adjacent
The description's Testing section predates the momentum-regression test — it reports three tests / eight assertions, while the branch has four tests. Worth refreshing alongside the next local run so the recorded output matches what ships.
| if (event.shiftKey && (event.key === '=' || event.key === '+')) { | ||
| event.preventDefault(); | ||
| this.zoomIn(); | ||
| } else if (event.shiftKey && event.key === '-') { | ||
| event.preventDefault(); | ||
| this.zoomOut(); | ||
| } else if (event.shiftKey && event.key === '0') { | ||
| event.preventDefault(); | ||
| this.zoom100(); | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Two of the three keyboard shortcuts are unreachable on a US layout, and the one that works collides with the browser's own zoom shortcut.
The mechanism. KeyboardEvent.key reports the character after modifiers are applied. With Shift held on a US layout, the main-row keys produce: = → '+', - → '_', 0 → ')'. So while event.shiftKey is true, event.key === '-' and event.key === '0' never match — Shift+- and Shift+0 are dead. Shift+= works only because the '+' alternative is checked alongside '='. The live tests deliberately leave keyboard interaction uncovered, so this wouldn't have surfaced in the local run.
The browser-zoom collision. Chrome's zoom-in shortcut Ctrl++ is physically Ctrl+Shift+=. That event arrives here with shiftKey === true and key === '+', matches the first branch, and gets preventDefault()-ed — and since the listener is on window, the board swallows browser zoom-in anywhere in the app while an isolated board is rendered. Browser zoom is an accessibility affordance low-vision users rely on, so it shouldn't be interceptable by a card.
The way out. Match the layout-independent physical key and bail on non-Shift modifiers:
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (!event.shiftKey) return;
if (event.code === 'Equal') {
event.preventDefault();
this.zoomIn();
} else if (event.code === 'Minus') {
event.preventDefault();
this.zoomOut();
} else if (event.code === 'Digit0') {
event.preventDefault();
this.zoom100();
}event.code names the physical key (Equal, Minus, Digit0) regardless of layout or shift state, which is exactly the "Shift + this key" contract the hint text and description promise.
Scope: regression (ships with this PR), and I'd fix it here — the shortcuts are part of what the PR advertises.
| handlePointerDown = (rawEvent: Event) => { | ||
| const event = rawEvent as PointerEvent; | ||
| const target = event.target as HTMLElement; | ||
| if (target.closest('[data-poster-board-hud]')) { | ||
| return; | ||
| } | ||
| this.panSession = this.surfaceRig.startPan(event.clientX, event.clientY); | ||
| this.isPanning = true; | ||
| (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); | ||
| event.preventDefault(); | ||
| }; |
There was a problem hiding this comment.
[Claude Code 🤖] The pan session accepts any button and any pointer: right-drag pans the board, and a second touch pointer corrupts the drag.
Non-primary buttons. pointerdown fires for every mouse button, and there's no event.button check, so a right-click starts a pan session and captures the pointer while the context menu is opening on top of it — right-drag pans, which no canvas tool does. Worse, on platforms where the context menu takes the subsequent pointer events, the matching pointerup for button 2 may never reach this element, leaving panSession live and isPanning stuck: the board then pans on bare mouse movement with no button held, cursor frozen on grabbing. The standard contract is primary-button-only:
if (event.button !== 0) return;Pointer identity. With touch-action: none, a second finger fires its own pointerdown, which silently overwrites this.panSession mid-drag — and handlePointerMove feeds both pointers' coordinates into whichever session is current, so the camera jumps between finger positions. Recording event.pointerId at down and ignoring move/up/cancel events for other ids scopes the session to one pointer. (Touch pinch-zoom is a separate feature and a fine follow-up; this is only about not corrupting the single-pointer path.)
Scope: regression (ships with this PR). The button filter is a one-liner worth doing here; the pointer-id guard is small and touch-only, fine either way.
| const panScale = 1 / rig.magnify; | ||
| const dxWorld = -event.deltaX * panScale; | ||
| const dyWorld = -event.deltaY * panScale; | ||
| rig.worldX += dxWorld; | ||
| rig.worldY += dyWorld; | ||
| this.panVelocityX = dxWorld / dt; | ||
| this.panVelocityY = dyWorld / dt; | ||
| this.scheduleMomentumStart(); | ||
| this.onChange?.(); |
There was a problem hiding this comment.
[Claude Code 🤖] The pan path consumes raw wheel deltas while the zoom path normalizes deltaMode — a line-mode wheel pans at a small fraction of the intended speed.
The mechanism. The zoom branch above scales the delta by deltaScale (16× for DOM_DELTA_LINE, 120× for DOM_DELTA_PAGE) before applying it. This pan branch uses event.deltaX/event.deltaY as-is. A wheel event with deltaMode === 1 reports deltas in lines — typically 3 per notch (Firefox with a plain mouse wheel is the classic producer) — so the board pans ~3 world px per notch instead of ~48, while zoom on the same device feels normal because it goes through the normalization.
The way out. Hoist the deltaScale computation above the isZoom branch and apply it to both paths:
const deltaScale =
event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 120 : 1;
...
const dxWorld = -event.deltaX * deltaScale * panScale;
const dyWorld = -event.deltaY * deltaScale * panScale;Scope: regression (ships with this PR); non-blocking, but it's a three-line change in the feel-critical file, so worth folding in here.
| const dt = Math.max(1, now - this.kineticLastTime); | ||
| this.kineticLastTime = now; | ||
|
|
||
| const rig = this.rig; | ||
| rig.worldX += this.panVelocityX * dt; | ||
| rig.worldY += this.panVelocityY * dt; | ||
|
|
||
| const decay = Math.pow(PAN_INERTIA_DECAY, dt / 16.67); | ||
| this.panVelocityX *= decay; | ||
| this.panVelocityY *= decay; | ||
|
|
||
| const nextSpeed = Math.hypot(this.panVelocityX, this.panVelocityY); | ||
| if (nextSpeed < PAN_INERTIA_MIN_SPEED) { | ||
| this.stopKineticPan(); | ||
| return; | ||
| } | ||
|
|
||
| this.kineticRafId = requestAnimationFrame(step); | ||
| this.onChange?.(); |
There was a problem hiding this comment.
[Claude Code 🤖] Two hardening notes for when the rig promotes to a shared library — both latent today, neither blocking.
The final momentum frame skips onChange. When the decayed speed drops below PAN_INERTIA_MIN_SPEED, the step function has already mutated rig.worldX/rig.worldY but returns from inside the stop branch before reaching this.onChange?.(). The zoom loop in startZoomMomentum has the same shape. The only consumer today renders from the @tracked RigState fields, so nothing observes the gap — but any consumer that syncs external state via onChange (the documented purpose of the option) will miss the camera's final resting position. Calling onChange right after the mutation, before the speed check, closes it.
dt is unbounded across a background-tab pause. requestAnimationFrame stops while the tab is hidden; on refocus mid-momentum, now - kineticLastTime can be seconds, so rig.worldX += this.panVelocityX * dt teleports the camera one giant step before the decay kills the velocity. Clamping the frame delta keeps the resume graceful:
const dt = Math.min(64, Math.max(1, now - this.kineticLastTime));Same applies to the zoom loop. (The wheel handler's dt is safe as-is — a large gap there only shrinks the computed velocity.)
Scope: follow-up — a natural fit for the unit-test pass when this file moves to a shared home.
| .poster-board-grid { | ||
| position: absolute; | ||
| inset: calc(var(--pb-grid-extent) / -2); | ||
| width: var(--pb-grid-extent); | ||
| height: var(--pb-grid-extent); | ||
| pointer-events: none; | ||
| background-image: radial-gradient( | ||
| circle, | ||
| color-mix(in oklch, var(--muted-foreground) 35%, transparent) 1px, | ||
| transparent 1px | ||
| ); | ||
| background-size: var(--pb-grid-cell-size) var(--pb-grid-cell-size); | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] The dot grid is finite — 625rem (10,000px) a side — and its edge is reachable almost immediately at low zoom.
At the rig's default minimum zoom (0.2), a 1,920px-wide viewport spans 9,600 world px — nearly the grid's whole width — so a small pan exposes the blank edge; even at 100%, roughly 5,000px of pan reaches it. For the infinite-canvas feel the grid needs to appear unbounded. The standard trick keeps the dots in plane space (composited, no repaints) but snaps the grid element's own translation to the nearest cell multiple as the camera moves, so the pattern tiles forever while the element stays small. The cheaper alternative paints the pattern on the static root and drives background-position/background-size from the camera — correct, but it trades the composited transform for a repaint per frame, which is why plane-space grids are usually preferred.
Scope: follow-up, not this PR — the polish step is the natural home. Bumping --pb-grid-extent is a one-token stopgap if the edge shows up in demos before then.
| .poster-board-plane { | ||
| will-change: transform; | ||
| } |
There was a problem hiding this comment.
[Claude Code 🤖] Confirmation — no change needed, documenting why this works and when it would stop.
.poster-board-plane has no position property, yet its absolutely-positioned children (.poster-board-grid, .poster-board-hint) anchor to it rather than to .poster-board-root. That's correct because a transformed element is a containing block for its absolute descendants, and the plane always carries both the inline transform (from planeStyle, which unconditionally emits one) and will-change: transform — either alone suffices per spec.
The condition to watch: if a future change makes the transform conditional (say, skipping the inline style at identity) and drops will-change, the grid and hint silently re-anchor to the viewport and stop moving with the camera. Adding position: relative here would pin the anchoring independently of the transform's presence, if anyone wants the belt-and-suspenders.
Adds
poster-board/, step 1 of the minimal poster-board plan (CS-12193): an infinite-canvas board card with pan, zoom, and a zoom toolbar — no cards on the board yet (that's step 2).Closes CS-12213.
What's here
rig.gts— pure-TS pan/zoom camera engine ("rig" as in camera rig): cursor-anchored wheel/pinch zoom, drag-pan sessions with velocity sampling, rAF momentum with time-normalized inertia decay for both pan and zoom. No DOM or card-api dependencies; ported nearly verbatim from the experiments-realm prototype.poster-board.gts— thePosterBoardcard def. Isolated view wires pointer/wheel events into the rig and renders from its tracked state: singlescale() translate()transform on one plane element, dot grid, zoom HUD (+ / % / − / 100% / Fit), Shift+=/−/0keyboard zoom. Fit currently resets the view; real fit-to-content lands in step 4 (CS-12216).poster-board.test.gts— live tests (3 tests / 8 assertions): isolated render + toolbar + computedcardTitle, zoom buttons changing/resetting the zoom readout, Fit resetting the view.PosterBoard/demo-poster-board.json— a demo instance.README.md— how to run the tests (browser and headless).Conventions applied
Semantic HTML (
h1/p/header,role='toolbar',<output>readout, aria labels), semantic color tokens only with bg/foreground pairing (HUD is a--cardsurface;color-mixfor translucency — zero hex), local--pb-*custom properties for all metric values,htmlSafegetters for the dynamic transform/cursor styles,data-test-*attributes last on each element, and full teardown inwillDestroy(momentum loops + window listener).Testing
The live-test CI job does not run on catalog-only PRs (its path filter excludes this repo's mount point), so tests were run locally against a running dev stack via the QUnit live-test page filtered to
poster-board:ESLint, ember-template-lint, and glint (
ember-tsc) all pass forcontents/poster-board/.🤖 Generated with Claude Code