Decision records
ADR 0098 — Blueprint IR 2.1.0: cell placement and layout titles
- Status: Accepted — implemented (2026-09-23)
- Date: 2026-09-23
- Implements: §2.1 (“A”) of the loom-ui slice 6 design (
loom/docs/requirements/2026-09-23-genui-migration-design.md) (loom PR #439) — genUI migrating onto loom-ui. - Builds on: ADR 0096 (the version pin, and D2’s
major/minor rule this bump follows), ADR 0097 (the
GridCelledge this ADR adds placement to), ADR 0088 (validateBlueprint, extended with one code). - KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged (1.18.0).
BLUEPRINT_IR_VERSION2.0.0 → 2.1.0.
Context
genUI’s dashboards are free-placed — every component has position { x, y, w, h } on a grid —
and its views are titled. loom-ui compiles to the Blueprint, and genUI is migrating onto loom-ui,
so both facts have to reach loom’s IR: a GridCell carries only span (a width, placed by the
grid’s auto-flow) and no Layout carries a heading. The standing ruling is to grow the IR rather
than smuggle a field through props or a side channel (ADR 0097’s
Context records what the last workaround cost), so this ADR grows it.
Decisions
D1 — Placement lives on the GridCell edge, three independent optional fields
GridCell gains row?, rowSpan? and column?. They are 1-based grid lines, the same
coordinates as CSS grid and as GridLayout.columns: the cell occupies columns
column..column+span-1 and rows row..row+rowSpan-1 (rowSpan defaults to 1). span keeps its
2.0.0 meaning — width — so genUI’s w is span, not a new field. Each field is independently
optional; all absent is flow placement, exactly as under 2.0.0. The fields go on the cell for the
reason ADR 0097 D1 put span there: a position is a fact about the edge from a grid to its
child, not about the child, and a flex/stack child has no grid to be placed in.
cell(span, node, placement?) writes only the placement keys it is given; asCell already
returns a real cell unchanged, so placement survives grid() and upgradeBlueprint.
traverseBlueprint passes the fields through ContainerContext.cells (present only when set).
traverseBlueprintAsync has no cells surface, as before.
D2 — Every Layout gains an optional title
A section heading is a property of the container it heads. A heading widget was rejected: it
would need a binding: "none" entry in every catalogue that composes a titled view, and it would
be a sibling of the section rather than attached to it, which is the relationship a screen
reader needs. grid/flex/stack take title? and omit the key when it is absent. The React
renderer’s <section> also carries aria-label={title}, so a titled layout is a NAMED region
landmark, not merely a section that happens to contain a heading.
D3 — A minor bump, and 2.0.0 artifacts are read as they are
All four fields are optional, which is ADR 0096 D2’s
minor case: BLUEPRINT_IR_VERSION becomes 2.1.0, BLUEPRINT_IR_SHAPE gains the keys, and
irVersion.test.ts pins a third digest beside the frozen 1.0.0 and 2.0.0 rows. A 2.0.0
artifact is already a valid 2.1.0 artifact, so:
validateBlueprintknows2.0.0as well as2.1.0(otherwise every stored 2.0.0 artifact would suddenly reportir-version-unknown);upgradeBlueprintis identity on 2.0.0 input, not only on the current version: re-stamping it would change its content address with no change to its shape. It still wraps and stamps a 1.0.0 artifact.
Writers (blueprint$) stamp 2.1.0 from here on.
D4 — What each renderer does with them
- React (
renderReactContainer, shared by the eager and fiber paths): a placed cell is wrapped even at span 1, withgridColumn: "<column> / span <span>"(orspan <span>) andgridRow: "<row> / span <rowSpan ?? 1>"(orspan <rowSpan>, or nothing); the wrapper keeps the child’s key, so a sibling’s removal does not rebuild it. A layout with a non-empty string title renders<section><h2>…</h2><div …/></section>, the layout style staying on the inner element so the heading is not a grid item. Alwaysh2: the blueprint title is theh1, and a container has no depth to derive a level from. - Terminal (
renderBlueprintToString): a one-column surface honours placement as reading order — cells sort by row, then column, an absent coordinate after every present one, ties in document order. A title prints on its own line, underlined with─. - An unplaced, untitled artifact renders byte-identical (terminal) and DOM-identical (React) to 2.0.0 on both.
- Cross-renderer reading order is guaranteed only at the two ends of the placement spectrum:
every cell of a grid carries both
rowandcolumn(fully placed), or none carries any placement (fully unplaced). A partially placed grid — some cells placed and some not, or a cell missing one of the two axes — is where React and the terminal read differently, and by design: React has an actual grid, so an unplaced or partially placed cell falls to CSS grid’s own (sparse) auto-placement algorithm, which drops it in the next free slot at or after the auto-placement cursor — a cursor that only ever advances, so it never backtracks into an earlier gap an explicitly placed cell skipped over. The terminal has no grid to consult — it sorts by(row, column)with an absent coordinate placed after every present one — so it is free to disagree with wherever CSS chose to drop that cell. This is a documented divergence, not a defect: the terminal deliberately does not reimplement CSS grid auto-placement to chase parity it cannot cheaply guarantee.
D5 — One new finding: placement-out-of-range
A model-composed artifact can place a cell off the grid. validateBlueprint reports, once per
cell, a row, rowSpan or column that is not a positive integer, a column past columns,
and a column + span - 1 that overflows the grid. It never cascades: an invalid span is
reported only as span-out-of-range, and columns-invalid still suppresses every cell check of
its grid.
D6 — What this does not do
- No overlap check. CSS grid draws overlapping cells; detecting them needs an occupancy map
per grid, and genUI’s positions come from an editor that already prevents them. Nothing renders
an overlap as an error either: two cells placed at the same
(row, column)keep document order in the terminal (the stable sort’s tie-break), while CSS simply stacks them on top of each other in React — neither renderer merges or rejects the coincidence. - No upper bound on
row. A grid grows implicit rows. - No finding for a malformed
title. A renderer draws a title only when it is a non-empty string; the migration’s need was placement, and a new code is added when a consumer needs it. - No placement or title on the
Blueprintitself. It already has atitle. - No cross-renderer reading-order guarantee for a partially placed grid. See D4: React and the terminal agree only when a grid is fully placed or fully unplaced; the middle ground is left to each renderer’s own placement rule (CSS auto-placement vs. absent-last sort) rather than reconciled.
Consequences
BLUEPRINT_IR_VERSIONis"2.1.0". Every reader written against 2.0.0 keeps working: the new fields are optional and absent on every existing artifact.- Newly authored blueprints are stamped
2.1.0, so re-composing content identical to an earlier2.0.0emission yields a different content address; stored2.0.0artifacts are unaffected and keep the address they were stamped with. loom-ui’s exact"2.0.0"pin is adapted, not removed, in its phase-1LOOM_REFbump. - loom-ui’s
packages/core/src/blueprintIr.test.tspins"2.0.0"and goes red when loom-ui bumps itsLOOM_REFpast this ADR. That is the intended signal: the same PR teaches loom-ui’scompileto emit placement and titles (phase 1 of the slice 6 design). KERNEL_ABI_VERSIONstays 1.1.0 andEVENT_SCHEMA_VERSIONstays 1.18.0.@loomwork/speclists this ADR inNOT_A_CAPABILITY, for the same reason as 0096 and 0097: an IR contract and renderer behaviour, not a runtime subsystem to grade.IR_VERSIONS_READ_AS_ISmust gain the outgoing version (2.1.0) on every future minor bump, the same way it gained2.0.0here — the list is how a future reader stays backward-compatible with everything this ADR did not just deprecate.
Source: docs/adr/0098-blueprint-ir-placement-and-titles.md in the loom repository.