ADRs
ADR 0061 — `layerTypeOf(node)` as the single layer-type dispatch authority across Studio
  • 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' for node.ref === 'Text'; tool B's check returns 'text' for node.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.ts covering the same cases.
  • Glossary divergence. Each tool's glossary.md defines box / 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 layerTypeOf from the shared kernel. It does NOT inline the check.
  • Each consumer's contract.md cross-references this ADR by section.
  • Each consumer's scenarios.md has a test (dispatch-shared-with-<other-tool>) asserting the function is shared, not duplicated.
  • ESLint rule (planned) flags node.ref === 'Text' and isContainer(node) patterns outside layer-type.ts and demand the import.

When LayerType changes

Adding a new layer type (e.g. 'group') is a major cross-tool change:

  1. Extend the LayerType union in layer-type.ts.
  2. Extend the layerTypeOf implementation.
  3. Each consumer tool (Punta, dito, passo, plus any new consumer) gets a major bump in its contract-version.ts.
  4. Each consumer's contract.md adds a row for the new type with its sidebar / action set / overlay flavour.
  5. Cross-tool migration entries in every affected docs/<tool>/migration.md.
  6. 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:

  1. Migrate the existing data shapes that used the retired type.
  2. Bump every consumer's contract-version.ts major.
  3. Remove the type from the union in the same PR.
  4. Migration entries everywhere.

Enforcement

  1. Code review. Inline dispatches outside layer-type.ts are rejected.
  2. ESLint rule (planned) — pattern-matches isContainer(...) and node.ref === '...' outside the kernel.
  3. CI check — scans every consumer's contract.md for 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 box is also seen as box by dito and passo. Designers never encounter cross-tool drift.
  • Cheap evolution. Adding group is one ADR + one PR touching every consumer in a coordinated way.
  • Glossary discipline. Each tool's glossary.md references the shared definition; the canonical phrasing lives once.

Negative

  • Tight coupling between consumers and the shared kernel. A change in LayerType ripples to every consumer. Mitigation: the change is explicit and coordinated — this is the cost of having a shared dispatch.
  • Kernel grows. layer-type.ts joins studio-bus.ts, studio-events.ts, tree-ops.ts, types.ts, useStudioTree.ts, registry.tsx as 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: LayerType stored on the node). Rejected — pollutes the tree shape with a derived value; risk that node.type says one thing and node.layerType says another.
  • Visitor pattern with one visitor per tool. Rejected — the visitor is heavier than a single function call for the same outcome.
  • String-typed layerTypeOf returning 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 single LayerType. 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).