diff --git a/client/src/ReflowHelp.stories.tsx b/client/src/ReflowHelp.stories.tsx
new file mode 100644
index 0000000..f8bfee7
--- /dev/null
+++ b/client/src/ReflowHelp.stories.tsx
@@ -0,0 +1,21 @@
+import type { Story } from "@ladle/react";
+import { ReflowHelp } from "./ReflowHelp";
+
+export default { title: "ReflowHelp" };
+
+const noop = () => {};
+
+/** The in-app mount: a close button and the apply-on-close prompt. */
+export const InApp: Story = () => (
+
+
+
+);
+
+/** The standalone mount (``help.html``): no close, no apply — the sliders are
+ * a sandbox only. */
+export const Standalone: Story = () => (
+
+
+
+);
diff --git a/client/src/ReflowHelp.test.tsx b/client/src/ReflowHelp.test.tsx
new file mode 100644
index 0000000..f85a783
--- /dev/null
+++ b/client/src/ReflowHelp.test.tsx
@@ -0,0 +1,72 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ReflowHelp } from "./ReflowHelp";
+
+afterEach(cleanup);
+
+describe("ReflowHelp — content", () => {
+ it("answers the three questions the page exists for", () => {
+ render(
);
+ expect(screen.getByRole("heading", { name: "How your buttons are laid out" })).not.toBeNull();
+ expect(screen.getByRole("heading", { name: "Why do my buttons move?" })).not.toBeNull();
+ expect(
+ screen.getByRole("heading", { name: /why can.t i see all my buttons/i }),
+ ).not.toBeNull();
+ expect(screen.getByRole("heading", { name: /why isn.t the last row full/i })).not.toBeNull();
+ });
+
+ it("reports the sandbox layout from the real algorithm", () => {
+ // 240x340 at min 100 / gap 8 holds 2x3 whole cells, so 6 of 8 show as 2+2+2.
+ render(
);
+ const stats = screen.getByText(/Showing 6 of 8/).closest("p");
+ expect(stats).not.toBeNull();
+ expect(stats?.textContent).toContain("2+2+2");
+ expect(stats?.textContent).toContain("2 hidden");
+ });
+});
+
+describe("ReflowHelp — apply on close", () => {
+ it("closes straight away when nothing was changed", () => {
+ const onClose = vi.fn();
+ const onApply = vi.fn();
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: "Close help" }));
+ expect(onClose).toHaveBeenCalledOnce();
+ expect(onApply).not.toHaveBeenCalled();
+ });
+
+ it("prompts before applying, and Apply writes the band then closes", () => {
+ const onClose = vi.fn();
+ const onApply = vi.fn();
+ render(
);
+ fireEvent.change(screen.getByLabelText("Min size"), { target: { value: "160" } });
+ fireEvent.click(screen.getByRole("button", { name: "Close help" }));
+ expect(onApply).not.toHaveBeenCalled();
+ expect(screen.getByRole("dialog")).not.toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
+ expect(onApply).toHaveBeenCalledWith({ minCell: 160, maxCell: 240 });
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it("Don't apply leaves the device untouched but still closes", () => {
+ const onClose = vi.fn();
+ const onApply = vi.fn();
+ render(
);
+ fireEvent.change(screen.getByLabelText("Max size"), { target: { value: "320" } });
+ fireEvent.click(screen.getByRole("button", { name: "Close help" }));
+ fireEvent.click(screen.getByRole("button", { name: /don.t apply/i }));
+ expect(onApply).not.toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+});
+
+describe("ReflowHelp — standalone mount", () => {
+ it("has no close affordance and never shows the apply dialog", () => {
+ render(
);
+ expect(screen.queryByRole("button", { name: "Close help" })).toBeNull();
+ // The sandbox still works as a demo.
+ fireEvent.change(screen.getByLabelText("Min size"), { target: { value: "160" } });
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+});
diff --git a/client/src/ReflowHelp.tsx b/client/src/ReflowHelp.tsx
new file mode 100644
index 0000000..101d006
--- /dev/null
+++ b/client/src/ReflowHelp.tsx
@@ -0,0 +1,604 @@
+/** User-facing explainer for how the button grid lays itself out (ADR-0011).
+ *
+ * This is a help surface, not a preview: it teaches the rule rather than
+ * mirroring the user's screen. Their window is unstable (a browser can be
+ * resized at any moment), so there is deliberately no device frame and no
+ * chrome model — the diagrams show "the area your buttons live in", drawn at
+ * an illustrative size.
+ *
+ * The geometry is not re-implemented. Every diagram calls the same
+ * ``computeReflow`` that ``ButtonGrid`` uses, so the pictures cannot drift from
+ * the product. The only thing this file owns is presentation, the interactive
+ * sandbox, and the copy.
+ *
+ * Mounted two ways (one component, no second source of truth):
+ * - in-app as the ``help`` view (``/help``), opened from Settings;
+ * - standalone as ``help.html`` for a public, linkable page.
+ * The standalone mount passes no ``onClose``/``onApply``, so the size sliders
+ * are a sandbox you can't accidentally write to a device with. */
+
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import type {
+ KeyboardEvent as ReactKeyboardEvent,
+ PointerEvent as ReactPointerEvent,
+ ReactNode,
+ RefObject,
+} from "react";
+
+import { computeReflow, fillRows } from "./reflow";
+import type { OverflowMode } from "./reflow";
+import { CELL_SIZE_MAX, CELL_SIZE_MIN, CELL_SIZE_STEP } from "./settings-store";
+
+/** Cell gap, in CSS pixels. Must match ``.grid { gap }`` in ``style.css`` /
+ * ``GRID_GAP`` in ``ButtonGrid.tsx`` so the diagrams agree with the product. */
+const GAP = 8;
+
+/* --- sandbox bounds (in the drawing's own pixel space) ------------------- */
+const BOX_MIN_W = 120;
+const BOX_MAX_W = 360;
+const BOX_MIN_H = 120;
+const BOX_MAX_H = 420;
+const BOX_DEFAULT_W = 240;
+const BOX_DEFAULT_H = 340;
+const COUNT_MIN = 1;
+const COUNT_MAX = 40;
+const COUNT_DEFAULT = 8;
+
+/* --- illustration inputs. Fixed examples, so the pictures stay legible at
+ * any setting; the sandbox above is the live one. Each is one props object so
+ * the drawing and the caption that names its row shape cannot disagree. ---- */
+const SHAPE_UNITS = 6;
+const SHAPE_SCALE = 0.6;
+const SHAPE_TALL: MiniGridProps = {
+ width: 150, height: 210, units: SHAPE_UNITS, minCell: 48, maxCell: CELL_SIZE_MAX, mode: "shrink-to-fit",
+};
+const SHAPE_WIDE: MiniGridProps = {
+ width: 210, height: 150, units: SHAPE_UNITS, minCell: 48, maxCell: CELL_SIZE_MAX, mode: "shrink-to-fit",
+};
+
+const OVERFLOW_UNITS = 12;
+const OVERFLOW_MIN = 56;
+const OVERFLOW_SCALE = 0.6;
+const OVERFLOW_SHRINK: MiniGridProps = {
+ width: 170, height: 230, units: OVERFLOW_UNITS, minCell: OVERFLOW_MIN, maxCell: CELL_SIZE_MAX, mode: "shrink-to-fit",
+};
+const OVERFLOW_CLIP: MiniGridProps = { ...OVERFLOW_SHRINK, mode: "clip" };
+
+const DIST_UNITS = 10;
+const DIST_SCALE = 0.6;
+const DIST_GRID: MiniGridProps = {
+ width: 200, height: 240, units: DIST_UNITS, minCell: 48, maxCell: CELL_SIZE_MAX, mode: "shrink-to-fit",
+};
+/** The row count the 10-button example lands at, read off the algorithm rather
+ * than written down a second time. */
+const DIST_ROWS = layoutOf(DIST_GRID).rows.length;
+
+const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
+
+/** Max-balanced split (rows as even as possible), used only to name the
+ * arrangement stage 3 deliberately does NOT produce: 10 in 4 rows is 3+3+3+1,
+ * never 3+3+2+2. */
+function balancedRows(units: number, rows: number): number[] {
+ const base = Math.floor(units / rows);
+ let extra = units % rows;
+ return Array.from({ length: rows }, () => base + (extra-- > 0 ? 1 : 0));
+}
+
+export type ReflowHelpProps = {
+ /** The device's current band, seeding the sandbox and detecting an edit. */
+ minCell?: number;
+ maxCell?: number;
+ /** The resolved overflow policy, so the page can point at the diagram that
+ * matches what this device actually does. */
+ overflow?: OverflowMode;
+ /** Apply the sandbox's edited band to this device. Absent on the standalone
+ * page, where the controls are read-only. */
+ onApply?: (next: { minCell: number; maxCell: number }) => void;
+ /** Leave the page. In-app, this is the target of the close button — the
+ * apply prompt fires first if the band was edited. Absent standalone. */
+ onClose?: () => void;
+};
+
+type MiniGridProps = {
+ /** Area the buttons live in, in the drawing's own pixels. */
+ width: number;
+ height: number;
+ units: number;
+ minCell: number;
+ maxCell: number;
+ mode: OverflowMode;
+};
+
+/** Run the real geometry for one diagram, and report its row breakdown — the
+ * shared source for both the squares and the caption that names their shape. */
+function layoutOf({ width, height, units, minCell, maxCell, mode }: MiniGridProps) {
+ const result = computeReflow({
+ containerWidth: width,
+ containerHeight: height,
+ minCell,
+ maxCell,
+ gap: GAP,
+ totalUnits: units,
+ mode,
+ });
+ return { ...result, rows: fillRows(result.visibleUnits, result.cols) };
+}
+
+/** A schematic grid: numbered squares, no icons, drawn with the real geometry.
+ *
+ * Uses the same fixed-track CSS grid as ``ButtonGrid`` — ``justify-content:
+ * center`` centres the block while short rows wash left against it — so a
+ * diagram is structurally identical to the surface. */
+function MiniGrid({ width, height, ...rest }: MiniGridProps) {
+ const { cols, cellPx, visibleUnits, rows } = layoutOf({ width, height, ...rest });
+ const gridHeight = rows.length * cellPx + Math.max(0, rows.length - 1) * GAP;
+ return (
+
+
+ {Array.from({ length: visibleUnits }, (_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+ );
+}
+
+/** Wraps a true-pixel ``MiniGrid`` and scales it down for display. Layout still
+ * runs at the drawing's real size — only the pixels are shrunk — so a scaled
+ * diagram is not a different geometry, just a smaller picture of the same one. */
+function Diagram({
+ width,
+ height,
+ scale,
+ children,
+}: {
+ width: number;
+ height: number;
+ scale: number;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+/** Track the sandbox stage's width so the drawing can shrink to fit a narrow
+ * phone instead of overflowing. */
+function useMeasuredWidth(): [RefObject
, number] {
+ const ref = useRef(null);
+ const [width, setWidth] = useState(0);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const update = () => setWidth(el.clientWidth);
+ update();
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+ return [ref, width];
+}
+
+export function ReflowHelp({
+ minCell = 100,
+ maxCell = 240,
+ overflow = "clip",
+ onApply,
+ onClose,
+}: ReflowHelpProps) {
+ const [stageRef, stageWidth] = useMeasuredWidth();
+
+ const [draftMin, setDraftMinState] = useState(minCell);
+ const [draftMax, setDraftMaxState] = useState(maxCell);
+ const [count, setCount] = useState(COUNT_DEFAULT);
+ const [boxW, setBoxW] = useState(BOX_DEFAULT_W);
+ const [boxH, setBoxH] = useState(BOX_DEFAULT_H);
+ const [asking, setAsking] = useState(false);
+
+ // Latest-value refs so the clamp callbacks below don't need to be rebuilt on
+ // every keystroke of the sliders.
+ const draftMinRef = useRef(draftMin);
+ const draftMaxRef = useRef(draftMax);
+ draftMinRef.current = draftMin;
+ draftMaxRef.current = draftMax;
+
+ // Keep the draft ordered, exactly as ``useCellBand`` keeps the shipped band:
+ // pushing the floor past the cap drags the cap along, and vice versa.
+ const setDraftMin = useCallback((n: number) => {
+ setDraftMinState(Math.min(n, draftMaxRef.current));
+ }, []);
+ const setDraftMax = useCallback((n: number) => {
+ setDraftMaxState(Math.max(n, draftMinRef.current));
+ }, []);
+
+ const dirty = draftMin !== minCell || draftMax !== maxCell;
+
+ // The drawing is scaled to the stage width so a 300px-wide box still fits a
+ // narrow phone. Dragging divides the on-screen delta by the scale captured
+ // at pointer-down (see below), so the box tracks the finger 1:1.
+ const scale = stageWidth > 0 ? Math.min(1, stageWidth / boxW) : 1;
+
+ const dragRef = useRef<{ x: number; y: number; w: number; h: number; scale: number } | null>(null);
+
+ const onResizeStart = (e: ReactPointerEvent) => {
+ e.preventDefault();
+ e.currentTarget.setPointerCapture?.(e.pointerId);
+ dragRef.current = { x: e.clientX, y: e.clientY, w: boxW, h: boxH, scale };
+ };
+ const onResizeMove = (e: ReactPointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ const dw = Math.round((e.clientX - d.x) / d.scale);
+ const dh = Math.round((e.clientY - d.y) / d.scale);
+ setBoxW(clamp(d.w + dw, BOX_MIN_W, BOX_MAX_W));
+ setBoxH(clamp(d.h + dh, BOX_MIN_H, BOX_MAX_H));
+ };
+ const onResizeEnd = (e: ReactPointerEvent) => {
+ dragRef.current = null;
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
+ };
+ // Keyboard equivalent for the drag, so the sandbox isn't pointer-only.
+ const onResizeKey = (e: ReactKeyboardEvent) => {
+ const step = e.shiftKey ? 40 : 10;
+ if (e.key === "ArrowRight") setBoxW((w) => clamp(w + step, BOX_MIN_W, BOX_MAX_W));
+ else if (e.key === "ArrowLeft") setBoxW((w) => clamp(w - step, BOX_MIN_W, BOX_MAX_W));
+ else if (e.key === "ArrowDown") setBoxH((h) => clamp(h + step, BOX_MIN_H, BOX_MAX_H));
+ else if (e.key === "ArrowUp") setBoxH((h) => clamp(h - step, BOX_MIN_H, BOX_MAX_H));
+ else return;
+ e.preventDefault();
+ };
+
+ const applyPreset = (w: number, h: number) => {
+ setBoxW(clamp(w, BOX_MIN_W, BOX_MAX_W));
+ setBoxH(clamp(h, BOX_MIN_H, BOX_MAX_H));
+ };
+
+ const requestClose = () => {
+ if (dirty && onApply) setAsking(true);
+ else onClose?.();
+ };
+
+ // Sandbox geometry, from the real algorithm, at the drawing's true pixels.
+ const sandbox = computeReflow({
+ containerWidth: boxW,
+ containerHeight: boxH,
+ minCell: draftMin,
+ maxCell: draftMax,
+ gap: GAP,
+ totalUnits: count,
+ mode: overflow,
+ });
+ const sandboxRows = fillRows(sandbox.visibleUnits, sandbox.cols);
+ const sandboxGridHeight =
+ sandboxRows.length * sandbox.cellPx + Math.max(0, sandboxRows.length - 1) * GAP;
+
+ return (
+
+
+ How your buttons are laid out
+ {onClose ? (
+
+ ×
+
+ ) : 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:
+
+
+
+
+
+
+
+ {Array.from({ length: sandbox.visibleUnits }, (_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {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. */}
+
+ applyPreset(220, 380)}>Tall
+ applyPreset(360, 210)}>Wide
+ applyPreset(330, 420)}>Large
+ applyPreset(150, 150)}>Small
+
+
+
+
+ Buttons
+ setCount(Number(e.target.value))}
+ />
+ {count}
+
+
+ Min size
+ setDraftMin(Number(e.target.value))}
+ />
+ {draftMin}px
+
+
+ Max size
+ setDraftMax(Number(e.target.value))}
+ />
+ {draftMax}px
+
+
+
+ {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.
+
+
+
+ Don’t apply
+
+
+ Apply
+
+
+
+
+ );
+}
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..399d09a 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,
@@ -56,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 62bcf53..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,
@@ -22,6 +23,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 +41,18 @@ 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";
+ /** Open the in-app explainer for the sizing controls below (ADR-0011). */
+ onOpenHelp?: () => void;
jogWidth: number;
onJogWidthChange: (n: number) => void;
bottomScale: number;
@@ -83,8 +95,14 @@ export function Settings({
onWakeLockChange,
contentScale,
onContentScaleChange,
- cellSize,
- onCellSizeChange,
+ minCell,
+ onMinCellChange,
+ maxCell,
+ onMaxCellChange,
+ overflow,
+ onOverflowChange,
+ layoutOverflow,
+ onOpenHelp,
jogWidth,
onJogWidthChange,
bottomScale,
@@ -192,25 +210,73 @@ 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]) => (
+ onOverflowChange(value)}
+ >
+ {label}
+
+ ))}
+
+
+ {onOpenHelp ? (
+
+
+ How layout and sizing work
+
+ ) : null}
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/help-entry.tsx b/client/src/help-entry.tsx
new file mode 100644
index 0000000..28016d6
--- /dev/null
+++ b/client/src/help-entry.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { ReflowHelp } from "./ReflowHelp";
+import "./fonts";
+import "./help.css";
+
+/** Standalone mount of the layout help page (`help.html`).
+ *
+ * No socket, no daemon, no app state: it is a public, linkable page. The size
+ * controls are a sandbox here rather than a device preference, so no
+ * ``onApply``/``onClose`` is passed. */
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
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 21bead0..6ce2bc1 100644
--- a/client/src/reflow.test.ts
+++ b/client/src/reflow.test.ts
@@ -1,87 +1,219 @@
import { describe, expect, it } from "vitest";
-import { computeReflow } from "./reflow";
+import { capacityUnits, computeReflow, fillRows, 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);
+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(fillRows(r.visibleUnits, r.cols)).toEqual([3, 2]);
});
- 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);
+ 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(fillRows(portrait.visibleUnits, portrait.cols)).toEqual([2, 2, 1]);
+ expect(portrait.cellPx).toBeGreaterThan(reflow(304, 578, 5).cellPx - 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("the same widgets reshape rather than resize when the area turns", () => {
+ const portrait = reflow(334, 782, 8);
+ const landscape = reflow(756, 328, 8);
+ 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);
+ });
+
+ 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);
+ });
+});
+
+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(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 = 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);
+ expect(Math.max(...rows)).toBe(rows[0]);
+ }
+ }
+ });
+});
+
+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("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("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("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("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);
+ 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;
+ }
+ }
+ });
+});
+
+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..747bba4 100644
--- a/client/src/reflow.ts
+++ b/client/src/reflow.ts
@@ -1,111 +1,171 @@
-/** 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;
+/** 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;
+}
- let cols = colsForWidth(w);
- let cellPx = cellForCols(cols);
+type Shape = { cols: number; rows: number; cell: number };
- // 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);
- }
+/** 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));
+
+ 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..48e1e41 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,64 @@ 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;
+}
+
+/* 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/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/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/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..22311e1
--- /dev/null
+++ b/docs/adr/0011-reflow.md
@@ -0,0 +1,62 @@
+# 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`.
+- 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`.
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
+
+ min cell
+
+ 100px
+
+
+ max cell
+
+ 240px
+
+ no room
+ hide extras shrink buttons
+ environment — not settings; these just reshape the test viewport
+
+ bottom bar
+
+ 62px
+
+
+ jogstrip
+
+ 1.00
+
+
+
+
+
+
+ Where each of these is stored
+
+ Setting Lives in Scope
+ min cell / max cell
+ localStorage — deckd.cellSize
+ This browser, this device. Never sent to the daemon.
+ bottom bar
+ localStorage — deckd.bottomScale
+ This browser, this device.
+ jogstrip width
+ localStorage — deckd.jogWidth
+ This 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/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
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: