The playground's local manifest projection and builder now emit only
the resolved target path; workflow-name fallback, titles, and the
workflows-map key are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parallel stage summary rendered a Duration tile directly below
StageMetaBar, which already shows the same stage's duration with a live
ticking clock and a started-at tooltip. The two disagreed while running:
the meta bar counted up, the tile showed the static word "running". The
cancelled-stage bug lived only in the duplicate.
Drop the tile. The meta bar owns duration for every stage renderer, and
it was already correct for cancelled, pending and skipped stages. That
removes the three-way duration branch, the "--" sentinel decode, and the
ACTIVE_STAGE_STATES and formatDurationMs imports.
With the tile gone, ParallelOverview.durationMs is dead, as were
successCount, failureCount and isComplete — the renderer counts the
branch rows it draws. ParallelOverview reduces to branch identity.
For run event write failures, log the first at error with run_id and
event name, the rest at debug, and summarize new losses at flush. A
broken sink fails for every event, so a bare error would emit one
"investigate me" line per event for the life of the run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The steering dock on run pages now starts collapsed, staying out of
the way until the operator opens it. A run that is interrupted and
waiting for steering still forces the dock open.
While collapsed, the whole dock header is a click target that expands
it. Clicks on buttons in the bar (Interrupt, the chevron) keep their
own behavior, and the chevron remains the keyboard/assistive-tech
toggle. The interview dock shares the shell, so it gets the same
click-to-open behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simplification pass over the for_each branch. No behavior change.
Share what was duplicated:
- Node::prompt_or_label replaces the "prompt, else label" fallback that
agent, prompt, and the for_each item injector each wrote out.
- context::lookup_flat replaces the "exact key, then strip context."
lookup that condition.rs had twice and the for_each source had again.
- is_llm_handler_type replaces the inline agent/prompt match, so the
runtime and the for_each_contract rule agree by construction.
- find_join_node now takes branch ids, so a for_each fan-out passes its
template target instead of needing find_join_for_target.
- collect_events moves to test_support; parallel and integration tests
shared one copy already.
- One ScriptedHandler replaces four test handlers that differed only in
what they returned.
Straighten the branch retry loop:
- Reserve the branch scope once before the loop instead of guarding it
with an Option, which removes three expect() calls.
- acquire_branch_permit and backoff_or_cancel replace the cancel-aware
select! blocks the loop repeated verbatim.
- Keep the match arms in Executor::execute_with_retry order so the two
loops stay easy to compare.
Drop redundant state:
- BranchPlan::is_for_each derives from template_target_id.
- for_each_contract checks node type before source shape, so one
mistake reports one diagnostic.
Prove the new wire fields survive the OpenAPI boundary: the fabro-api
round-trip fixture now carries index and item_label.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflicts were between this branch's parallel-branch identity work and
main's stage billing, review targets, and live stage timing.
- Stage fixtures: main added `billing` to each per-file `makeStage`; this
branch had hoisted one builder into `lib/test-utils`. Kept the hoisted
builder and gave it `billing: makeBilledTokenCounts()`, so both intents
hold and the field list stays in one place. `stage-sidebar.test.ts` also
builds raw `RunStage` wire payloads, so it keeps importing
`makeBilledTokenCounts` directly.
- Import lists (`run_projection.rs`, `fabro-api/src/lib.rs`,
`run_state.rs`, `stage_projection_round_trip.rs`): unioned both sides —
`ParallelBranchId` alongside `timing`, `ReviewTarget`,
`ReviewTargetKind`, `AttrValue`, `Node`, and
`StageToolBatchProjection`.
- `fabro-server` tests: git interleaved two unrelated new tests into one
body. Split them back into
`list_run_stages_exposes_parallel_branch_identity` and
`run_billing_includes_live_stage_timing_in_rows_and_totals`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branch indexes are sparse. A branch queued behind max_parallel reserves
no stage identity until it acquires the semaphore, tokio task order is
not index order, and a branch cancelled while queued never reserves one
at all. Sizing the row list by `stagesByBranchIndex.size` treated an
entry count as a dense index range, so a running branch at index 2 with
nothing at 0 or 1 rendered as a single "pending" placeholder and the
running branch disappeared. Size from the highest index observed.
Also:
- Derive the Succeeded/Failed tiles from the rendered rows instead of the
completed-event rollup, so the tiles cannot contradict the list. This
drops the isComplete fork and both live counters.
- Show the fallback branch count in the Branches tile, which previously
read "-" above N rows in exactly the case the fallback exists for.
- BranchRow carries `label` and `stageId`; ChildRow owns the route it
links to. `id` had become a display label on one path and a raw node id
on the other, and a view model should not hold a URL.
- Drop `branchIndex`, which only ever served as the React key and always
equalled the array index.
- Cover the sparse-index and pending-placeholder paths, and derive the
rollup counts in `completedEvent` instead of passing contradictory ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branches bypass the engine's stage.started/stage.completed lifecycle, so
no SWR key invalidated the stages list while a fork ran. The new live
branch rows stayed frozen at their first observed state until an
incidental refetch. Map parallel.* events to the stages list, run events,
and graph keys.
Also:
- Label branch rows with formatStageLabel so a re-entered branch renders
as `review_glm@2`, matching the sidebar and waterfall.
- Build branch rows in one pass and count live outcomes in one loop.
- Name ParallelBranchId in the OpenAPI spec and reuse fabro_types::
ParallelBranchId, replacing two copies of an inline string format.
- Hoist makeStage and textContent into lib/test-utils so widening Stage
cannot leave per-file fixtures stale (tests are excluded from
typecheck, so the two component-test copies had already gone stale).
- Query stat tiles by data-stat instead of an exact Tailwind class.
- Reuse append_scoped_stage_event's body via append_event_with_scope and
add test_branch_event instead of poking envelope fields.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflict in apps/fabro-web/app/components/interview-dock.tsx. Main moved
the dock onto the shared collapsible `RunDockShell` and replaced the
local button constants with shared ones.
Kept main's structure whole and re-applied the review target rendering
onto it: the question paragraph in the shell's `body` becomes the linked
`ReviewTargetQuestion` when the target passes `safeReviewTarget`, and
plain text otherwise. Both now share main's paragraph classes through
`QUESTION_TEXT`, so the two renderings stay visually identical.
`peek` keeps using `question.text`, which is the correct plain-text
collapsed summary for a review target question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review question sentence was written in four places and the URL
safety rules in three. Collapse each to one definition.
- Add `ReviewTarget::question_text_with_link` as the single definition of
the question wording. `question_text()` and the Slack header both use
it, so a wording change is now one edit.
- Delete `ReviewTargetKind::noun()`. The enum already derives
`strum::Display` with the same snake_case output.
- Share one `review_target_line` helper between the console interviewer
and the CLI attach client, which held a byte-identical copy. Print only
the URL: `question.text` already carries the label and the noun.
- Trim the web-side check to the URL scheme, host, and credentials, which
are what a raw `href` can act on. Label length and control characters
cannot affect the DOM and stay server-side.
- Split validation from presentation in the web UI. `safeReviewTarget`
returns the target or null, and each caller picks its own fallback, so
an unsafe target now falls back to the same Markdown rendering as a
question with no target.
- Derive the resource noun from `kind` in the web UI instead of
hardcoding "document".
- Use `ReviewTargetKind.DOCUMENT` and the shared `isRecord` guard when
parsing events, instead of a raw string and a hand-rolled object check
that accepted arrays.
- Drop `deny_unknown_fields` from the wire struct. The OpenAPI schema
leaves `additionalProperties` permissive, so an added field would
otherwise make persisted events unreadable.
- Import `ReviewTarget` by name, and stop naming Slack in a fabro-types
error message.
- Document that `review_target=true` replaces the gate's `label`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The interview question panel could take half the viewport, and the page
reserved a fixed 18rem beneath it regardless of how tall it actually was,
so a long question covered the stage rows it was asking about.
Add a shared `RunDockShell` for the two controls docked at the bottom of
the run detail route. It is three zones: a header that is always visible
and doubles as the collapsed bar, a body that scrolls, and actions that
stay pinned so the controls needed to answer or send never scroll out of
reach.
The interview dock drops the question-type subtitle the answer buttons
already state, turns the 160px context box into a closed disclosure with
a first-line preview, drops the "or" divider row, and reveals the
keyboard hint on focus inside the composer row instead of standing below
it. Options stack into a list once a label is too long to sit in a pill.
For the sample question this is 506px down to 325px, or 43px collapsed.
The steering dock gains the same header. `Interrupt` moves into it,
because it acts on the run rather than on the message being composed, and
the waiting notice folds into the header status instead of adding a row.
Both docks now share one composer.
Clearance is measured from the rendered dock rather than assumed. The
former constants remain as the pre-measurement first frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The board cards showed wall-clock duration in the footer's bottom-right
corner. Replace it with the same SizeChip the list view and run detail
header use, so the cost signal is consistent across all three views.
The chip inherits the tooltip, which names the tier and adds the cost
once a run has terminal billing.
Add SizeChip tests pinning the tooltip label for each tier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Size column in the runs list rendered SizeChip without the billed
total, so its tooltip read "Size M" while the run detail header showed
"Size M · $12.34 billed".
The tooltip was also unreachable: the row title link paints a
`before:absolute before:inset-0` overlay across the whole row, which sat
above the chip and swallowed hover. Wrapping the chip in `relative z-10`
lifts it above that overlay, matching how the created-by and pull request
cells already handle interactive content.
Runs without terminal billing keep the plain "Size M" label, same as the
header.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tab left open across a deploy keeps running the previous build's
JavaScript indefinitely. index.html is fetched only on a full page load,
all later navigation is client-side, and hashed bundles are served
`immutable`, so nothing reveals that the code is stale. This produced a
false-positive bug report where two correctly-deployed fixes appeared to
be missing.
Publishes a build id and offers a reload when the running document falls
behind. The toast never reloads on its own; the only automatic reload is
recovery from a chunk that no longer exists.
Build id derivation
-------------------
The obvious approach — hash the emitted asset filenames, which already
embed content hashes — does not work: Bun's minified identifier naming is
not deterministic. Building an unchanged tree twice produces byte-different
output roughly one run in three (same length, ~100k differing bytes, all of
it mangled names). Output hashes therefore move with no source change,
which would fire the toast on redeploys of identical code and train people
to ignore it.
The id is instead derived from the bundle's source inputs, so it changes if
and only if something we control changed. Verified stable across eight
consecutive builds while the entry hash flipped between both variants.
This non-determinism also means two builds of the same commit embed
different bytes into the server binary, which is worth addressing
separately for reproducible builds.
Detection
---------
SWR with `refreshInterval` + `revalidateOnFocus`, per the repo's React
effects policy. SWR does not poll while the document is hidden, so
background tabs stay quiet without extra gating. Unknown state on either
side — missing meta tag, failed fetch, 503 during a dev rebuild — never
produces a prompt.
Stylesheet hashing
------------------
Tailwind's output was stable-named and therefore served `no-cache`, letting
a tab revalidate into new CSS while running old JS. Tailwind purges unused
classes per build, so classes the old bundle still emits could silently
lose their styles. It is now content-hashed and moves with the build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Model request · waiting on <model>" readout sat directly above the
Chat/Thread/Debug toolbar and appeared and disappeared as requests opened
and closed, shifting the toolbar underneath it.
Drops the StageInferenceIndicator component and everything that existed
only to feed it: the inference/runSettled prop threading through
RunStages, and StageActivity's watchdogTimedOut field. The watchdog.timeout
event now falls through to the same ignore path it always would have, since
it was never in STAGE_ACTIVITY_EVENT_TYPES.
The run-events invalidations for watchdog.timeout and agent.llm.* stay:
they still refresh stage events for the Debug tab and run state for the
insights sidebar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
During a long LLM turn the durable event stream was silent: between
`agent.tool.completed` and the next `agent.message` nothing was emitted,
so "the model is generating" and "the worker is wedged" were
indistinguishable from the run store, SSE, or the UI.
The signal already existed. `AssistantTextStart` fired at exactly the
right point — after `build_request()`, after compaction, immediately
before the stream opens — then was classified as streaming noise and
thrown away. This promotes it rather than inventing a new one.
Two events, each asserting only what is provable when it is emitted:
- `agent.llm.started` carries the *requested* provider/model. No usage,
no cost, no context window: none of it exists yet, and failover can
re-target, so `agent.message` stays authoritative for what answered.
- `agent.llm.first_output` is edge-triggered on the first output of an
attempt and names what arrived. `ToolCall` is required, not optional:
a turn that opens with a tool call produces no text or reasoning
delta, so a latch keyed on those two would stay silent for exactly
the tool-heavy rounds where liveness matters most.
`agent.llm.retry` now also fires on the one previously invisible
mid-turn path — a stream that ends without a finish event, which
replays the turn and discards its output with nothing to show for it.
Its `attempt` field was already fed by two independent counters, so an
optional `phase` (open | consume) names which loop it counts.
`StageProjection.inference` projects the open bracket. `Some` means
"the event log contains an unclosed inference bracket", not "the model
is computing now" — a SIGKILLed worker leaves it open, which is the
truthful statement of what we know, and `watchdog.timeout` remains the
authority on actually-stuck.
The close is the subtle part. Terminal cancel and wall-clock timeout
tear the session down through `discard_session` without emitting a
message, error, or interrupt, so a session-lifecycle backstop is
required. It has to be `agent.session.ended`, not
`agent.session.deactivated`: deactivation is emitted by `lease.release()`
*before* the forwarder drains queued agent events, so a queued
`agent.llm.started` can arrive after it and re-open the bracket. But
`agent.session.ended` carries no stage identity, so the close takes
ordering from the event and identity from the projection, scanning for
brackets the ending session opened. A normal stage lookup there finds
no target and silently no-ops.
Presentation states what the log proves and nothing more: no progress
bar or ETA (no completion estimate exists), "reasoning" only when the
provider sent reasoning output, elapsed counted since the request
opened, and no live animation once the run is terminal.
Scope is session-backed agent stages. One-shot completions call
`client.complete` directly and never build a session; covering them
means moving the emit point into `fabro-llm`, filed as a follow-up.
`agent.output.start` was never persisted — it existed in a name map,
an `unreachable!` arm, and docs — so the rename carries no migration
risk. Corrects `events.md`, which documented it as a real emitted
event, and the v2 proposal, which mapped it to `message.part.started`
despite it firing before the request opens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Empty-text `agent.message` events were discarded, erasing the boundaries
between batches of tool calls. Eight short shell calls issued across five
model responses collapsed into one `Bash x8` group whose DNA bar spanned
the model-response gaps between them, showing a misleading six-minute
duration. Filtering could recreate the same artificial adjacency.
- Always emit an assistant turn for `agent.message`, carrying
`tool_call_count` so a text-free response renders as
"Requested N tool calls" instead of a blank row.
- Derive grouping and DNA timing from the complete turn stream, then
apply kind/search filters as a pure visibility pass over display
items. Hiding a tool can no longer inflate an adjacent Agent bar, and
hiding an Agent can no longer merge the tool groups on either side.
- Give a tool group the wall-clock envelope of its children (earliest
start to latest end) rather than the sum of their durations or the
span to the last array element. Row, details header, DNA bar, and
tooltip all read the same values.
- Advance the DNA previous-activity cursor by the maximum observed end
so out-of-order or overlapping completions cannot move it backward.
Frontend only: no event, persistence, or API schema changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves conflicts with the shared-checkout parallel rewrite (#607) and the
cached-run/billing dedup (de60eb900):
- handler/parallel.rs: rebuilt on main's shared-checkout version. Branch
ordinals are still reserved inside the branch task right before
ParallelBranchStarted (with graph_visit/resumed_from_stage_id), and the
reserved StageScope is shared with post-await error paths via a OnceLock
slot instead of main's dispatch-time visit=1 scope, so completion events
are never emitted under a guessed ordinal.
- billing.rs: keep this branch's run_stage_from_projection (RunStage grew
graph_visit/resumed_from_stage_id and a typed id), adopt main's
state.cached_run() and drop the removed run_stage_from_stage_id import.
- run_projection.rs: adopt main's typed parallel_results
(Option<Vec<ParallelBranchResult>>).
- run_event/misc.rs: union of both sides' imports.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A node cancelled (or lost to a crash) mid-flight and then resumed now
starts a new stage execution with the next StageId ordinal (work@2)
instead of reusing and clearing the cancelled execution's projection.
The old execution stays immutable with its own events, session, output,
timing, billing, and termination state.
Engine:
- Add a run-scoped StageExecutionTracker on RunServices with per-node
high-water marks. Ordinals are reserved after the StageStart hook
passes on the first attempt (retries reuse the reservation), ensured
at the composite checkpoint pre-step for hook-skips, and reserved in
on_terminal_reached for terminal nodes' synthetic events.
- Keep three concepts distinct: graph visit (max_visits/checkpoints,
unchanged), stage execution ordinal (the @N in StageId), and handler
attempt. The tracker is not checkpointed; the append-only stage event
history is its durable source of truth.
- resume() seeds the allocator from the run projection and computes a
node -> StageId provenance map of executions observed after the
selected checkpoint, threaded through execute_persisted_run,
RunSession, and InitOptions.
Events and projections:
- stage.started, parallel.branch.started, and checkpoint.completed
carry optional graph_visit and resumed_from_stage_id; StageProjection
stores both. Old events deserialize with None and legacy duplicate
stage.started replays keep last-attempt behavior.
- The CheckpointCompleted reducer is envelope-first: diffs and
skipped-stage synthesis attach to the exact execution StageId, an
existing Retrying projection finalizes as Skipped without losing
identity, and historical node_outcomes no longer create or collide
with newer ordinals (node_visits remains a legacy fallback).
Handlers:
- Parallel fan-out reserves child ordinals through the shared tracker,
derives worktree pass{N} from the parent's execution ordinal, and
seeds branch contexts with explicit child stage scopes so branch
lifecycle and nested handler events agree.
- Artifact capture and manager-loop child logs follow the ordinal.
API and UI:
- RunStage documents visit as the execution ordinal and adds optional
graph_visit and resumed_from_stage_id; Rust and TypeScript clients
regenerated.
- The web sidebar lists both executions chronologically; resumed stages
show a "Resumed from" link in the stage detail header and hover
popover, with the graph visit surfaced when it diverges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Extract emit_branch_completed() to replace three near-identical
ParallelBranchCompleted constructions; status now reads consistently
from outcome.status
- Add context_diff_public() so parallel.rs and manager_loop.rs share the
diff-minus-engine-internal-keys step; move context_diff tests next to
the function in context.rs
- Replace fan_in's dead BranchShape struct with the canonical
Vec<ParallelBranchResult> (from_value moves, so no payload cloning)
- Narrow parseParallelOverview to ParallelBranchSummary {id, status};
its only consumer renders just those fields
- Drop helpers.test.ts's duplicate envelope() fixture in favor of the
shared makeEventEnvelope
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cumulative implement + simplify_fable diff recovered from the run's meta
branch (fabro/meta/01KY7YH7RYCJ1BDVTTP96ZA4HV, stage 006 diff.patch).
The run validated this tree clean: cargo nextest (7,007 passed), clippy,
fmt, TS client regen + typecheck, web tests (679 passed), docs check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Extract the quadruplicated watchdog check-and-clear logic in
schedule_worker_cancel_escalation into ManagedRun methods
(escalation_still_current, clear_escalation_for)
- Derive strum::IntoStaticStr for WorkerRef instead of a hand-written
variant-to-string match in kind()
- Use the generated AgentControlState constant instead of the raw
"waiting_for_steer" literal in run-detail.tsx
- Replace optimisticCancellationRunId state with a boolean; the
component is keyed by run id, so the stored id could only ever be
this run's own
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
## Problem
In the runs list view, avatars in the **By** column render as squished
ovals.
## Cause
The "By" column `<td>` is `w-8` (32px) with `px-3` padding (24px total),
leaving ~8px of content width. The glyph sits inside the Tooltip's
`inline-flex`, so its wrapper is a shrinkable flex item that collapses
to that 8px. Since Tailwind Preflight sets `img { max-width: 100% }`,
the 20px avatar's width shrinks to ~8px while `size-5` keeps its height
at 20px — producing the squished oval.
## Fix
Wrap the glyph in `inline-flex shrink-0` so it keeps its 20px intrinsic
width and the auto-layout column grows to fit instead of compressing the
image. This also covers the non-user principal icon glyphs
(agent/system/slack/webhook/worker).
Typecheck passes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>