From a0c07ac217b10918dde0beda712e81d5d161d64e Mon Sep 17 00:00:00 2001 From: Jono Date: Tue, 22 Sep 2026 20:58:37 -0700 Subject: [PATCH 1/3] feat(reflow)!: row-count-driven layout, real cell band, overflow as a 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 --- CONTEXT.md | 14 +- client/src/App.tsx | 41 ++- client/src/ButtonGrid.stories.tsx | 6 +- client/src/ButtonGrid.tsx | 77 ++++-- client/src/EditorCanvas.tsx | 7 +- client/src/Gallery.tsx | 4 +- client/src/Settings.stories.tsx | 9 +- client/src/Settings.test.tsx | 9 +- client/src/Settings.tsx | 80 +++++- client/src/Surface.stories.tsx | 6 +- client/src/reflow.test.ts | 240 +++++++++++++---- client/src/reflow.ts | 206 +++++++++------ client/src/settings-store.ts | 109 ++++++-- client/src/style.css | 57 +++- daemon/deckd/layouts.py | 13 +- daemon/deckd/protocol.py | 2 +- docs/adr/0010-grid-reflow.md | 2 +- docs/adr/0011-reflow.md | 61 +++++ docs/adr/README.md | 19 ++ docs/mockups/reflow-adr0011.html | 420 ++++++++++++++++++++++++++++++ tests/test_layouts.py | 7 +- 21 files changed, 1146 insertions(+), 243 deletions(-) create mode 100644 docs/adr/0011-reflow.md create mode 100644 docs/mockups/reflow-adr0011.html diff --git a/CONTEXT.md b/CONTEXT.md index 23c8cec..50655df 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,9 +14,17 @@ _Avoid_: profile, config, scene A single interactive element placed on a page. Current kinds: `button`, `jogstrip`, `trackpad`, `meter`, `stats`, `media`. A media widget is a composite surface with internal playback, position, volume, and metadata controls. _Avoid_: control, element, tile -**Grid placement**: -The `[x, y, w, h]` coordinates that position a widget within a page's grid. Columns and rows are defined by the layout; coordinates are zero-based. -_Avoid_: position, slot, cell +**Reflow**: +How the client turns a layout's ordered widget list into a grid (ADR-0011). Recomputed live from the measured area on every resize and orientation change. Three stages: **capacity** (how many cells are visible at the user's minimum button size), **shape** (the row count that makes cells largest, which fixes the column count and cell size), and **distribution** (rows fill to the column count, the remainder landing in the bottom row). There are no authored coordinates and no transpose. +_Avoid_: layout pass, packing, tiling + +**Cell**: +One square slot in the reflowed grid. A widget occupies one cell by default, or `size: [w, h]` cells. Cell size is *derived* from the viewport — never authored in layout YAML, and not set directly by any setting. +_Avoid_: tile, square, grid item + +**Capacity**: +How many whole cells the current area holds at the user's minimum button size. Under `clip` the deck is trimmed to it, so trailing widgets can be hidden; under `shrink-to-fit` it is never consulted. +_Avoid_: budget, limit, max widgets **Chrome**: The persistent UI shell that surrounds every layout. Consists of a bottom strip (app badge, connection indicator, manual control mode button, settings button) and a right-side jogstrip. Chrome is always visible; layouts render in the remaining space. The right-side jogstrip can be disabled per-layout with `jogstrip: false`. The bottom strip's app badge optionally carries a `display_name`, a `theme` colour, and an `icon` (ADR-0007) the daemon relays opaquely from the active layout. diff --git a/client/src/App.tsx b/client/src/App.tsx index e12492b..2d9afc8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,7 +14,8 @@ import { useMeterStore } from "./meter-store"; import { useMediaStore } from "./media-store"; import { clampCellSize, - useCellSize, + useCellBand, + useOverflowPreference, useBottomScale, useContentScale, useJogWidth, @@ -292,16 +293,22 @@ export function App() { const trackpad = useTrackpadSettings(); const wakeLock = useWakeLockSetting(); const contentScale = useContentScale(); - const cellSize = useCellSize(); + const cellBand = useCellBand(); + const overflowPref = useOverflowPreference(); // In demo mode, allow the gallery (or any URL-driven caller) to override the - // cell size via query param so each frame can be tuned independently. - const effectiveCellSize = useMemo(() => { - if (!isDemo) return cellSize; + // band via query param so each frame can be tuned independently. + const effectiveBand = useMemo(() => { + if (!isDemo) return cellBand; const p = new URLSearchParams(window.location.search); - const urlSize = p.get("cellSize"); - if (urlSize === null) return cellSize; - return { size: clampCellSize(Number(urlSize)), setSize: cellSize.setSize }; - }, [isDemo, cellSize]); + const urlMin = p.get("minCell"); + const urlMax = p.get("maxCell"); + if (urlMin === null && urlMax === null) return cellBand; + return { + ...cellBand, + minCell: urlMin === null ? cellBand.minCell : clampCellSize(Number(urlMin)), + maxCell: urlMax === null ? cellBand.maxCell : clampCellSize(Number(urlMax)), + }; + }, [isDemo, cellBand]); const jogWidth = useJogWidth(); const bottomScale = useBottomScale(); const labelScale = useLabelScale(); @@ -658,7 +665,7 @@ export function App() { { "--content-scale": contentScale.scale, "--label-scale": labelScale.scale, - "--cell-size": `${effectiveCellSize.size}px`, + "--cell-size": `${effectiveBand.minCell}px`, } as CSSProperties } > @@ -736,8 +743,13 @@ export function App() { onWakeLockChange={wakeLock.setEnabled} contentScale={contentScale.scale} onContentScaleChange={contentScale.setScale} - cellSize={effectiveCellSize.size} - onCellSizeChange={effectiveCellSize.setSize} + minCell={effectiveBand.minCell} + onMinCellChange={effectiveBand.setMinCell} + maxCell={effectiveBand.maxCell} + onMaxCellChange={effectiveBand.setMaxCell} + overflow={overflowPref.overflow} + onOverflowChange={overflowPref.setOverflow} + layoutOverflow={layout?.overflow ?? "clip"} jogWidth={jogWidth.width} onJogWidthChange={jogWidth.setWidth} bottomScale={bottomScale.scale} @@ -797,8 +809,9 @@ export function App() { ) : layout ? ( {}; type Controls = { contentScale: number; cellSize: number }; const controls = { - args: { contentScale: CONTENT_SCALE_DEFAULT, cellSize: CELL_SIZE_DEFAULT }, + args: { contentScale: CONTENT_SCALE_DEFAULT, cellSize: MIN_CELL_DEFAULT }, argTypes: { contentScale: { control: { type: "range" as const, min: CONTENT_SCALE_MIN, max: CONTENT_SCALE_MAX, step: CONTENT_SCALE_STEP }, @@ -56,7 +56,7 @@ function Frame({ scrollInvert={false} onMediaCommand={noop} showKeyHints={showKeyHints} - cellSize={cellSize} + minCell={cellSize} /> ); diff --git a/client/src/ButtonGrid.tsx b/client/src/ButtonGrid.tsx index 01dff30..ba6e779 100644 --- a/client/src/ButtonGrid.tsx +++ b/client/src/ButtonGrid.tsx @@ -10,7 +10,7 @@ import type { MediaReading } from "./media-store"; import type { MeterReading } from "./meter-store"; import { computeReflow } from "./reflow"; import type { OverflowMode } from "./reflow"; -import { CELL_SIZE_DEFAULT } from "./settings-store"; +import { MAX_CELL_DEFAULT, MIN_CELL_DEFAULT } from "./settings-store"; import { onActivate } from "./a11y"; /** Gap between cells, in CSS pixels. Kept in sync with ``.grid { gap }`` so the @@ -24,15 +24,20 @@ type Props = { onJogEnd: (id: string, velocity: number) => void; scrollScale: number; scrollInvert: boolean; - /** Overflow behaviour when widgets exceed the capacity the band yields at - * the current viewport (ADR-0010): ``clip`` (default) leaves trailing - * widgets off-surface; ``shrink-to-fit`` shrinks cells below the floor so - * every widget fits. Comes from the layout's ``overflow`` field. */ + /** What to do when the deck exceeds what the viewport holds at ``minCell`` + * (ADR-0011): ``clip`` (default) trims trailing widgets so the survivors + * keep their size; ``shrink-to-fit`` keeps every widget by letting cells + * fall below the floor. Resolved by ``App`` from the device preference, + * falling back to the layout's ``overflow`` field. */ overflow?: OverflowMode; - /** Cell size target (client-side device preference, ADR-0010). Columns are - * packed around this value; cells fill the width evenly. Defaults let - * harnesses that don't wire settings still render sensibly. */ - cellSize?: number; + /** Readability floor (client-side device preference, ADR-0011): the smallest + * cell the user accepts. Decides how many widgets are visible under + * ``clip``; ignored entirely under ``shrink-to-fit``. Defaults let harnesses + * that don't wire settings still render sensibly. */ + minCell?: number; + /** Comfort cap (client-side device preference, ADR-0011): stops a nearly + * empty deck from becoming a few enormous buttons. */ + maxCell?: number; /** Latest reading per sensor source. Missing sources render with no * value (bar empty, "—" numeric). Stale readings show the bar at * its last position with a dimmed readout. */ @@ -102,8 +107,9 @@ export function ButtonGrid({ onJogEnd, scrollScale, scrollInvert, - overflow = "shrink-to-fit", - cellSize = CELL_SIZE_DEFAULT, + overflow = "clip", + minCell = MIN_CELL_DEFAULT, + maxCell = MAX_CELL_DEFAULT, meterReadings, labelScale, mediaStates, @@ -112,35 +118,62 @@ export function ButtonGrid({ }: Props) { const [gridRef, size] = useMeasuredSize(); - // Cells occupied by flow widgets (spans counted), used by shrink-to-fit to - // estimate the row count. ``full`` widgets leave the flow, so they don't add. - const totalUnits = widgets.reduce((sum, w) => { - if (w.size === "full") return sum; + // Cells occupied by flow widgets (spans counted). ``full`` widgets leave the + // flow, so they don't add. + const unitsOf = (w: Widget) => { + if (w.size === "full") return 0; const [cw, ch] = spanOf(w); - return sum + cw * ch; - }, 0); + return cw * ch; + }; + const totalUnits = widgets.reduce((sum, w) => sum + unitsOf(w), 0); - const { cols, cellPx } = computeReflow({ + const { cols, rows, cellPx, visibleUnits } = computeReflow({ containerWidth: size.width, containerHeight: size.height, - cellSize, + minCell, + maxCell, gap: GRID_GAP, totalUnits, mode: overflow, }); + // ADR-0011: ``clip`` trims the deck rather than letting CSS crop it, so the + // surface never shows a row sliced in half at the fold. Strict order, so the + // prefix stops at the first widget that would not fit whole — we never skip + // a wide widget to squeeze in a later narrow one. ``full`` widgets cost no + // units and so always survive the trim. + const shown = + visibleUnits >= totalUnits + ? widgets + : (() => { + let used = 0; + return widgets.filter((w) => { + const units = unitsOf(w); + if (used + units > visibleUnits) return false; + used += units; + return true; + }); + })(); + // Square, fixed tracks: every column is ``cellPx`` wide and every implicit // row is ``cellPx`` tall, so a cell is square and an ``[w, h]`` span is - // exactly ``w`` columns by ``h`` rows (gaps included). Leftover width is - // centered and leftover height sits below (both set in ``.grid`` CSS). + // exactly ``w`` columns by ``h`` rows (gaps included). CSS grid's own + // auto-placement performs the fill-and-wrap, and ``justify-content: center`` + // on a fixed track list centres the block while leaving short rows washed + // left against it — exactly the distribution ADR-0011 specifies. const gridStyle: CSSProperties = { gridTemplateColumns: `repeat(${cols}, ${cellPx}px)`, gridAutoRows: `${cellPx}px`, + // Centre the block vertically too, but only when it genuinely fits: a + // spanned widget can wrap into more rows than the maths predicted, and + // centring an overflowing grid would crop its top as well as its bottom. + alignContent: + rows * cellPx + Math.max(0, rows - 1) * GRID_GAP <= size.height ? "center" : "start", }; return (
- {widgets.map((w) => { + {shown.map((w) => { const full = w.size === "full"; const [cw, ch] = spanOf(w); // Cap a span at the current column count so a too-wide widget doesn't diff --git a/client/src/EditorCanvas.tsx b/client/src/EditorCanvas.tsx index 979235b..f347228 100644 --- a/client/src/EditorCanvas.tsx +++ b/client/src/EditorCanvas.tsx @@ -19,7 +19,7 @@ import { GripVertical, Minimize, Maximize, Columns2, Plus, Minus } from "lucide- import { computeReflow } from "./reflow"; import type { OverflowMode } from "./reflow"; import type { Widget, WidgetSize } from "./protocol"; -import { CELL_SIZE_DEFAULT } from "./settings-store"; +import { CELL_SIZE_MAX, MIN_CELL_DEFAULT } from "./settings-store"; import { Icon } from "./Icon"; const GRID_GAP = 8; @@ -252,7 +252,7 @@ export function EditorCanvas({ onWidgetChange, onOverflowChange, onSelectWidget, - cellSize = CELL_SIZE_DEFAULT, + cellSize = MIN_CELL_DEFAULT, }: Props) { const [gridRef, size] = useMeasuredSize(); const [previewWidth, setPreviewWidth] = useState(0); @@ -294,7 +294,8 @@ export function EditorCanvas({ const { cols, cellPx } = computeReflow({ containerWidth: displayWidth, containerHeight: size.height, - cellSize, + minCell: cellSize, + maxCell: CELL_SIZE_MAX, gap: GRID_GAP, totalUnits, mode: overflow, diff --git a/client/src/Gallery.tsx b/client/src/Gallery.tsx index f424111..ef5608a 100644 --- a/client/src/Gallery.tsx +++ b/client/src/Gallery.tsx @@ -3,7 +3,7 @@ import { DEMO_NAMES } from "./demo"; import { CELL_SIZE_MIN, CELL_SIZE_MAX, - CELL_SIZE_DEFAULT, + MIN_CELL_DEFAULT, CELL_SIZE_STEP, } from "./settings-store"; @@ -76,7 +76,7 @@ export function Gallery() { const [demo, setDemo] = useState(DEMO_NAMES[0] ?? "firefox"); const [orientation, setOrientation] = useState("landscape"); const [keyHints, setKeyHints] = useState(false); - const [cellSize, setCellSize] = useState(CELL_SIZE_DEFAULT); + const [cellSize, setCellSize] = useState(MIN_CELL_DEFAULT); return (
diff --git a/client/src/Settings.stories.tsx b/client/src/Settings.stories.tsx index 23c3408..7c71af3 100644 --- a/client/src/Settings.stories.tsx +++ b/client/src/Settings.stories.tsx @@ -23,8 +23,13 @@ export const Default: Story = () => ( onWakeLockChange={noop} contentScale={1} onContentScaleChange={noop} - cellSize={100} - onCellSizeChange={noop} + minCell={100} + onMinCellChange={() => {}} + maxCell={240} + onMaxCellChange={() => {}} + overflow={null} + onOverflowChange={() => {}} + layoutOverflow="clip" jogWidth={1} onJogWidthChange={noop} bottomScale={1} diff --git a/client/src/Settings.test.tsx b/client/src/Settings.test.tsx index 0ce9f74..8aec9ad 100644 --- a/client/src/Settings.test.tsx +++ b/client/src/Settings.test.tsx @@ -19,8 +19,13 @@ function renderSettings(overrides: Partial[0]> = {}) onWakeLockChange: () => {}, contentScale: 1, onContentScaleChange: () => {}, - cellSize: 100, - onCellSizeChange: () => {}, + minCell: 100, + onMinCellChange: () => {}, + maxCell: 240, + onMaxCellChange: () => {}, + overflow: null, + onOverflowChange: () => {}, + layoutOverflow: "clip" as const, jogWidth: 1, onJogWidthChange: () => {}, bottomScale: 1, diff --git a/client/src/Settings.tsx b/client/src/Settings.tsx index 62bcf53..cf45aa3 100644 --- a/client/src/Settings.tsx +++ b/client/src/Settings.tsx @@ -22,6 +22,7 @@ import { SCROLL_SCALE_MAX, SCROLL_SCALE_MIN, } from "./settings-store"; +import type { OverflowPreference } from "./settings-store"; import type { ServerLayout } from "./protocol"; type SocketStatus = "connecting" | "open" | "closed"; @@ -39,8 +40,16 @@ type Props = { onWakeLockChange: (v: boolean) => void; contentScale: number; onContentScaleChange: (n: number) => void; - cellSize: number; - onCellSizeChange: (n: number) => void; + minCell: number; + onMinCellChange: (n: number) => void; + maxCell: number; + onMaxCellChange: (n: number) => void; + /** The device's override of the layout's overflow policy; null follows the + * layout (ADR-0011). */ + overflow: OverflowPreference; + onOverflowChange: (next: OverflowPreference) => void; + /** What the active layout asks for, shown so "Follow layout" isn't opaque. */ + layoutOverflow: "clip" | "shrink-to-fit"; jogWidth: number; onJogWidthChange: (n: number) => void; bottomScale: number; @@ -83,8 +92,13 @@ export function Settings({ onWakeLockChange, contentScale, onContentScaleChange, - cellSize, - onCellSizeChange, + minCell, + onMinCellChange, + maxCell, + onMaxCellChange, + overflow, + onOverflowChange, + layoutOverflow, jogWidth, onJogWidthChange, bottomScale, @@ -192,25 +206,67 @@ export function Settings({

Display

- {/* Cell size target (ADR-0010): the square cell edge (CSS px) the grid - packs columns around. Cells fill the width evenly — more columns fit - as the viewport widens, keeping the result near the target. */} + {/* The cell-size band (ADR-0011). Cell size is derived from the + viewport, so these two don't set it — the floor decides how many + buttons are visible, the cap stops a sparse deck from ballooning. */}
- Cell size + Min button size onCellSizeChange(Number(e.target.value))} + value={minCell} + onChange={(e) => onMinCellChange(Number(e.target.value))} /> - {cellSize}px + {minCell}px
+
+ Max button size + onMaxCellChange(Number(e.target.value))} + /> + + {maxCell}px + +
+ {/* The one sizing decision that is both a layout concern and a device + concern (ADR-0011), so both get a say: the layout ships a default + and this overrides it. "Follow layout" clears the override. */} +
+ When there’s no room +
+ {( + [ + [null, "Follow layout", `Layout says: ${layoutOverflow === "clip" ? "hide extras" : "shrink buttons"}`], + ["clip", "Hide extras", "Keep buttons at least the minimum size"], + ["shrink-to-fit", "Shrink buttons", "Show every button, however small"], + ] as const + ).map(([value, label, hint]) => ( + + ))} +
+
Content nudge {}; type Controls = { cellSize: number }; const controls = { - args: { cellSize: CELL_SIZE_DEFAULT }, + args: { cellSize: MIN_CELL_DEFAULT }, argTypes: { cellSize: { control: { type: "range" as const, min: CELL_SIZE_MIN, max: CELL_SIZE_MAX, step: CELL_SIZE_STEP }, @@ -52,7 +52,7 @@ function Device({ onJogEnd={noop} scrollScale={3} scrollInvert={false} - cellSize={cellSize} + minCell={cellSize} />
diff --git a/client/src/reflow.test.ts b/client/src/reflow.test.ts index 21bead0..f59b037 100644 --- a/client/src/reflow.test.ts +++ b/client/src/reflow.test.ts @@ -1,87 +1,215 @@ import { describe, expect, it } from "vitest"; -import { computeReflow } from "./reflow"; +import { capacityUnits, computeReflow, HARD_FLOOR } from "./reflow"; +import type { OverflowMode } from "./reflow"; -const TARGET = 96; const GAP = 8; +const MIN = 100; +const MAX = 240; -describe("computeReflow — clip", () => { - it("fits fewer columns as the viewport narrows", () => { - const narrow = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 800, totalUnits: 8, mode: "clip" }); - const wide = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 1000, containerHeight: 800, totalUnits: 8, mode: "clip" }); - expect(narrow.cols).toBe(2); // floor(308/104) = 2 - expect(wide.cols).toBeGreaterThan(narrow.cols); +const reflow = ( + containerWidth: number, + containerHeight: number, + totalUnits: number, + o: { minCell?: number; maxCell?: number; mode?: OverflowMode } = {}, +) => + computeReflow({ + containerWidth, + containerHeight, + totalUnits, + gap: GAP, + minCell: o.minCell ?? MIN, + maxCell: o.maxCell ?? MAX, + mode: o.mode ?? "clip", }); - it("always yields at least one column, even below the floor", () => { - const tiny = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 40, containerHeight: 800, totalUnits: 4, mode: "clip" }); - expect(tiny.cols).toBe(1); +/** The rows plain fill-wrapping produces at a given column count. */ +const rowsOf = (visible: number, cols: number) => { + const rows: number[] = []; + let left = visible; + while (left > 0) { + rows.push(Math.min(cols, left)); + left -= cols; + } + return rows; +}; + +describe("shape — the row count is the free variable", () => { + it("five widgets on a roomy landscape area wrap 3+2, never 4+1", () => { + const r = reflow(700, 480, 5); + expect([r.cols, r.rows]).toEqual([3, 2]); + expect(rowsOf(r.visibleUnits, r.cols)).toEqual([3, 2]); + }); + + it("a narrow portrait area prefers more rows because cells come out bigger", () => { + // 2 columns of 148px beats 3 columns of 96px on a 304x578 area. + const portrait = reflow(304, 578, 5); + expect(portrait.cols).toBe(2); + expect(rowsOf(portrait.visibleUnits, portrait.cols)).toEqual([2, 2, 1]); + expect(portrait.cellPx).toBeGreaterThan(reflow(304, 578, 5).cellPx - 1); + }); + + it("the same widgets reshape rather than resize when the area turns", () => { + const portrait = reflow(334, 782, 8); + const landscape = reflow(756, 328, 8); + expect(rowsOf(portrait.visibleUnits, portrait.cols)).toEqual([2, 2, 2, 2]); + expect(rowsOf(landscape.visibleUnits, landscape.cols)).toEqual([4, 4]); + expect(portrait.visibleUnits).toBe(landscape.visibleUnits); + }); + + it("caps cell size so two widgets don't eat a 4K panel", () => { + expect(reflow(3840, 2160, 2).cellPx).toBe(MAX); + expect(reflow(3840, 2160, 2, { maxCell: 120 }).cellPx).toBe(120); }); +}); - it("cells grow when fewer columns fit (no explicit max cap)", () => { - const r = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 150, containerHeight: 800, totalUnits: 4, mode: "clip" }); - expect(r.cols).toBe(1); - expect(r.cellPx).toBe(150); +describe("distribution — fill to the column count, remainder at the bottom", () => { + it("puts the shortfall in the bottom row alone", () => { + // 10 units over 4 rows is 3+3+3+1, not the max-balanced 3+3+2+2. + const r = reflow(656, 1071, 10); + expect(rowsOf(r.visibleUnits, r.cols)).toEqual([3, 3, 3, 1]); }); - it("ignores height in clip mode", () => { - const short = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 50, totalUnits: 30, mode: "clip" }); - const tall = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 5000, totalUnits: 30, mode: "clip" }); - expect(short).toEqual(tall); + it("always yields exactly `rows` non-empty rows, and the bottom row is never the widest", () => { + for (let units = 1; units <= 200; units++) { + for (const [w, h] of [[334, 782], [756, 328], [1092, 758], [200, 200]]) { + const r = reflow(w, h, units, { mode: "shrink-to-fit" }); + const rows = rowsOf(r.visibleUnits, r.cols); + expect(rows.length).toBe(r.rows); + expect(rows.every((n) => n >= 1)).toBe(true); + expect(rows.reduce((a, b) => a + b, 0)).toBe(r.visibleUnits); + expect(Math.max(...rows)).toBe(rows[0]); + } + } }); }); -describe("computeReflow — even-row scan", () => { - it("8 widgets at 5 target cols → scan to 4 (4×2)", () => { - // cellSize=72, width=394: floor(402/80)=5. 5→4 gives even 4×2, 5+3 is ragged. - const r = computeReflow({ cellSize: 72, gap: 8, containerWidth: 394, containerHeight: 800, totalUnits: 8, mode: "clip" }); - expect(r.cols).toBe(4); +describe("capacity — `minCell` is a promise under clip", () => { + it("never renders a cell below minCell", () => { + let checked = 0; + for (let w = 220; w <= 1400; w += 37) { + for (let h = 220; h <= 1400; h += 37) { + for (const minCell of [64, 100, 160, 240]) { + for (const units of [1, 5, 7, 8, 13, 20, 40]) { + const r = reflow(w, h, units, { minCell }); + if (r.hiddenUnits === units) continue; // nothing left to draw + checked++; + expect(r.cellPx).toBeGreaterThanOrEqual(Math.min(minCell, r.cellPx) - 1e-9); + expect(r.cellPx + 1e-9).toBeGreaterThanOrEqual( + r.visibleUnits > 0 && minCell <= r.cellPx ? minCell : r.cellPx, + ); + } + } + } + } + expect(checked).toBeGreaterThan(1000); + }); + + it("honours minCell exactly, trimming the visible set instead of shrinking", () => { + for (let w = 240; w <= 1200; w += 53) { + for (let h = 240; h <= 1200; h += 53) { + for (const minCell of [64, 100, 160]) { + const r = reflow(w, h, 60, { minCell }); + if (r.visibleUnits === 0) continue; + // At least one cell fits, so the floor must hold. + if (capacityUnits(w, h, minCell, GAP) >= 1) { + expect(r.cellPx).toBeGreaterThanOrEqual(minCell - 1e-9); + } + } + } + } }); - it("8 widgets at 3 target cols → stays 3 (2 blocked by w/3 cap, scan only goes down)", () => { - // cellSize=100, portrait 360: target=3. c=2: perfect but 176px > w/3=120 → skip. - const r = computeReflow({ cellSize: 100, gap: 8, containerWidth: 360, containerHeight: 668, totalUnits: 8, mode: "clip" }); - expect(r.cols).toBe(3); + it("shows fewer widgets as minCell rises, never more", () => { + for (const [w, h] of [[334, 782], [756, 328], [732, 1118]]) { + let previous = Infinity; + for (let minCell = 48; minCell <= 240; minCell += 4) { + const visible = reflow(w, h, 40, { minCell }).visibleUnits; + expect(visible).toBeLessThanOrEqual(previous); + previous = visible; + } + } }); +}); - it("6 widgets at 4 target cols → scan to 3 (3×2)", () => { - // cellSize=72, width=314: floor(322/80)=4. 4→3 gives even 3×2. - const r = computeReflow({ cellSize: 72, gap: 8, containerWidth: 314, containerHeight: 800, totalUnits: 6, mode: "clip" }); - expect(r.cols).toBe(3); +describe("overflow modes", () => { + it("shrink-to-fit never consults minCell — the reason clip is the default", () => { + const low = reflow(334, 782, 24, { minCell: 64, mode: "shrink-to-fit" }); + const high = reflow(334, 782, 24, { minCell: 240, mode: "shrink-to-fit" }); + expect(low).toEqual(high); + expect(low.hiddenUnits).toBe(0); }); - it("7 widgets at 6 target cols → best candidate is 4 (4+3, not 6+1)", () => { - // cellSize=100, width=747: target=floor(755/108)=6. Score(6)=2 (ragged 6+1). - // Candidates: 5 (score 2), 4 (score 1: 3≥2 half-full, rows=2), 3 (score 2), - // 2 (score 1 but cellPx=370 > 200 cap). Best: 4. - const r = computeReflow({ cellSize: 100, gap: 8, containerWidth: 747, containerHeight: 300, totalUnits: 7, mode: "clip" }); - expect(r.cols).toBe(4); + it("is identical to clip whenever the deck already fits", () => { + const clip = reflow(756, 328, 6); + const shrink = reflow(756, 328, 6, { mode: "shrink-to-fit" }); + expect(clip).toEqual(shrink); + expect(clip.hiddenUnits).toBe(0); }); - it("7 widgets at 3 target cols → stays at 3 (2 would be 176px, w/3 cap blocks it)", () => { - const r = computeReflow({ cellSize: 100, gap: 8, containerWidth: 360, containerHeight: 800, totalUnits: 7, mode: "clip" }); - expect(r.cols).toBe(3); + it("clip hides the tail; shrink-to-fit keeps everything at a smaller size", () => { + const clip = reflow(334, 782, 24, { minCell: 160 }); + const shrink = reflow(334, 782, 24, { minCell: 160, mode: "shrink-to-fit" }); + expect(clip.hiddenUnits).toBeGreaterThan(0); + expect(clip.cellPx).toBeGreaterThanOrEqual(160); + expect(shrink.hiddenUnits).toBe(0); + expect(shrink.cellPx).toBeLessThan(160); }); }); -describe("computeReflow — shrink-to-fit", () => { - it("adds columns so all widgets fit a short viewport", () => { - const clip = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 120, totalUnits: 12, mode: "clip" }); - const fit = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 120, totalUnits: 12, mode: "shrink-to-fit" }); - expect(fit.cols).toBeGreaterThan(clip.cols); - const rows = Math.ceil(12 / fit.cols); - expect(rows * fit.cellPx + (rows - 1) * GAP).toBeLessThanOrEqual(120 + 1e-6); +describe("resize is stable — no hysteresis layer needed", () => { + it("cell size never shrinks as the area widens, and shapes change rarely", () => { + for (const units of [5, 7, 8, 12, 13]) { + let lastCell = -Infinity; + let shape = ""; + let changes = 0; + for (let w = 240; w <= 1600; w++) { + const r = reflow(w, 700, units, { mode: "shrink-to-fit" }); + expect(r.cellPx).toBeGreaterThanOrEqual(lastCell - 1e-9); + lastCell = r.cellPx; + const key = `${r.cols}x${r.rows}`; + if (key !== shape) { + if (shape !== "") changes++; + shape = key; + } + } + expect(changes).toBeLessThanOrEqual(4); + } }); +}); - it("allows cells below the hard floor to fit everything", () => { - const fit = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 90, totalUnits: 20, mode: "shrink-to-fit" }); - expect(fit.cellPx).toBeLessThan(TARGET); - expect(fit.cellPx).toBeGreaterThanOrEqual(16); +describe("degenerate viewports stay finite", () => { + const cases: Array<[number, number, number]> = [ + [800, 1, 6], + [1, 800, 6], + [0, 0, 6], + [200, 200, 600], + [393.3333, 659.6667, 7], + [1920, 180, 8], + ]; + it.each(cases)("%ix%i with %i units", (w, h, units) => { + for (const mode of ["clip", "shrink-to-fit"] as const) { + const r = reflow(w, h, units, { mode }); + expect(Number.isFinite(r.cellPx)).toBe(true); + expect(Number.isFinite(r.cols)).toBe(true); + expect(r.cols).toBeGreaterThanOrEqual(1); + expect(r.cellPx).toBeGreaterThanOrEqual(0); + // Only meaningful once measured: before that nothing has been decided, + // so nothing counts as hidden (asserted separately below). + if (w > 0 && h > 0) expect(r.visibleUnits + r.hiddenUnits).toBe(units); + // A resolved cell is always tappable; an unmeasured one is 0 by design. + if (w > 0 && h > 0 && r.visibleUnits > 0) { + expect(r.cellPx).toBeGreaterThanOrEqual(HARD_FLOOR); + } + } }); - it("matches clip when the content already fits the height", () => { - const clip = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 2000, totalUnits: 6, mode: "clip" }); - const fit = computeReflow({ cellSize: TARGET, gap: GAP, containerWidth: 300, containerHeight: 2000, totalUnits: 6, mode: "shrink-to-fit" }); - expect(fit).toEqual(clip); + it("shows every widget at zero size before the first measurement", () => { + // Trimming needs a measurement. Reporting nothing visible here would blank + // the surface for a frame — or forever, without a ResizeObserver. + const r = reflow(0, 0, 12); + expect(r.visibleUnits).toBe(12); + expect(r.hiddenUnits).toBe(0); + expect(r.cellPx).toBe(0); }); }); diff --git a/client/src/reflow.ts b/client/src/reflow.ts index 52515c0..5573748 100644 --- a/client/src/reflow.ts +++ b/client/src/reflow.ts @@ -1,111 +1,155 @@ -/** Ordered-list reflow geometry (ADR-0010). +/** Reflow geometry (ADR-0011). * - * The grid has no authored shape: widgets pack in list order, left-to-right, - * wrapping down, and the client computes how many columns fit the available - * width against a client-side cell-size target. This module is the pure - * geometry — given a measured container and the target, it yields the column - * count and the resolved square cell size. ``ButtonGrid`` feeds it live - * measurements from a ``ResizeObserver`` and turns the result into - * ``grid-template-columns`` + a ``--cell-px`` content-sizing var. + * The grid has no authored shape. Widgets pack in list order, and the client + * derives the whole layout from the measured container in three stages, each + * consuming exactly one piece of configuration: * - * Kept side-effect-free (no DOM, no React) so the packing maths is unit - * testable in isolation. */ + * 1. CAPACITY minCell + viewport -> how many cells are VISIBLE + * 2. SHAPE visible count -> column count + resolved cell size + * 3. DISTRIBUTION cols -> fill rows, remainder at the bottom + * + * Stage 2 takes no configuration at all: it is pure arithmetic on the + * viewport, and the row count is its only free variable. Stage 3 is ordinary + * text-style wrapping, which CSS grid auto-placement already performs — so + * this module only has to produce the column count and cell size. + * + * Kept side-effect-free (no DOM, no React) so the geometry is unit testable in + * isolation. ``ButtonGrid`` feeds it live ``ResizeObserver`` measurements. */ export type OverflowMode = "clip" | "shrink-to-fit"; export type ReflowInput = { /** Inner width of the grid area, in CSS pixels. */ containerWidth: number; - /** Inner height of the grid area, in CSS pixels. Only consulted for - * ``shrink-to-fit`` — ``clip`` never looks at height (it just clips). */ + /** Inner height of the grid area, in CSS pixels. */ containerHeight: number; - /** Target square cell edge (CSS px). Columns are packed so the resolved - * cell size stays near this value; exact-fit distributes leftover width - * evenly (no separate max/cap — more columns simply fit as width grows). */ - cellSize: number; + /** Readability floor (device preference): the smallest cell the user is + * willing to accept. Under ``clip`` this is a hard promise — the visible + * set is trimmed until every cell can meet it. Under ``shrink-to-fit`` it + * is never consulted, because nothing is ever trimmed. */ + minCell: number; + /** Comfort cap (device preference): stops two widgets on a 4K panel from + * becoming two enormous buttons. */ + maxCell: number; /** Gap between cells, in CSS pixels (matches the CSS ``gap``). */ gap: number; - /** Total occupied cells, counting spans (sum of ``w*h`` over flow widgets). - * Used only by ``shrink-to-fit`` to estimate the row count. */ + /** Total occupied cells, counting spans (sum of ``w*h`` over flow widgets). */ totalUnits: number; mode: OverflowMode; }; export type ReflowResult = { - /** Number of columns to render (``grid-template-columns: repeat(cols, 1fr)``). */ + /** Columns to render (``grid-template-columns: repeat(cols, cellPx)``). */ cols: number; - /** Resolved square cell edge in CSS pixels, for ``--cell-px`` content sizing. */ + /** Rows the visible units occupy at ``cols``. */ + rows: number; + /** Resolved square cell edge in CSS pixels. */ cellPx: number; + /** Units that fit. Equals ``totalUnits`` under ``shrink-to-fit``. */ + visibleUnits: number; + /** Units trimmed by ``clip``. Always 0 under ``shrink-to-fit``. */ + hiddenUnits: number; }; -/** Absolute floor for ``shrink-to-fit`` so a pathological layout can't drive - * cells to zero (or negative) size. */ -const HARD_FLOOR = 16; +/** Absolute floor so a pathological viewport can't drive cells to zero (or + * negative) size. Below this nothing is tappable anyway. */ +export const HARD_FLOOR = 16; -export function computeReflow(input: ReflowInput): ReflowResult { - const { containerWidth, containerHeight, cellSize, gap, totalUnits, mode } = input; - const w = Math.max(0, containerWidth); - const targetPlusGap = cellSize + gap; +/** How many whole cells of edge ``minCell`` the viewport holds at all. + * + * Whole ``cols * rows`` deliberately: it is what lets ``clip`` show complete + * cells rather than slicing a row at the fold, which is what ADR-0010's + * CSS-only ``overflow: hidden`` did. */ +export function capacityUnits( + containerWidth: number, + containerHeight: number, + minCell: number, + gap: number, +): number { + const pitch = minCell + gap; + if (pitch <= 0) return 0; + const cols = Math.floor((containerWidth + gap) / pitch); + const rows = Math.floor((containerHeight + gap) / pitch); + return Math.max(0, cols) * Math.max(0, rows); +} - // Columns that fit at the target cell size. - const colsForWidth = (width: number) => Math.max(1, Math.floor((width + gap) / targetPlusGap)); - // Cell edge when ``cols`` columns share the width evenly (no leftover). - const cellForCols = (cols: number) => (w - (cols - 1) * gap) / cols; +type Shape = { cols: number; rows: number; cell: number }; - let cols = colsForWidth(w); - let cellPx = cellForCols(cols); +/** Choose the row count that makes cells largest, and report the column count + * and cell edge that follow from it. + * + * The dominance prune is load-bearing, not an optimisation: skipping the ``R`` + * whose columns would already hold every unit in ``R - 1`` rows is exactly + * what keeps the candidate set closed under ``(rows, cols) -> (cols, rows)``, + * so a portrait grid and its landscape counterpart resolve consistently. It + * also guarantees plain fill-wrapping yields exactly ``R`` non-empty rows. */ +function bestShape( + units: number, + width: number, + height: number, + gap: number, + maxCell: number, +): Shape | null { + if (units <= 0 || width <= 0 || height <= 0) return null; + const aspect = width / height; + // How far a candidate's grid shape sits from the container's shape. Only + // consulted to break ties, which happen once ``maxCell`` clamps several + // candidates to the same size. + const shapeErr = (cols: number, rows: number) => + Math.abs(Math.log(cols / rows / aspect)); - // Reflow favours fewer columns (larger cells). Scan downward from the - // target — never upward, since the user asked for fewer columns — and - // pick the best: prefer perfectly even rows, then at-least-half-full - // rows, then fewest total rows. The dynamic max-px cap is 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). - if (totalUnits > 0) { - const rowsFor = (c: number) => Math.ceil(totalUnits / c); - const fill = (c: number) => totalUnits % c || c; - const score = (c: number): number => { - if (totalUnits % c === 0) return 0; - if (fill(c) >= Math.ceil(c / 2)) return 1; - return 2; - }; - const maxPx = Math.min(w / 3, Math.max(cellSize * 1.5, 200)); - let best = cols; - let bestScore = score(cols); - let bestRows = rowsFor(cols); - for (let c = cols - 1; c >= 2; c--) { - if (cellForCols(c) > maxPx) continue; - const s = score(c); - if (s > bestScore) continue; - if (s < bestScore || rowsFor(c) < bestRows || (rowsFor(c) === bestRows && c < best)) { - best = c; bestScore = s; bestRows = rowsFor(c); - } + let best: Shape | null = null; + let bestErr = Infinity; + for (let rows = 1; rows <= units; rows++) { + const cols = Math.ceil(units / rows); + if ((rows - 1) * cols >= units) continue; // dominated by `rows - 1` + const byWidth = (width - (cols - 1) * gap) / cols; + const byHeight = (height - (rows - 1) * gap) / rows; + const cell = Math.min(byWidth, byHeight); + if (cell <= 0) continue; + const err = shapeErr(cols, rows); + if ( + best === null || + Math.min(cell, maxCell) - Math.min(best.cell, maxCell) > 1e-9 || + (Math.abs(Math.min(cell, maxCell) - Math.min(best.cell, maxCell)) <= 1e-9 && err < bestErr) + ) { + best = { cols, rows, cell }; + bestErr = err; } - cols = best; - cellPx = cellForCols(cols); } + return best; +} - if (mode === "shrink-to-fit" && totalUnits > 0 && containerHeight > 0) { - const rowsFor = (c: number) => Math.ceil(totalUnits / c); - const fits = (c: number, px: number) => { - const rows = rowsFor(c); - return rows * px + (rows - 1) * gap <= containerHeight; - }; - // Add columns (which shrinks cells) until every widget fits the height, - // or everything is packed into a single row. - while (!fits(cols, cellPx) && cols < totalUnits) { - cols += 1; - cellPx = cellForCols(cols); - } - // Even packed as wide as it goes it still overflows the height: clamp the - // cell to the height budget so the last row is visible, honouring the - // hard floor. - if (!fits(cols, cellPx)) { - const rows = rowsFor(cols); - cellPx = Math.min(cellPx, (containerHeight - (rows - 1) * gap) / rows); - } - cellPx = Math.max(HARD_FLOOR, cellPx); +export function computeReflow(input: ReflowInput): ReflowResult { + const { containerWidth, containerHeight, minCell, maxCell, gap, totalUnits, mode } = input; + const units = Math.max(0, totalUnits); + // Not measured yet (first paint, or no ResizeObserver): report everything as + // visible at zero size rather than trimming to nothing. Trimming is a + // decision that needs a measurement, and reporting nothing visible would + // blank the surface for a frame — or forever, in a host without a + // ResizeObserver. + if (units === 0 || containerWidth <= 0 || containerHeight <= 0) { + return { cols: 1, rows: units, cellPx: 0, visibleUnits: units, hiddenUnits: 0 }; } - return { cols, cellPx: Math.max(0, cellPx) }; + // Stage 1. ``shrink-to-fit`` never trims, so it never consults capacity — + // and therefore never consults ``minCell`` either. ``clip`` trims to whole + // cells that can honour the floor, but always shows at least one widget so a + // hostile viewport can't blank the surface entirely. + const visibleUnits = + mode === "shrink-to-fit" + ? units + : Math.min(units, Math.max(1, capacityUnits(containerWidth, containerHeight, minCell, gap))); + + // Stage 2. + const shape = bestShape(visibleUnits, containerWidth, containerHeight, gap, maxCell); + if (shape === null) return { cols: 1, rows: units, cellPx: 0, visibleUnits: units, hiddenUnits: 0 }; + + return { + cols: shape.cols, + rows: shape.rows, + cellPx: Math.max(HARD_FLOOR, Math.min(shape.cell, maxCell)), + visibleUnits, + hiddenUnits: units - visibleUnits, + }; } diff --git a/client/src/settings-store.ts b/client/src/settings-store.ts index 0a1b534..bbc9fb8 100644 --- a/client/src/settings-store.ts +++ b/client/src/settings-store.ts @@ -17,7 +17,9 @@ const INVERT_KEY = "deckd.scrollInvert"; const PAD_SENS_KEY = "deckd.trackpadSensitivity"; const WAKE_LOCK_KEY = "deckd.wakeLock"; const CONTENT_SCALE_KEY = "deckd.contentScale"; -const CELL_SIZE_KEY = "deckd.cellSize"; +const MIN_CELL_KEY = "deckd.minCell"; +const MAX_CELL_KEY = "deckd.maxCell"; +const OVERFLOW_KEY = "deckd.overflow"; const JOG_WIDTH_KEY = "deckd.jogWidth"; const BOTTOM_SCALE_KEY = "deckd.bottomScale"; const LABEL_SCALE_KEY = "deckd.labelScale"; @@ -32,16 +34,19 @@ export const SCROLL_SCALE_MIN = 1; export const SCROLL_SCALE_MAX = 20; export const SCROLL_SCALE_DEFAULT = 3; -// Target cell size (ADR-0010): the square cell edge (CSS px) the grid packs -// columns around. Cells grow/shrink to fill the width evenly (no separate max -// cap — more columns simply fit as width grows, keeping the result near the -// target). A client-side per-device preference (ADR-0006, like content scale) -// — never authored in the layout YAML. Icon/label size derives from the -// resolved cell size via CSS container units. -export const CELL_SIZE_MIN = 64; -export const CELL_SIZE_MAX = 240; -export const CELL_SIZE_DEFAULT = 100; +// The readability floor and the comfort cap (ADR-0011). Cell size itself is +// *derived* from the viewport — neither value sets it directly. The floor +// decides how many buttons are visible: under `clip` the client trims the deck +// until every cell can honour it, so it is a promise rather than a hint. The +// cap only stops a nearly-empty deck from becoming a few enormous buttons. +// Client-side per-device preferences (ADR-0006), never authored in layout +// YAML. Icon/label size derives from the resolved cell size via CSS container +// units. +export const CELL_SIZE_MIN = 48; +export const CELL_SIZE_MAX = 400; export const CELL_SIZE_STEP = 4; +export const MIN_CELL_DEFAULT = 100; +export const MAX_CELL_DEFAULT = 240; // Secondary nudge applied on top of the cell-derived content size (issue #37): // 1.0 leaves the derived look, and the user can bias icon/label a little @@ -115,7 +120,7 @@ function roundToStep(n: number, step: number): number { } export function clampCellSize(n: number): number { - if (!Number.isFinite(n)) return CELL_SIZE_DEFAULT; + if (!Number.isFinite(n)) return MIN_CELL_DEFAULT; return roundToStep(Math.max(CELL_SIZE_MIN, Math.min(CELL_SIZE_MAX, n)), CELL_SIZE_STEP); } @@ -197,31 +202,87 @@ function readInitialContentScale(): number { } -function readInitialCellSize(): number { +function readInitialCell(urlParam: string, key: string, fallback: number): number { try { - const url = new URLSearchParams(window.location.search).get("cellSize"); + const url = new URLSearchParams(window.location.search).get(urlParam); if (url !== null) return clampCellSize(Number(url)); - const stored = localStorage.getItem(CELL_SIZE_KEY); + const stored = localStorage.getItem(key); if (stored !== null) return clampCellSize(Number(stored)); } catch { // see readInitialScale. } - return CELL_SIZE_DEFAULT; + return fallback; } -/** The target cell size (ADR-0010): the square cell edge (CSS px) the grid - * packs columns around. Client-side per-device preference; drives the - * ``--cell-size`` CSS var and is fed to the reflow maths. */ -export function useCellSize() { - const [size, setSizeState] = useState(readInitialCellSize); +/** What to do when the deck exceeds what the viewport holds at the floor + * (ADR-0011). ``null`` means "follow the layout" and is the default: the + * layout's ``overflow`` field supplies the starting policy, and the user may + * override it per device. This is the one sizing decision that is genuinely + * both a layout concern and a device concern, so both get a say. */ +export type OverflowPreference = "clip" | "shrink-to-fit" | null; + +function readInitialOverflow(): OverflowPreference { + try { + const url = new URLSearchParams(window.location.search).get("overflow"); + const raw = url ?? localStorage.getItem(OVERFLOW_KEY); + if (raw === "clip" || raw === "shrink-to-fit") return raw; + } catch { + // see readInitialScale. + } + return null; +} + +/** The device's override of the layout's overflow policy. */ +export function useOverflowPreference() { + const [overflow, setOverflowState] = useState(readInitialOverflow); + + const setOverflow = useCallback((next: OverflowPreference) => { + setOverflowState(next); + try { + if (next === null) localStorage.removeItem(OVERFLOW_KEY); + else localStorage.setItem(OVERFLOW_KEY, next); + } catch { + // see safeSet. + } + }, []); + + return { overflow, setOverflow }; +} + +/** The readability floor and the comfort cap (ADR-0011). The pair lives in one + * hook because the two must stay ordered: pushing the floor past the cap drags + * the cap up with it, and vice versa, so the band can never invert. */ +export function useCellBand() { + const [minCell, setMinState] = useState(() => + readInitialCell("minCell", MIN_CELL_KEY, MIN_CELL_DEFAULT), + ); + const [maxCell, setMaxState] = useState(() => + readInitialCell("maxCell", MAX_CELL_KEY, MAX_CELL_DEFAULT), + ); + + const setMinCell = useCallback((n: number) => { + const clamped = clampCellSize(n); + setMinState(clamped); + safeSet(MIN_CELL_KEY, String(clamped)); + setMaxState((currentMax) => { + if (currentMax >= clamped) return currentMax; + safeSet(MAX_CELL_KEY, String(clamped)); + return clamped; + }); + }, []); - const setSize = useCallback((n: number) => { + const setMaxCell = useCallback((n: number) => { const clamped = clampCellSize(n); - setSizeState(clamped); - safeSet(CELL_SIZE_KEY, String(clamped)); + setMaxState(clamped); + safeSet(MAX_CELL_KEY, String(clamped)); + setMinState((currentMin) => { + if (currentMin <= clamped) return currentMin; + safeSet(MIN_CELL_KEY, String(clamped)); + return clamped; + }); }, []); - return { size, setSize }; + return { minCell, maxCell, setMinCell, setMaxCell }; } function readInitialJogWidth(): number { diff --git a/client/src/style.css b/client/src/style.css index 00acd75..04349b1 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -382,12 +382,19 @@ button:focus-visible { * Layout grid area (chrome-excluded) * ------------------------------------------------------------------------- */ -/* Ordered-list reflow grid (ADR-0010). ``grid-template-columns`` and - ``grid-auto-rows`` are set inline by ``ButtonGrid`` from the measured width - and the cell-size band: fixed square ``cellPx`` tracks. Leftover width is - centered (``justify-content``) and leftover height sits below the top- - aligned rows (``align-content: start``) — horizontal fill only, by choice. - ``overflow: hidden`` realises ``clip`` overflow: rows past the fold are cut. */ +/* Ordered-list reflow grid (ADR-0011). ``grid-template-columns``, + ``grid-auto-rows`` and ``align-content`` are all set inline by + ``ButtonGrid`` from the measured area: fixed square ``cellPx`` tracks. + + ``justify-content: center`` on a *fixed* track list is what produces the + specified alignment for free — the whole grid block is centred, while a + short final row stays washed left against the block's left edge, so columns + line up. Vertical centring is inline rather than here because it is + conditional (start, not centre, when a spanned widget wraps the block past + the available height — centring an overflowing grid would crop its top too). + + ``overflow: hidden`` is now only a backstop: ADR-0011's ``clip`` trims the + widget list to whole cells before render, so rows are never sliced. */ .grid { display: grid; gap: 8px; @@ -3466,3 +3473,41 @@ select.prop-field-input { outline: 2px solid #4b91f1; outline-offset: 2px; } + +/* --------------------------------------------------------------------------- + * Settings: segmented choice control (ADR-0011's "when there's no room"). + * A radio group in behaviour, buttons in markup so the pressed state is + * conveyed by ``aria-pressed`` and the whole row stays thumb-sized. + * ------------------------------------------------------------------------- */ + +.settings-control-choice { + flex-wrap: wrap; + align-items: flex-start; +} + +.settings-choice { + display: flex; + gap: 6px; + flex: 1 1 100%; + min-width: 0; +} + +.settings-choice-option { + flex: 1 1 0; + min-width: 0; + padding: 7px 8px; + border-radius: 9px; + border: 1px solid #2a333d; + background: #1b222a; + color: #e6e9ef; + font-size: 12px; + letter-spacing: 0.02em; + touch-action: manipulation; +} + +.settings-choice-option[aria-pressed="true"] { + background: #59b8df; + border-color: #59b8df; + color: #06222e; + font-weight: 700; +} diff --git a/daemon/deckd/layouts.py b/daemon/deckd/layouts.py index 8d1f0a0..d0758f5 100644 --- a/daemon/deckd/layouts.py +++ b/daemon/deckd/layouts.py @@ -359,12 +359,13 @@ class Layout(BaseModel): id: str = "" match: list[str] = Field(default_factory=list) widgets: list[Widget] = Field(default_factory=list) - # What happens when the widgets exceed the capacity the client's cell-size - # band yields at the current viewport (ADR-0010). ``clip`` leaves trailing - # widgets off-surface; ``shrink-to-fit`` lets cells drop below the band's - # floor so all widgets fit. The one genuinely per-layout sizing knob — - # every other cell-size concern is a client-side device preference. - overflow: Literal["clip", "shrink-to-fit"] = "shrink-to-fit" + # What happens when the deck exceeds what the viewport holds at the + # client's minimum button size (ADR-0011). ``clip`` (the default) trims + # trailing widgets so the survivors keep that size; ``shrink-to-fit`` keeps + # every widget by letting cells fall below the floor. The layout supplies + # the default and the client may override it per device, so this is the one + # sizing knob that is both a layout and a device concern. + overflow: Literal["clip", "shrink-to-fit"] = "clip" jogstrip: bool = True # Chrome app-identity presentation relayed opaquely to the client # (ADR-0007). The client renders these in the always-on bottom strip: diff --git a/daemon/deckd/protocol.py b/daemon/deckd/protocol.py index d41d41f..1605566 100644 --- a/daemon/deckd/protocol.py +++ b/daemon/deckd/protocol.py @@ -55,7 +55,7 @@ class LayoutMessage(BaseModel): # Overflow behaviour for the client's reflow (ADR-0010): ``clip`` drops # trailing widgets off-surface, ``shrink-to-fit`` shrinks cells below the # band floor so all fit. Relayed from the layout's ``overflow`` field. - overflow: Literal["clip", "shrink-to-fit"] = "shrink-to-fit" + overflow: Literal["clip", "shrink-to-fit"] = "clip" jogstrip_enabled: bool = True # Chrome app badge (ADR-0007), relayed opaquely. The client renders a # branded pill in the always-on bottom strip from these three: diff --git a/docs/adr/0010-grid-reflow.md b/docs/adr/0010-grid-reflow.md index 01b30e1..353585c 100644 --- a/docs/adr/0010-grid-reflow.md +++ b/docs/adr/0010-grid-reflow.md @@ -1,6 +1,6 @@ # Grid layout: ordered-list reflow with a banded cell size -**Supersedes [ADR-0004](0004-orientation-scaling.md).** Tracked in issue #92; **implemented** — the `Widget` schema carries an ordered list with an optional `size` span (no coordinates), the client reflows against a client-side cell-size band, and the transpose is gone. +**Superseded by [ADR-0011](0011-reflow.md)**, which keeps the ordered-list model and replaces the sizing geometry, the band, and the overflow default. **Supersedes [ADR-0004](0004-orientation-scaling.md).** Tracked in issue #92; **implemented** — the `Widget` schema carries an ordered list with an optional `size` span (no coordinates), the client reflows against a client-side cell-size band, and the transpose is gone. ADR-0004 authored layouts as a fixed grid of absolute `[x, y, w, h]` coordinates and handled portrait by transposing them diagonally. That model assumes a grid whose shape is known when the layout is written — the Stream Deck premise, where the hardware *is* the grid. deckd's "deck" is an arbitrary browser viewport: a phone, a tablet, a laptop window being dragged narrower, a super-wide-but-short panel. There is no fixed grid shape to author against, so absolute coordinates are the wrong vocabulary. This ADR replaces them. diff --git a/docs/adr/0011-reflow.md b/docs/adr/0011-reflow.md new file mode 100644 index 0000000..9a59faa --- /dev/null +++ b/docs/adr/0011-reflow.md @@ -0,0 +1,61 @@ +# Reflow: the row count is the only free variable + +**Supersedes [ADR-0010](0010-grid-reflow.md).** Keeps that ADR's 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. + +ADR-0010 sized the grid by asking *"how many columns fit the width?"* and letting rows fall out ragged. Column count only sees one axis, so height had to be smuggled back in as a proxy; the shipped implementation ended up with a tuned constant (`Math.min(w / 3, Math.max(cellSize * 1.5, 200))`) whose comments named the specific test cases it existed to satisfy. That is the signal that the free variable was wrong. + +## Decisions + +### Choose the row count; everything else follows + +Row count `R` is the only genuinely free integer in the problem. For a given `R`: + +``` +c = ceil(units / R) widest row needed +cell = min( (W - (c-1)·gap) / c , what the width allows + (H - (R-1)·gap) / R ) what the height allows +``` + +Pick the `R` that maximises `cell`, clamped to a comfort cap. Ties — common once the cap binds — break toward the grid shape closest to the container's shape, which is derived from the viewport, not tuned. There are no magic constants. + +Both axes now enter honestly. This **reverses ADR-0010's "fill is horizontal only; leftover height is breathing room below, by choice, not omission."** The grid is centred on both axes instead. + +A candidate `R` is skipped when `(R - 1) · c >= units` — its columns would already hold everything in one fewer row, so it only shrinks cells for nothing. **This prune is load-bearing, not an optimisation.** It is exactly what keeps the candidate set closed under `(rows, cols) -> (cols, rows)`, so a portrait grid and its landscape counterpart resolve consistently; and it guarantees plain fill-wrapping yields exactly `R` non-empty rows. Deleting it breaks both properties silently. + +### Rows fill to the column count; the remainder lands in the bottom row + +Not balanced across rows. Ten widgets in four rows is `3+3+3+1`, **not** `3+3+2+2`. The bottom row holds the fewest, and no row above repeats that count unless every row is equal. + +This is ordinary text wrapping at width `c` — which is already ADR-0010's strict-order packing, and which CSS grid auto-placement performs natively. So this stage adds no new concept and almost no code; all the novelty is in choosing `c` via the row count. It also means **spans need no special handling**: a spanned widget shelf-packs in strict order exactly as before. + +### The grid block is centred; rows wash left within it + +The block is as wide as the widest row. It is centred in the area, and every row starts at the block's left edge — so columns stay aligned (widget 5 of a `2+2+1` sits directly under widget 1) while a short row leaves its gap on the right. Centring each row *independently* was considered and rejected: it breaks the column alignment that makes the arrangement read as a grid. + +`justify-content: center` over a fixed track list produces this for free. + +### The band is a floor and a cap, with distinct jobs + +ADR-0010 described a min/max band but shipped a single `cellSize` target. The band is now real, and **cell size is derived from the viewport — neither value sets it**: + +- **`minCell`** — *how many buttons you see.* Capacity is `cols × rows` of whole cells at this size. Under `clip` it is a promise the renderer keeps, not a hint. +- **`maxCell`** — *how big they may get.* Stops a two-widget deck from becoming two enormous buttons on a large panel. + +Both remain client-side per-device preferences ([ADR-0006](0006-widget-visual-styling.md)) in `localStorage`. Layout YAML still carries no pixel sizes. + +### Overflow is a device setting with a layout-supplied default + +ADR-0010 made overflow layout-only, as "layout-semantic rather than device-ergonomic". That no longer holds: `minCell` is a device preference that *creates* the shortage, so the policy resolving it must be reachable from the same place. The layout's `overflow` field now supplies the **default**, which the device may override (`deckd.overflow`; unset means follow the layout). + +**The default changes from `shrink-to-fit` to `clip`.** The reason is not 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. Note the two modes are **identical whenever the deck already fits** — they diverge only on oversized decks. + +`clip` is also materially better than it was. 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, so the surface shows complete, well-sized buttons and never a cropped half-row. + +## Consequences + +- **Breaking default change.** `Layout.overflow` defaults to `clip` in the daemon, and `ButtonGrid`'s prop default matches. No layout YAML in the repo sets `overflow`, so every deck that currently overflows will start hiding trailing widgets instead of shrinking everything. Decks that fit are unaffected. +- **A hidden widget is unreachable, with no affordance announcing it.** The hidden count is known at render time, so surfacing it is cheap and worth doing. Pagination — named in ADR-0010 as the successor to clip — dissolves the trade-off entirely and remains the real fix. +- `client/src/reflow.ts` is rewritten: `capacityUnits` + a row-count search, returning `cols`, `rows`, `cellPx`, `visibleUnits`, `hiddenUnits`. Before the first measurement it reports everything visible at zero size — trimming is a decision that requires a measurement, and reporting nothing would blank the surface for a frame. +- `settings-store.ts` replaces `useCellSize` with `useCellBand` (which keeps floor ≤ cap) and adds `useOverflowPreference`. Keys: `deckd.minCell`, `deckd.maxCell`, `deckd.overflow`. +- Settings gains **Min button size**, **Max button size**, and **When there's no room** (Follow layout / Hide extras / Shrink buttons). +- The interactive model, with both orientations at true ratios, lives at `docs/mockups/reflow-adr0011.html`. diff --git a/docs/adr/README.md b/docs/adr/README.md index dde5d66..d9dad2e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,8 @@ _Amended by: [0006](0006-widget-visual-styling.md), [0007](0007-chrome-app-ident Layouts authored in landscape. Portrait transposes every widget's grid diagonally `[x,y,w,h] -> [y,x,h,w]`. Same buttons, same arrangement, cells sized for the surface. +_Superseded by: [0010](0010-grid-reflow.md) — there is no fixed grid shape to author against_ + ## 0005 — Future: dynamic widget state for MPRIS and runtime content [0005-dynamic-widget-state-future.md](0005-dynamic-widget-state-future.md) @@ -63,3 +65,20 @@ _Amends: [0003](0003-persistent-chrome.md) — chrome knowledge now includes pay [0009-bind-scope-control.md](0009-bind-scope-control.md) Replace `--host` with repeatable `--bind` supporting literal IPs and `iface:`. Default `127.0.0.1` + `::1`. Localhost-only by default; LAN reachability is opt-in. + +## 0010 — Grid layout: ordered-list reflow with a banded cell size + +[0010-grid-reflow.md](0010-grid-reflow.md) + +Widgets become an ordered list that reflows to the viewport; `grid: [x,y,w,h]` coordinates and the portrait transpose are deleted. Cell size is a client-side device preference, never authored in layout YAML. + +_Supersedes: [0004](0004-orientation-scaling.md) — authored coordinates + diagonal transpose_ +_Superseded by: [0011](0011-reflow.md) — the sizing geometry, the band, and the overflow default_ + +## 0011 — Reflow: the row count is the only free variable + +[0011-reflow.md](0011-reflow.md) + +Sizing picks the row count that makes cells largest, using both axes, so no tuned constants remain. Rows fill to the column count with the remainder in the bottom row; the grid block centres on both axes with rows washed left. `minCell` / `maxCell` gain distinct jobs, and overflow becomes a device setting defaulting to `clip`. + +_Supersedes: [0010](0010-grid-reflow.md) — keeps the ordered-list model, replaces the geometry_ diff --git a/docs/mockups/reflow-adr0011.html b/docs/mockups/reflow-adr0011.html new file mode 100644 index 0000000..a63384d --- /dev/null +++ b/docs/mockups/reflow-adr0011.html @@ -0,0 +1,420 @@ + + + + + +deckd — reflow (ADR-0011 candidate) + + + + +
+

Reflow — both orientations, true ratios

+

Chrome geometry measured from client/src/style.css in Chromium: + bottom strip 62px at scale 1, jogstrip clamp(56px, 12vw, 88px). + Rotation is not a transpose — the strip stays at the bottom and the jogstrip + widens in landscape.

+
+ +
settings +

keeping — the real user-facing knobs

+
+ + + 100px +
+
+ + + 240px +
+
+
+

environment — not settings; these just reshape the test viewport

+
+ + + 62px +
+
+ + + 1.00 +
+
+ +
+ +
+

Where each of these is stored

+ + + + + + + + + + + + + + +
SettingLives inScope
min cell / max celllocalStorage — deckd.cellSizeThis browser, this device. Never sent to the daemon.
bottom barlocalStorage — deckd.bottomScaleThis browser, this device.
jogstrip widthlocalStorage — deckd.jogWidthThis browser, this device.
no room (shrink / hide)localStorage — deckd.overflow (proposed)
+ default from overflow: in the layout *.yaml
This browser, this device — overriding the layout’s default. + Today it is layout-only, with no device override.
+

+ Everything the client owns is per-device by design (ADR-0006): the same daemon + driving a phone and a laptop gives each its own button size, and a device that + clears its browser storage falls back to the compiled-in defaults. Layout YAML + carries no pixel sizes at all. Read order for client settings is + URL query param > localStorage > built-in default. +

+

+ Settled. “When there isn’t room” becomes a device setting, + with the layout’s overflow: field surviving as the default it starts from. + Both halves of the decision — the min cell that creates the shortage and the + policy that resolves it — now live in the same place. + Default is “hide extras”, because under “shrink buttons” the + visible count is always every widget, so min cell is never consulted and the + slider does nothing. +

+
+ +

+ Settled rules, no longer toggleable. + Rows: pick the row count that makes cells biggest, then fill each row to that width and + let the remainder land in the bottom row alone (10 widgets in 4 rows → 3+3+3+1, + not 3+3+2+2). + Alignment: the grid block is centred in the area on both axes, and rows wash left against + the block’s left edge, so columns stay aligned and a short row leaves its gap on the right. + Capacity: per-orientation — each orientation shows as many as it comfortably can, so + rotating may hide or reveal a button (2.78% of configurations do). + The blue-outlined cell is widget 1, so you can watch sequence survive the reshape. +

+ + + + diff --git a/tests/test_layouts.py b/tests/test_layouts.py index 7abeed2..5366d0a 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -160,9 +160,12 @@ def test_blank_widget_rejects_content_fields(field: str, value: object) -> None: Widget.model_validate({"id": "gap", "kind": "blank", field: value}) -def test_layout_overflow_defaults_to_shrink_to_fit() -> None: +def test_layout_overflow_defaults_to_clip() -> None: + """ADR-0011 flipped this from shrink-to-fit: under shrink-to-fit the + visible count is always every widget, so the client never consults the + reader's minimum button size and that preference goes dead.""" layout = Layout.model_validate({"match": ["x"], "widgets": []}) - assert layout.overflow == "shrink-to-fit" + assert layout.overflow == "clip" def test_layout_accepts_shrink_to_fit_overflow() -> None: From d70023d1219277053dc8e29efc3e3b5d919918d9 Mon Sep 17 00:00:00 2001 From: Jono Date: Tue, 22 Sep 2026 23:09:32 -0700 Subject: [PATCH 2/3] Add in-app layout help page explaining button sizing --- client/help.html | 14 + client/src/App.tsx | 22 +- client/src/ReflowHelp.stories.tsx | 21 ++ client/src/ReflowHelp.test.tsx | 72 ++++ client/src/ReflowHelp.tsx | 604 ++++++++++++++++++++++++++++++ client/src/Settings.test.tsx | 18 + client/src/Settings.tsx | 10 + client/src/help-entry.tsx | 18 + client/src/help.css | 418 +++++++++++++++++++++ client/src/main.tsx | 1 + client/src/reflow.test.ts | 40 +- client/src/reflow.ts | 16 + client/src/style.css | 23 ++ client/src/view-routing.test.ts | 1 + client/src/view-routing.ts | 12 +- client/vite.config.ts | 15 +- docs/GUIDE.md | 6 + docs/adr/0011-reflow.md | 1 + 18 files changed, 1290 insertions(+), 22 deletions(-) create mode 100644 client/help.html create mode 100644 client/src/ReflowHelp.stories.tsx create mode 100644 client/src/ReflowHelp.test.tsx create mode 100644 client/src/ReflowHelp.tsx create mode 100644 client/src/help-entry.tsx create mode 100644 client/src/help.css diff --git a/client/help.html b/client/help.html new file mode 100644 index 0000000..0c60717 --- /dev/null +++ b/client/help.html @@ -0,0 +1,14 @@ + + + + + + + + deckd · how button layout works + + +
+ + + diff --git a/client/src/App.tsx b/client/src/App.tsx index 2d9afc8..f6e4a27 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -45,6 +45,7 @@ import type { import { EDITOR_VIEW_ID, MPRIS_VIEW_ID, WINDOWS_VIEW_ID } from "./protocol"; import { isTypingTarget, onActivate } from "./a11y"; import { Editor } from "./Editor"; +import { ReflowHelp } from "./ReflowHelp"; import { ConfirmModal } from "./ConfirmModal"; import type { Widget, ConfirmRequestMessage } from "./protocol"; import { wireWindowsToServer } from "./protocol"; @@ -542,6 +543,7 @@ export function App() { if (view === "trackpad") return "Manual control"; if (view === "nowplaying") return "Now playing"; if (view === "settings") return "Settings"; + if (view === "help") return "Button layout help"; if (view === "editor") return "Layout editor"; if (view === "windows") return "Running programs"; if (layout?.error) return "Layout error"; @@ -770,6 +772,22 @@ export function App() { onReduceMotionChange={reduceMotion.setEnabled} showKeyHints={showKeyHints.enabled} onShowKeyHintsChange={showKeyHints.setEnabled} + onOpenHelp={() => navigate("help")} + /> + ) : view === "help" ? ( + // User-facing explainer for the grid geometry (ADR-0011). Opened + // from Settings and seeded with this device's live band, so the + // sandbox starts where the user actually is. Applying writes the + // same two preferences the Settings sliders write. + { + effectiveBand.setMinCell(minCell); + effectiveBand.setMaxCell(maxCell); + }} + onClose={() => navigate("settings")} /> ) : view === "editor" ? ( + ) : null} + + +

+ deckd works out the layout from the space it has, every time the window changes. Nothing is + pinned to fixed positions — that is why buttons move when you resize or rotate. + Buttons always keep their order, so the first button is always first. +

+ + {/* --- Sandbox ------------------------------------------------------ */} +
+

Try it

+

+ Drag the corner of the box, or tap a shape. In this box: +

+ +
+
+
+
+ +
+
+
+
+ +

+ + {sandbox.hiddenUnits > 0 + ? `Showing ${sandbox.visibleUnits} of ${count}` + : `${count} ${count === 1 ? "button" : "buttons"}`} + {" "} + · {sandboxRows.join("+")} · {Math.round(sandbox.cellPx)}px each + {sandbox.hiddenUnits > 0 ? ( + <> + {" "} + · {sandbox.hiddenUnits} hidden + + ) : null} +

+ + {/* Shapes, roughly matched in area to the default so switching mostly + changes the arrangement rather than how much fits. */} +
+ + + + +
+ +
+ + + +
+ + {overflow === "shrink-to-fit" ? ( +

+ You use Shrink buttons, so every button is always shown and the minimum size is + never applied. Switch to Hide extras in Settings to see the minimum take effect. +

+ ) : null} +
+ + {/* --- Why they move ------------------------------------------------ */} +
+

Why do my buttons move?

+

+ The app measures the space, then picks the arrangement that makes the buttons as big as + possible. A tall space gets more rows, a wide one gets more columns — same buttons, + same order, only the shape changes. +

+
+
+ + + +
Tall area → {layoutOf(SHAPE_TALL).rows.join("+")}
+
+
+ + + +
Wide area → {layoutOf(SHAPE_WIDE).rows.join("+")}
+
+
+
+ + {/* --- Why some are missing ----------------------------------------- */} +
+

Why can’t I see all my buttons?

+

+ Min size is a promise: buttons are never drawn smaller than it. When there + isn’t room for them all at that size, When there’s no room decides what + happens. +

+
+
+ + + +
+ Shrink buttons → all {OVERFLOW_UNITS} shown, smaller + {overflow === "shrink-to-fit" ? (you use this) : null} +
+
+
+ + + +
+ Hide extras → keeps the minimum, hides the rest + {overflow === "clip" ? (you use this) : null} +
+
+
+
+ + {/* --- Why the last row is short ------------------------------------ */} +
+

Why isn’t the last row full?

+

+ Buttons fill each row across, then wrap. When the count doesn’t divide evenly, the + leftover sits in the bottom row on its own — {DIST_UNITS} buttons in{" "} + {DIST_ROWS} rows is {layoutOf(DIST_GRID).rows.join("+")}, never{" "} + {balancedRows(DIST_UNITS, DIST_ROWS).join("+")}. The whole block is centred, and every row + starts at the same left edge. +

+
+
+ + + +
{layoutOf(DIST_GRID).rows.join("+")}
+
+
+
+ + {/* --- Band ---------------------------------------------------------- */} +
+

How big can they get?

+

+ Max size is a cap. Only a few buttons in a big space would otherwise grow into + enormous tiles; the cap keeps them button-sized. +

+
+ +

+ The blue square is button 1 — watch it stay first as the grid reshapes. + {onApply ? ( + <> + {" "} + Changes to Min size and Max size here are only applied to this device if + you tap Apply when you close. + + ) : ( + <> Open this page from Settings to apply changes to your device. + )} +

+ + {asking ? ( + { + setAsking(false); + onClose?.(); + }} + onApply={() => { + onApply?.({ minCell: draftMin, maxCell: draftMax }); + setAsking(false); + onClose?.(); + }} + /> + ) : null} +
+ ); +} + +/** The apply-on-close prompt. Deliberately local rather than reusing + * ``ConfirmModal``: that one speaks the daemon's ``confirm_id`` handshake for + * dangerous widgets, which has nothing to do with a device preference. */ +function ApplyDialog({ + minCell, + maxCell, + onCancel, + onApply, +}: { + minCell: number; + maxCell: number; + onCancel: () => void; + onApply: () => void; +}) { + const applyRef = useRef(null); + useEffect(() => { + applyRef.current?.focus(); + }, []); + return ( +
{ + if (e.key === "Escape") { + e.stopPropagation(); + onCancel(); + } + }} + > +
+

+ Apply these button sizes? +

+

+ Min size {minCell}px, max size {maxCell}px. This changes this device only. +

+
+ + +
+
+
+ ); +} diff --git a/client/src/Settings.test.tsx b/client/src/Settings.test.tsx index 8aec9ad..399d09a 100644 --- a/client/src/Settings.test.tsx +++ b/client/src/Settings.test.tsx @@ -61,6 +61,24 @@ describe("Settings — log out", () => { }); }); +describe("Settings — layout help link", () => { + afterEach(cleanup); + + it("opens the explainer when the link is wired", () => { + const onOpenHelp = vi.fn(); + renderSettings({ onOpenHelp }); + fireEvent.click(screen.getByRole("button", { name: /how layout and sizing work/i })); + expect(onOpenHelp).toHaveBeenCalledOnce(); + }); + + it("hides the link when no handler is supplied", () => { + renderSettings(); + expect( + screen.queryByRole("button", { name: /how layout and sizing work/i }), + ).toBeNull(); + }); +}); + describe("Settings — accessibility toggles", () => { afterEach(cleanup); diff --git a/client/src/Settings.tsx b/client/src/Settings.tsx index cf45aa3..538c30f 100644 --- a/client/src/Settings.tsx +++ b/client/src/Settings.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { Info as InfoIcon } from "lucide-react"; import { useOrientation } from "./orientation"; import { BOTTOM_SCALE_MAX, @@ -50,6 +51,8 @@ type Props = { onOverflowChange: (next: OverflowPreference) => void; /** What the active layout asks for, shown so "Follow layout" isn't opaque. */ layoutOverflow: "clip" | "shrink-to-fit"; + /** Open the in-app explainer for the sizing controls below (ADR-0011). */ + onOpenHelp?: () => void; jogWidth: number; onJogWidthChange: (n: number) => void; bottomScale: number; @@ -99,6 +102,7 @@ export function Settings({ overflow, onOverflowChange, layoutOverflow, + onOpenHelp, jogWidth, onJogWidthChange, bottomScale, @@ -267,6 +271,12 @@ export function Settings({ ))}
+ {onOpenHelp ? ( + + ) : null}
Content nudge +
+ +
+ , +); diff --git a/client/src/help.css b/client/src/help.css new file mode 100644 index 0000000..9605ac8 --- /dev/null +++ b/client/src/help.css @@ -0,0 +1,418 @@ +/* Styles for the user-facing layout help page (). + * + * Imported by BOTH the app (main.tsx) and the standalone help.html entry, so + * the two mounts look identical without the standalone page having to pull in + * the whole app stylesheet. Everything is namespaced `help-` for that reason. + * + * The base block below duplicates style.css intentionally: help.html does not + * import style.css (it would ship the entire app CSS for one page), so it + * needs its own reset. In the app these declarations are simply redundant. */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: #12161b; + color: #e6e9ef; + font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-text-size-adjust: 100%; +} + +/* The app stylesheet zeroes button chrome globally; help.html does not load + it, so repeat the reset here. A bare element selector (not `.help button`) + so it stays weaker than the component classes below, which are what give + the close, preset and dialog buttons their look. */ +button { + font: inherit; + color: inherit; + background: none; + border: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +/* Keyboard focus ring, standalone only: in-app, style.css draws the app's own + focus treatment and a second outline here would show through as a double. */ +.help-page button:focus-visible, +.help-page input:focus-visible { + outline: 2px solid #7dd3fc; + outline-offset: 2px; +} + +.help-page { + height: 100dvh; + padding: 10px; + box-sizing: border-box; +} + +/* The card: fills the app surface in-app, bounds the scrolling page standalone. */ +.help { + width: 100%; + max-width: 720px; + margin: 0 auto; + height: 100%; + padding: 18px 16px 28px; + border: 1px solid #2a333d; + border-radius: 16px; + background: #11161b; + color: #e6e9ef; + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +.help-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 10px; +} + +.help-title { + flex: 1; + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.25; +} + +.help-close { + flex: 0 0 auto; + width: 34px; + height: 34px; + margin: -4px -4px 0 0; + border-radius: 999px; + background: #1b222a; + color: #e6e9ef; + font-size: 22px; + line-height: 1; + cursor: pointer; + touch-action: manipulation; +} + +.help-lede { + margin: 0 0 4px; + color: #8a96a3; + font-size: 13px; + line-height: 1.5; +} + +.help-section { + padding: 18px 0; + border-top: 1px solid #232a32; +} + +.help-heading { + margin: 0 0 6px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #7dd3fc; +} + +.help-body { + margin: 0 0 12px; + font-size: 13px; + line-height: 1.55; + color: #c7ced8; +} + +.help-body b, +.help-note b { + color: #e6e9ef; +} + +/* --- sandbox ------------------------------------------------------------ */ + +.help-stage { + display: flex; + justify-content: center; + margin: 6px 0 12px; +} + +/* The drawing area. 1px inset shadow instead of a border so the frame's + content box is exactly `width`, matching the pixels computeReflow saw. */ +.help-frame { + position: relative; + background: #0f141a; + border-radius: 8px; + box-shadow: inset 0 0 0 1px #2a333d; + overflow: hidden; +} + +.help-grid { + display: grid; +} + +.help-cell { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + border-radius: 3px; + background: #1b222a; + box-shadow: inset 0 0 0 1px #2a333d; + color: #6b7785; + font-size: 11px; + font-variant-numeric: tabular-nums; + overflow: hidden; +} + +/* Button 1 is called out so sequence is visible as the grid reshapes. */ +.help-cell-first { + background: #14364a; + box-shadow: inset 0 0 0 1px #7dd3fc; + color: #7dd3fc; + font-weight: 700; +} + +.help-resize { + position: absolute; + right: 0; + bottom: 0; + width: 26px; + height: 26px; + cursor: nwse-resize; + touch-action: none; +} + +.help-resize::after { + content: ""; + position: absolute; + right: 5px; + bottom: 5px; + width: 9px; + height: 9px; + border-right: 2px solid #7dd3fc; + border-bottom: 2px solid #7dd3fc; +} + +.help-stats { + margin: 0 0 12px; + text-align: center; + font-size: 12px; + font-variant-numeric: tabular-nums; + color: #8a96a3; +} + +.help-stats b { + color: #e6e9ef; +} + +.help-hidden { + color: #e0a33e; +} + +.help-presets { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + margin-bottom: 14px; +} + +.help-presets button { + padding: 6px 14px; + border: 1px solid #2a333d; + border-radius: 999px; + background: #1b222a; + color: #e6e9ef; + font-size: 12px; + letter-spacing: 0.03em; + cursor: pointer; + touch-action: manipulation; +} + +.help-controls { + display: flex; + flex-direction: column; + gap: 10px; +} + +.help-control { + display: grid; + grid-template-columns: minmax(64px, 22%) 1fr auto; + align-items: center; + gap: 12px; +} + +.help-control-label { + font-size: 12px; + color: #8a96a3; + letter-spacing: 0.03em; +} + +.help-control-value { + min-width: 46px; + text-align: right; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 700; + color: #7dd3fc; +} + +/* Mirrors `.slider` in style.css; kept separate so help.html doesn't need the + app stylesheet. */ +.help-slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + border-radius: 999px; + background: linear-gradient(90deg, #14364a, #2a333d); + outline: none; + touch-action: pan-x; + cursor: pointer; +} + +.help-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: #7dd3fc; + border: 2px solid #101418; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} + +.help-slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: #7dd3fc; + border: 2px solid #101418; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} + +.help-note { + margin: 12px 0 0; + padding: 9px 12px; + border-left: 2px solid #e0a33e; + background: #161b16; + border-radius: 0 8px 8px 0; + font-size: 12px; + line-height: 1.5; + color: #b6bec9; +} + +/* --- illustrated examples ---------------------------------------------- */ + +.help-pair { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: center; + gap: 18px; +} + +.help-example { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin: 0; +} + +.help-example figcaption { + max-width: 150px; + text-align: center; + font-size: 11px; + line-height: 1.4; + color: #8a96a3; +} + +.help-example-on figcaption { + color: #7dd3fc; +} + +.help-you { + color: #7dd3fc; + font-weight: 700; +} + +/* Clips the scaled-down drawing; layout inside runs at true pixels. */ +.help-diagram { + overflow: hidden; + border-radius: 8px; +} + +.help-footer { + margin-top: 18px; + border-left: 0; + background: transparent; + padding: 0; + color: #8a96a3; +} + +/* --- apply-on-close dialog --------------------------------------------- */ + +.help-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(4, 8, 12, 0.72); + backdrop-filter: blur(6px); +} + +.help-dialog { + width: 100%; + max-width: 340px; + padding: 20px; + border: 1px solid #2a333d; + border-radius: 16px; + background: #11161b; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6); +} + +.help-dialog-title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 700; +} + +.help-dialog-body { + margin: 0 0 18px; + font-size: 13px; + line-height: 1.5; + color: #c7ced8; +} + +.help-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.help-dialog-button { + padding: 9px 16px; + border: 1px solid #2a333d; + border-radius: 999px; + background: #1b222a; + color: #e6e9ef; + font-size: 13px; + font-weight: 600; + cursor: pointer; + touch-action: manipulation; +} + +.help-dialog-primary { + border-color: #7dd3fc; + background: #7dd3fc; + color: #06222e; +} diff --git a/client/src/main.tsx b/client/src/main.tsx index eaa7025..7f051ac 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import { App } from "./App"; import "./fonts"; import "./style.css"; +import "./help.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/client/src/reflow.test.ts b/client/src/reflow.test.ts index f59b037..6ce2bc1 100644 --- a/client/src/reflow.test.ts +++ b/client/src/reflow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { capacityUnits, computeReflow, HARD_FLOOR } from "./reflow"; +import { capacityUnits, computeReflow, fillRows, HARD_FLOOR } from "./reflow"; import type { OverflowMode } from "./reflow"; const GAP = 8; @@ -23,37 +23,26 @@ const reflow = ( mode: o.mode ?? "clip", }); -/** The rows plain fill-wrapping produces at a given column count. */ -const rowsOf = (visible: number, cols: number) => { - const rows: number[] = []; - let left = visible; - while (left > 0) { - rows.push(Math.min(cols, left)); - left -= cols; - } - return rows; -}; - describe("shape — the row count is the free variable", () => { it("five widgets on a roomy landscape area wrap 3+2, never 4+1", () => { const r = reflow(700, 480, 5); expect([r.cols, r.rows]).toEqual([3, 2]); - expect(rowsOf(r.visibleUnits, r.cols)).toEqual([3, 2]); + expect(fillRows(r.visibleUnits, r.cols)).toEqual([3, 2]); }); it("a narrow portrait area prefers more rows because cells come out bigger", () => { // 2 columns of 148px beats 3 columns of 96px on a 304x578 area. const portrait = reflow(304, 578, 5); expect(portrait.cols).toBe(2); - expect(rowsOf(portrait.visibleUnits, portrait.cols)).toEqual([2, 2, 1]); + expect(fillRows(portrait.visibleUnits, portrait.cols)).toEqual([2, 2, 1]); expect(portrait.cellPx).toBeGreaterThan(reflow(304, 578, 5).cellPx - 1); }); it("the same widgets reshape rather than resize when the area turns", () => { const portrait = reflow(334, 782, 8); const landscape = reflow(756, 328, 8); - expect(rowsOf(portrait.visibleUnits, portrait.cols)).toEqual([2, 2, 2, 2]); - expect(rowsOf(landscape.visibleUnits, landscape.cols)).toEqual([4, 4]); + expect(fillRows(portrait.visibleUnits, portrait.cols)).toEqual([2, 2, 2, 2]); + expect(fillRows(landscape.visibleUnits, landscape.cols)).toEqual([4, 4]); expect(portrait.visibleUnits).toBe(landscape.visibleUnits); }); @@ -67,14 +56,14 @@ describe("distribution — fill to the column count, remainder at the bottom", ( it("puts the shortfall in the bottom row alone", () => { // 10 units over 4 rows is 3+3+3+1, not the max-balanced 3+3+2+2. const r = reflow(656, 1071, 10); - expect(rowsOf(r.visibleUnits, r.cols)).toEqual([3, 3, 3, 1]); + expect(fillRows(r.visibleUnits, r.cols)).toEqual([3, 3, 3, 1]); }); it("always yields exactly `rows` non-empty rows, and the bottom row is never the widest", () => { for (let units = 1; units <= 200; units++) { for (const [w, h] of [[334, 782], [756, 328], [1092, 758], [200, 200]]) { const r = reflow(w, h, units, { mode: "shrink-to-fit" }); - const rows = rowsOf(r.visibleUnits, r.cols); + const rows = fillRows(r.visibleUnits, r.cols); expect(rows.length).toBe(r.rows); expect(rows.every((n) => n >= 1)).toBe(true); expect(rows.reduce((a, b) => a + b, 0)).toBe(r.visibleUnits); @@ -84,6 +73,21 @@ describe("distribution — fill to the column count, remainder at the bottom", ( }); }); +describe("fillRows — the row breakdown the help page reports", () => { + it("fills each row to the column count, remainder alone at the bottom", () => { + expect(fillRows(10, 3)).toEqual([3, 3, 3, 1]); + expect(fillRows(5, 3)).toEqual([3, 2]); + expect(fillRows(8, 4)).toEqual([4, 4]); + expect(fillRows(3, 5)).toEqual([3]); + expect(fillRows(0, 3)).toEqual([]); + }); + + it("never emits a zero-width row and guards a non-positive column count", () => { + expect(fillRows(7, 0)).toEqual([1, 1, 1, 1, 1, 1, 1]); + expect(fillRows(7, -2)).toEqual([1, 1, 1, 1, 1, 1, 1]); + }); +}); + describe("capacity — `minCell` is a promise under clip", () => { it("never renders a cell below minCell", () => { let checked = 0; diff --git a/client/src/reflow.ts b/client/src/reflow.ts index 5573748..747bba4 100644 --- a/client/src/reflow.ts +++ b/client/src/reflow.ts @@ -73,6 +73,22 @@ export function capacityUnits( return Math.max(0, cols) * Math.max(0, rows); } +/** Split ``visibleUnits`` into rows of at most ``cols``, filling each row left + * to right so the remainder lands in the bottom row alone (ADR-0011 stage 3). + * Returns one count per non-empty row: 10 units at 3 columns -> ``[3, 3, 3, 1]``. + * + * The renderer gets this for free from CSS grid auto-placement (``ButtonGrid`` + * just hands ``cols`` to ``grid-template-columns``). This helper exists so + * surfaces that draw their own rectangles — the user-facing help page — can + * report the row shape without re-deriving a rule the layout already owns. */ +export function fillRows(visibleUnits: number, cols: number): number[] { + const units = Math.max(0, Math.floor(visibleUnits)); + const perRow = Math.max(1, Math.floor(cols)); + const rows: number[] = []; + for (let left = units; left > 0; left -= perRow) rows.push(Math.min(perRow, left)); + return rows; +} + type Shape = { cols: number; rows: number; cell: number }; /** Choose the row count that makes cells largest, and report the column count diff --git a/client/src/style.css b/client/src/style.css index 04349b1..48e1e41 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -3511,3 +3511,26 @@ select.prop-field-input { color: #06222e; font-weight: 700; } + +/* Link into the in-app layout explainer (ADR-0011), sitting under the sizing + controls it describes. Full-width so it reads as a row, not a stray button. */ +.settings-help-link { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 9px 12px; + border: 1px solid #2a333d; + border-radius: 9px; + background: #161c22; + color: #7dd3fc; + font-size: 12px; + letter-spacing: 0.03em; + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.settings-help-link:hover { + border-color: #3a4a57; +} diff --git a/client/src/view-routing.test.ts b/client/src/view-routing.test.ts index 1a99b3a..56b9dbc 100644 --- a/client/src/view-routing.test.ts +++ b/client/src/view-routing.test.ts @@ -9,6 +9,7 @@ describe("view routing", () => { ["/now-playing", "nowplaying"], ["/editor", "editor"], ["/windows", "windows"], + ["/help", "help"], ] as const)("maps %s to %s", (path, view) => { expect(viewFromPath(path)).toBe(view); expect(pathForView(view)).toBe(path); diff --git a/client/src/view-routing.ts b/client/src/view-routing.ts index 8160344..e9e3530 100644 --- a/client/src/view-routing.ts +++ b/client/src/view-routing.ts @@ -1,4 +1,11 @@ -export type View = "layout" | "trackpad" | "settings" | "nowplaying" | "editor" | "windows"; +export type View = + | "layout" + | "trackpad" + | "settings" + | "nowplaying" + | "editor" + | "windows" + | "help"; const PATH_BY_VIEW: Record = { layout: "/", @@ -7,6 +14,9 @@ const PATH_BY_VIEW: Record = { nowplaying: "/now-playing", editor: "/editor", windows: "/windows", + // Reached from Settings rather than an always-on chrome button: it explains + // the sizing controls sitting right above the link (ADR-0011). + help: "/help", }; const VIEW_BY_PATH: Record = Object.fromEntries( diff --git a/client/vite.config.ts b/client/vite.config.ts index 47c6a50..63db79c 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -75,10 +75,23 @@ export default defineConfig({ // Ladle reuses this vite config but supplies its own stories entry, // so guard on ``VITE_LADLE_APP_ID`` (set by the ladle CLI) to avoid // clobbering Ladle's input and ending up with 0 stories built. - ...(process.env.VITE_LADLE_APP_ID ? {} : { input: ["index.html", "gallery.html", "screenshots.html"] }), + ...(process.env.VITE_LADLE_APP_ID + ? {} + : { input: ["index.html", "gallery.html", "screenshots.html", "help.html"] }), output: { manualChunks(id) { if (id.includes("node_modules/simple-icons")) return "simple-icons"; + // React in its own chunk, so an entry that renders no icons doesn't + // get the whole glyph set pulled in beside it. Without this the + // standalone help page (help.html) preloads lucide simply because + // React happened to land in the same chunk. + if ( + id.includes("node_modules/react/") || + id.includes("node_modules/react-dom/") || + id.includes("node_modules/scheduler/") + ) { + return "react"; + } if (id.includes("node_modules/lucide-react")) return "lucide"; }, }, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 1362bd0..2ba2cf3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -207,6 +207,7 @@ The client can be viewed and design-iterated without a running daemon: - **Demo mode** — append `?demo=` to the client URL (`firefox`, `default`, or `showcase`) to render a fixture layout with the WebSocket disabled. The `showcase` fixture exercises every icon path (Lucide glyphs, Simple Icons brand logos, per-button colour, a no-icon button, and the unknown-icon placeholder). Dev-only; adds no cost when the param is absent. (For forcing a *real* daemon layout with a live backend, use the per-client `?layout=` pin — see [Layout override](#dev-ux-auto-ignore--layout-override).) - **Responsive gallery** — `cd client && npm run dev`, then open `/gallery.html`. Renders the real client in phone / large-phone / 7" / 10"-tablet iframes at once, with layout, orientation, and **key hints** selectors — for checking how a layout reads across screen sizes (the key-hints toggle drives each frame's `?showKeyHints=1`). Dev-only entry, not in the production build. - **Screenshot page** — `cd client && npm run dev`, then open `/screenshots.html`. Renders curated demo views inside phone-framed iframes, one per configured shot — the source of truth for `just screenshots`. Curate the list by editing the `SHOTS` array in `client/src/Screenshots.tsx`. +- **Layout explainer** — `cd client && npm run dev`, then open `/help`. The user-facing help page (also mounted standalone as `/help.html`, and from Settings via the **How layout and sizing work** link). It imports the real `client/src/reflow.ts`, so its diagrams cannot disagree with the app; a design mockup with chrome modelling and every settled-fork toggle lives at `docs/mockups/reflow-adr0011.html`. - **Ladle** (component workbench) — `cd client && npm run ladle`. Browse `ButtonGrid` / `Icon` / `JogStrip` stories in isolation with width/theme controls, plus `Surface → Device sizes` stories that render the grid in fixed phone/tablet frames (size + orientation) for a quick per-component resolution check. Stories live in `src/*.stories.tsx` (Storybook-compatible CSF). - **Lint** — `cd client && npm run lint` (ESLint flat config; `npm run build` still runs `tsc --noEmit`). @@ -218,6 +219,7 @@ Every push to `main` builds the client and Ladle and publishes them to GitHub Pa - **Live client (demo mode)** — `https://jonocodes.github.io/deckd/?demo=showcase` (also `?demo=firefox`, `?demo=default`). Without `?demo=` the client loads and shows "disconnected" since there's no daemon behind the Pages site; the param lets the fixture layout run. - **Responsive gallery** — `https://jonocodes.github.io/deckd/gallery.html`. +- **Layout help** — `https://jonocodes.github.io/deckd/help.html`. The standalone build of the in-app explainer (below); no daemon, no icons. - **Ladle stories** — `https://jonocodes.github.io/deckd/ladle/`. Source: `.github/workflows/deploy-pages.yml`. The Vite build uses `VITE_BASE_PATH=/deckd/` and the Ladle build uses `--base /deckd/ladle/` so the Project-Pages sub-path resolves; local dev keeps base `/`. Reproduce the deploy bundle locally with `just build-pages` (output: `client/dist/`) and serve it with `npx serve client/dist`. @@ -344,6 +346,10 @@ Tap the `settings` button in the bottom chrome for a control panel: - **Scroll invert** (toggle) — flip vertical scroll direction. - **Bar width** (slider, 40%–100%, default 100%) — width of the persistent right-side jogstrip (the scroll bar), as a fraction of its responsive base width, so you can slim it down on devices where it reads as too wide. - **Trackpad sensitivity** (slider, float 0.5×–3.0×, default 1.0×) — multiplier applied to raw pointer deltas before they're sent to the daemon. +- **Min button size** (slider, 48–400 px, default 100 px) — the smallest a button may be drawn, which is what decides *how many* fit: raise it and fewer show. Under **Hide extras** it is a hard floor; under **Shrink buttons** it is never applied. +- **Max button size** (slider, 48–400 px, default 240 px) — a cap on how large buttons grow, so a two-widget deck doesn't become two enormous tiles on a large screen. +- **When there's no room** (Follow layout / Hide extras / Shrink buttons) — what happens when the deck doesn't fit at **Min button size**. **Hide extras** keeps the floor and hides trailing buttons; **Shrink buttons** shows every button and ignores the floor; **Follow layout** uses the active layout's `overflow:` default. Default is **Hide extras** ([ADR-0011](adr/0011-reflow.md)). +- **How layout and sizing work** — opens the in-app explainer at `/help`: a live, resizable sandbox plus three illustrated answers (why buttons move, why some are hidden, and why the last row is short). The same page ships standalone as `help.html`; changes to the size sliders there are only applied if you tap **Apply** when you close. - **Content size** (slider, float 0.75×–2.5×, default 1.0×) — multiplier for grid content (button icon + label, in-grid jogstrip) on top of the responsive base, so the deck stays readable across phone and tablet screens. The persistent chrome is unaffected. - **Text size** (slider, float 0.5×–1.5×, default 1.0×) — multiplier for the button label (the caption under each icon), applied on top of Content size, so the text can be dialled down without shrinking the icon. - **Bottom bar** (slider, 40%–100%, default 100%) — size of the persistent bottom chrome bar (app badge, connection indicator, trackpad + settings buttons), so you can shrink it down on devices where it reads as too tall. diff --git a/docs/adr/0011-reflow.md b/docs/adr/0011-reflow.md index 9a59faa..22311e1 100644 --- a/docs/adr/0011-reflow.md +++ b/docs/adr/0011-reflow.md @@ -59,3 +59,4 @@ ADR-0010 made overflow layout-only, as "layout-semantic rather than device-ergon - `settings-store.ts` replaces `useCellSize` with `useCellBand` (which keeps floor ≤ cap) and adds `useOverflowPreference`. Keys: `deckd.minCell`, `deckd.maxCell`, `deckd.overflow`. - Settings gains **Min button size**, **Max button size**, and **When there's no room** (Follow layout / Hide extras / Shrink buttons). - The interactive model, with both orientations at true ratios, lives at `docs/mockups/reflow-adr0011.html`. +- The user-facing explainer ships as `client/src/ReflowHelp.tsx`, mounted in-app at `/help` (linked from Settings) and standalone as `help.html`. It imports the real `reflow.ts`, so its diagrams are the product's own geometry rather than a second implementation. `fillRows` is exported from the module for the page's row breakdown — the same rule CSS grid auto-placement applies in `ButtonGrid`. From 7443a4fbc40d805c4e5f14d5d56c3782facd2725 Mon Sep 17 00:00:00 2001 From: Jono Date: Tue, 22 Sep 2026 23:16:05 -0700 Subject: [PATCH 3/3] fix(nix): include help.html in the client fileset 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. --- nix/deckd.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nix/deckd.nix b/nix/deckd.nix index 637ab2f..f164413 100644 --- a/nix/deckd.nix +++ b/nix/deckd.nix @@ -24,6 +24,7 @@ let ../client/index.html ../client/gallery.html ../client/screenshots.html + ../client/help.html ../client/package.json ../client/package-lock.json ../client/tsconfig.json