- 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):
- Fit-to-screen — split across three homes with no single owner:
packages/tela/src/fit-mode.ts— the persistent enabled toggle store (aglobalThissingleton) +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+cellResizeObserver).
- The
--artboard-zoomCSS custom property — an implicit cross-package contract with no owner:artboards-canvas.tsxwrites it ("--artboard-zoom": zoom) andpackages/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/telastops re-exporting it;Tela.tsximports{ useFitEnabled, toggleFitEnabled }from@arno/compassoand gains@arno/compassoas a dependency. Tela is now a consumer, not the owner. -
fit-math.ts(fromapps/web/src/components/fit-to-screen.ts) —computeFitZoom,FIT_PADDING,FitBox,unionBox. -
use-fit-to-screen.ts(new) — the camera-wiring hook extracted fromArtboardsCanvas. It owns the camera-side behavior (mode-follows-active re-fit, reversal-restore, disengage-on-pan, the frame+cellResizeObserver) and takes every DOM/tela-specific input by dependency injection socompassokeeps depending only onponte: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 viagetTargetRect), and the select-on-toggle side effect (artboardSelection/setPickedBox— that isponteselection 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 byartboards-canvas).counterScaleByWorldZoom()→"scale(calc(1 / var(--artboard-zoom)))"and areadWorldZoomVar(el)reader (used byalbero/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. compassodoc set gains afit-to-screenfeature +world-zoom-varshared 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}. compassostill depends only on@arno/ponte— the injection boundary inuseFitToScreenis 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
telaSizeviauseTela(), which would makecompasso → telaand close a cycle. Dependency injection keeps compasso onponteonly.