- Date: 2026-06-18
- Status: Accepted (codifies existing convention; load-bearing cross-tool contract)
Context
Multiple Studio tools need to ask the same question: "given this LayoutNode, what kind of layer is it?" Three tools (Punta, dito, passo) already dispatch on the answer:
- Punta renders one of three sidebar variants (
<BoxSidebar/>/<TextSidebar/>/<ComponentSidebar/>) per layer type. - dito picks one of three action lists (
BOX_ACTIONS/TEXT_ACTIONS/COMPONENT_ACTIONS) per layer type. - passo (planned) may show different overlay flavours per layer type.
The taxonomy is the same across all three: box / text / component. The detection rule is the same: isContainer(node) ? 'box' : (node.ref === 'Text' ? 'text' : 'component').
Without a single authority, the rule is duplicated across three tools. The risks compound:
- Silent drift. Tool A's check returns
'text'fornode.ref === 'Text'; tool B's check returns'text'fornode.ref === 'Text' || node.ref === 'TextLeaf'. Two tools, two semantics, no way to tell from outside. - Rule evolution costs N. Adding a fourth layer type (
group) requires touching every duplicated check. - Test surface duplicates. Each tool has its own
layer-type-dispatch.test.tscovering the same cases. - Glossary divergence. Each tool's
glossary.mddefinesbox/text/component; over time the prose drifts.
Decision
layerTypeOf(node): LayerType is the single authority for layer-type dispatch across every Studio tool. It lives in apps/web/src/dev-tools/studio/layer-type.ts and is exported as part of the shared kernel.
Type signature
export type LayerType = 'box' | 'text' | 'component';
export function layerTypeOf(node: LayoutNode): LayerType;Detection rule
export function layerTypeOf(node: LayoutNode): LayerType {
if (isContainer(node)) return 'box';
if (node.type === 'component' && node.ref === 'Text') return 'text';
return 'component';
}Consumer rules
- Every Studio tool that needs to dispatch on layer type imports
layerTypeOffrom the shared kernel. It does NOT inline the check. - Each consumer's
contract.mdcross-references this ADR by section. - Each consumer's
scenarios.mdhas a test (dispatch-shared-with-<other-tool>) asserting the function is shared, not duplicated. - ESLint rule (planned) flags
node.ref === 'Text'andisContainer(node)patterns outsidelayer-type.tsand demand the import.
When LayerType changes
Adding a new layer type (e.g. 'group') is a major cross-tool change:
- Extend the
LayerTypeunion inlayer-type.ts. - Extend the
layerTypeOfimplementation. - Each consumer tool (Punta, dito, passo, plus any new consumer) gets a major bump in its
contract-version.ts. - Each consumer's
contract.mdadds a row for the new type with its sidebar / action set / overlay flavour. - Cross-tool migration entries in every affected
docs/<tool>/migration.md. - Single PR — incremental rollouts of a taxonomy change cause silent fallbacks in tools that did not yet adopt.
When LayerType shrinks
Retiring a layer type is also a major cross-tool change:
- Migrate the existing data shapes that used the retired type.
- Bump every consumer's
contract-version.tsmajor. - Remove the type from the union in the same PR.
- Migration entries everywhere.
Enforcement
- Code review. Inline dispatches outside
layer-type.tsare rejected. - ESLint rule (planned) — pattern-matches
isContainer(...)andnode.ref === '...'outside the kernel. - CI check — scans every consumer's
contract.mdfor the required cross-reference to this ADR.
Consequences
Positive
- Single source of truth. One file owns the rule; one test covers it.
- Consistent UX. A row in Layers that Punta sees as
boxis also seen asboxby dito and passo. Designers never encounter cross-tool drift. - Cheap evolution. Adding
groupis one ADR + one PR touching every consumer in a coordinated way. - Glossary discipline. Each tool's
glossary.mdreferences the shared definition; the canonical phrasing lives once.
Negative
- Tight coupling between consumers and the shared kernel. A change in
LayerTyperipples to every consumer. Mitigation: the change is explicit and coordinated — this is the cost of having a shared dispatch. - Kernel grows.
layer-type.tsjoinsstudio-bus.ts,studio-events.ts,tree-ops.ts,types.ts,useStudioTree.ts,registry.tsxas the seventh shared kernel file. The kernel doc set (docs/kernel/— see ADR 0058) documents it.
Risk: ghost dispatches
The biggest failure mode is a consumer importing layerTypeOf but ALSO retaining an inline check (e.g. if (node.ref === 'Text') return <SpecialCase/> inside a render method). The check is shadow-coupled to the dispatch and drifts independently. Mitigation: the ESLint rule (above) and PR review must look for the pattern wherever the dispatch is used.
Alternatives considered
- Per-tool dispatch (status quo before this ADR). Rejected — drift cost.
- Tagged union on the node (
node.layerType: LayerTypestored on the node). Rejected — pollutes the tree shape with a derived value; risk thatnode.typesays one thing andnode.layerTypesays another. - Visitor pattern with one visitor per tool. Rejected — the visitor is heavier than a single function call for the same outcome.
- String-typed
layerTypeOfreturning arbitrary strings. Rejected — the union type catches typos at compile time, which is the entire point.
Open questions
- How do we handle compound layer types (
box + group,text + heading)? Today they do not exist. If they do in the future, the dispatch may need to return a richer shape than a singleLayerType. This is a v2 problem; the v1 union is sufficient.
References
- Existing consumers documenting the rule:
docs/punta/contract.md§"Sidebars by layer type",docs/dito/contract.md§"Required: layer-type dispatch". - Related ADRs: 0058 (canonical doc set), 0060 (docs / code separation), 0062 (carrier contract).
- Kernel docs:
docs/kernel/— full reference for the shared kernel surface (planned in the same PR as this ADR).