- 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-idwhile dito usesdata-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
querySelectoron 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
- Emit rule.
<StudioRenderer/>attachesdata-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. - Stable values. The attribute value is exactly
node.id— no transformation, no prefix, no suffix. - Uniqueness. Within one rendered tree, every
data-arno-box-idvalue is unique (mirrors theLayoutNode.iduniqueness invariant). - 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. - 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 | nullinapps/web/src/dev-tools/studio/canvas/Carrier.ts. - Auxiliary carriers — additional
data-arno-*attributes for shared metadata (e.g.data-layer-type,data-arno-depth) are added through the carrier registry inCarrier.ts. The primary carrierdata-arno-box-idstays the addressing key.
Auxiliary carriers (v1)
| Attribute | Source | Consumers |
|---|---|---|
data-arno-box-id | node.id | Layers (magnet pick), dito (anchor), passo (measurement), Selection ring |
data-layer-type | layerTypeOf(node) (ADR 0061) | Punta (sidebar dispatch), dito (action dispatch) |
data-arno-depth | numeric depth from root | reserved 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
- Code review. A registry-registered component that does not forward
data-arno-box-idto its DOM root is rejected. - CI sentinel.
Canvasemits arender_anomalytelemetry 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. - Consumer test — every consumer tool's
scenarios.mdhas an entry (position-source-is-carrierfor passo,anchor-uses-row-data-attributefor dito,every-render-has-carrierfor 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.
querySelectoragainst 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_anomalyevent 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_anomalysentinel 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.mdhas 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-idfor brevity? Considered. Rejected —data-arno-box-idis 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-typemirror 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 (
layerTypeOfshared dispatch). - Kernel docs:
docs/kernel/documents the auxiliarydata-layer-typecarrier alongsidelayerTypeOf.