ADRs
ADR 0077 — `@arno/compasso`: consolidating ruler + zoom/pan into one reusable package
  • Date: 2026-07-20
  • Status: Accepted
  • Builds on: ADR 0063 (Studio tools as per-tool reusable packages over a shared ponte kernel), ADR 0059 (naming chord)

Context

Ruler and zoom/pan code for Arno's canvases is scattered across apps/web/src/{components,lib}, duplicated in spirit across three consumers that each drive a different camera mechanic:

  • apps/web/src/components/artboards-canvas.tsx — the Workflow tab's free 2D grid canvas, driven by the hand-rolled usePanZoom hook (apps/web/src/lib/use-pan-zoom.ts).
  • apps/web/src/components/reconstructed-page.tsx — the Reconstructed Page view, also on usePanZoom.
  • apps/web/src/components/workflow-canvas.tsx — the React-Flow graph canvas, driven by @xyflow/react's own useViewport()/useReactFlow().

All three render the same Ruler component (apps/web/src/components/workflow-ruler.tsx, already unified behind two thin adapters — WorkflowCanvasRuler/WorkflowRuler and ReconstructedPageRuler) and the same ZoomToolbar (apps/web/src/components/zoom-toolbar.tsx). A separate global concern — blocking the browser's own pinch/ctrl-wheel page zoom (apps/web/src/lib/use-block-browser-zoom.ts, apps/web/src/components/browser-zoom-blocker.tsx) — lives in the same file neighborhood.

A codebase-wide sweep (.claude/agents/state/01-analysis.md § Exhaustive sweep) confirms this is every ruler/zoom/pan concern in the repo; hits outside this set (Passo's measurement-guide ruler, Rotta/selection-frame's --artboard-zoom counter-scale consumers, the persisted per-project viewport DTO, unrelated viewport/scale string hits) are a different concern each and are explicitly left alone (requirements/compasso.md § Left in place).

There is no single place to fix a ruler tick-formatting bug or a zoom-toolbar regression today — a fix must be applied (or, worse, is applied once and silently drifts) across workflow-ruler.tsx, its two adapters, and the two usePanZoom consumers independently of the React-Flow consumer. Every other Studio tool in this codebase (dito, tela, layers, punta, passo, quadro, albero) has already been extracted into its own reusable packages/<tool> per ADR 0063, for exactly this single-source-of-truth reason. Ruler + zoom/pan has not been.

Decision

(a) Consolidate all ruler + zoom/pan code into one reusable package: @arno/compasso

A new top-level Studio package, packages/compasso/, scaffolded identically to the newest reference package packages/albero/ (package.json naming @arno/compasso, @arno/ponte as a workspace:* dependency, react/react-dom peer deps, tsconfig.json extending @arno/tsconfig/base.json, vitest.config.ts, src/index.ts barrel, src/features/<feature>/ + src/shared/ layout). Full contract in requirements/compasso.md.

Every module identified by the sweep moves into it verbatim — no behavior change, no signature change, no refactor of the camera math:

  • Ruler / WorkflowCanvasRuler (WorkflowRuler) / ReconstructedPageRuler / pickStep / formatTicksrc/features/ruler/
  • ZoomToolbarsrc/features/zoom-toolbar/
  • usePanZoom (PanZoomConfig, PanZoomReturn, anchorZoom, fitTo) → src/features/pan-zoom/
  • BrowserZoomBlocker / useBlockBrowserZoomsrc/features/browser-zoom/

