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/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 e12492b..f6e4a27 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, @@ -44,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"; @@ -292,16 +294,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(); @@ -535,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"; @@ -658,7 +667,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 +745,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} @@ -758,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.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]) => ( + + ))} +
+
+ {onOpenHelp ? ( + + ) : 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

+
+ + + 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/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: