- Date: 2026-07-14
- Status: Accepted
- Feature: Responsive Columns
- Builds on: ADR 0063 (Studio tools as
@arno/*packages over thepontekernel)
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 (migrateEvenSizingcopies unknown keys through; it is deliberately kept out ofEVEN_PRIMITIVE_SIZE_KEYS/ Punta'sEVEN_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 baseminCell = 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.ContainercallsuseTela().size?.width(apps/web depends on both@arno/telaand@arno/ponte; ponte does not depend on tela, so the resolution lives on the app side) and passes the resolvedcolumnstoSwitcher, overriding the spread. Subscribing every container to the Tela size is intentional — the count re-folds live as the canvas resizes. - Punta
TRACKSreads the sameuseTela().size?.widthto (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/telawas added to@arno/puntadependencies 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
minCellis 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.columnsAtkeys 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 incontract.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.