ADRs
ADR 0062 — Carrier contract: `data-arno-box-id` as the single positioning anchor across Studio
  • Date: 2026-06-18
  • Status: Accepted (codifies existing convention; load-bearing cross-tool contract)

Context

Three Studio tools need to address a rendered tree node back to its source LayoutNode:

  • Layers — clicking on canvas resolves to a node id (magnet pick). Layers reads the carrier on the click target's nearest ancestor.
  • dito — anchors its floating action toolbar to the hovered canvas node. dito reads the carrier of the hovered row.
  • passo — measures spacing / distance from the picked node's carrier rect.

Without a shared addressing convention, each tool would re-invent the resolution mechanism. The risks:

  • Drift. Layers might use data-arno-id while dito uses data-arno-node-ref. Each tool ships its own renderer or its own carrier-emit logic.
  • Performance. A reverse-resolution that walks the tree on every click / hover is O(n); a single querySelector on a unique attribute is O(1) per browser optimisation.
  • DOM-side mutation. Without a single contract, tools may mutate carriers to encode state (e.g. data-arno-selected="true"), defeating the contract that carriers are derived from the tree, not from UI state.

Decision

Every rendered element in the editor canvas carries data-arno-box-id={node.id} as the single positioning / anchoring attribute. Owned by Canvas (StudioRenderer); consumed by Layers, dito, passo, and any future tool that needs to address a rendered node.

Contract

  1. Emit rule. <StudioRenderer/> attaches data-arno-box-id={node.id} on every rendered DOM element. Component leaves (rendered from the registry) forward the attribute to their root DOM element. The component registry contract requires this forwarding.
  2. Stable values. The attribute value is exactly node.id — no transformation, no prefix, no suffix.
  3. Uniqueness. Within one rendered tree, every data-arno-box-id value is unique (mirrors the LayoutNode.id uniqueness invariant).
  4. Read-only for consumers. No consumer mutates the attribute. Carriers are derived from the tree, not from UI state. UI state lives on studio-bus (pick, hover) or on consumer-internal state; never on carriers.
  5. Resolution API. The canonical way to resolve a node id to a DOM element is document.querySelector('[data-arno-box-id="<id>"]'). Helper: findElementByBoxId(boxId): Element | null in apps/web/src/dev-tools/studio/canvas/Carrier.ts.
  6. Auxiliary carriers — additional data-arno-* attributes for shared metadata (e.g. data-layer-type, data-arno-depth) are added through the carrier registry in Carrier.ts. The primary carrier data-arno-box-id stays the addressing key.

Auxiliary carriers (v1)

AttributeSourceConsumers
data-arno-box-idnode.idLayers (magnet pick), dito (anchor), passo (measurement), Selection ring
data-layer-typelayerTypeOf(node) (ADR 0061)Punta (sidebar dispatch), dito (action dispatch)
data-arno-depthnumeric depth from rootreserved for future ruler / outline overlays

Sandbox surfaces

The Storybook preview, the Foundation catalogue, and any other surface that renders LayoutNode trees without mounting Canvas MUST still emit the carriers. These surfaces import <StudioRenderer/> directly; the carrier emission stays consistent.

A surface that hand-rolls its own renderer (legacy) is responsible for attaching carriers on every rendered element. Any new such surface is a contract violation caught at review.

Enforcement

  1. Code review. A registry-registered component that does not forward data-arno-box-id to its DOM root is rejected.
  2. CI sentinel. Canvas emits a render_anomaly telemetry event when the tree node count and the rendered carrier count diverge (document.querySelectorAll('[data-arno-box-id]').length). The sentinel catches forwarding regressions at runtime.
  3. Consumer test — every consumer tool's scenarios.md has an entry (position-source-is-carrier for passo, anchor-uses-row-data-attribute for dito, every-render-has-carrier for Canvas) asserting the contract is honoured.

Consequences

Positive

  • Single anchor. Every cross-tool addressing scenario uses one mechanism. New tools can pick it up trivially.
  • O(1) resolution per query. querySelector against a unique attribute is fast.
  • Stable across renders. The carrier is derived from node.id, which is stable; UI state mutation does not affect it.
  • Sandbox compatibility. Storybook / Foundation catalogue work without mounting Canvas because they emit the same carriers.
  • Telemetry sentinel. The render_anomaly event surfaces drift before it becomes a user-visible bug.

Negative

  • Single-failure surface. A bug in StudioRenderer's carrier emission ripples to every consumer. Mitigation: the contract is small (one attribute) and the test surface is correspondingly small.
  • Registry contract drag. Every registered component must forward the attribute. New components occasionally forget. Mitigation: the render_anomaly sentinel catches it, plus the registry component contract is typed (forward props as required).
  • DOM-only. Future non-DOM renderers (WebGL, native) would need a parallel addressing mechanism. We accept this — the editor is DOM-based for the foreseeable future (see Canvas architecture doc).

Risk: silent drift

The biggest failure mode is a contributor adding an "improvement" like data-arno-box-id="${node.id}-v2" to "version" the attribute. The change passes type-check, breaks every consumer that expects raw node.id. Mitigation:

  • The CI render-anomaly sentinel fires on the very first render.
  • Every consumer's scenarios.md has a test that resolves a known node id and expects a non-null element — that test fails immediately.

Alternatives considered

React refs through context

Pass refs through React context; consumers receive Map<NodeId, Element> and look up the element directly.

Cons: ref tracking creates a parallel data structure that drifts from the DOM (orphan refs after unmount, stale refs during transitions); reading the DOM directly via attribute is simpler and always live.

Rejected.

Class name encoding

className="...arno-box-id-${node.id}...".

Cons: classNames are not unique by design; CSS selectors are slower than attribute selectors; consumers parse the className for the id.

Rejected.

Custom element registry

customElements.define('arno-box', class extends HTMLElement { ... }). Each rendered element is a <arno-box> with a boxId property.

Cons: changes the rendered HTML to a custom element semantic; conflicts with React's element handling; cross-browser support requires polyfill considerations; overkill for an attribute.

Rejected.

Tree traversal on demand

Every consumer walks the tree to find the node corresponding to a click target.

Cons: O(n) per lookup; tree may not be in the consumer's scope; coupling between every consumer and the tree access pattern.

Rejected.

Open questions

  • Should the attribute be renamed to data-node-id for brevity? Considered. Rejected — data-arno-box-id is explicit, prefixed by the product namespace (arno), and historically established. A rename would be major-bump churn for marginal aesthetic gain.
  • Should auxiliary carriers like data-layer-type mirror in CSS variables for theme adaptation? Open. Today they are HTML attributes only. CSS variables would let stylesheets react to layer-type without JavaScript intermediation. Defer until first concrete need.

References

  • Existing consumers documenting the rule: docs/canvas/contract.md §"Required: carrier registration", docs/layers/architecture.md (magnet pick), docs/dito/contract.md §"Required: row anchor contract", docs/passo/contract.md §"Required: carrier contract".
  • Related ADRs: 0058 (canonical doc set), 0061 (layerTypeOf shared dispatch).
  • Kernel docs: docs/kernel/ documents the auxiliary data-layer-type carrier alongside layerTypeOf.