Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions client/help.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101418" />
<meta name="description" content="How deckd arranges your buttons, and what the button-size settings do." />
<title>deckd · how button layout works</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/help-entry.tsx"></script>
</body>
</html>
63 changes: 47 additions & 16 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { useMeterStore } from "./meter-store";
import { useMediaStore } from "./media-store";
import {
clampCellSize,
useCellSize,
useCellBand,
useOverflowPreference,
useBottomScale,
useContentScale,
useJogWidth,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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
}
>
Expand Down Expand Up @@ -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}
Expand All @@ -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.
<ReflowHelp
minCell={effectiveBand.minCell}
maxCell={effectiveBand.maxCell}
overflow={overflowPref.overflow ?? layout?.overflow ?? "clip"}
onApply={({ minCell, maxCell }) => {
effectiveBand.setMinCell(minCell);
effectiveBand.setMaxCell(maxCell);
}}
onClose={() => navigate("settings")}
/>
) : view === "editor" ? (
<Editor
Expand Down Expand Up @@ -797,8 +827,9 @@ export function App() {
) : layout ? (
<ButtonGrid
widgets={layout.widgets}
overflow={layout.overflow}
cellSize={effectiveCellSize.size}
overflow={overflowPref.overflow ?? layout.overflow ?? "clip"}
minCell={effectiveBand.minCell}
maxCell={effectiveBand.maxCell}
onPress={press}
onJog={jog}
onJogEnd={jogEnd}
Expand Down Expand Up @@ -939,9 +970,9 @@ export function App() {
</Tooltip>
<Tooltip ref={settingsBtnRef} label="settings">
<button
className={`chrome-btn${view === "settings" ? " chrome-btn-active" : ""}`}
className={`chrome-btn${view === "settings" || view === "help" ? " chrome-btn-active" : ""}`}
aria-label="settings"
aria-pressed={view === "settings"}
aria-pressed={view === "settings" || view === "help"}
onPointerDown={() => {
viewOriginRef.current = settingsBtnRef.current;
lastChromeFocus.current = settingsBtnRef.current;
Expand Down
6 changes: 3 additions & 3 deletions client/src/ButtonGrid.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { DEMO_LAYOUTS } from "./demo";
import {
CELL_SIZE_MIN,
CELL_SIZE_MAX,
CELL_SIZE_DEFAULT,
MIN_CELL_DEFAULT,
CELL_SIZE_STEP,
CONTENT_SCALE_DEFAULT,
CONTENT_SCALE_MAX,
Expand All @@ -20,7 +20,7 @@ const noop = () => {};
type Controls = { contentScale: number; cellSize: number };

const controls = {
args: { contentScale: CONTENT_SCALE_DEFAULT, cellSize: CELL_SIZE_DEFAULT },
args: { contentScale: CONTENT_SCALE_DEFAULT, cellSize: MIN_CELL_DEFAULT },
argTypes: {
contentScale: {
control: { type: "range" as const, min: CONTENT_SCALE_MIN, max: CONTENT_SCALE_MAX, step: CONTENT_SCALE_STEP },
Expand Down Expand Up @@ -56,7 +56,7 @@ function Frame({
scrollInvert={false}
onMediaCommand={noop}
showKeyHints={showKeyHints}
cellSize={cellSize}
minCell={cellSize}
/>
</div>
);
Expand Down
77 changes: 55 additions & 22 deletions client/src/ButtonGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { MediaReading } from "./media-store";
import type { MeterReading } from "./meter-store";
import { computeReflow } from "./reflow";
import type { OverflowMode } from "./reflow";
import { CELL_SIZE_DEFAULT } from "./settings-store";
import { MAX_CELL_DEFAULT, MIN_CELL_DEFAULT } from "./settings-store";
import { onActivate } from "./a11y";

/** Gap between cells, in CSS pixels. Kept in sync with ``.grid { gap }`` so the
Expand All @@ -24,15 +24,20 @@ type Props = {
onJogEnd: (id: string, velocity: number) => void;
scrollScale: number;
scrollInvert: boolean;
/** Overflow behaviour when widgets exceed the capacity the band yields at
* the current viewport (ADR-0010): ``clip`` (default) leaves trailing
* widgets off-surface; ``shrink-to-fit`` shrinks cells below the floor so
* every widget fits. Comes from the layout's ``overflow`` field. */
/** What to do when the deck exceeds what the viewport holds at ``minCell``
* (ADR-0011): ``clip`` (default) trims trailing widgets so the survivors
* keep their size; ``shrink-to-fit`` keeps every widget by letting cells
* fall below the floor. Resolved by ``App`` from the device preference,
* falling back to the layout's ``overflow`` field. */
overflow?: OverflowMode;
/** Cell size target (client-side device preference, ADR-0010). Columns are
* packed around this value; cells fill the width evenly. Defaults let
* harnesses that don't wire settings still render sensibly. */
cellSize?: number;
/** Readability floor (client-side device preference, ADR-0011): the smallest
* cell the user accepts. Decides how many widgets are visible under
* ``clip``; ignored entirely under ``shrink-to-fit``. Defaults let harnesses
* that don't wire settings still render sensibly. */
minCell?: number;
/** Comfort cap (client-side device preference, ADR-0011): stops a nearly
* empty deck from becoming a few enormous buttons. */
maxCell?: number;
/** Latest reading per sensor source. Missing sources render with no
* value (bar empty, "—" numeric). Stale readings show the bar at
* its last position with a dimmed readout. */
Expand Down Expand Up @@ -102,8 +107,9 @@ export function ButtonGrid({
onJogEnd,
scrollScale,
scrollInvert,
overflow = "shrink-to-fit",
cellSize = CELL_SIZE_DEFAULT,
overflow = "clip",
minCell = MIN_CELL_DEFAULT,
maxCell = MAX_CELL_DEFAULT,
meterReadings,
labelScale,
mediaStates,
Expand All @@ -112,35 +118,62 @@ export function ButtonGrid({
}: Props) {
const [gridRef, size] = useMeasuredSize();

// Cells occupied by flow widgets (spans counted), used by shrink-to-fit to
// estimate the row count. ``full`` widgets leave the flow, so they don't add.
const totalUnits = widgets.reduce((sum, w) => {
if (w.size === "full") return sum;
// Cells occupied by flow widgets (spans counted). ``full`` widgets leave the
// flow, so they don't add.
const unitsOf = (w: Widget) => {
if (w.size === "full") return 0;
const [cw, ch] = spanOf(w);
return sum + cw * ch;
}, 0);
return cw * ch;
};
const totalUnits = widgets.reduce((sum, w) => sum + unitsOf(w), 0);

const { cols, cellPx } = computeReflow({
const { cols, rows, cellPx, visibleUnits } = computeReflow({
containerWidth: size.width,
containerHeight: size.height,
cellSize,
minCell,
maxCell,
gap: GRID_GAP,
totalUnits,
mode: overflow,
});

// ADR-0011: ``clip`` trims the deck rather than letting CSS crop it, so the
// surface never shows a row sliced in half at the fold. Strict order, so the
// prefix stops at the first widget that would not fit whole — we never skip
// a wide widget to squeeze in a later narrow one. ``full`` widgets cost no
// units and so always survive the trim.
const shown =
visibleUnits >= totalUnits
? widgets
: (() => {
let used = 0;
return widgets.filter((w) => {
const units = unitsOf(w);
if (used + units > visibleUnits) return false;
used += units;
return true;
});
})();

// Square, fixed tracks: every column is ``cellPx`` wide and every implicit
// row is ``cellPx`` tall, so a cell is square and an ``[w, h]`` span is
// exactly ``w`` columns by ``h`` rows (gaps included). Leftover width is
// centered and leftover height sits below (both set in ``.grid`` CSS).
// exactly ``w`` columns by ``h`` rows (gaps included). CSS grid's own
// auto-placement performs the fill-and-wrap, and ``justify-content: center``
// on a fixed track list centres the block while leaving short rows washed
// left against it — exactly the distribution ADR-0011 specifies.
const gridStyle: CSSProperties = {
gridTemplateColumns: `repeat(${cols}, ${cellPx}px)`,
gridAutoRows: `${cellPx}px`,
// Centre the block vertically too, but only when it genuinely fits: a
// spanned widget can wrap into more rows than the maths predicted, and
// centring an overflowing grid would crop its top as well as its bottom.
alignContent:
rows * cellPx + Math.max(0, rows - 1) * GRID_GAP <= size.height ? "center" : "start",
};

return (
<div ref={gridRef} className="grid" style={gridStyle}>
{widgets.map((w) => {
{shown.map((w) => {
const full = w.size === "full";
const [cw, ch] = spanOf(w);
// Cap a span at the current column count so a too-wide widget doesn't
Expand Down
7 changes: 4 additions & 3 deletions client/src/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { GripVertical, Minimize, Maximize, Columns2, Plus, Minus } from "lucide-
import { computeReflow } from "./reflow";
import type { OverflowMode } from "./reflow";
import type { Widget, WidgetSize } from "./protocol";
import { CELL_SIZE_DEFAULT } from "./settings-store";
import { CELL_SIZE_MAX, MIN_CELL_DEFAULT } from "./settings-store";
import { Icon } from "./Icon";

const GRID_GAP = 8;
Expand Down Expand Up @@ -252,7 +252,7 @@ export function EditorCanvas({
onWidgetChange,
onOverflowChange,
onSelectWidget,
cellSize = CELL_SIZE_DEFAULT,
cellSize = MIN_CELL_DEFAULT,
}: Props) {
const [gridRef, size] = useMeasuredSize();
const [previewWidth, setPreviewWidth] = useState(0);
Expand Down Expand Up @@ -294,7 +294,8 @@ export function EditorCanvas({
const { cols, cellPx } = computeReflow({
containerWidth: displayWidth,
containerHeight: size.height,
cellSize,
minCell: cellSize,
maxCell: CELL_SIZE_MAX,
gap: GRID_GAP,
totalUnits,
mode: overflow,
Expand Down
4 changes: 2 additions & 2 deletions client/src/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DEMO_NAMES } from "./demo";
import {
CELL_SIZE_MIN,
CELL_SIZE_MAX,
CELL_SIZE_DEFAULT,
MIN_CELL_DEFAULT,
CELL_SIZE_STEP,
} from "./settings-store";

Expand Down Expand Up @@ -76,7 +76,7 @@ export function Gallery() {
const [demo, setDemo] = useState(DEMO_NAMES[0] ?? "firefox");
const [orientation, setOrientation] = useState<Orientation>("landscape");
const [keyHints, setKeyHints] = useState(false);
const [cellSize, setCellSize] = useState(CELL_SIZE_DEFAULT);
const [cellSize, setCellSize] = useState(MIN_CELL_DEFAULT);

return (
<div className="gallery">
Expand Down
21 changes: 21 additions & 0 deletions client/src/ReflowHelp.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<main className="surface" style={{ width: 390, height: 780 }}>
<ReflowHelp minCell={100} maxCell={240} overflow="clip" onApply={noop} onClose={noop} />
</main>
);

/** The standalone mount (``help.html``): no close, no apply — the sliders are
* a sandbox only. */
export const Standalone: Story = () => (
<div className="help-page" style={{ width: 390, height: 780 }}>
<ReflowHelp />
</div>
);
Loading
Loading