ADRs
ADR 0064 — Columns (Switcher) responsive to Tela width
  • Date: 2026-07-14
  • Status: Accepted
  • Feature: Responsive Columns
  • Builds on: ADR 0063 (Studio tools as @arno/* packages over the ponte kernel)

Context

The Columns primitive (the switcher type; label "Columns") rendered grid-template-columns: repeat(N, 1fr) at every Tela width — a fixed count, no fold (Switcher.tsx). That reads well on a wide canvas but turns to mush on a narrow preset: at 375px a base of 4 gives four ~90px columns whose cell content collapses.

The user asked for: (1) the count auto-switches to fewer columns when the content no longer fits (base 4 on wide → 2 on 375), fully automatic; (2) the designer can still override the auto value at a given width and the system records that as intentional; (3) the primitive type is fixed per breakpoint — on a preset you may only adjust a primitive's settings, not swap Carousel for Gallery; the type is chosen once, in Adaptive.

"Breakpoint" here binds to Tela: the viewport preset strip (@arno/tela, presets 375 / 768 / 1024 / 1440 / 1620; size === null = the Adaptive chip — a fluid frame with no fixed width). There is no separate "Adaptive tab" in Punta; the Adaptive/preset distinction is Tela's active size.

Decision

Make the effective column count a function of the current Tela width, with per-width manual overrides, and gate the type picker on the Tela mode. Scope: the Columns primitive only — a first, testable slice of a broader per-breakpoint override model.

Data model — primitiveProps.columnsAt

  • primitiveProps.columns (existing) is the base count, set in Adaptive.
  • New primitiveProps.columnsAt?: Record<number, number> maps a Tela preset width in px → a pinned count for that width. A width present = the designer overrode the auto-fold there; absent = auto. Un-pressing the count at a width deletes its entry → back to auto.
  • It rides on primitiveProps, so it survives the raw JSON tree save/load (useStudioTree.ts) untouched and is ignored by the size-snap migrations (migrateEvenSizing copies unknown keys through; it is deliberately kept out of EVEN_PRIMITIVE_SIZE_KEYS / Punta's EVEN_SIZE_KEYS — its keys are widths, not sizes to snap).

Resolution — resolveSwitcherColumns (pure)

@arno/ponte/switcher-columns (pure, no Tela dependency, unit-tested):

resolve(base, columnsAt, width, minCell = 180):
  width == null (Adaptive) → base                    // fluid, nothing to fold against
  columnsAt[width] present  → columnsAt[width]        // manual pin
  else                      → min(base, floor(width / minCell))   // auto-fold, capped at base

minCell = 180 is a heuristic floor (not content-measured) chosen so a 375px preset folds a base-4 grid to 2.

Wiring

  • The renderer resolves the count: StudioRenderer.Container calls useTela().size?.width (apps/web depends on both @arno/tela and @arno/ponte; ponte does not depend on tela, so the resolution lives on the app side) and passes the resolved columns to Switcher, overriding the spread. Subscribing every container to the Tela size is intentional — the count re-folds live as the canvas resizes.
  • Punta TRACKS reads the same useTela().size?.width to (a) highlight the count in effect at the current width, (b) write/clear the per-width override (columnsAt), and (c) lock the primitive-type strip: on a preset the type buttons are disabled (only the active one stays live, so the Reel badge can still flip direction), so the type is changeable only in Adaptive. @arno/tela was added to @arno/punta dependencies for this read.

Consequences

Positive

  • The mush is gone: narrow presets fold to a sane count automatically.
  • Per-width control without losing the wide layout — pin any preset, clear to auto.
  • Primitive type is stable across breakpoints (a preset can't diverge the type), matching the mental model "one component, responsive settings".
  • Resolution is a pure, tested function shared by renderer and Punta — one source of truth for the fold, no drift between canvas and panel.

Costs / risks

  • minCell is a fixed heuristic, not a per-content measurement — a very wide or very narrow cell content can still look off; tunable, revisit with real use.
  • columnsAt keys are literal Tela preset widths, coupling saved data to the current preset set (375/768/1024/1440/1620). A preset-set change would orphan entries (harmless — an orphaned width simply never resolves).
  • Only Columns is covered; the other primitives' props are not yet per-breakpoint.
  • The aspirational threshold-name model sketched in contract.md (xs|sm|md| lg|xl) is not the path taken here; this is a concrete, px-keyed, Tela-driven mechanism. The two must be reconciled when the model generalises.

Regression gate: resolveSwitcherColumns unit tests (fold, base cap, per-width override, Adaptive pass-through, clamp/coercion) in packages/ponte/src/adaptive/switcher-columns.test.ts. Manual smoke on the test stand per docs/adaptive/scenarios.md (switcher-responsive-*).

Alternatives rejected

  • Leave Columns fixed-N; tell users to pick Grid for responsive. Grid (repeat(auto-fill, minmax(minCol, 1fr))) already folds, but it drops the explicit "exactly N on wide" property the designer wants. Rejected: it moves the problem to primitive choice instead of solving it.
  • Pure-CSS repeat(auto-fit, minmax(min(100%, minCell), 1fr)). Folds with no JS, but cannot cap the column count at the designer's N on a wide canvas, and abandons the explicit count entirely. Rejected: loses intent.
  • Per-breakpoint count as a named-threshold model now (xs|sm|md|lg|xl). The contract's aspirational direction, but heavier (name↔width registry, indirection) for a one-primitive slice. Deferred to the generalisation step.