All five current and future consumers (artboards-canvas.tsx, reconstructed-page.tsx, workflow-canvas.tsx, app/app/workflow/page.tsx, app/layout.tsx) import these symbols from the @arno/compasso barrel instead of from apps/web/src/{components,lib}. An edit to packages/compasso/src/** reaches every consumer on the next build — the same single-source-of-truth mechanism ADR 0063 established for dito/tela/layers/punta. This is a pure structural consolidation: behavior is byte-for-byte identical to the pre-consolidation code (requirements/compasso.md, top).

Naming: per ADR 0059's Italian/Renaissance chord, compasso (compass/dividers) — an instrument that both measures (rules a distance — the ruler) and describes an arc / sets a radius (scales — the zoom). It sits alongside ponte/tela/punta/layers/passo/quadro/dito/albero in packages/.

(b) One public API — <Ruler> + <ZoomToolbar> + a typed CameraPort — with two adapters underneath

The package exposes a single public surface consumed identically regardless of which camera mechanic backs it:

type Viewport = { x: number; y: number; zoom: number };
 
interface CameraPort {
  getViewport(): Viewport;
  subscribe(cb: (v: Viewport) => void): () => void;
  zoomBy(factor: number): void;
  reset(): void;
}

Two adapters conform to this port, each wrapping an existing, unmodified camera implementation:

  • usePanZoomCamera(pz) — wraps the hand-rolled usePanZoom return (artboards-canvas.tsx, reconstructed-page.tsx). getViewport reads {x: pan.x, y: pan.y, zoom}; subscribe fires on pan/zoom change; zoomBy(f) calls the existing anchorZoom(z => z*f, centerX, centerY).
  • useReactFlowCamera() — wraps @xyflow/react's useViewport() + useReactFlow() (workflow-canvas.tsx). getViewport returns useViewport() verbatim; zoomBy(f>1)/zoomBy(f<1) delegate to React-Flow's own zoomIn/zoomOut (React-Flow keeps owning the animation/step); subscribe tracks useViewport().

reset() is intentionally host-defined, not a single invented behavior: each consumer's % button keeps its own existing semantics (ArtboardsCanvasfitTo(0,0,1); Reconstructed Page → fitArtboard; React-Flow → fitView()/setCenter). The port only standardizes the shape of the call, not what it does — because what it does is already consumer-specific today and changing that would be a behavior change.

The camera store is factory-not-singleton — each consumer instantiates its own camera state, the same pattern already established by packages/albero/src/shared/albero-visibility.ts and packages/quadro/src/shared/connector-stores.ts. Camera is instance-scoped view state: two canvases on screen at once (present or future) each get their own camera, never a shared module-level singleton.

(c) Why a port, not the studio-bus — camera is instance-scoped view state, not bus SSOT

@arno/ponte's studio-bus is the shared kernel's event bus, and it is tempting to assume any cross-tool Studio concern belongs there. It does not, for this concern. A grep of packages/ponte/src/studio-bus.ts (and every subscribePickRequest/publish* call site) confirms the bus carries exactly: selection, hover, clone, and pick-target requests — the SSOT for what is selected/hovered, consumed by dito/layers/punta (reference_studio_bus_pick_resolver). It carries no viewport, zoom, or pan events today, and camera state is not selection state — it does not need to be globally observable by every Studio tool, only by the Ruler/ZoomToolbar pair mounted against one specific canvas instance.

Routing camera through the bus would also collide with factory-not-singleton: the bus is deliberately one-per-app (ADR 0063 § Consequences, "bundler dedupe of @arno/ponte is load-bearing" — one bus, not one per canvas). Camera, by contrast, must be one-per-canvas-instance (Workflow tab's usePanZoom and the React-Flow graph's viewport are independent and must never observe each other). A typed CameraPort object, constructed per-consumer by usePanZoomCamera/useReactFlowCamera and passed as a prop to <Ruler>/<ZoomToolbar>, is the correct shape for instance-scoped state; a bus is the correct shape for cross-tool app-wide SSOT. Using the bus here would be forcing the wrong tool onto this problem.

(d) Cursor-anchored wheel-zoom is explicitly NOT on the port; Option C (single camera implementation) is deferred

Cursor-anchored wheel/pinch zoom (the mechanic that keeps the point under the cursor fixed while the scroll wheel changes zoom level) stays entirely inside each back-end and is not exposed through CameraPort:

  • usePanZoom keeps its own wheel/gesture* handlers unchanged.
  • React-Flow keeps its own zoomOnPinch/panOnScroll configuration unchanged.

Putting cursor-anchored zoom on the port would require re-implementing React-Flow's internal wheel-zoom handler on top of the port abstraction — a behavior change to a canvas whose interaction model is tightly coupled to its own node/edge/drag machinery (flagged as the single highest-risk item in the consolidation, .claude/agents/state/01-analysis.md § Risk callouts). That is out of scope for a pure structural move and is explicitly forbidden by the byte-for-byte-identical constraint this ADR operates under.

Similarly, Option C — merging usePanZoom and the React-Flow viewport into one shared camera implementation (so there is only one camera mechanic instead of two adapters over two mechanics) is a real, larger question worth asking eventually, but it is explicitly out of scope here and deferred to a future ADR. This ADR's decision is Option B: one public API, two adapters, zero implementation-merge. Collapsing to a single implementation would mean rewriting React-Flow's own viewport handling (or replacing usePanZoom with React-Flow-style state for non-graph canvases) — a product-behavior-risking change this consolidation does not need in order to achieve its stated goal (single source of truth for the shared UI — ruler + toolbar — not for the camera mechanics themselves).

Consequences

Positive

  • Single source of truth for Ruler, ZoomToolbar, usePanZoom, and the browser-zoom block: one edit in packages/compasso/src/** reaches all five consumers on rebuild.
  • <Ruler>/<ZoomToolbar> can be dropped onto a future canvas (hand-rolled or React-Flow-based) by wiring whichever CameraPort adapter fits, without duplicating ruler/toolbar code again.
  • The port makes the previously-implicit "these two camera mechanics present the same shape to the UI" assumption explicit and typed.

Costs / risks

  • Two camera implementations still exist side by side (usePanZoom and React-Flow's own viewport) — this ADR does not reduce that duplication, only the ruler/toolbar/browser-zoom duplication around it. Tracked as the deferred Option C.
  • The React-Flow adapter (useReactFlowCamera) is the highest-risk piece of the whole consolidation: workflow-canvas.tsx's viewport is coupled to its node/edge/drag/connect interaction model, and a bad adapter could silently regress graph interactions that unit tests won't catch — requires manual (L8) smoke verification in addition to unit coverage.
  • reset() being host-defined means the port does not fully unify toolbar behavior — a future consumer must still supply its own reset semantics, matching today's reality but leaving that inconsistency formalized rather than resolved.

Alternatives considered

  • Merge usePanZoom and React-Flow's viewport into one camera implementation now (Option C) — rejected for this ADR: requires rewriting React-Flow's internal wheel/pinch/drag handling or replacing the hand-rolled camera with React-Flow-shaped state on non-graph canvases, either of which is a behavior change this consolidation is explicitly scoped to avoid. Revisit in a dedicated future ADR once/if the product wants one true camera mechanic.
  • Route camera state through @arno/ponte/studio-bus — rejected: the bus is grep-confirmed to carry only selection/hover/clone/pick-target SSOT, is deliberately one-per-app (singleton), and camera is instance-scoped per-canvas view state. Using the bus would misuse a global singleton for local state and couple unrelated canvases' cameras together.
  • Put cursor-anchored wheel-zoom on the CameraPort — rejected: would force re-implementing React-Flow's own wheel handler to conform to a shared anchored-zoom algorithm, a behavior change to the highest-risk consumer for no stated requirement.
  • Leave ruler/zoom code where it is and fix duplication ad hoc per bug — rejected: this is the status quo that motivated the consolidation; every other Studio tool already moved to the ADR 0063 package pattern for the same reason, and ruler/zoom is the last significant carve-out.