Skip to content

feat(reflow)!: row-count-driven layout, real cell band, overflow as a device setting (ADR-0011) - #159

Merged
jonocodes merged 3 commits into
mainfrom
design/balanced-row-distribution
Sep 23, 2026
Merged

jonocodes merged 3 commits into
mainfrom
design/balanced-row-distribution

Conversation

@jonocodes

Copy link
Copy Markdown
Owner

Supersedes ADR-0010. Keeps its model — widgets are an ordered list with no coordinates, packed in strict order — and replaces the geometry that sized them, plus the settings that drove it.

Why

ADR-0010 sized the grid by asking "how many columns fit the width?" and let rows fall out ragged. Column count only sees one axis, so height had to be smuggled back in as a proxy. The shipped code ended up with a tuned constant whose own comments named the test cases it existed to satisfy:

const maxPx = Math.min(w / 3, Math.max(cellSize * 1.5, 200));
// tighter on narrow screens (prevents 2-col phone layouts) and looser on
// wide ones (allows 4-col reflow of 7 widgets on a 747px screen)

That is the signal the free variable was wrong.

What changed

Pick the row count instead. It's the only genuinely free integer — column count, cell size and row shape all follow from it, using both axes honestly. No tuned constants remain.

  • Rows fill to the column count, remainder in the bottom row alone: 10 widgets over 4 rows is 3+3+3+1, not 3+3+2+2. That's ordinary text wrapping, which CSS grid auto-placement already performs — so spans need no special handling.
  • The block centres on both axes while rows wash left against its left edge, so columns stay aligned. justify-content over a fixed track list gives this for free.
  • minCell / maxCell gain distinct jobs. The floor decides how many buttons are visible; the cap stops a sparse deck from ballooning. Cell size is derived from the viewport — neither value sets it.
  • The dominance prune (R-1)*c >= units is load-bearing, not an optimisation: it keeps the candidate set closed under (rows, cols) -> (cols, rows), so an orientation and its counterpart resolve consistently. Deleting it breaks that silently.

Breaking change

Layout.overflow now defaults to clip instead of shrink-to-fit, and overflow becomes a device setting that the layout merely supplies a default for.

The reason isn't aesthetic: under shrink-to-fit the visible count is always every widget, so capacity is never consulted and minCell has no effect at all. Defaulting to it would ship a preference that does nothing.

The two modes are identical whenever the deck already fits — they diverge only on oversized decks, which now hide trailing widgets rather than shrinking everything. No layout YAML in this repo sets overflow, so decks that fit are unaffected.

clip is also materially better than before. ADR-0010 realised it as CSS overflow: hidden over a height-blind column count, so rows past the fold were sliced mid-cell. It now trims the widget list to whole cells before render.

Verification

  • 345/345 vitest pass · tsc --noEmit clean · eslint clean · npm run build clean
  • Protocol drift check passes — the codegen is AST-based and doesn't emit defaults, so flipping one needed no regeneration
  • Browser-driven against the built client via ?demo=: portrait 2+2+2+2 ⇄ landscape 4+4 (same widgets, reshaped), every row sharing one left edge, minCell=240 trimming to 3 widgets at exactly 240px, overflow=shrink-to-fit showing all 9 at 146px while ignoring minCell

A bug the suite caught, worth a reviewer's eye: trimming initially ran before the first ResizeObserver measurement, where clientWidth is 0 — so the grid rendered empty for a frame, and permanently in any host without a ResizeObserver. computeReflow now reports everything visible at zero size when unmeasured.

Not verified

The Python suite was not run — no python3/uv/venv on this machine and flox's token is expired. The daemon change is a one-line default plus a comment, and test_layout_overflow_defaults_to_shrink_to_fit was renamed to ..._defaults_to_clip with its assertion updated, but that edit is unverified by execution. Please run just test-all before merging.

Also in here

  • docs/adr/0011-reflow.md; ADR-0010 marked superseded; both added to the ADR index (0010 had never been listed)
  • CONTEXT.md: the stale Grid placement entry still describing [x, y, w, h] coordinates — deleted back in ADR-0010 — replaced with Reflow, Cell, Capacity
  • docs/mockups/reflow-adr0011.html — an interactive model of the behaviour across ten viewports, both orientations at true ratios, with chrome geometry measured from style.css in Chromium

🤖 Generated with Claude Code

… device setting (ADR-0011)

ADR-0010 sized the grid by asking how many columns fit the width, then let
rows fall out ragged. Column count only sees one axis, so height had to be
smuggled back in as a proxy -- the shipped code carried a tuned constant,
Math.min(w / 3, Math.max(cellSize * 1.5, 200)), whose comments named the
specific test cases it existed to satisfy.

Pick the row count instead. It is the only genuinely free integer: the column
count, the cell size and the row shape all follow from it, using both axes
honestly. No tuned constants remain.

- Rows fill to the column count, remainder in the bottom row alone: 10 widgets
  over 4 rows is 3+3+3+1, not 3+3+2+2. That is ordinary text wrapping, which
  CSS grid auto-placement already performs -- so spans need no special handling.
- The grid block centres on both axes while rows wash left against its left
  edge, so columns stay aligned. justify-content over a fixed track list gives
  this for free.
- minCell and maxCell gain distinct jobs: the floor decides how many buttons
  are visible, the cap stops a sparse deck from ballooning. Cell size is
  derived from the viewport; neither value sets it.
- The dominance prune (R-1)*c >= units is load-bearing, not an optimisation:
  it keeps the candidate set closed under (rows, cols) -> (cols, rows), so an
  orientation and its counterpart resolve consistently.

BREAKING CHANGE: Layout.overflow now defaults to clip instead of shrink-to-fit,
and overflow becomes a device setting that the layout merely supplies a default
for. Under shrink-to-fit the visible count is always every widget, so capacity
is never consulted and minCell has no effect at all -- defaulting to it would
ship a preference that does nothing. The two modes are identical whenever the
deck already fits; they diverge only on oversized decks, which will now hide
trailing widgets rather than shrinking everything.

clip is also materially better than before: ADR-0010 realised it as CSS
overflow:hidden over a height-blind column count, so rows past the fold were
sliced mid-cell. It now trims the widget list to whole cells before render.

Verified: 345 vitest pass, tsc/eslint/build clean, protocol drift check passes.
Browser-driven against the built client via ?demo=: portrait 2+2+2+2 <-> landscape
4+4, every row sharing one left edge, minCell=240 trimming to 3 widgets at
exactly 240px, shrink-to-fit showing all 9 at 146px while ignoring minCell.

The Python suite was NOT run (no python3/uv/venv available, flox token expired).
The daemon change is a one-line default plus a comment, and the matching test
was renamed and updated, but that edit is unverified by execution.

Interactive model of the behaviour: docs/mockups/reflow-adr0011.html

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jonocodes
jonocodes force-pushed the design/balanced-row-distribution branch from ffc01f3 to a0c07ac Compare September 23, 2026 05:33
The nix client derivation lists each Vite HTML entry by name, so the
standalone help page was missing from the sandbox and Rollup failed with
"Could not resolve entry module help.html". The CI test job builds from
the full checkout, which is why only the nix job caught it.
@jonocodes
jonocodes merged commit b9b5968 into main Sep 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant