Decision records
ADR 0099 — Blueprint IR 2.2.0: layout roles
- Status: Accepted — implemented (2026-09-23)
- Date: 2026-09-23
- Implements: Q1 and the loom half of Q6 of the loom-ui G2 design (a container-renderer seam for loom-ui’s DOM adapter) — genUI migrating onto loom-ui, phase 2.
- Builds on: ADR 0096 (the version pin, and D2’s
major/minor rule this bump follows), ADR 0098
(the previous minor bump, whose
titlerule this ADR’s validation rule copies), ADR 0094 (node identity, which a role deliberately does not join). - KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0).
BLUEPRINT_IR_VERSION2.1.0 → 2.2.0.
Context
An app can give a WIDGET its own renderer — Widget.kind is an open string and a
RendererRegistry is keyed by it — but it cannot do the same for a CONTAINER: a stepped or
paginated section, a tab set, a collapsible group. Every Layout is drawn by the renderer
itself from its closed geometry (grid/flex/stack). loom-ui is adding a container-renderer
registry to its DOM adapter, and the registry needs a key the Blueprint carries.
Two ways to find a container without growing the IR were rejected in the G2 design, and both are the side channels the standing ruling forbids:
- Matching on
title. A title is localised display text: a locale switch would silently un-match the renderer. - Matching on an id. A
Layouthas no id; inventing one here would be a second identity axis thatpreservesBlueprintNodes(ADR 0094) does not cover.
So the IR grows by the one field the need names.
Decisions
D1 — Every Layout gains an optional role
GridLayout, FlexLayout and StackLayout gain role?: string: the open, app-defined axis
for containers that Widget.kind is for widgets. type stays the closed GEOMETRY; role is the
presentation a renderer that knows it may give that geometry ("stepped", "tabbed",
"collapsible" — any name the app chooses). It is never an ARIA role and never display
text; a renderer that honours one writes it as data (loom-ui: data-container-role), not as
role=.
grid/flex/stack take role? and omit the key when it is absent, so an unroled layout’s
content address is byte-identical to 2.1.0’s. loom ships no role vocabulary; built-in names, if
any, belong to whichever framework ships the renderers for them.
D2 — A role is a registry key, and has a key’s shape
A well-formed role matches /^[a-z][a-z0-9-]{0,63}$/: a lowercase ASCII letter, then lowercase
letters, digits or hyphens, 1–64 characters. The reasons:
- Non-empty, because an empty key is “no role” spelled differently.
- Lowercase ASCII, no
_or., so__proto__,constructorand friends can never be a role (a registry that is ever an object map cannot be poisoned through one), a role never needs escaping in a CSS attribute selector ([data-container-role=stepped]) or a log line, and two spellings of one name (Stepped/stepped) cannot silently select different renderers. - At most 64 characters, because a role is a name, not a payload, and a model-composed artifact is data that must not be able to buy unbounded work in a lookup or a DOM attribute.
- Hyphens anywhere after the first character — trailing (
a-) and doubled (a--b) are accepted. The rule is a key’s alphabet and length, not a style guide; a stricter spelling would reject names that are harmless as keys. The pattern is anchored with no multiline flag, so a trailing newline ("stepped\n") or any whitespace is rejected.
isLayoutRole(value: unknown): value is string is the one spelling of the rule, exported from
@loom/blueprint-runtime so a compiler’s schema (loom-ui core) and a renderer’s lookup (loom-ui’s
DOM adapter) apply the same predicate and cannot drift apart.
A role that is absent, malformed, or unknown to the renderer in use draws the plain container, exactly as it draws under 2.1.0. At RENDER time malformed and unknown are the same case on purpose: a renderer never throws, and never half-honours a role. At VALIDATION time they are not (D4).
D3 — A role is part of a container’s identity for keyed reconcile
A renderer that reconciles a recomposed Blueprint against what it has mounted keys containers by
ordinal among container siblings + type + role. A role change is therefore a remount of
that container — swept out and mounted fresh, its widgets reported as remounted — exactly like a
type change, because the chrome state a role’s renderer holds (the current step, the open tab)
was built for that role and means nothing to another. Same role, same ordinal, same type: the
container is kept and its chrome state survives, the same rule as a widget’s user-entered state.
This is a contract for renderers that key containers at all (loom-ui’s DOM adapter). loom’s React
renderer keys children positionally and ignores the role (D5), so nothing about its reconcile
changes. A role does not join node identity: blueprintNodeIds/preservesBlueprintNodes
remain widget-id sets, and a Layout still has no id. (Amended by
ADR 0101: a Layout may now carry an optional id, which is
node identity and which preservesBlueprintNodes compares; a role still is not. loom’s React
renderer no longer keys containers purely positionally: a container with a well-formed id is keyed
by it, and only one without an id keeps its index key. It still ignores the role.)
D4 — One new finding: role-invalid, at warn
loom has no Blueprint parser: validateBlueprint is the only gate a model-composed artifact
passes before a renderer sees it, so a malformed role that it did not report would pass silently
everywhere — the author would see a plain container and no reason why. That is the reasoning ADR
0098 used for placement-out-of-range, and it applies here.
validateBlueprint reports role-invalid once per layout whose role is present (not
undefined) and fails isLayoutRole — a non-string, empty, uppercase, whitespace, _/., or
more than 64 characters. nodeId and kind are "<layout>" (a layout has no id). (Amended by
ADR 0101 D3: nodeId is now the layout’s id when it has a
well-formed one, and "<layout>" otherwise; kind stays "<layout>".) The message
echoes the role bounded to 64 characters plus its length, because the value is untrusted and can
be any size.
It is a warn, not an error: nothing is lost but the custom rendering — every renderer
draws the plain container, which is still the complete content — so ok stays true. A caller
that wants a malformed role to be a refusal treats the warning as one, or refuses it earlier in
its compiler’s schema with isLayoutRole.
A well-formed but unknown role is not a finding. Whether some renderer has it registered
is a fact about the registry in use, which validateBlueprint does not see; a role unknown to one
renderer is another’s chrome. The builders still validate nothing (as for every other field).
title keeps ADR 0098 D6’s no-finding rule: a malformed title is display text that renders
nothing, while a malformed role is a key that silently selects nothing.
D5 — loom’s own renderers ignore it
- React (
renderReactContainer): no container registry, so every role draws the plain container — DOM-identical to the same layout without a role. A React seam would mirror loom-ui’s and is out of scope until something asks for it. - Terminal (
renderBlueprintToString): a role is an interaction hint, and a one-shot text surface has no interaction. Every child under the title, in reading order, is already the whole content, so output is byte-identical with or without a role. traverseBlueprinthandsrenderContainerthe layout itself, role included, unchanged.
D6 — A minor bump, and 2.1.0 artifacts are read as they are
The field is optional, which is ADR 0096 D2’s
minor case: BLUEPRINT_IR_VERSION becomes 2.2.0, BLUEPRINT_IR_SHAPE gains
role:optional on the three layout manifests, and irVersion.test.ts pins a fourth digest beside
the frozen 1.0.0, 2.0.0 and 2.1.0 rows. IR_VERSIONS_READ_AS_IS gains 2.1.0 (the guard
added after ADR 0098 fails otherwise), so validateBlueprint knows 2.0.0, 2.1.0 and 2.2.0, and
upgradeBlueprint is identity on all three. Writers (blueprint$) stamp 2.2.0 from here on.
Consequences
BLUEPRINT_IR_VERSIONis"2.2.0". Every reader written against 2.1.0 keeps working: the new field is optional and absent on every existing artifact.- Newly authored blueprints are stamped
2.2.0, so re-composing content identical to an earlier 2.1.0 emission yields a different content address; stored 2.0.0/2.1.0 artifacts keep theirs. - loom-ui’s
packages/core/src/blueprintIr.test.tspins"2.1.0"and goes red when loom-ui bumps itsLOOM_REFpast this ADR. That is the intended signal: the same PR teaches loom-ui’scompileto emitroleand adds the container-renderer seam that reads it. BlueprintFindingCodegainsrole-invalid(warn). A consumer that switches exhaustively over the codes sees a new case; one that gates onokis unaffected.@loom/blueprint-runtimeexports one new value,isLayoutRole. It is part of the IR contract, not vocabulary: it names no role.KERNEL_ABI_VERSIONstays 1.1.0 andEVENT_SCHEMA_VERSIONstays 1.18.0.@loom/speclists this ADR inNOT_A_CAPABILITY, for the same reason as 0096–0098: an IR contract and renderer behaviour, not a runtime subsystem to grade.- The next minor bump adds
2.2.0toIR_VERSIONS_READ_AS_IS.
Source: docs/adr/0099-blueprint-ir-layout-roles.md in the loom repository.