commit a2cb339df2e8eb9f2fcb92370e327de462ce076a
parent a8fd648a462d2a175ded12b8531ad223aebc0b2e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 6 Aug 2026 14:24:02 -0400
Merge feat/widget-floorplan: widget builder floorplan board
Diffstat:
15 files changed, 1784 insertions(+), 266 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **The monitor widget's builder is a floorplan you rearrange, not a list of checkboxes.** The widget rendered its nine strips in an order hardcoded in JSX — controls, last sync, scheduler, disk, cleanable total, needs cleaning, workers, active jobs, needs work — and there was no order field in its config at all, so reference data you glance at once (the two cleanable strips) sat permanently above the thing you were actually watching. The builder couldn't help, because it wasn't spatial: 22 checkboxes in three fieldsets, nothing in the control surface corresponding to where anything landed. `/widget/builder` is now a scale model of the widget. Each section is a draggable card carrying a small CSS-drawn likeness of what that strip really renders — four dots for Workers, a part-filled bar for Active jobs, a meter with the low-disk notch for Disk, stacked rows for the two channel lists — laid out in the columns and order it will appear in, with the switched-off ones in a tray underneath. Drag it with the mouse or use the ↑/↓/◀/▶ buttons on every card; nothing here is mouse-only, and a section is one checkbox that changes place rather than a control that vanishes when you turn it off. **Columns are new** — up to three, each header showing the number that actually decides whether columns are viable (the per-column pixel width at the current preview size, flagged when it falls under 150px), alongside two wider presets, **Wide** 640×260 and **Panel** 720×420, because columns need width to be worth having. They are honored at every size by default, since an arrangement you made on purpose shouldn't quietly undo itself; **Stack columns when narrow** opts a widget you intend to resize into collapsing back to a single stack, on a container query measured against the widget's own box rather than the viewport (which, embedded in an iframe, is the host page's). Per-section display options moved onto the section that owns them — the batch bar, heading progress and ETA on the Active jobs card, worker names on Workers, the Sync flags on Controls — leaving only the genuinely global ones (section titles, hide-when-idle, the gear, absolute times) in a row of their own. The in-widget gear gets the same model in a shape that fits its ~320px overlay: one row per section in render order with move buttons and a column picker, driven by the same registry and the same placement functions as the board rather than a second copy of them. **Every link written before this still works, and still renders in the original order.** The arrangement rides in one new `l=` param (`/widget?l=ctl.disk.wk.jobs-cln.act` — `.` between sections, `-` between columns, both characters a URL leaves literal where a comma would come back as `%2C`), it is omitted entirely whenever the arrangement is the one the visibility flags already imply, and an inherited layout is normalized against those flags on arrival: unknown codes and repeats are dropped, a section since switched off is dropped, and a section switched **on** but unlisted is appended rather than silently disappearing — which is what keeps an already-copied link working after you enable something new from the widget's own gear. Underneath, the nine hardcoded conditionals became a section registry that drives both configuration surfaces, so adding a tenth strip is now a table entry (there is one waiting: `/api/widget/sync` already polls a digest field that nothing renders). The builder's remembered form state resets once, since a saved entry from before this has no layout to carry forward. See `editor/app/widget/lib/{sections,placement,config}.ts`, `editor/app/widget/builder/components/{LayoutBoard,SectionCard,SectionMini}.tsx`, and the normalization tests in `editor/app/widget/lib/placement.test.ts`.
- **Every schedule is set in minutes, hours, or days now — not a raw minute count.** Seven different cadences in the editor were plain number boxes measured in minutes, which meant knowing that `10080` is a week and `44640` is the documented maximum. Each one is now an amount plus a unit, with a line underneath restating both the stored number and what it works out to — "Every 1,440 minutes · about 1 full listing fetch per channel per day" — so the rate you are about to ask of a video host is visible while you set it, not after. A cadence typed by hand into a config file keeps its exact value: 137 minutes stays 137 minutes rather than being rounded to the nearest tidy unit. The **deep pass** cadence is settable at last — globally in Settings and on the Scheduler page, and per channel on the channel form, where a big archive can be told to re-read its listing weekly while everything else does it daily. **Sync all** gained a **Full sweep all** button (and each channel a **Full sweep** one) for when you don't want to wait out a day of cadence — right after upgrading, say, when nothing has been swept yet. The Scheduler page now shows both cadences for every channel at a glance and can retune many at once: tick the channels, set one or both cadences, apply. Leaving a field alone leaves that cadence alone, so forty channels' deep passes can be re-tuned without touching anyone's ordinary sync.
- **Fixed: the keep-latest check interval was reset to its default every time you saved settings.** It was the one scheduler cadence with no input anywhere in the app, and saving an unrelated setting quietly overwrote whatever you had put in the config file by hand. It now has an input, and the save path preserves any field the form doesn't render.
- **Sync now notices when a video disappears.** A channel's video list was being fetched three separate times for three purposes that never shared their work: "store playlist" refreshed the stored list, Sync walked the newest 50 entries at a time, and "Quick check" re-read the whole listing to find videos that had gone missing. Because Sync only ever saw the newest slice, it could never spot a deletion — and it never refreshed the stored list either, so "Download missing" and the report's not-yet-downloaded count kept working off whatever the last "store playlist" click wrote, possibly months earlier. Sync now periodically pays for **one** full read of the channel and gets all three out of it: the stored list is refreshed, videos that have left the listing are flagged, and new uploads are downloaded as before. That means **Sync all** surfaces upstream deletions across every channel on its own, where it used to take a per-channel "Quick check" click. The deep pass runs at most once a day per channel by default (it is much more expensive than a normal sync on a large channel); every sync in between stays exactly as cheap as it was. When a handful of videos are flagged — 25 or fewer by default — the same job goes on to work out which are deleted, private, or merely unlisted; past that it flags them and leaves the call to you. **What it will not do is download anything a normal sync wouldn't**: on a channel you deliberately keep only the newest few hundred of, a deep pass will not start dragging down the back catalogue. It changes what the editor *knows*, never what it *fetches*. If the channel listing comes back empty — a network blip, an expired cookie — the deep pass leaves the stored list and the missing-video flags untouched rather than concluding your whole archive vanished.
diff --git a/editor/app/widget/builder/components/LayoutBoard.tsx b/editor/app/widget/builder/components/LayoutBoard.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import { Fragment, useState } from "react";
+import type { WidgetConfig } from "../../lib/config";
+import {
+ MAX_COLUMNS,
+ insertAt,
+ moveToColumn,
+ moveWithin,
+ reconcileColumns,
+ setColumnCount,
+} from "../../lib/placement";
+import { SECTIONS, SECTION_BY_ID, type SectionId } from "../../lib/sections";
+import { SectionCard } from "./SectionCard";
+import { SectionMini } from "./SectionMini";
+
+// The floorplan: a scale model of the widget, laid out in the same columns and
+// order it will render in. Drag a card to move it; the tray underneath holds
+// the sections that are switched off.
+//
+// Native HTML5 drag-and-drop, deliberately — it costs no dependency, and the
+// arrow buttons on every card cover keyboard use (and touch, which HTML5 drag
+// does not support) so nothing here is mouse-only.
+
+// Below this a column is too narrow for the strips inside it to read.
+const NARROW_PX = 150;
+
+export function LayoutBoard({
+ config,
+ onChange,
+ previewWidth,
+}: {
+ config: WidgetConfig;
+ onChange: (patch: Partial<WidgetConfig>) => void;
+ previewWidth: number;
+}) {
+ const [draggingId, setDraggingId] = useState<SectionId | null>(null);
+ const [drop, setDrop] = useState<{ col: number; index: number } | null>(null);
+ const [expanded, setExpanded] = useState<SectionId[]>([]);
+
+ const columns = config.columns;
+ const off = SECTIONS.filter((s) => !s.enabled(config));
+
+ // The widget's padding (p-2 either side) and the gaps between columns come
+ // out of the width before it is split, so this is the real number.
+ const columnPx = Math.round(
+ (previewWidth - 16 - (columns.length - 1) * 12) / columns.length,
+ );
+
+ function toggleExpanded(id: SectionId) {
+ setExpanded((e) => (e.includes(id) ? e.filter((x) => x !== id) : [...e, id]));
+ }
+
+ function clearDrag() {
+ setDraggingId(null);
+ setDrop(null);
+ }
+
+ // Put a section at an explicit slot, switching it on first if it was in the
+ // tray. Both halves go out as one patch so the layout is never briefly
+ // inconsistent with the flags.
+ function place(id: SectionId, col: number, index: number) {
+ const def = SECTION_BY_ID[id];
+ const flagPatch = def.enabled(config) ? {} : def.setEnabled(true);
+ const nextFlags = { ...config, ...flagPatch };
+ const base = reconcileColumns(columns, nextFlags);
+ onChange({ ...flagPatch, columns: insertAt(base, id, col, index) });
+ }
+
+ // Where a drop at this pointer position would land: the first card whose
+ // midpoint is below the cursor, or the end of the column.
+ function indexFromPointer(colEl: HTMLElement, clientY: number): number {
+ const cards = Array.from(
+ colEl.querySelectorAll<HTMLElement>("[data-section-card]"),
+ );
+ for (let i = 0; i < cards.length; i++) {
+ const r = cards[i].getBoundingClientRect();
+ if (clientY < r.top + r.height / 2) return i;
+ }
+ return cards.length;
+ }
+
+ return (
+ <div className="flex flex-col gap-3">
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">Columns</span>
+ <div className="flex gap-1">
+ {Array.from({ length: MAX_COLUMNS }, (_, i) => i + 1).map((n) => (
+ <button
+ key={n}
+ type="button"
+ aria-label={`${n} column${n === 1 ? "" : "s"}`}
+ aria-pressed={columns.length === n}
+ onClick={() => onChange({ columns: setColumnCount(columns, n) })}
+ className={`h-7 w-7 rounded border text-xs font-medium ${
+ columns.length === n
+ ? "border-primary bg-primary text-primary-foreground"
+ : "border-border hover:bg-muted"
+ }`}
+ >
+ {n}
+ </button>
+ ))}
+ </div>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Drag a section to move it, or use the arrows on each card.
+ </p>
+ </div>
+
+ {/* The board itself sits on `surface` so it reads as a canvas the `card`
+ strips are resting on, rather than more of the page. */}
+ <div className="rounded-lg border border-border bg-surface p-3">
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
+ {columns.map((col, ci) => (
+ <div key={ci} className="flex min-w-0 flex-1 flex-col gap-1.5">
+ <div className="flex items-baseline justify-between gap-2 px-0.5">
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+ Column {ci + 1}
+ </span>
+ <span
+ className={`text-xs tabular-nums ${
+ columnPx < NARROW_PX
+ ? "text-warning"
+ : "text-muted-foreground/70"
+ }`}
+ title={
+ columnPx < NARROW_PX
+ ? "Too narrow for these strips to read at the current preview size"
+ : "Width this column gets at the current preview size"
+ }
+ >
+ {columnPx}px
+ </span>
+ </div>
+ <ul
+ onDragOver={(e) => {
+ if (!draggingId) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ const index = indexFromPointer(e.currentTarget, e.clientY);
+ setDrop((d) =>
+ d && d.col === ci && d.index === index ? d : { col: ci, index },
+ );
+ }}
+ onDrop={(e) => {
+ if (!draggingId) return;
+ e.preventDefault();
+ place(draggingId, ci, indexFromPointer(e.currentTarget, e.clientY));
+ clearDrag();
+ }}
+ className="flex min-h-16 flex-col gap-1.5 rounded-md p-1"
+ >
+ {col.map((id, index) => (
+ <Fragment key={id}>
+ <DropLine active={drop?.col === ci && drop.index === index} />
+ <SectionCard
+ def={SECTION_BY_ID[id]}
+ config={config}
+ col={ci}
+ index={index}
+ colLength={col.length}
+ columnCount={columns.length}
+ dragging={draggingId === id}
+ onChange={onChange}
+ onMove={(dir) =>
+ onChange({ columns: moveWithin(columns, ci, index, dir) })
+ }
+ onMoveColumn={(dir) =>
+ onChange({ columns: moveToColumn(columns, ci, index, dir) })
+ }
+ onDragStart={() => setDraggingId(id)}
+ onDragEnd={clearDrag}
+ expanded={expanded.includes(id)}
+ onToggleExpanded={() => toggleExpanded(id)}
+ />
+ </Fragment>
+ ))}
+ <DropLine active={drop?.col === ci && drop.index === col.length} />
+ {col.length === 0 && (
+ <li className="px-1 py-3 text-center text-xs text-muted-foreground/70">
+ Empty — drop a section here
+ </li>
+ )}
+ </ul>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <div
+ onDragOver={(e) => {
+ if (!draggingId) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ setDrop(null);
+ }}
+ onDrop={(e) => {
+ if (!draggingId) return;
+ e.preventDefault();
+ onChange(SECTION_BY_ID[draggingId].setEnabled(false));
+ clearDrag();
+ }}
+ className="flex flex-wrap items-center gap-2 rounded-lg border border-dashed border-border p-2"
+ >
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+ Off
+ </span>
+ {off.length === 0 ? (
+ <span className="text-xs text-muted-foreground/70">
+ Every section is on the board.
+ </span>
+ ) : (
+ // Same checkbox as on the card, deliberately: a section is one
+ // control that changes place, not two that appear and disappear —
+ // which is what makes it findable by name whether it is on or off.
+ off.map((s) => (
+ <label
+ key={s.id}
+ draggable
+ onDragStart={(e) => {
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", s.id);
+ setDraggingId(s.id);
+ }}
+ onDragEnd={clearDrag}
+ title={s.hint}
+ className="flex cursor-grab items-center gap-1.5 rounded-full border border-border bg-card py-1 pl-2 pr-3 text-xs hover:border-border-strong hover:bg-muted"
+ >
+ <input
+ type="checkbox"
+ checked={false}
+ onChange={() =>
+ place(s.id, columns.length - 1, columns[columns.length - 1].length)
+ }
+ className="h-3.5 w-3.5"
+ />
+ <SectionMini id={s.id} />
+ <span>{s.label}</span>
+ </label>
+ ))
+ )}
+ {draggingId !== null && (
+ <span className="text-xs text-muted-foreground">
+ Drop here to switch off.
+ </span>
+ )}
+ </div>
+ </div>
+ );
+}
+
+// The insertion marker. Always in the DOM at every slot so the list doesn't
+// reflow as it moves between them — only its color changes.
+function DropLine({ active }: { active: boolean }) {
+ return (
+ <li
+ aria-hidden
+ className={`h-0.5 rounded-full motion-safe:transition-colors ${
+ active ? "bg-primary" : "bg-transparent"
+ }`}
+ />
+ );
+}
diff --git a/editor/app/widget/builder/components/SectionCard.tsx b/editor/app/widget/builder/components/SectionCard.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import type { WidgetConfig } from "../../lib/config";
+import type { SectionDef } from "../../lib/sections";
+import { Check } from "../../components/WidgetFields";
+import { SectionMini } from "./SectionMini";
+
+// One strip on the floorplan, drawn roughly where it will render: its own
+// miniature, its name, the move controls, and — folded away until asked for —
+// the display options that belong to this section and nowhere else.
+//
+// The on/off control is a real checkbox rather than a close button: unchecking
+// it is what sends the section to the tray, and it keeps the section reachable
+// by name for both keyboard users and the e2e suite.
+
+const BTN =
+ "flex h-6 w-6 items-center justify-center rounded border border-border text-xs font-medium text-muted-foreground hover:bg-muted disabled:opacity-40";
+
+export function SectionCard({
+ def,
+ config,
+ col,
+ index,
+ colLength,
+ columnCount,
+ dragging,
+ onChange,
+ onMove,
+ onMoveColumn,
+ onDragStart,
+ onDragEnd,
+ expanded,
+ onToggleExpanded,
+}: {
+ def: SectionDef;
+ config: WidgetConfig;
+ col: number;
+ index: number;
+ colLength: number;
+ columnCount: number;
+ dragging: boolean;
+ onChange: (patch: Partial<WidgetConfig>) => void;
+ onMove: (dir: -1 | 1) => void;
+ onMoveColumn: (dir: -1 | 1) => void;
+ onDragStart: () => void;
+ onDragEnd: () => void;
+ expanded: boolean;
+ onToggleExpanded: () => void;
+}) {
+ return (
+ <li
+ data-section-card={def.id}
+ draggable
+ onDragStart={(e) => {
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", def.id);
+ onDragStart();
+ }}
+ onDragEnd={onDragEnd}
+ className={`cursor-grab rounded-md border border-border bg-card p-2 shadow-sm motion-safe:transition ${
+ dragging
+ ? "opacity-60 shadow-lg motion-safe:scale-[1.02]"
+ : "hover:border-border-strong"
+ }`}
+ >
+ {/* Wraps rather than truncates: in a three-column board there isn't room
+ for the name and the five controls on one line, and the name is the
+ part you can't do without. */}
+ <div className="flex flex-wrap items-start gap-x-2 gap-y-1">
+ <div className="flex min-w-[7rem] flex-1 flex-col gap-1">
+ <div className="flex items-center gap-2">
+ <span
+ aria-hidden
+ title="Drag to move"
+ className="select-none text-sm leading-none text-muted-foreground/60"
+ >
+ ⠿
+ </span>
+ <SectionMini id={def.id} />
+ <label className="flex min-w-0 items-center gap-2">
+ <input
+ type="checkbox"
+ checked
+ onChange={() => onChange(def.setEnabled(false))}
+ title={`Turn off ${def.label}`}
+ className="h-4 w-4 shrink-0"
+ />
+ <span className="text-sm font-medium">{def.label}</span>
+ </label>
+ </div>
+ <p className="pl-6 text-xs text-muted-foreground">{def.hint}</p>
+ </div>
+ <div className="ml-auto flex shrink-0 items-center gap-1">
+ <button
+ type="button"
+ className={BTN}
+ disabled={index === 0}
+ onClick={() => onMove(-1)}
+ aria-label={`Move ${def.label} up`}
+ title="Move up"
+ >
+ ↑
+ </button>
+ <button
+ type="button"
+ className={BTN}
+ disabled={index === colLength - 1}
+ onClick={() => onMove(1)}
+ aria-label={`Move ${def.label} down`}
+ title="Move down"
+ >
+ ↓
+ </button>
+ {columnCount > 1 && (
+ <>
+ <button
+ type="button"
+ className={BTN}
+ disabled={col === 0}
+ onClick={() => onMoveColumn(-1)}
+ aria-label={`Move ${def.label} left`}
+ title="Move to the previous column"
+ >
+ ◀
+ </button>
+ <button
+ type="button"
+ className={BTN}
+ disabled={col === columnCount - 1}
+ onClick={() => onMoveColumn(1)}
+ aria-label={`Move ${def.label} right`}
+ title="Move to the next column"
+ >
+ ▶
+ </button>
+ </>
+ )}
+ {def.options.length > 0 && (
+ <button
+ type="button"
+ className={BTN}
+ onClick={onToggleExpanded}
+ aria-expanded={expanded}
+ aria-label={`Options for ${def.label}`}
+ title={`${def.options.length} display ${
+ def.options.length === 1 ? "option" : "options"
+ }`}
+ >
+ <span
+ aria-hidden
+ className={`leading-none motion-safe:transition-transform ${
+ expanded ? "rotate-180" : ""
+ }`}
+ >
+ ⌄
+ </span>
+ </button>
+ )}
+ </div>
+ </div>
+ {expanded && def.options.length > 0 && (
+ <div className="mt-2 flex flex-col gap-1.5 border-t border-border pl-6 pt-2">
+ {def.options.map((o) => (
+ <Check
+ key={o.key}
+ label={o.label}
+ hint={o.hint}
+ checked={config[o.key]}
+ onChange={(v) => onChange({ [o.key]: v } as Partial<WidgetConfig>)}
+ />
+ ))}
+ </div>
+ )}
+ </li>
+ );
+}
diff --git a/editor/app/widget/builder/components/SectionMini.tsx b/editor/app/widget/builder/components/SectionMini.tsx
@@ -0,0 +1,109 @@
+// Miniatures: a tiny CSS-drawn likeness of what each section actually renders,
+// so the floorplan board is legible as a shape before you read a label — four
+// dots for Workers, a stubbed progress bar for Active jobs, a meter with a
+// threshold notch for Disk. Purely decorative (aria-hidden); the section's real
+// name is always next to it.
+
+import type { SectionId } from "../../lib/sections";
+
+const FRAME =
+ "flex h-5 w-8 shrink-0 flex-col justify-center gap-[2px] overflow-hidden rounded-sm border border-border/60 bg-background px-[3px]";
+
+// A row of text, as the widget's strips mostly are: a rule of some width.
+function Rule({ w, tone = "bg-muted-foreground/40" }: { w: string; tone?: string }) {
+ return <span className={`block h-[3px] rounded-full ${tone}`} style={{ width: w }} />;
+}
+
+function Dot({ tone }: { tone: string }) {
+ return <span className={`block h-[4px] w-[4px] rounded-full ${tone}`} />;
+}
+
+// A list row: a label rule with a small chip pushed to the right, the shape the
+// Needs work / Needs cleaning strips repeat.
+function ListRow({ chip }: { chip: string }) {
+ return (
+ <span className="flex items-center justify-between gap-[2px]">
+ <Rule w="60%" />
+ <span className={`block h-[4px] w-[6px] rounded-[1px] ${chip}`} />
+ </span>
+ );
+}
+
+const MINIS: Record<SectionId, () => React.ReactElement> = {
+ // Two buttons side by side.
+ controls: () => (
+ <span className="flex items-center gap-[2px]">
+ <span className="block h-[7px] w-[11px] rounded-[2px] bg-primary/70" />
+ <span className="block h-[7px] w-[8px] rounded-[2px] bg-muted-foreground/30" />
+ </span>
+ ),
+ // Two readout lines, the second shorter.
+ lastSync: () => (
+ <>
+ <Rule w="100%" />
+ <Rule w="65%" />
+ </>
+ ),
+ // Status dot plus a line.
+ scheduler: () => (
+ <span className="flex items-center gap-[3px]">
+ <Dot tone="bg-success" />
+ <Rule w="70%" />
+ </span>
+ ),
+ // A meter with the low-disk threshold notched into it.
+ disk: () => (
+ <span className="relative block h-[6px] w-full overflow-hidden rounded-[2px] bg-muted">
+ <span className="absolute inset-y-0 left-0 w-[62%] bg-success/70" />
+ <span className="absolute inset-y-0 left-[78%] w-[1.5px] bg-destructive" />
+ </span>
+ ),
+ // One number, warning-tinted.
+ cleanable: () => (
+ <span className="flex items-center gap-[3px]">
+ <Dot tone="bg-warning" />
+ <Rule w="55%" tone="bg-warning/50" />
+ </span>
+ ),
+ // Stacked channel rows with reclaim chips.
+ cleanChannels: () => (
+ <>
+ <ListRow chip="bg-warning/60" />
+ <ListRow chip="bg-warning/60" />
+ </>
+ ),
+ // Four worker dots, one idle.
+ workers: () => (
+ <span className="flex items-center gap-[3px]">
+ <Dot tone="bg-success" />
+ <Dot tone="bg-success" />
+ <Dot tone="bg-success" />
+ <Dot tone="bg-muted-foreground/40" />
+ </span>
+ ),
+ // A job heading over a part-filled progress bar.
+ jobs: () => (
+ <>
+ <Rule w="55%" />
+ <span className="relative block h-[4px] w-full overflow-hidden rounded-[2px] bg-muted">
+ <span className="absolute inset-y-0 left-0 w-[45%] bg-success" />
+ </span>
+ </>
+ ),
+ // Stacked channel rows with backlog chips.
+ actionable: () => (
+ <>
+ <ListRow chip="bg-info/60" />
+ <ListRow chip="bg-info/60" />
+ </>
+ ),
+};
+
+export function SectionMini({ id }: { id: SectionId }) {
+ const Draw = MINIS[id];
+ return (
+ <span aria-hidden className={FRAME}>
+ <Draw />
+ </span>
+ );
+}
diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx
@@ -3,18 +3,29 @@
import { useEffect, useMemo, useState } from "react";
import {
buildWidgetQuery,
+ patchWidgetConfig,
WIDGET_DEFAULTS,
type WidgetConfig,
} from "../../lib/config";
-import { WidgetConfigForm } from "../../components/WidgetConfigForm";
+import { GlobalOptions } from "../../components/WidgetFields";
import { loadWidgetConfig, saveWidgetConfig } from "../widgetConfigStorage";
+import { LayoutBoard } from "./LayoutBoard";
+// The two wide presets exist because columns need width to be worth having —
+// the original three are all narrower than two readable columns.
const SIZE_PRESETS: { label: string; width: number; height: number }[] = [
{ label: "Small", width: 320, height: 200 },
{ label: "Medium", width: 380, height: 320 },
{ label: "Tall", width: 360, height: 520 },
+ { label: "Wide", width: 640, height: 260 },
+ { label: "Panel", width: 720, height: 420 },
];
+// How long the preview waits before re-navigating. Changing an iframe's src is a
+// full navigation, and a drag produces a change per pointer move — so the link
+// field updates instantly and only the preview is debounced.
+const PREVIEW_DEBOUNCE_MS = 250;
+
export function WidgetBuilder() {
const [config, setConfig] = useState<WidgetConfig>(WIDGET_DEFAULTS);
const [size, setSize] = useState(SIZE_PRESETS[0]);
@@ -46,8 +57,14 @@ export function WidgetBuilder() {
const relativeUrl = query ? `/widget?${query}` : "/widget";
const fullUrl = origin ? `${origin}${relativeUrl}` : relativeUrl;
+ const [previewUrl, setPreviewUrl] = useState("/widget");
+ useEffect(() => {
+ const t = setTimeout(() => setPreviewUrl(relativeUrl), PREVIEW_DEBOUNCE_MS);
+ return () => clearTimeout(t);
+ }, [relativeUrl]);
+
function patch(p: Partial<WidgetConfig>) {
- setConfig((c) => ({ ...c, ...p }));
+ setConfig((c) => patchWidgetConfig(c, p));
setCopied(false);
}
@@ -72,12 +89,16 @@ export function WidgetBuilder() {
}
return (
- <div className="flex flex-col gap-6 lg:flex-row lg:items-start">
- <form className="flex flex-col gap-4 lg:w-80 lg:shrink-0">
- <WidgetConfigForm config={config} onChange={patch} />
- </form>
+ <div className="flex flex-col gap-6 xl:flex-row xl:items-start">
+ <div className="flex min-w-0 flex-1 flex-col gap-4">
+ <LayoutBoard config={config} onChange={patch} previewWidth={size.width} />
+ <fieldset className="flex flex-col gap-2 rounded-lg border border-border p-3">
+ <legend className="px-1 text-sm font-medium">Whole widget</legend>
+ <GlobalOptions config={config} onChange={patch} />
+ </fieldset>
+ </div>
- <div className="flex flex-1 flex-col gap-3 min-w-0">
+ <div className="flex flex-col gap-3 xl:shrink-0">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">Widget link</span>
<div className="flex gap-2">
@@ -87,49 +108,50 @@ export function WidgetBuilder() {
aria-label="widget URL"
value={fullUrl}
onFocus={(e) => e.currentTarget.select()}
- className="flex-1 min-w-0 rounded border border-border bg-muted px-2 py-1 font-mono text-xs"
+ className="min-w-0 flex-1 rounded border border-border bg-muted px-2 py-1 font-mono text-xs"
/>
<button
type="button"
onClick={copy}
- className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 shrink-0"
+ className="shrink-0 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
{copied ? "Copied" : "Copy"}
</button>
<button
type="button"
onClick={openPopup}
- className="px-3 py-1.5 rounded-md border border-border text-sm font-medium hover:bg-muted shrink-0"
+ className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
Open popup
</button>
</div>
- <p className="text-xs text-muted-foreground">
- Open this in a small pinned window, or embed it with an{" "}
- <code className="font-mono"><iframe></code>. It has no sidebar;
- by default it's read-only, but{" "}
- <strong>Show control buttons</strong> adds Pause/Resume
- Transcriptions, Drain all, Retry all failed, and per-channel
- Download/Transcribe on the needs-work list. The{" "}
- <strong>settings gear</strong> lets you reconfigure the widget in
- place; turn it off for a locked-down link. <strong>Open popup</strong>{" "}
- launches a chromeless window at the selected preview size.
+ <p className="max-w-prose text-xs text-muted-foreground">
+ The link carries the whole floorplan, so the arrangement above is
+ what a pinned window or an embedded{" "}
+ <code className="font-mono"><iframe></code> will show. Sections
+ in the <strong>Off</strong> tray are simply absent from it — the link
+ only names what is on, which is why an old link still works and still
+ renders in the original order. The{" "}
+ <strong>settings gear</strong> reopens these same controls inside the
+ widget; turn it off for a locked-down link.{" "}
+ <strong>Open popup</strong> launches a chromeless window at the
+ selected preview size.
</p>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Preview</span>
- <div className="flex gap-1">
+ <div className="flex flex-wrap gap-1">
{SIZE_PRESETS.map((p) => (
<button
key={p.label}
type="button"
onClick={() => setSize(p)}
aria-pressed={size.label === p.label}
- className={`px-2 py-0.5 rounded text-xs border ${
+ className={`rounded border px-2 py-0.5 text-xs ${
size.label === p.label
- ? "bg-primary text-primary-foreground border-primary"
+ ? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-muted"
}`}
>
@@ -138,10 +160,10 @@ export function WidgetBuilder() {
))}
</div>
</div>
- <div className="inline-block rounded-lg border border-border overflow-hidden bg-card shadow-sm">
+ <div className="inline-block max-w-full overflow-auto rounded-lg border border-border bg-card shadow-sm">
<iframe
title="widget preview"
- src={relativeUrl}
+ src={previewUrl}
width={size.width}
height={size.height}
className="block"
diff --git a/editor/app/widget/builder/page.tsx b/editor/app/widget/builder/page.tsx
@@ -4,19 +4,21 @@ import { WidgetBuilder } from "./components/WidgetBuilder";
export const metadata: Metadata = { title: "Monitor widget" };
// Lives inside the normal app shell (AppFrame strips chrome only on exactly
-// "/widget"). Lets you compose a read-only monitor link from form controls,
-// copy it, and preview the real bare widget in an iframe.
+// "/widget"). The board below is a scale model of the widget: arrange it there,
+// preview the real bare widget in an iframe, copy the link.
export default function WidgetBuilderPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Monitor widget</h1>
</div>
- <p className="text-sm text-muted-foreground max-w-2xl">
- Build a read-only monitoring widget that shows worker status and active
- jobs with no sidebar or controls — sized to sit in a small pinned window
- or an embedded <code className="font-mono"><iframe></code>. Toggle
- what it shows below, then copy the link.
+ <p className="max-w-2xl text-sm text-muted-foreground">
+ A read-only monitor with no sidebar, sized to sit in a small pinned
+ window or an embedded <code className="font-mono"><iframe></code>.
+ The board below is laid out the way the widget will be — drag the strips
+ into the order you want to read them in, split them across columns if
+ you're giving it the width, and drop the ones you don't want
+ into the tray. Then copy the link.
</p>
<WidgetBuilder />
</div>
diff --git a/editor/app/widget/builder/widgetConfigStorage.ts b/editor/app/widget/builder/widgetConfigStorage.ts
@@ -5,11 +5,33 @@
// what actually renders (see buildWidgetQuery).
import { WIDGET_DEFAULTS, type WidgetConfig } from "../lib/config";
+import { reconcileColumns, type Columns } from "../lib/placement";
+import { SECTION_BY_ID, type SectionId } from "../lib/sections";
const KEY = "ytdlp-tb:widget-config";
-const VERSION = 1;
+// v2 adds the section layout. A v1 entry resets to defaults, which is right:
+// it predates layouts and has nothing to carry forward but the flags.
+const VERSION = 2;
-type StoredWidgetConfig = { v: 1; config: WidgetConfig };
+type StoredWidgetConfig = { v: typeof VERSION; config: WidgetConfig };
+
+// The layout is the one stored field that isn't a boolean, a string or a
+// number, so it needs its own validation: an array of arrays of section ids we
+// still recognize. Anything else is discarded rather than trusted.
+function parseColumns(v: unknown): Columns | null {
+ if (!Array.isArray(v) || v.length === 0) return null;
+ const columns: Columns = [];
+ for (const col of v) {
+ if (!Array.isArray(col)) return null;
+ const ids: SectionId[] = [];
+ for (const id of col) {
+ if (typeof id !== "string" || !(id in SECTION_BY_ID)) continue;
+ ids.push(id as SectionId);
+ }
+ columns.push(ids);
+ }
+ return columns;
+}
// Overlay validated fields onto the defaults so the saved form survives the
// config gaining new fields (the new field simply takes its default).
@@ -29,6 +51,11 @@ function parseStored(raw: string): WidgetConfig | null {
const next: WidgetConfig = { ...WIDGET_DEFAULTS };
for (const key of Object.keys(WIDGET_DEFAULTS) as (keyof WidgetConfig)[]) {
const v = r[key];
+ if (key === "columns") {
+ const columns = parseColumns(v);
+ if (columns) next.columns = columns;
+ continue;
+ }
if (key === "channel") {
if (typeof v === "string") next.channel = v || undefined;
continue;
@@ -43,7 +70,9 @@ function parseStored(raw: string): WidgetConfig | null {
(next[key] as boolean) = v;
}
}
- return next;
+ // Re-normalize rather than trust: a stored layout could name a section whose
+ // flag was stored off (or miss one stored on), and the flags are what decide.
+ return { ...next, columns: reconcileColumns(next.columns, next) };
}
export function loadWidgetConfig(): WidgetConfig {
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { Fragment, useState, type ReactNode } from "react";
import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format";
import { usePolledPayload, useNow } from "../lib/usePolledPayload";
import { fmtTime } from "../lib/relativeTime";
@@ -22,7 +22,12 @@ import type {
CleanableChannel,
} from "../../api/widget/cleanable/route";
import type { WidgetSyncPayload } from "../../api/widget/sync/route";
-import { buildWidgetQuery, type WidgetConfig } from "../lib/config";
+import {
+ buildWidgetQuery,
+ patchWidgetConfig,
+ type WidgetConfig,
+} from "../lib/config";
+import type { SectionId } from "../lib/sections";
import { WidgetConfigForm } from "./WidgetConfigForm";
import { WidgetControls } from "./WidgetControls";
@@ -100,7 +105,7 @@ export function MonitorWidget({
// round-trip and the global AutoRefresh race. An all-default config drops the
// query entirely.
function patch(p: Partial<WidgetConfig>) {
- const next = { ...config, ...p };
+ const next = patchWidgetConfig(config, p);
setConfig(next);
const q = buildWidgetQuery(next);
window.history.replaceState(null, "", q ? `?${q}` : window.location.pathname);
@@ -152,67 +157,121 @@ export function MonitorWidget({
);
}
+ // What each placeable section renders. Only the `config.columns` layout
+ // decides whether and where it appears — the guards left here are the ones
+ // about data, not configuration (a strip with nothing to show yet).
+ function renderSection(id: SectionId): ReactNode {
+ switch (id) {
+ case "controls":
+ return (
+ <WidgetControls
+ controls={config.controls}
+ sync={config.sync}
+ channel={config.channel}
+ confirmSyncAll={config.syncConfirm}
+ paused={workersPayload?.paused ?? false}
+ downloadsPaused={workersPayload?.downloadsPaused ?? false}
+ onWorkersChange={refetchWorkers}
+ onSynced={refetchSync}
+ />
+ );
+ case "lastSync":
+ return syncData ? (
+ <LastSyncStrip
+ data={syncData}
+ now={now}
+ absolute={config.syncTimeAbsolute}
+ />
+ ) : null;
+ case "scheduler":
+ return syncData ? (
+ <SchedulerStrip
+ data={syncData}
+ now={now}
+ absolute={config.syncTimeAbsolute}
+ />
+ ) : null;
+ case "disk":
+ return disk?.enabled ? <DiskStrip disk={disk} /> : null;
+ case "cleanable":
+ return cleanablePayload ? (
+ <CleanableStrip bytes={cleanablePayload.bytes} />
+ ) : null;
+ case "cleanChannels":
+ return (
+ <CleanableChannelsStrip
+ channels={cleanableChannels}
+ showTitle={config.showTitles}
+ interactive={config.controls}
+ />
+ );
+ case "workers":
+ return (
+ <WorkersStrip
+ workers={workers}
+ paused={workersPayload?.paused ?? false}
+ showTitle={config.showTitles}
+ showLabels={config.workerLabels}
+ />
+ );
+ case "jobs":
+ return (
+ <ActiveJobsStrip
+ jobs={jobs}
+ showTitle={config.showTitles}
+ compact={config.compact}
+ jobBar={config.jobBar}
+ headingProgress={config.headingProgress}
+ eta={config.eta}
+ />
+ );
+ case "actionable":
+ return (
+ <ActionableStrip
+ channels={actionableChannels}
+ showTitle={config.showTitles}
+ interactive={config.controls}
+ />
+ );
+ }
+ }
+
return (
- <div className="relative flex flex-col gap-3 p-2 text-foreground">
+ // @container so the stack-when-narrow thresholds below measure the widget's
+ // own box — the popup or iframe it was embedded at — rather than the
+ // viewport, which for an embedded widget is the host page's.
+ <div className="@container relative p-2 text-foreground">
{settingsLayer}
- {(config.controls || config.sync) && (
- <WidgetControls
- controls={config.controls}
- sync={config.sync}
- channel={config.channel}
- confirmSyncAll={config.syncConfirm}
- paused={workersPayload?.paused ?? false}
- downloadsPaused={workersPayload?.downloadsPaused ?? false}
- onWorkersChange={refetchWorkers}
- onSynced={refetchSync}
- />
- )}
- {config.lastSync && syncData && (
- <LastSyncStrip data={syncData} now={now} absolute={config.syncTimeAbsolute} />
- )}
- {config.scheduler && syncData && (
- <SchedulerStrip data={syncData} now={now} absolute={config.syncTimeAbsolute} />
- )}
- {config.disk && disk?.enabled && <DiskStrip disk={disk} />}
- {config.cleanable && cleanablePayload && (
- <CleanableStrip bytes={cleanablePayload.bytes} />
- )}
- {config.cleanChannels && (
- <CleanableChannelsStrip
- channels={cleanableChannels}
- showTitle={config.showTitles}
- interactive={config.controls}
- />
- )}
- {config.workers && (
- <WorkersStrip
- workers={workers}
- paused={workersPayload?.paused ?? false}
- showTitle={config.showTitles}
- showLabels={config.workerLabels}
- />
- )}
- {config.jobs && (
- <ActiveJobsStrip
- jobs={jobs}
- showTitle={config.showTitles}
- compact={config.compact}
- jobBar={config.jobBar}
- headingProgress={config.headingProgress}
- eta={config.eta}
- />
- )}
- {config.actionable && (
- <ActionableStrip
- channels={actionableChannels}
- showTitle={config.showTitles}
- interactive={config.controls}
- />
- )}
+ <div className={columnsClass(config)}>
+ {config.columns.map((col, i) => (
+ <div key={i} className="flex min-w-0 flex-1 flex-col gap-3">
+ {col.map((id) => (
+ <Fragment key={id}>{renderSection(id)}</Fragment>
+ ))}
+ </div>
+ ))}
+ </div>
</div>
);
}
+// Tailwind needs literal class names in the source, so the stack thresholds are
+// a static map rather than an interpolated `@min-[${n}rem]:`. Each is the width
+// below which that many columns stop being readable at all.
+const STACK_AT: Record<number, string> = {
+ 2: "flex flex-col gap-3 @min-[24rem]:flex-row",
+ 3: "flex flex-col gap-3 @min-[36rem]:flex-row",
+};
+
+function columnsClass(config: WidgetConfig): string {
+ const cols = config.columns.length;
+ if (cols <= 1) return "flex flex-col gap-3";
+ // Columns are honored at every width by default: an arrangement you made on
+ // purpose shouldn't quietly undo itself. `stackNarrow` opts into collapsing.
+ if (config.stackNarrow) return STACK_AT[cols] ?? "flex flex-col gap-3";
+ return "flex flex-row gap-3";
+}
+
// Compact state → dot color, mirroring stateBadge() in WorkersView.
function workerDotClass(w: WorkerView): string {
if (w.degraded) return "bg-destructive";
diff --git a/editor/app/widget/components/WidgetConfigForm.tsx b/editor/app/widget/components/WidgetConfigForm.tsx
@@ -1,12 +1,23 @@
"use client";
import type { WidgetConfig } from "../lib/config";
+import {
+ MAX_COLUMNS,
+ moveWithin,
+ reconcileColumns,
+ insertAt,
+ setColumnCount,
+} from "../lib/placement";
+import { SECTIONS, SECTION_BY_ID, type SectionDef } from "../lib/sections";
+import { Check, GlobalOptions } from "./WidgetFields";
-// The widget's configuration form — the section/display checkboxes plus the
-// channel and poll-interval inputs. Shared by the /widget/builder page (which
-// wraps it with the link/copy/preview UI) and the in-widget settings overlay
-// (which lets you reconfigure a pinned widget in place). Callers own the
-// surrounding <form> element.
+// The in-widget gear's configuration form. Same registry and the same layout
+// operations as the /widget/builder floorplan, in a presentation that fits the
+// ~320px overlay a pinned widget opens: one row per section in the order they
+// render, with its own display options folded in underneath, rather than a
+// drag board there'd be no room to use.
+//
+// Callers own the surrounding <form> element.
export function WidgetConfigForm({
config,
onChange,
@@ -14,167 +25,157 @@ export function WidgetConfigForm({
config: WidgetConfig;
onChange: (patch: Partial<WidgetConfig>) => void;
}) {
+ const columns = config.columns;
+ const off = SECTIONS.filter((s) => !s.enabled(config));
+
+ // Switch a section on where the builder would put it — the end of the last
+ // column — so the two surfaces agree about what "add" means.
+ function enable(def: SectionDef) {
+ const flagPatch = def.setEnabled(true);
+ const nextFlags = { ...config, ...flagPatch };
+ const base = reconcileColumns(columns, nextFlags);
+ const last = base.length - 1;
+ onChange({
+ ...flagPatch,
+ columns: insertAt(base, def.id, last, base[last].length),
+ });
+ }
+
return (
<>
<fieldset className="flex flex-col gap-2">
- <legend className="text-sm font-medium mb-1">Sections</legend>
- <Check
- label="Workers"
- checked={config.workers}
- onChange={(v) => onChange({ workers: v })}
- />
- <Check
- label="Active jobs"
- checked={config.jobs}
- onChange={(v) => onChange({ jobs: v })}
- />
- <Check
- label="Channels needing work"
- checked={config.actionable}
- onChange={(v) => onChange({ actionable: v })}
- />
- <Check
- label="Channels needing cleanup"
- checked={config.cleanChannels}
- onChange={(v) => onChange({ cleanChannels: v })}
- />
- </fieldset>
+ <legend className="mb-1 text-sm font-medium">Layout</legend>
+ <label className="flex items-center gap-2 text-sm">
+ <span>Columns</span>
+ <select
+ value={columns.length}
+ onChange={(e) =>
+ onChange({ columns: setColumnCount(columns, Number(e.target.value)) })
+ }
+ className="rounded border border-border bg-card px-1.5 py-0.5 text-sm"
+ >
+ {Array.from({ length: MAX_COLUMNS }, (_, i) => i + 1).map((n) => (
+ <option key={n} value={n}>
+ {n}
+ </option>
+ ))}
+ </select>
+ </label>
- <fieldset className="flex flex-col gap-2">
- <legend className="text-sm font-medium mb-1">Display</legend>
- <Check
- label="Compact (hide per-task detail)"
- checked={config.compact}
- onChange={(v) => onChange({ compact: v })}
- />
- <Check
- label="Show batch progress bar"
- checked={config.jobBar}
- onChange={(v) => onChange({ jobBar: v })}
- />
- <Check
- label="Show progress in job heading"
- checked={config.headingProgress}
- onChange={(v) => onChange({ headingProgress: v })}
- />
- <Check
- label="Show time estimates (ETA)"
- checked={config.eta}
- onChange={(v) => onChange({ eta: v })}
- />
- <Check
- label="Show disk indicator"
- checked={config.disk}
- onChange={(v) => onChange({ disk: v })}
- />
- <Check
- label="Show cleanable indicator"
- checked={config.cleanable}
- onChange={(v) => onChange({ cleanable: v })}
- />
- <Check
- label="Show worker names"
- checked={config.workerLabels}
- onChange={(v) => onChange({ workerLabels: v })}
- />
- <Check
- label="Show section titles"
- checked={config.showTitles}
- onChange={(v) => onChange({ showTitles: v })}
- />
- <Check
- label="Hide when idle"
- checked={config.hideIdle}
- onChange={(v) => onChange({ hideIdle: v })}
- />
- <Check
- label="Show settings gear"
- checked={config.settings}
- onChange={(v) => onChange({ settings: v })}
- />
- <Check
- label="Show control buttons (pause, drain, retry, per-channel actions)"
- checked={config.controls}
- onChange={(v) => onChange({ controls: v })}
- />
+ {columns.map((col, ci) => (
+ <div key={ci} className="flex flex-col gap-2">
+ {columns.length > 1 && (
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+ Column {ci + 1}
+ </span>
+ )}
+ {col.length === 0 && (
+ <span className="text-xs text-muted-foreground/70">Empty</span>
+ )}
+ {col.map((id, index) => {
+ const def = SECTION_BY_ID[id];
+ return (
+ <div key={id} className="flex flex-col gap-1.5">
+ <div className="flex items-center gap-1.5">
+ <label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked
+ onChange={() => onChange(def.setEnabled(false))}
+ className="h-4 w-4 shrink-0"
+ />
+ <span className="truncate">{def.label}</span>
+ </label>
+ <button
+ type="button"
+ className={MOVE_BTN}
+ disabled={index === 0}
+ onClick={() =>
+ onChange({ columns: moveWithin(columns, ci, index, -1) })
+ }
+ aria-label={`Move ${def.label} up`}
+ >
+ ↑
+ </button>
+ <button
+ type="button"
+ className={MOVE_BTN}
+ disabled={index === col.length - 1}
+ onClick={() =>
+ onChange({ columns: moveWithin(columns, ci, index, 1) })
+ }
+ aria-label={`Move ${def.label} down`}
+ >
+ ↓
+ </button>
+ {columns.length > 1 && (
+ <select
+ value={ci}
+ aria-label={`Column for ${def.label}`}
+ onChange={(e) =>
+ onChange({
+ columns: insertAt(
+ columns,
+ def.id,
+ Number(e.target.value),
+ columns[Number(e.target.value)].length,
+ ),
+ })
+ }
+ className="rounded border border-border bg-card px-1 py-0.5 text-xs"
+ >
+ {columns.map((_, n) => (
+ <option key={n} value={n}>
+ {n + 1}
+ </option>
+ ))}
+ </select>
+ )}
+ </div>
+ {def.options.length > 0 && (
+ <div className="flex flex-col gap-1.5 border-l border-border pl-3">
+ {def.options.map((o) => (
+ <Check
+ key={o.key}
+ label={o.label}
+ hint={o.hint}
+ checked={config[o.key]}
+ onChange={(v) =>
+ onChange({ [o.key]: v } as Partial<WidgetConfig>)
+ }
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ ))}
</fieldset>
+ {off.length > 0 && (
+ <fieldset className="flex flex-col gap-2">
+ <legend className="mb-1 text-sm font-medium">Off</legend>
+ {off.map((def) => (
+ <Check
+ key={def.id}
+ label={def.label}
+ hint={def.hint}
+ checked={false}
+ onChange={() => enable(def)}
+ />
+ ))}
+ </fieldset>
+ )}
+
<fieldset className="flex flex-col gap-2">
- <legend className="text-sm font-medium mb-1">Sync</legend>
- <Check
- label="Sync button"
- checked={config.sync}
- onChange={(v) => onChange({ sync: v })}
- />
- <Check
- label="Confirm before Sync all"
- checked={config.syncConfirm}
- onChange={(v) => onChange({ syncConfirm: v })}
- />
- <Check
- label="Last-sync readout"
- checked={config.lastSync}
- onChange={(v) => onChange({ lastSync: v })}
- />
- <Check
- label="Scheduler status"
- checked={config.scheduler}
- onChange={(v) => onChange({ scheduler: v })}
- />
- <Check
- label="Absolute timestamps"
- checked={config.syncTimeAbsolute}
- onChange={(v) => onChange({ syncTimeAbsolute: v })}
- />
+ <legend className="mb-1 text-sm font-medium">Widget</legend>
+ <GlobalOptions config={config} onChange={onChange} />
</fieldset>
-
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Channel filter (slug, optional)</span>
- <input
- type="text"
- value={config.channel ?? ""}
- onChange={(e) => onChange({ channel: e.target.value || undefined })}
- placeholder="all channels"
- className="rounded border border-border bg-card px-2 py-1"
- />
- </label>
-
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">Poll interval (seconds)</span>
- <input
- type="number"
- min={1}
- value={config.pollSeconds}
- onChange={(e) =>
- onChange({
- pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)),
- })
- }
- className="rounded border border-border bg-card px-2 py-1 w-24"
- />
- </label>
</>
);
}
-function Check({
- label,
- checked,
- onChange,
-}: {
- label: string;
- checked: boolean;
- onChange: (v: boolean) => void;
-}) {
- return (
- <label className="flex items-center gap-2 text-sm">
- <input
- type="checkbox"
- checked={checked}
- onChange={(e) => onChange(e.target.checked)}
- className="h-4 w-4"
- />
- <span>{label}</span>
- </label>
- );
-}
+const MOVE_BTN =
+ "flex h-6 w-6 shrink-0 items-center justify-center rounded border border-border text-xs text-muted-foreground hover:bg-muted disabled:opacity-40";
diff --git a/editor/app/widget/components/WidgetFields.tsx b/editor/app/widget/components/WidgetFields.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import type { WidgetConfig } from "../lib/config";
+
+// The small shared pieces both configuration surfaces use: the checkbox, and
+// the settings that belong to the widget as a whole rather than to any one
+// section. Keeping the labels in one place matters — they are the accessible
+// names the e2e suite selects by.
+
+export function Check({
+ label,
+ hint,
+ checked,
+ onChange,
+}: {
+ label: string;
+ hint?: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+ <label className="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={checked}
+ onChange={(e) => onChange(e.target.checked)}
+ className="mt-0.5 h-4 w-4 shrink-0"
+ />
+ <span className="min-w-0">
+ <span className="block">{label}</span>
+ {hint && (
+ <span className="block text-xs text-muted-foreground">{hint}</span>
+ )}
+ </span>
+ </label>
+ );
+}
+
+// Everything that isn't about one section: chrome, time formatting, how the
+// columns behave when the window is small, and the two value inputs.
+export function GlobalOptions({
+ config,
+ onChange,
+ className = "",
+}: {
+ config: WidgetConfig;
+ onChange: (patch: Partial<WidgetConfig>) => void;
+ className?: string;
+}) {
+ return (
+ <div className={`flex flex-col gap-3 ${className}`}>
+ <div className="flex flex-wrap gap-x-6 gap-y-2">
+ <Check
+ label="Show section titles"
+ checked={config.showTitles}
+ onChange={(v) => onChange({ showTitles: v })}
+ />
+ <Check
+ label="Hide when idle"
+ checked={config.hideIdle}
+ onChange={(v) => onChange({ hideIdle: v })}
+ />
+ <Check
+ label="Show settings gear"
+ checked={config.settings}
+ onChange={(v) => onChange({ settings: v })}
+ />
+ <Check
+ label="Absolute timestamps"
+ checked={config.syncTimeAbsolute}
+ onChange={(v) => onChange({ syncTimeAbsolute: v })}
+ />
+ <Check
+ label="Stack columns when narrow"
+ checked={config.stackNarrow}
+ onChange={(v) => onChange({ stackNarrow: v })}
+ />
+ </div>
+ <div className="flex flex-wrap gap-4">
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Channel filter (slug, optional)</span>
+ <input
+ type="text"
+ value={config.channel ?? ""}
+ onChange={(e) => onChange({ channel: e.target.value || undefined })}
+ placeholder="all channels"
+ className="rounded border border-border bg-card px-2 py-1"
+ />
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Poll interval (seconds)</span>
+ <input
+ type="number"
+ min={1}
+ value={config.pollSeconds}
+ onChange={(e) =>
+ onChange({
+ pollSeconds: Math.max(1, Math.round(Number(e.target.value) || 1)),
+ })
+ }
+ className="w-24 rounded border border-border bg-card px-2 py-1"
+ />
+ </label>
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts
@@ -2,6 +2,15 @@
// Used by the bare /widget page (parse) and the /widget/builder UI (parse +
// serialize), so a link the builder copies always renders the way it previewed.
+import {
+ defaultColumns,
+ parseLayoutParam,
+ reconcileColumns,
+ serializeLayout,
+ type Columns,
+} from "./placement";
+import type { SectionFlags } from "./sections";
+
export type WidgetConfig = {
// Which sections to render.
jobs: boolean;
@@ -61,9 +70,19 @@ export type WidgetConfig = {
// window.confirm before a full Sync-all sweep. Off by default. (A pinned
// single-channel Sync is never gated.)
syncConfirm: boolean;
+ // Where each enabled section sits: one array per column, in render order.
+ // Derived, not authoritative — the booleans above still decide what is on,
+ // and a layout is normalized against them on parse (see parseLayoutParam), so
+ // the two can never disagree. Defaults to one column in the widget's original
+ // order, which is why every link written before layouts existed is unchanged.
+ columns: Columns;
+ // Let the columns collapse back to a single stack below a container-query
+ // width, for a widget you intend to resize. Off by default: a layout you
+ // arranged is honored at every size unless you ask for this.
+ stackNarrow: boolean;
};
-export const WIDGET_DEFAULTS: WidgetConfig = {
+const WIDGET_FLAG_DEFAULTS: SectionFlags = {
jobs: true,
workers: true,
channel: undefined,
@@ -86,6 +105,15 @@ export const WIDGET_DEFAULTS: WidgetConfig = {
scheduler: false,
syncTimeAbsolute: false,
syncConfirm: false,
+ stackNarrow: false,
+};
+
+// Split in two because the default layout is a function of the default
+// visibility flags — one column holding whatever is on — rather than a literal
+// that could drift away from them.
+export const WIDGET_DEFAULTS: WidgetConfig = {
+ ...WIDGET_FLAG_DEFAULTS,
+ columns: defaultColumns(WIDGET_FLAG_DEFAULTS),
};
// Next's searchParams give each key as string | string[] | undefined.
@@ -114,7 +142,7 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig {
Number.isFinite(pollRaw) && pollRaw >= 1
? Math.min(3600, Math.round(pollRaw))
: WIDGET_DEFAULTS.pollSeconds;
- return {
+ const flags: SectionFlags = {
jobs: parseBool(params.jobs, WIDGET_DEFAULTS.jobs),
workers: parseBool(params.workers, WIDGET_DEFAULTS.workers),
channel: channel || undefined,
@@ -137,7 +165,24 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig {
scheduler: parseBool(params.sched, WIDGET_DEFAULTS.scheduler),
syncTimeAbsolute: parseBool(params.abstime, WIDGET_DEFAULTS.syncTimeAbsolute),
syncConfirm: parseBool(params.syncask, WIDGET_DEFAULTS.syncConfirm),
+ stackNarrow: parseBool(params.stack, WIDGET_DEFAULTS.stackNarrow),
};
+ // Last, because normalizing a layout means knowing which sections are on.
+ return { ...flags, columns: parseLayoutParam(first(params.l), flags) };
+}
+
+// Apply a config change and keep the layout consistent with it: a section
+// switched off leaves the board, one switched on joins the last column. Every
+// edit surface (the builder board, the in-widget gear) goes through this so
+// neither can produce a config whose `l=` disagrees with its booleans. A patch
+// carrying explicit `columns` — a drag, a reorder — is reconciled too, which is
+// a no-op when the caller already placed things correctly.
+export function patchWidgetConfig(
+ config: WidgetConfig,
+ patch: Partial<WidgetConfig>,
+): WidgetConfig {
+ const next = { ...config, ...patch };
+ return { ...next, columns: reconcileColumns(next.columns, next) };
}
// Serialize a config to a query string, omitting anything left at its default so
@@ -184,5 +229,12 @@ export function buildWidgetQuery(config: WidgetConfig): string {
sp.set("abstime", config.syncTimeAbsolute ? "1" : "0");
if (config.syncConfirm !== WIDGET_DEFAULTS.syncConfirm)
sp.set("syncask", config.syncConfirm ? "1" : "0");
+ if (config.stackNarrow !== WIDGET_DEFAULTS.stackNarrow)
+ sp.set("stack", config.stackNarrow ? "1" : "0");
+ // Last, and omitted entirely when the arrangement is the one the visibility
+ // flags already imply — so a config that only toggles sections still produces
+ // the same short link it did before layouts existed.
+ const layout = serializeLayout(config.columns, config);
+ if (layout) sp.set("l", layout);
return sp.toString();
}
diff --git a/editor/app/widget/lib/placement.test.ts b/editor/app/widget/lib/placement.test.ts
@@ -0,0 +1,179 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ defaultColumns,
+ insertAt,
+ moveToColumn,
+ moveWithin,
+ parseLayoutParam,
+ reconcileColumns,
+ removeSection,
+ sameLayout,
+ serializeLayout,
+ setColumnCount,
+ type Columns,
+} from "./placement";
+import { WIDGET_DEFAULTS } from "./config";
+import type { SectionFlags } from "./sections";
+
+// Run from the repo root with:
+// ./node_modules/.bin/tsx --test editor/app/widget/lib/placement.test.ts
+//
+// The point of most of these is the normalization rule: a layout arrives from a
+// URL or from localStorage and can disagree with the visibility flags, which
+// are authoritative. Getting that wrong means a link that renders nothing.
+
+const flags = (over: Partial<SectionFlags> = {}): SectionFlags => ({
+ ...WIDGET_DEFAULTS,
+ ...over,
+});
+
+test("the default layout is one column of the enabled sections, in render order", () => {
+ assert.deepEqual(defaultColumns(flags()), [["disk", "workers", "jobs"]]);
+ assert.deepEqual(defaultColumns(flags({ workers: false })), [["disk", "jobs"]]);
+ // Order is the widget's original render order, not the order flags were set.
+ assert.deepEqual(defaultColumns(flags({ actionable: true, lastSync: true })), [
+ ["lastSync", "disk", "workers", "jobs", "actionable"],
+ ]);
+});
+
+test("no layout param means the default layout", () => {
+ assert.deepEqual(parseLayoutParam(undefined, flags()), defaultColumns(flags()));
+ assert.deepEqual(parseLayoutParam("", flags()), defaultColumns(flags()));
+});
+
+test("a layout param is decoded into columns", () => {
+ assert.deepEqual(parseLayoutParam("wk.jobs-disk", flags()), [
+ ["workers", "jobs"],
+ ["disk"],
+ ]);
+});
+
+test("an unknown code is dropped, not rendered and not fatal", () => {
+ assert.deepEqual(parseLayoutParam("wk.nosuch.jobs-disk", flags()), [
+ ["workers", "jobs"],
+ ["disk"],
+ ]);
+});
+
+test("a duplicated section is placed once, where it appears first", () => {
+ assert.deepEqual(parseLayoutParam("wk.jobs-wk.disk", flags()), [
+ ["workers", "jobs"],
+ ["disk"],
+ ]);
+});
+
+test("a section that is enabled but unlisted joins the last column", () => {
+ // `disk` is on by default and absent from the param: it must still render, or
+ // a link baked before a section was switched on would silently lose it.
+ assert.deepEqual(parseLayoutParam("wk-jobs", flags()), [
+ ["workers"],
+ ["jobs", "disk"],
+ ]);
+});
+
+test("a section named in the layout but switched off is dropped", () => {
+ assert.deepEqual(parseLayoutParam("wk.jobs-disk", flags({ disk: false })), [
+ ["workers", "jobs"],
+ [],
+ ]);
+});
+
+test("a layout that survives nothing falls back to the default", () => {
+ assert.deepEqual(parseLayoutParam("nope.alsonope", flags()), defaultColumns(flags()));
+});
+
+test("more columns than the maximum are merged into the last", () => {
+ assert.deepEqual(parseLayoutParam("wk-jobs-disk-cln", flags({ cleanable: true })), [
+ ["workers"],
+ ["jobs"],
+ ["disk", "cleanable"],
+ ]);
+});
+
+test("serializing a default layout gives the empty string", () => {
+ // This is what keeps an all-default config serializing to a bare /widget.
+ assert.equal(serializeLayout(defaultColumns(flags()), flags()), "");
+ const off = flags({ workers: false });
+ assert.equal(serializeLayout(defaultColumns(off), off), "");
+});
+
+test("serialize round-trips through parse", () => {
+ const columns: Columns = [
+ ["workers", "jobs"],
+ ["disk"],
+ ];
+ const raw = serializeLayout(columns, flags());
+ assert.equal(raw, "wk.jobs-disk");
+ assert.deepEqual(parseLayoutParam(raw, flags()), columns);
+});
+
+test("an empty trailing column round-trips", () => {
+ const columns: Columns = [["workers", "jobs", "disk"], []];
+ const raw = serializeLayout(columns, flags());
+ assert.equal(raw, "wk.jobs.disk-");
+ assert.deepEqual(parseLayoutParam(raw, flags()), columns);
+});
+
+test("reconcile drops what is off and appends what is on", () => {
+ assert.deepEqual(
+ reconcileColumns([["workers", "jobs"], ["disk"]], flags({ disk: false })),
+ [["workers", "jobs"], []],
+ );
+ assert.deepEqual(
+ reconcileColumns([["workers"], ["jobs"]], flags({ actionable: true })),
+ [["workers"], ["jobs", "disk", "actionable"]],
+ );
+});
+
+test("moveWithin swaps with the neighbour and stops at the bounds", () => {
+ const columns: Columns = [["workers", "jobs", "disk"]];
+ assert.deepEqual(moveWithin(columns, 0, 2, -1), [["workers", "disk", "jobs"]]);
+ assert.equal(moveWithin(columns, 0, 0, -1), columns);
+ assert.equal(moveWithin(columns, 0, 2, 1), columns);
+});
+
+test("moveToColumn lands at the same height, or the end of a shorter column", () => {
+ assert.deepEqual(moveToColumn([["workers", "jobs"], ["disk"]], 0, 1, 1), [
+ ["workers"],
+ ["disk", "jobs"],
+ ]);
+ assert.deepEqual(moveToColumn([["workers", "jobs", "disk"], []], 0, 2, 1), [
+ ["workers", "jobs"],
+ ["disk"],
+ ]);
+});
+
+test("insertAt accounts for the removal when moving down within a column", () => {
+ // Dragging `workers` (index 0) to the slot below `jobs` (index 2 as drawn)
+ // must land it after jobs, not before.
+ assert.deepEqual(insertAt([["workers", "jobs", "disk"]], "workers", 0, 2), [
+ ["jobs", "workers", "disk"],
+ ]);
+ assert.deepEqual(insertAt([["workers", "jobs"], ["disk"]], "disk", 0, 0), [
+ ["disk", "workers", "jobs"],
+ [],
+ ]);
+});
+
+test("setColumnCount merges trailing columns rather than dropping them", () => {
+ assert.deepEqual(setColumnCount([["workers"], ["jobs"], ["disk"]], 2), [
+ ["workers"],
+ ["jobs", "disk"],
+ ]);
+ assert.deepEqual(setColumnCount([["workers", "jobs"]], 3), [
+ ["workers", "jobs"],
+ [],
+ [],
+ ]);
+});
+
+test("removeSection and sameLayout", () => {
+ assert.deepEqual(removeSection([["workers", "jobs"], ["disk"]], "jobs"), [
+ ["workers"],
+ ["disk"],
+ ]);
+ assert.ok(sameLayout([["workers"]], [["workers"]]));
+ assert.ok(!sameLayout([["workers"]], [["workers"], []]));
+ assert.ok(!sameLayout([["workers", "jobs"]], [["jobs", "workers"]]));
+});
diff --git a/editor/app/widget/lib/placement.ts b/editor/app/widget/lib/placement.ts
@@ -0,0 +1,194 @@
+// Placement math for the monitor widget: which section sits in which column, in
+// what order. Pure functions over `SectionId[][]` — no React, no DOM — so the
+// floorplan board, the in-widget gear and the URL parser all move sections the
+// same way, and the tricky part (normalizing an inherited layout against the
+// visibility flags) is testable on its own. See ./placement.test.ts.
+//
+// URL shape: `.` between sections, `-` between columns —
+//
+// /widget?l=ctl.disk.wk.jobs-cln.act
+//
+// Both are unreserved characters URLSearchParams leaves literal, unlike `,`
+// which would come back as %2C and make a hand-shared link unreadable.
+
+import {
+ DEFAULT_ORDER,
+ SECTION_BY_ID,
+ enabledSections,
+ sectionByCode,
+ type SectionFlags,
+ type SectionId,
+} from "./sections";
+
+export const COLUMN_SEP = "-";
+export const SECTION_SEP = ".";
+
+// Three columns is already past the point where a 320px widget has anything
+// useful to show; a hand-written `l=` with more is merged down rather than
+// rejected.
+export const MAX_COLUMNS = 3;
+
+export type Columns = SectionId[][];
+
+function clone(columns: Columns): Columns {
+ return columns.map((col) => col.slice());
+}
+
+// One column holding every enabled section in the widget's original render
+// order — the layout a link with no `l=` gets, and the baseline
+// serializeLayout() compares against so an all-default config stays queryless.
+export function defaultColumns(config: SectionFlags): Columns {
+ return [enabledSections(config)];
+}
+
+export function sameLayout(a: Columns, b: Columns): boolean {
+ return (
+ a.length === b.length &&
+ a.every((col, i) => col.length === b[i].length && col.every((id, j) => id === b[i][j]))
+ );
+}
+
+// Bring a layout back in line with the visibility flags: drop sections that are
+// now off, and append ones that are on but unplaced to the LAST column, in
+// DEFAULT_ORDER. The append rule is what keeps a baked link working when a new
+// section is enabled from the in-widget gear (or added to the registry in a
+// later release) — it shows up rather than silently vanishing.
+export function reconcileColumns(columns: Columns, config: SectionFlags): Columns {
+ const on = new Set(enabledSections(config));
+ const seen = new Set<SectionId>();
+ const next: Columns = columns.map((col) =>
+ col.filter((id) => {
+ if (!on.has(id) || seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ }),
+ );
+ if (next.length === 0) next.push([]);
+ const missing = DEFAULT_ORDER.filter((id) => on.has(id) && !seen.has(id));
+ if (missing.length > 0) next[next.length - 1].push(...missing);
+ return next;
+}
+
+// Decode + normalize an `l=` value. Unknown codes and repeats are dropped
+// (first placement wins), disabled sections are dropped, and anything enabled
+// but unlisted is appended by reconcileColumns. A value that survives none of
+// that falls back to the default single column rather than rendering nothing.
+export function parseLayoutParam(
+ raw: string | undefined,
+ config: SectionFlags,
+): Columns {
+ if (!raw) return defaultColumns(config);
+ const seen = new Set<SectionId>();
+ const columns: Columns = [];
+ for (const colRaw of raw.split(COLUMN_SEP)) {
+ const col: SectionId[] = [];
+ for (const code of colRaw.split(SECTION_SEP)) {
+ const def = sectionByCode(code.trim());
+ if (!def || seen.has(def.id)) continue;
+ seen.add(def.id);
+ col.push(def.id);
+ }
+ columns.push(col);
+ }
+ // A hand-written layout with too many columns is merged down, not rejected.
+ const clamped =
+ columns.length > MAX_COLUMNS ? setColumnCount(columns, MAX_COLUMNS) : columns;
+ if (clamped.every((col) => col.length === 0)) return defaultColumns(config);
+ return reconcileColumns(clamped, config);
+}
+
+// "" when the layout is what a link with no `l=` would already produce, so an
+// all-default config still serializes to a bare /widget.
+export function serializeLayout(columns: Columns, config: SectionFlags): string {
+ if (sameLayout(columns, defaultColumns(config))) return "";
+ return columns
+ .map((col) => col.map((id) => SECTION_BY_ID[id].code).join(SECTION_SEP))
+ .join(COLUMN_SEP);
+}
+
+// Swap with the neighbour above/below inside one column. Same idiom as the
+// reorder in SocialLinksField.
+export function moveWithin(
+ columns: Columns,
+ col: number,
+ idx: number,
+ dir: -1 | 1,
+): Columns {
+ const next = clone(columns);
+ const target = next[col];
+ if (!target) return columns;
+ const j = idx + dir;
+ if (j < 0 || j >= target.length) return columns;
+ [target[idx], target[j]] = [target[j], target[idx]];
+ return next;
+}
+
+// Move a section to the adjacent column, landing at the same height where that
+// column is long enough and at the end where it isn't.
+export function moveToColumn(
+ columns: Columns,
+ col: number,
+ idx: number,
+ dir: -1 | 1,
+): Columns {
+ const to = col + dir;
+ if (to < 0 || to >= columns.length) return columns;
+ const next = clone(columns);
+ const [id] = next[col].splice(idx, 1);
+ if (id === undefined) return columns;
+ next[to].splice(Math.min(idx, next[to].length), 0, id);
+ return next;
+}
+
+// Place a section at an explicit slot, pulling it out of wherever it currently
+// sits first. `index` is read against the column as the user sees it, so a move
+// down within one column has to account for the removal shifting things up.
+export function insertAt(
+ columns: Columns,
+ id: SectionId,
+ col: number,
+ index: number,
+): Columns {
+ const next = clone(columns);
+ if (col < 0 || col >= next.length) return columns;
+ let target = index;
+ for (let c = 0; c < next.length; c++) {
+ const at = next[c].indexOf(id);
+ if (at === -1) continue;
+ next[c].splice(at, 1);
+ if (c === col && at < index) target -= 1;
+ break;
+ }
+ next[col].splice(Math.max(0, Math.min(target, next[col].length)), 0, id);
+ return next;
+}
+
+export function removeSection(columns: Columns, id: SectionId): Columns {
+ return columns.map((col) => col.filter((x) => x !== id));
+}
+
+// Grow by appending empty columns; shrink by merging every trailing column into
+// the last one that survives, so nothing is ever silently dropped.
+export function setColumnCount(columns: Columns, n: number): Columns {
+ const count = Math.max(1, Math.min(MAX_COLUMNS, Math.round(n)));
+ if (count === columns.length) return columns;
+ if (count > columns.length) {
+ const next = clone(columns);
+ while (next.length < count) next.push([]);
+ return next;
+ }
+ const next = clone(columns.slice(0, count));
+ for (const col of columns.slice(count)) next[count - 1].push(...col);
+ return next;
+}
+
+export function findSection(
+ columns: Columns,
+ id: SectionId,
+): { col: number; index: number } | null {
+ for (let col = 0; col < columns.length; col++) {
+ const index = columns[col].indexOf(id);
+ if (index !== -1) return { col, index };
+ }
+ return null;
+}
diff --git a/editor/app/widget/lib/sections.ts b/editor/app/widget/lib/sections.ts
@@ -0,0 +1,182 @@
+// The registry of placeable widget sections.
+//
+// One entry per strip the monitor widget can render. This is the single list
+// that decides what exists, what it's called, which config flag turns it on,
+// and which display options belong to it — replacing the nine hardcoded
+// conditionals MonitorWidget used to carry and driving both configuration
+// surfaces (the /widget/builder floorplan board and the in-widget gear).
+//
+// Deliberately React-free so the pure layout math in ./placement.ts (and its
+// tests) can import it without pulling in a renderer. The card miniatures live
+// with the board that draws them, in ../builder/components/SectionMini.tsx.
+
+import type { WidgetConfig } from "./config";
+
+export type SectionId =
+ | "controls"
+ | "lastSync"
+ | "scheduler"
+ | "disk"
+ | "cleanable"
+ | "cleanChannels"
+ | "workers"
+ | "jobs"
+ | "actionable";
+
+// The visibility/display flags a section reads, i.e. the whole config minus the
+// placement it is being placed into. Taking this rather than WidgetConfig lets
+// parseWidgetConfig ask "which sections are on?" while it is still building the
+// config that will hold the answer.
+export type SectionFlags = Omit<WidgetConfig, "columns">;
+
+// Only the boolean flags are togglable as a section option, so a typo like
+// `key: "pollSeconds"` is a compile error rather than a checkbox that writes a
+// number field.
+// `-?` strips optionality first, so the optional `channel` narrows to `never`
+// rather than leaking `undefined` into the union.
+export type BooleanConfigKey = {
+ [K in keyof WidgetConfig]-?: WidgetConfig[K] extends boolean ? K : never;
+}[keyof WidgetConfig];
+
+export type SectionOption = {
+ key: BooleanConfigKey;
+ label: string;
+ // Optional second line, for an option whose effect reaches beyond its section.
+ hint?: string;
+};
+
+export type SectionDef = {
+ id: SectionId;
+ // Short, stable code used in the `l=` layout param. Changing one breaks
+ // already-copied links, so treat these as part of the URL contract.
+ code: string;
+ label: string;
+ // One line of card copy: what the strip is for.
+ hint: string;
+ enabled(config: SectionFlags): boolean;
+ setEnabled(on: boolean): Partial<WidgetConfig>;
+ options: SectionOption[];
+};
+
+// Declared in the widget's original render order, so DEFAULT_ORDER below is the
+// pre-layout behaviour and a link with no `l=` renders exactly as it always did.
+export const SECTIONS: SectionDef[] = [
+ {
+ id: "controls",
+ code: "ctl",
+ label: "Controls & sync",
+ hint: "Pause, drain, retry and the Sync button.",
+ // WidgetControls renders when either capability is on, so the card is on
+ // when either is — and turning the card off has to clear both.
+ enabled: (c) => c.controls || c.sync,
+ setEnabled: (on) => (on ? { controls: true } : { controls: false, sync: false }),
+ options: [
+ {
+ key: "controls",
+ label: "Interactive buttons",
+ hint: "Also adds per-channel actions to the Needs work and Needs cleaning lists.",
+ },
+ { key: "sync", label: "Sync button" },
+ { key: "syncConfirm", label: "Confirm before Sync all" },
+ ],
+ },
+ {
+ id: "lastSync",
+ code: "lsync",
+ label: "Last sync",
+ hint: "How long ago the corpus was last swept.",
+ enabled: (c) => c.lastSync,
+ setEnabled: (on) => ({ lastSync: on }),
+ options: [],
+ },
+ {
+ id: "scheduler",
+ code: "sched",
+ label: "Scheduler",
+ hint: "Auto-sync on/off, next due, last run.",
+ enabled: (c) => c.scheduler,
+ setEnabled: (on) => ({ scheduler: on }),
+ options: [],
+ },
+ {
+ id: "disk",
+ code: "disk",
+ label: "Disk space",
+ hint: "Free space against the floor that blocks downloads.",
+ enabled: (c) => c.disk,
+ setEnabled: (on) => ({ disk: on }),
+ options: [],
+ },
+ {
+ id: "cleanable",
+ code: "cln",
+ label: "Cleanable total",
+ hint: "One number: audio you could reclaim right now.",
+ enabled: (c) => c.cleanable,
+ setEnabled: (on) => ({ cleanable: on }),
+ options: [],
+ },
+ {
+ id: "cleanChannels",
+ code: "clnl",
+ label: "Needs cleaning",
+ hint: "The channels holding that reclaimable audio.",
+ enabled: (c) => c.cleanChannels,
+ setEnabled: (on) => ({ cleanChannels: on }),
+ options: [],
+ },
+ {
+ id: "workers",
+ code: "wk",
+ label: "Workers",
+ hint: "A dot per worker: busy, idle, draining, degraded.",
+ enabled: (c) => c.workers,
+ setEnabled: (on) => ({ workers: on }),
+ options: [{ key: "workerLabels", label: "Show worker names" }],
+ },
+ {
+ id: "jobs",
+ code: "jobs",
+ label: "Active jobs",
+ hint: "Running and queued work, with progress bars.",
+ enabled: (c) => c.jobs,
+ setEnabled: (on) => ({ jobs: on }),
+ options: [
+ { key: "compact", label: "Compact (hide per-task detail)" },
+ { key: "jobBar", label: "Show batch progress bar" },
+ { key: "headingProgress", label: "Show progress in job heading" },
+ { key: "eta", label: "Show time estimates (ETA)" },
+ ],
+ },
+ {
+ id: "actionable",
+ code: "act",
+ label: "Needs work",
+ hint: "Channels with videos left to download or transcribe.",
+ enabled: (c) => c.actionable,
+ setEnabled: (on) => ({ actionable: on }),
+ options: [],
+ },
+];
+
+// The order a widget renders in when no layout is set. Unchanged from the
+// original hardcoded JSX, which is what keeps every already-copied link stable.
+export const DEFAULT_ORDER: SectionId[] = SECTIONS.map((s) => s.id);
+
+export const SECTION_BY_ID: Record<SectionId, SectionDef> = Object.fromEntries(
+ SECTIONS.map((s) => [s.id, s]),
+) as Record<SectionId, SectionDef>;
+
+const BY_CODE: Record<string, SectionDef> = Object.fromEntries(
+ SECTIONS.map((s) => [s.code, s]),
+);
+
+// Unknown codes resolve to undefined so a hand-edited or future-dated link is
+// normalized rather than throwing.
+export function sectionByCode(code: string): SectionDef | undefined {
+ return BY_CODE[code];
+}
+
+export function enabledSections(config: SectionFlags): SectionId[] {
+ return DEFAULT_ORDER.filter((id) => SECTION_BY_ID[id].enabled(config));
+}
diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts
@@ -290,8 +290,9 @@ test("display toggles serialize into the link and preview", async ({ page }) =>
const preview = page.locator('iframe[title="widget preview"]');
await expect(url).toHaveValue(/\/widget$/);
- // Each new display flag is omitted at its default and appears once toggled,
- // in both the copyable link and the live preview src.
+ // Per-section display options now live on the section's own card, folded away
+ // behind its options disclosure — so each group needs expanding first.
+ await page.getByRole("button", { name: "Options for Active jobs" }).click();
await page.getByRole("checkbox", { name: "Show batch progress bar" }).uncheck();
await expect(url).toHaveValue(/[?&]jobbar=0(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]jobbar=0(&|$)/);
@@ -304,32 +305,35 @@ test("display toggles serialize into the link and preview", async ({ page }) =>
await page.getByRole("checkbox", { name: "Show time estimates (ETA)" }).uncheck();
await expect(url).toHaveValue(/[?&]eta=0(&|$)/);
- await page.getByRole("checkbox", { name: "Show disk indicator" }).uncheck();
- await expect(url).toHaveValue(/[?&]disk=0(&|$)/);
-
+ await page.getByRole("button", { name: "Options for Workers" }).click();
await page.getByRole("checkbox", { name: "Show worker names" }).uncheck();
await expect(url).toHaveValue(/[?&]wnames=0(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]wnames=0(&|$)/);
- // New flags: the needs-work list (act) and the settings gear off-switch (gear).
- await page.getByRole("checkbox", { name: "Channels needing work" }).check();
+ // A section is switched off by unchecking its card, which sends it to the
+ // tray — where the same checkbox, now unchecked, brings it back.
+ await page.getByRole("checkbox", { name: "Disk space" }).uncheck();
+ await expect(url).toHaveValue(/[?&]disk=0(&|$)/);
+
+ await page.getByRole("checkbox", { name: "Needs work" }).check();
await expect(url).toHaveValue(/[?&]act=1(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]act=1(&|$)/);
- await page.getByRole("checkbox", { name: "Channels needing cleanup" }).check();
+ await page.getByRole("checkbox", { name: "Needs cleaning" }).check();
await expect(url).toHaveValue(/[?&]cleanlist=1(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]cleanlist=1(&|$)/);
+ // Flags that belong to the widget as a whole stay in the global row.
await page.getByRole("checkbox", { name: "Show settings gear" }).uncheck();
await expect(url).toHaveValue(/[?&]gear=0(&|$)/);
- await page
- .getByRole("checkbox", { name: "Show control buttons" })
- .check();
+ // The controls card owns both interactive capabilities: adding it turns on
+ // the buttons, and the Sync flags are its own options.
+ await page.getByRole("checkbox", { name: "Controls & sync" }).check();
await expect(url).toHaveValue(/[?&]controls=1(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]controls=1(&|$)/);
- // The Sync fieldset flags each serialize under their own key.
+ await page.getByRole("button", { name: "Options for Controls & sync" }).click();
await page.getByRole("checkbox", { name: "Sync button" }).check();
await expect(url).toHaveValue(/[?&]sync=1(&|$)/);
await expect(preview).toHaveAttribute("src", /[?&]sync=1(&|$)/);
@@ -337,12 +341,148 @@ test("display toggles serialize into the link and preview", async ({ page }) =>
await page.getByRole("checkbox", { name: "Confirm before Sync all" }).check();
await expect(url).toHaveValue(/[?&]syncask=1(&|$)/);
- await page.getByRole("checkbox", { name: "Last-sync readout" }).check();
+ await page.getByRole("checkbox", { name: "Last sync" }).check();
await expect(url).toHaveValue(/[?&]lastsync=1(&|$)/);
- await page.getByRole("checkbox", { name: "Scheduler status" }).check();
+ await page.getByRole("checkbox", { name: "Scheduler" }).check();
await expect(url).toHaveValue(/[?&]sched=1(&|$)/);
await page.getByRole("checkbox", { name: "Absolute timestamps" }).check();
await expect(url).toHaveValue(/[?&]abstime=1(&|$)/);
});
+
+test("reordering a section on the board writes a layout into the link", async ({
+ page,
+}) => {
+ await page.goto("/widget/builder");
+
+ const url = page.getByLabel("widget URL");
+ const preview = page.locator('iframe[title="widget preview"]');
+ // The default arrangement is the widget's original render order, so it needs
+ // no layout param at all.
+ await expect(url).toHaveValue(/\/widget$/);
+
+ // Disk, Workers, Active jobs — moving Active jobs above Workers is the whole
+ // point of the board, and it shows up as `l=`.
+ await page.getByRole("button", { name: "Move Active jobs up" }).click();
+ await expect(url).toHaveValue(/[?&]l=disk\.jobs\.wk(&|$)/);
+ await expect(preview).toHaveAttribute("src", /[?&]l=disk\.jobs\.wk(&|$)/);
+
+ // Moving it back restores the default, and the param disappears again rather
+ // than being baked into every link from then on.
+ await page.getByRole("button", { name: "Move Active jobs down" }).click();
+ await expect(url).toHaveValue(/\/widget$/);
+});
+
+test("dragging a card to a new slot rewrites the layout", async ({ page }) => {
+ await page.goto("/widget/builder");
+ const url = page.getByLabel("widget URL");
+ await expect(url).toHaveValue(/\/widget$/);
+
+ const workers = page.locator('[data-section-card="workers"]');
+ const jobs = page.locator('[data-section-card="jobs"]');
+ const column = page.locator('[data-section-card="workers"]').locator("xpath=..");
+ await expect(workers).toBeVisible();
+
+ // Native HTML5 drag isn't driveable with mouse moves, so drive the three
+ // events the board actually listens to. Dropping below the Active jobs card's
+ // midpoint is what puts Workers last.
+ const box = (await jobs.boundingBox())!;
+ const clientY = box.y + box.height * 0.9;
+ const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
+ await workers.dispatchEvent("dragstart", { dataTransfer });
+ await column.dispatchEvent("dragover", { dataTransfer, clientY });
+ await column.dispatchEvent("drop", { dataTransfer, clientY });
+
+ await expect(url).toHaveValue(/[?&]l=disk\.jobs\.wk(&|$)/);
+});
+
+test("a removed section comes back at the end of the board", async ({ page }) => {
+ await page.goto("/widget/builder");
+ const url = page.getByLabel("widget URL");
+
+ // Off → the flag serializes and the layout is still the default one.
+ await page.getByRole("checkbox", { name: "Disk space" }).uncheck();
+ await expect(url).toHaveValue(/\/widget\?disk=0$/);
+
+ // Back on from the tray → it appends rather than returning to its old slot
+ // (no parking memory, deliberately), a layout the default order can't express.
+ await page.getByRole("checkbox", { name: "Disk space" }).check();
+ await expect(url).toHaveValue(/[?&]l=wk\.jobs\.disk(&|$)/);
+ await expect(url).not.toHaveValue(/[?&]disk=0(&|$)/);
+});
+
+test("the column picker splits the board and the link carries it", async ({
+ page,
+}) => {
+ await page.goto("/widget/builder");
+ const url = page.getByLabel("widget URL");
+
+ await page.getByRole("button", { name: "2 columns" }).click();
+ // A second, empty column: everything is still in the first one.
+ await expect(url).toHaveValue(/[?&]l=disk\.wk\.jobs-(&|$)/);
+
+ await page.getByRole("button", { name: "Move Workers right" }).click();
+ await expect(url).toHaveValue(/[?&]l=disk\.jobs-wk(&|$)/);
+
+ // Stacking is opt-in: columns are otherwise honored at every width.
+ await page.getByRole("checkbox", { name: "Stack columns when narrow" }).check();
+ await expect(url).toHaveValue(/[?&]stack=1(&|$)/);
+});
+
+test("a two-column layout renders side by side, and stacks only when asked", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 900, height: 600 });
+ await page.goto("/widget?l=wk-jobs");
+
+ const workers = page.getByRole("region", { name: "Workers" });
+ const jobs = page.getByRole("region", { name: "Active jobs" });
+ await expect(workers).toBeVisible();
+ const wide = {
+ workers: await workers.boundingBox(),
+ jobs: await jobs.boundingBox(),
+ };
+ // Side by side: the second column starts to the right of the first, and the
+ // two share vertical space.
+ expect(wide.jobs!.x).toBeGreaterThan(wide.workers!.x);
+
+ // Narrow, without opting in: the columns are still honored.
+ await page.setViewportSize({ width: 340, height: 600 });
+ await expect(jobs).toBeVisible();
+ expect((await jobs.boundingBox())!.x).toBeGreaterThan(
+ (await workers.boundingBox())!.x,
+ );
+
+ // stack=1 collapses them below the container-query threshold. The @container
+ // is on the widget root, so this measures the widget's own box.
+ await page.goto("/widget?l=wk-jobs&stack=1");
+ await expect(workers).toBeVisible();
+ expect((await jobs.boundingBox())!.x).toBe((await workers.boundingBox())!.x);
+});
+
+test("a link written before layouts existed renders in the original order", async ({
+ page,
+}) => {
+ await page.goto("/widget?clean=1&cleanlist=1&act=1");
+
+ // The cleanable strips arrive on the first poll, not with the SSR'd markup.
+ await expect(page.getByRole("region", { name: "Cleanable data" })).toBeVisible({
+ timeout: 10_000,
+ });
+
+ // No `l=` means the default layout, which is the order the widget hardcoded
+ // before the board existed — cleanable above workers, not below it.
+ const order = await page
+ .locator("section[aria-label]")
+ .evaluateAll((els) => els.map((e) => e.getAttribute("aria-label")));
+ expect(
+ order.filter((l) => l !== "Disk space" && l !== "Controls"),
+ ).toEqual([
+ "Cleanable data",
+ "Needs cleaning",
+ "Workers",
+ "Active jobs",
+ "Needs work",
+ ]);
+});