Raises the LR graph zoom ceiling from 200% to 400%. TB is unchanged at
200%.
Zoom and pan are now tracked separately per direction instead of shared.
Switching LR to TB and back restores the viewport you left in each mode,
so a round trip no longer loses your position. Previously a single
shared zoom value was clamped down whenever you switched into TB, which
meant going LR to TB and back cost you your LR zoom.
`run-overview.tsx` holds two view states, remembered per run under
`<runId>-TB` and `<runId>-LR`. `clampZoom` and `zoomAtPoint` take a
`direction` argument and apply the matching ceiling, so the
clamp-on-direction-change effect is gone. 24 tests in
`graph-viewport.test.ts`.
Requirements:
docs/brainstorms/2026-07-21-graph-zoom-lr-increase-requirements.md
Plan: docs/plans/2026-07-21-graph-zoom-lr-increase-plan.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Switching from a run's Overview tab to another tab and back reset the
graph zoom and position to the default. Now it holds.
## Why
The viewport (pan and zoom) lived in `RunOverview` component state.
Overview and Stages are sibling routes under `runs/:id`, so switching
tabs unmounts Overview and drops that state.
## Fix
`apps/fabro-web/app/routes/run-overview.tsx`: cache the viewport per run
outside the component so it survives the remount, and reset it when the
run id changes, since the route instance is reused when only the id
changes.
Added two tests: viewport restores on remount for the same run, and does
not carry across runs.
Does not persist across a full page reload (in-memory only).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
## What
On **Runs → Overview**, the workflow graph now supports the standard
Figma/Excalidraw canvas interactions:
- **Two-finger scroll → pan**
- **⌘/Ctrl + scroll → zoom**, anchored under the cursor (mac trackpad
pinch works too — the browser delivers it as `ctrl+wheel`)
The graph already had drag-to-pan, stepped zoom (toolbar +/−), and
fit-to-window. This adds the missing wheel/trackpad input on top of that
existing transform state.
https://github.com/user-attachments/assets/15eac98b-2603-44c9-b438-7ee27034ccd7
## How
- **`app/lib/graph-viewport.ts`** (new) — pure, framework-free zoom
math: `zoomAtPoint` keeps the point under the cursor fixed while
scaling; `clampZoom` + zoom constants. Zoom becomes a continuous float
(was a discrete step index) so ⌘-scroll is smooth instead of jumping
between steps. Unit-tested (`graph-viewport.test.ts`), including the
cursor-anchor invariant.
- **`useElementEvent` in `hooks/effects.ts`** (new) — element-scoped,
non-passive listener, a sibling to the existing
`useWindowEvent`/`useDocumentEvent`. Non-passive is required so the
handler can `preventDefault()` the browser's own ⌘-zoom; a JSX `onWheel`
can't.
- **`routes/run-overview.tsx`** — coalesces zoom+pan into one `view`
state (atomic cursor-anchored updates), adds the wheel handler (plain
scroll → pan, ⌘/Ctrl → zoom), and `touch-none overscroll-contain` so a
horizontal swipe can't trigger browser back-nav.
- **`components/graph-toolbar.tsx`** — presentational continuous
interface; +/− buttons reuse `zoomAtPoint` (center-anchored). Deletes
the now-dead `graph-toolbar-constants.ts`.
## Testing
- `bun run typecheck` clean; `bun test` green (incl. 4 new viewport
tests).
- Verified live against a real 10-node run graph via Chrome DevTools:
two-finger pan tracks the scroll delta; ⌘+wheel zoom is cursor-anchored
(confirmed even with the cursor over a node); toolbar +/− step ×1.25 and
clamp/disable at 200%; fit-to-window sets a continuous scale; node
click/hover unaffected.
## Non-goals
- **Playground canvas** (`components/playground/canvas`) shares the same
hand-rolled pan/zoom pattern and also lacks wheel support — deliberately
out of scope; `graph-viewport.ts` is the seam to adopt it later.
- **No persistence** — zoom/pan stays ephemeral per visit, as it was
before.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>