ADRs
ADR 0065 — Generalize per-width primitive overrides (`propsAt`)
  • 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 === null UI 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-width propsAt override exactly as described below. The default is 1440×900 by default and, per the follow-up, a persisted per-project setting. null survives 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.
  • columnsAt is removed. A load migration folds columnsAt: {375:2} into propsAt: {375:{columns:2}} (migrateColumnsToPropsAt, pure/idempotent).
  • propsAt rides on primitiveProps, 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_KEYS in types.ts, EVEN_SIZE_KEYS in Punta) — but migrateEvenSizing does recurse into each override and snaps even-keyed values inside it (so a per-width gap heals to even on load, exactly like the base gap).

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/tela and @arno/ponte; ponte does not depend on tela): reads useTela().size?.width, spreads resolveEffectiveProps(node.primitiveProps, width) into the primitive component (replacing the old raw {...node.primitiveProps} spread), then overrides columns with 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 through propsAt: at Adaptive the base gap is edited; at a preset the edit pins propsAt[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 *At field 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 → propsAt on load (pure, idempotent).
  • propsAt values are Partial<PrimitiveProps> — looser typing than a named scalar field, but every PrimitiveProps member is already optional, so this adds no new unsafety.
  • Like columnsAt, propsAt keys 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 columnsAt and add propsAt alongside for the other props. Two parallel override mechanisms during a transition — exactly what (A) exists to avoid. Rejected: fold columns into propsAt in 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.md direction, heavier (name↔width registry, indirection). Same trade-off ADR 0064 deferred; unchanged here — propsAt stays px-keyed and Tela-driven, and the two reconcile when the hook/registry layer ships.