ADRs
ADR 0079 — `@arno/compasso` owns fit-to-screen and the `--artboard-zoom` world-zoom contract
  • Date: 2026-07-29
  • Status: Accepted
  • Builds on: ADR 0077 (compasso = the ruler + zoom/pan package), ADR 0063 (Studio tools as reusable packages over ponte)

Context

ADR 0077 consolidated the camera (pan/zoom hook, ruler, zoom toolbar, browser-zoom blocker, CameraPort) into @arno/compasso, but it explicitly left three zoom concerns in place (requirements/compasso.md § Left in place):

  1. Fit-to-screen — split across three homes with no single owner:
    • packages/tela/src/fit-mode.ts — the persistent enabled toggle store (a globalThis singleton) + useFitEnabled/getFitEnabled/setFitEnabled/toggleFitEnabled/subscribeFitEnabled. Tela owns it only because the Fit button happens to render in Tela's strip.
    • apps/web/src/components/fit-to-screen.ts — the pure fit math (computeFitZoom, FIT_PADDING, unionBox).
    • apps/web/src/components/artboards-canvas.tsx — ~200 lines of camera wiring (fitActiveArtboard, the mode-follows-active re-fit effect, the reversal-restore, disengage-on-pan, the frame+cell ResizeObserver).
  2. The --artboard-zoom CSS custom property — an implicit cross-package contract with no owner: artboards-canvas.tsx writes it ("--artboard-zoom": zoom) and packages/albero's Rotta reads it (getPropertyValue("--artboard-zoom"), scale(calc(1 / var(--artboard-zoom)))) to counter-scale its arrow overlay to a constant screen size. A rename on either side silently breaks the other.

This scattering is the exact anti-pattern ADR 0063/0077 exist to remove: there is no single place to fix a fit-to-screen regression (the zoom-jitter investigation of 2026-07-29 had to rule fit-to-screen in/out across tela and apps), and the world-zoom var is owned by nobody. Tela owning the fit-mode store is an inversion — Tela is a viewport-preset tool, not a camera; it should consume fit-to-screen, not own it.

Dependency graph today: every Studio package depends only on @arno/ponte; compasso and tela are siblings (neither imports the other). So tela → compasso and albero → compasso are both acyclic.

Decision

(a) @arno/compasso becomes the owner of fit-to-screen

Move, verbatim (no behavior change), into packages/compasso/src/features/fit-to-screen/:

  • fit-mode.ts (from @arno/tela) — the enabled-store + hooks. @arno/tela stops re-exporting it; Tela.tsx imports { useFitEnabled, toggleFitEnabled } from @arno/compasso and gains @arno/compasso as a dependency. Tela is now a consumer, not the owner.

  • fit-math.ts (from apps/web/src/components/fit-to-screen.ts) — computeFitZoom, FIT_PADDING, FitBox, unionBox.

  • use-fit-to-screen.ts (new) — the camera-wiring hook extracted from ArtboardsCanvas. It owns the camera-side behavior (mode-follows-active re-fit, reversal-restore, disengage-on-pan, the frame+cell ResizeObserver) and takes every DOM/tela-specific input by dependency injection so compasso keeps depending only on ponte:

    useFitToScreen({
      enabled,                       // from useFitEnabled()
      camera,                        // { setView, readView, fitTo } from usePanZoom()
      getFrame, getTargetRect,       // host resolves which cell (.artboard-cell) + frame
      getRulerInset, size,           // ruler band + telaSize, injected by the host
      isPanning,
    })

    Kept in the consumer (these are not camera concerns): resolving the active .artboard-cell (passed in via getTargetRect), and the select-on-toggle side effect (artboardSelection/setPickedBox — that is ponte selection state, not zoom).

All fit imports across the repo move to @arno/compasso (no back-compat re-export from @arno/tela — clean cut).

(b) @arno/compasso owns the --artboard-zoom world-zoom contract

New packages/compasso/src/shared/world-zoom-var.ts exports the single source of truth:

  • WORLD_ZOOM_VAR = "--artboard-zoom" — the canonical custom-property name.
  • writeWorldZoomVar(style, zoom) — the writer (used by artboards-canvas).
  • counterScaleByWorldZoom()"scale(calc(1 / var(--artboard-zoom)))" and a readWorldZoomVar(el) reader (used by albero/Rotta).

albero gains @arno/compasso as a dependency and reads through these helpers instead of the bare literal. CSS files (artboards-canvas.css, rotta.css) keep the literal --artboard-zoom (CSS cannot import a TS constant) but carry a comment pointing at WORLD_ZOOM_VAR as the owner. This reverses ADR 0077's "left in place" call for this one property, on purpose.

(c) The Reconstructed-Page iframe zoom bridge stays in RP

The srcdoc bridge (sandboxed captured-page transport that forwards ctrl-wheel/Safari-gesture intent via postMessage) is RP-specific and stays in reconstructed-page.tsx. It is a consumer of compasso's camera (anchorZoom); only the wheel-forward message protocol is documented in docs/compasso/integration.md. No transport abstraction is invented (YAGNI).

Consequences

  • Single owner for every fit-to-screen and world-zoom concern; a fix lands once in compasso.
  • New acyclic deps: tela → compasso, albero → compasso.
  • compasso doc set gains a fit-to-screen feature + world-zoom-var shared contract (_index, api-reference, integration, scenarios, testing). CHANGELOG updated.
  • Orphans removed: apps/web/src/components/fit-to-screen.{ts,test.ts}, packages/tela/src/fit-mode.{ts,test.ts}.
  • compasso still depends only on @arno/ponte — the injection boundary in useFitToScreen is what keeps it there.

Alternatives considered

  • Amend ADR 0077. Rejected: 0077 was a byte-for-byte structural move that explicitly left these in place; changing Tela's role and adding a typed contract is a new decision, not a scope tweak.
  • Keep fit-mode in tela, document only. Rejected: leaves the ownership inversion (a viewport-preset tool owning a camera concern) and the "nobody owns --artboard-zoom" gap.
  • Move the whole fit wiring into compasso as-is. Rejected: it reads telaSize via useTela(), which would make compasso → tela and close a cycle. Dependency injection keeps compasso on ponte only.