- Date: 2026-07-14
- Status: Accepted
- Feature: Responsive primitives (Tela-width overrides)
- Builds on: ADR 0064 (Columns responsive to Tela width)
Amendment (2026-07-14) — base-editing context. Adaptive was removed from Tela; there is no
size === nullUI mode anymore. The base value (the one that applies to every width) is now edited at the default device — the preset marked Default in Tela settings — instead of in Adaptive. Everywhere this ADR says "edited in Adaptive" / "width null (Adaptive) → base", read: the base is edited when the active Tela size is the default size (isDefaultSize(size)in@arno/tela); any other preset writes a per-widthpropsAtoverride exactly as described below. The default is 1440×900 by default and, per the follow-up, a persisted per-project setting.nullsurvives only as the internal empty-preset-list state and still maps to base.
Context
ADR 0064 made the Columns (Switcher) count respond to the current Tela
width via a per-width override map primitiveProps.columnsAt: Record<width, number>. That map is columns-only. The next step is to make the other
primitives responsive too — gap for every primitive (Stack/Cluster/Switcher/
Sidebar/Grid/Reel), Reel direction/height, Grid minColumnWidth, Sidebar
sideWidth/side, Frame aspectRatio/width.
Rolling that out with the columnsAt pattern would spawn one bespoke map per
prop (gapAt, directionAt, sideWidthAt, …) — on the order of a dozen new
fields, each with its own resolver, its own Punta writer/clearer, and its own
"keep it out of the even-snap key set" footgun repeated N times. The override
mechanism should be defined once, not per prop.
Key fact grounding the storage choice: for a primitive container the canvas
reads gap (and every layout prop) from primitiveProps, not from the
node-level gap/align fields — the renderer spreads node.primitiveProps
into the primitive component (StudioRenderer.Container), which is where
columnsAt already rides. So a single override map living on
primitiveProps covers every responsive layout prop, gap included.
Decision
Replace the columns-only columnsAt with a single generic override map on
primitiveProps, keyed by Tela preset width, whose value is a partial
PrimitiveProps layered over the base at that width.
Data model — primitiveProps.propsAt
interface PrimitiveProps {
// ...base props (gap, columns, sideWidth, direction, …)
propsAt?: Record<number, Partial<PrimitiveProps>>; // width(px) → override
}primitiveProps.<prop>is the base value, edited in Tela's Adaptive mode.propsAt[width]pins a partial override for that Tela preset width in px. A width absent → the base (or the prop's computed value) applies. Clearing a prop at a width removes it from that width's partial; an empty partial removes the width entry.columnsAtis removed. A load migration foldscolumnsAt: {375:2}intopropsAt: {375:{columns:2}}(migrateColumnsToPropsAt, pure/idempotent).propsAtrides onprimitiveProps, so it survives the raw JSON tree save/load untouched. It is a map, not a size, so it stays OUT of the even-snap key sets (EVEN_PRIMITIVE_SIZE_KEYSintypes.ts,EVEN_SIZE_KEYSin Punta) — butmigrateEvenSizingdoes recurse into each override and snaps even-keyed values inside it (so a per-widthgapheals to even on load, exactly like the basegap).
Resolution — resolveEffectiveProps (pure)
@arno/ponte/props-at (pure, no Tela/DOM dependency, unit-tested):
resolveEffectiveProps(base, width):
width == null (Adaptive) → base as-is // fluid canvas, nothing to override against
else → { ...base, ...(base.propsAt[width] ?? {}) }
(propsAt itself is stripped from the result — authoring metadata, not a prop)Computed resolution layers ON TOP of this generic merge. Some props are not
a plain override but a computed value (Switcher's column count auto-folds
against width, capped at the base). That per-primitive arithmetic stays in its
own resolver (resolveSwitcherColumns) and reads its override out of
propsAt[width].columns; the generic merge handles every plain override
(gap, direction, sideWidth, …) with no per-prop code.
Wiring (unchanged shape from ADR 0064)
- Renderer (
StudioRenderer.Container, apps/web — sees both@arno/telaand@arno/ponte; ponte does not depend on tela): readsuseTela().size?.width, spreadsresolveEffectiveProps(node.primitiveProps, width)into the primitive component (replacing the old raw{...node.primitiveProps}spread), then overridescolumnswith the folded value after the spread. Subscribing every container to Tela size is intentional — props re-resolve live as the canvas resizes. - Punta reads the same width to write/clear a prop at the current preset and
to gate the type-lock (already global from ADR 0064 — the type strip is
disabled on any preset for every primitive).
gap(Spacing section) becomes the first prop wired throughpropsAt: at Adaptive the basegapis edited; at a preset the edit pinspropsAt[width].gap, clearing the field removes it.
Consequences
Positive
- One override mechanism for all primitives and all props — a new responsive prop is free (no schema field, no new resolver, no new Punta plumbing beyond reading the effective value at the current width).
- One place for even-snap of per-width sizes, one migration path, one authoring
model. No
*Atfield proliferation. - Renderer generically applies every plain per-width override via a single spread; only genuinely computed props (columns fold) keep bespoke code.
Costs / risks
- One-time migration
columnsAt → propsAton load (pure, idempotent). propsAtvalues arePartial<PrimitiveProps>— looser typing than a named scalar field, but everyPrimitivePropsmember is already optional, so this adds no new unsafety.- Like
columnsAt,propsAtkeys are literal Tela preset widths, coupling saved data to the current preset set (375/768/1024/1440/1620). A preset-set change orphans entries harmlessly (an orphaned width never resolves).
Regression gate: resolveEffectiveProps unit tests
(packages/ponte/src/adaptive/props-at.test.ts); updated
resolveSwitcherColumns tests (override now sourced from propsAt);
migrateColumnsToPropsAt + migrateEvenSizing recursion tests
(packages/ponte/src/types.test.ts); per-width gap read/write tests
(packages/punta/src/node-bridge.test.ts). Manual smoke per
docs/adaptive/scenarios.md.
Alternatives rejected
- (B) Per-prop maps (
gapAt,directionAt, …). Simpler to add the very first one, but N primitives × several props each → ~a dozen bespoke fields, resolvers, and Punta writers, plus the even-snap-exclusion footgun repeated per map. Rejected: it re-creates the columns-only narrowness the moment a second prop appears; the whole point of generalising is to define the mechanism once. - Keep
columnsAtand addpropsAtalongside for the other props. Two parallel override mechanisms during a transition — exactly what (A) exists to avoid. Rejected: fold columns intopropsAtin the same change so there is only ever one mechanism. - A named-threshold model (xs|sm|md|lg|xl) instead of px keys. The
aspirational
contract.mddirection, heavier (name↔width registry, indirection). Same trade-off ADR 0064 deferred; unchanged here —propsAtstays px-keyed and Tela-driven, and the two reconcile when the hook/registry layer ships.