commit f471d3fccd1c8e4180544adc1662bbccc07c3b8c
parent 9262398967117e1fc8c9fd16c9d291d920f7514c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 10 Aug 2026 22:51:38 -0400
Merge feat/widget-grid-layout: the widget lays out on a real grid, and layouts have names
Rows of cells instead of columns of stacks, so a section can span the width,
four totals can share one rail, and a row can take the leftover height and
scroll under a pinned title. Every pre-rows link still decodes and re-emits
byte-identically. Adds named presets (a preset is a stored link), collapses
the builder board and the in-widget gear into one surface, puts Pause
Backfill in the widget's controls, and fixes two sections that rendered
nothing because they were gated on somebody else's fetch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
26 files changed, 3145 insertions(+), 702 deletions(-)
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -48,6 +48,11 @@ export type Paths = {
// re-launch with one button. Survives restarts, unlike the in-memory job
// registry. See common/jobs/bookmarks.ts.
bookmarksFile: string;
+ // Saved monitor-widget presets: named /widget query strings the builder and
+ // the in-widget menu both load from. A preset stores the LINK, not a parsed
+ // config, so a preset and a shared link are literally the same artifact. See
+ // common/lib/widgetPresets.ts.
+ widgetPresetsFile: string;
// The two CHANGELOG.md files the "cut release" flow reads and rewrites. They
// are TRACKED source files, and cutting a release optionally makes a real git
// commit — so e2e must be able to point them somewhere disposable. Overridable
@@ -176,6 +181,7 @@ export function getPaths(): Paths {
autoQueueStateFile: path.join(transcriptsDir, ".auto-queue", "state.json"),
workerDefaultsFile: path.join(transcriptsDir, ".workers", "defaults.json"),
bookmarksFile: path.join(transcriptsDir, ".bookmarks", "bookmarks.json"),
+ widgetPresetsFile: path.join(transcriptsDir, ".widget", "presets.json"),
lmdbPath: path.join(transcriptsDir, "index.mdb"),
exportDir,
exportPublicDir,
diff --git a/common/lib/widgetPresets.ts b/common/lib/widgetPresets.ts
@@ -0,0 +1,177 @@
+import fs from "node:fs";
+import path from "node:path";
+import type { Paths } from "./paths";
+
+// Persisted monitor-widget presets: named arrangements the /widget/builder board
+// and the in-widget menu both load from, so a layout you liked stops being
+// something you have to keep a URL of.
+//
+// A PRESET STORES THE QUERY STRING, not a parsed config. That is the whole
+// design decision here:
+// - a preset and a shared /widget link are then literally the same artifact,
+// so "save this link" and "save this preset" cannot mean different things;
+// - loading one re-runs parseWidgetConfig, which already normalizes, clamps
+// and drops unknown codes — so a preset written by an older release survives
+// a registry change for free, exactly as an old link does;
+// - nothing in this file has to know what a section or a row IS, so adding one
+// never touches the store.
+//
+// Modeled on common/jobs/bookmarks.ts: a small JSON file outside any in-memory
+// registry, written atomically (tmp + rename), with tolerant reads that coerce a
+// missing/corrupt file to an empty list rather than crashing.
+
+export type WidgetPreset = {
+ id: string;
+ name: string;
+ // The query string buildWidgetQuery produced, WITHOUT a leading "?". Empty
+ // string is legal and means the all-default widget.
+ query: string;
+ createdAt: number;
+};
+
+type PresetsFile = { v: 1; presets: WidgetPreset[] };
+
+// Names are what the operator picks a preset by, so they are trimmed and capped
+// but otherwise left alone (spaces and punctuation included).
+export const MAX_PRESET_NAME = 60;
+
+export function normalizePresetName(name: string): string {
+ return name.trim().slice(0, MAX_PRESET_NAME);
+}
+
+function newPresetId(): string {
+ return `wp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export async function readWidgetPresets(paths: Paths): Promise<WidgetPreset[]> {
+ let raw: unknown;
+ try {
+ raw = JSON.parse(await fs.promises.readFile(paths.widgetPresetsFile, "utf8"));
+ } catch {
+ return [];
+ }
+ if (!raw || typeof raw !== "object") return [];
+ const arr = (raw as Record<string, unknown>).presets;
+ if (!Array.isArray(arr)) return [];
+ const out: WidgetPreset[] = [];
+ for (const item of arr) {
+ if (!item || typeof item !== "object") continue;
+ const p = item as Record<string, unknown>;
+ if (typeof p.id !== "string" || !p.id) continue;
+ if (typeof p.query !== "string") continue;
+ const name = typeof p.name === "string" ? normalizePresetName(p.name) : "";
+ if (!name) continue;
+ out.push({
+ id: p.id,
+ name,
+ // Tolerate a stored value that kept its "?" — it is still a link.
+ query: p.query.replace(/^\?/, ""),
+ createdAt: typeof p.createdAt === "number" ? p.createdAt : 0,
+ });
+ }
+ return out;
+}
+
+async function writePresets(
+ paths: Paths,
+ presets: WidgetPreset[],
+): Promise<void> {
+ const out: PresetsFile = { v: 1, presets };
+ await fs.promises.mkdir(path.dirname(paths.widgetPresetsFile), {
+ recursive: true,
+ });
+ const tmp = `${paths.widgetPresetsFile}.tmp-${process.pid}`;
+ await fs.promises.writeFile(tmp, JSON.stringify(out, null, 2) + "\n");
+ await fs.promises.rename(tmp, paths.widgetPresetsFile);
+}
+
+// Save a preset under `name`. Saving over an existing name OVERWRITES its query
+// rather than making a second entry with the same label — "Save" and "Save
+// as… (an existing name)" then mean the same thing, which is what an operator
+// expects of a named slot.
+export async function addWidgetPreset(
+ paths: Paths,
+ name: string,
+ query: string,
+): Promise<WidgetPreset | null> {
+ const clean = normalizePresetName(name);
+ if (!clean) return null;
+ const existing = await readWidgetPresets(paths);
+ const q = query.replace(/^\?/, "");
+ const at = existing.findIndex((p) => p.name === clean);
+ if (at !== -1) {
+ const next = existing.slice();
+ next[at] = { ...next[at], query: q };
+ await writePresets(paths, next);
+ return next[at];
+ }
+ const preset: WidgetPreset = {
+ id: newPresetId(),
+ name: clean,
+ query: q,
+ createdAt: Date.now(),
+ };
+ await writePresets(paths, [...existing, preset]);
+ return preset;
+}
+
+export async function renameWidgetPreset(
+ paths: Paths,
+ id: string,
+ name: string,
+): Promise<boolean> {
+ const clean = normalizePresetName(name);
+ if (!clean) return false;
+ const existing = await readWidgetPresets(paths);
+ let changed = false;
+ const next = existing.map((p) => {
+ if (p.id !== id) return p;
+ changed = true;
+ return { ...p, name: clean };
+ });
+ if (changed) await writePresets(paths, next);
+ return changed;
+}
+
+export async function removeWidgetPreset(
+ paths: Paths,
+ id: string,
+): Promise<void> {
+ const existing = await readWidgetPresets(paths);
+ const next = existing.filter((p) => p.id !== id);
+ if (next.length !== existing.length) await writePresets(paths, next);
+}
+
+// Move a preset one slot up (dir -1) or down (dir +1) by swapping it with its
+// neighbour. Order is the array order — the order both the builder row and the
+// in-widget menu render — so this is the only place reordering lives. No-ops at
+// the bounds or for an unknown id.
+export async function moveWidgetPreset(
+ paths: Paths,
+ id: string,
+ dir: -1 | 1,
+): Promise<void> {
+ const existing = await readWidgetPresets(paths);
+ const i = existing.findIndex((p) => p.id === id);
+ const j = i + dir;
+ if (i < 0 || j < 0 || j >= existing.length) return;
+ const next = existing.slice();
+ [next[i], next[j]] = [next[j], next[i]];
+ await writePresets(paths, next);
+}
+
+// Resolve a `?preset=` value: by id first, then by name (case-insensitively), so
+// a hand-written link can say what it means. Returns null for an unknown one,
+// which the caller renders as "just the rest of the params".
+export function findWidgetPreset(
+ presets: WidgetPreset[],
+ ref: string,
+): WidgetPreset | null {
+ const needle = ref.trim();
+ if (!needle) return null;
+ return (
+ presets.find((p) => p.id === needle) ??
+ presets.find((p) => p.name.toLowerCase() === needle.toLowerCase()) ??
+ null
+ );
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
+- **The monitor widget lays out on a real grid now: rows, full-width sections, and a rail of totals.** The board could only ever split the widget into columns of stacked strips, so three things people kept asking for were impossible: a Controls row across the whole top, four one-line totals reading as one instrument cluster instead of four separate boxes, and a section that takes the leftover height. The floorplan now has rows as well as columns — drag the edge of a cell to make it span two tracks, and flip a cell to a **rail** to put its strips on one line with shortened labels (`synced 3m`, `412 GB free`, `1.2k backfill`). Every widget link written before this keeps working and still copies out in exactly the same short form; only an arrangement that genuinely needs rows writes the newer parameter.
+- **A row can now take the leftover height, and the sections in it scroll under their own headings.** The rail down the left of the board sets each row to *content height* (what every row was before) or *fill*. A widget with a fill row becomes exactly as tall as the window or iframe it is in, and the sections sharing that row each get half of it and scroll inside — with the section title pinned at the top so you always know what you are looking at. Those lists also drop the six-row cap while they are scrollable, because a list you can scroll that still says "+14 more channels" is not much of a list.
+- **Widget arrangements can be saved and named.** There was no way to keep one except saving the URL. Presets now sit above the board on the builder page and inside the widget's own settings, with **Save / Save as… / Rename / Delete**, and five ready-made ones: **Glance** (one dense rail for a 320×120 corner pin), **Jobs** (the default), **Cockpit** (controls, totals, then the scheduler beside two scrolling worklists), **Cleanup** (a full-height needs-cleaning list), and **Wall** (three columns for a spare monitor). A preset is just a widget link under a name, so `/widget?preset=cockpit` works too, and anything you spell out in the URL still wins over the preset.
+- **The widget's settings gear now opens the same board the builder page has.** It used to open a second, narrower list form that could do less — you could reorder strips but not really arrange them. There is one configuration surface now, folding to fit the ~320px overlay, so a preset behaves identically wherever you open it.
+- **The widget can hold the backfill lane.** Its controls row had Pause transcriptions, Pause downloads, Drain and Retry, but nothing for a backfill — the one job that runs for days and pins cores on a desktop somebody is sitting at. **Pause Backfill** is now there beside the other two, holding the lane without ending anything (a running job idles and keeps its place; nothing is re-derived on resume), and it writes the same setting the Settings page and the dashboard do. It appears only when a backfill feature is actually switched on.
+- **Fixed: `/widget?backfill=1` rendered an empty widget.** The backfill strip reads the same data as the last-sync and scheduler readouts, but the widget only fetched that data when one of *those two* was switched on — so asking for just the backfill strip produced nothing at all, with no hint that the section was fine and the fetch was the problem.
+- **Fixed: a widget with Active jobs switched off lost its disk indicator too.** Free-disk numbers arrive with the active-jobs data, and that fetch was gated on the Active jobs section alone, so `jobs=0` silently took the disk strip with it however clearly you had asked for it.
- **The widget's "+N more channels" is now a control instead of dead text.** Both the monitor widget's Needs-work and Needs-cleaning lists show six channels and then said how many more there were, with no way to see them — even though the widget already had every channel in hand. That line is now a button: it expands the full list inside its own scrollable box, so a pinned window shows the whole worklist without stretching to the height of the corpus, and **Show fewer** puts it back.
- **The digest backlog that could never start now has a button that clears it, and honest copy about why.** Nearly 2,000 videos across the corpus have a transcript but no compact transcript file beside it, and the digest generator refuses those — so they sat in a "waiting" count on the Digest card under text that said the problem would resolve on its own. It does not. Nothing writes that file automatically for a channel whose captions are *downloaded* rather than transcribed here, which is why one channel alone accounts for 1,683 of them and was showing about 6% digest coverage. The card now says what is actually wrong, says that nothing will fix it unattended, and offers **Normalize transcripts** right there to fix it for that channel — instead of the corpus-wide button on the Build page that walks all 79,000 video folders. The button appears only when there is something for it to do.
- **Skipped-video explanations on the Backfill card now match the reason each one was skipped.** The card had one hardcoded sentence about the speaker-capture length limit and showed it for every kind of skipped video, including ones skipped for completely unrelated reasons. Each kind of work now supplies its own explanation and gets its own line.
diff --git a/editor/app/api/widget/presets/route.ts b/editor/app/api/widget/presets/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ readWidgetPresets,
+ type WidgetPreset,
+} from "yt-dlp-transcript-common/lib/widgetPresets";
+import {
+ BUILT_IN_PRESETS,
+ type BuiltInPreset,
+} from "../../../widget/lib/builtInPresets";
+
+export const dynamic = "force-dynamic";
+
+// The preset list, for the IN-WIDGET menu. The builder page SSR-seeds from
+// readWidgetPresets directly; the widget is a bare client page with no server
+// parent to seed it, so it fetches this on open and after each mutation.
+//
+// Built-ins ride along rather than being duplicated client-side, so the two
+// surfaces can never show different starting points.
+export type WidgetPresetsPayload = {
+ builtIn: BuiltInPreset[];
+ saved: WidgetPreset[];
+};
+
+export async function GET() {
+ const saved = await readWidgetPresets(getPaths());
+ return NextResponse.json({
+ builtIn: BUILT_IN_PRESETS,
+ saved,
+ } satisfies WidgetPresetsPayload);
+}
diff --git a/editor/app/jobs/components/BackfillSweepControls.tsx b/editor/app/jobs/components/BackfillSweepControls.tsx
@@ -3,11 +3,10 @@
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
- pauseBackfillAction,
- resumeBackfillAction,
startBackfillSweepAction,
stopBackfillSweepAction,
} from "../actions";
+import { PauseBackfillButton } from "./PauseBackfillButton";
// Arm / disarm the corpus-wide backfill sweep, and hold it without ending it.
//
@@ -27,13 +26,12 @@ import {
//
// So the pause is surfaced here, and it writes THE SAME FIELD the Settings
// checkbox writes (settings.backfill.enabled) rather than a new `backfillPaused`
-// flag. One field, two places to set it, and they cannot drift. See
+// flag. One field, several places to set it, and they cannot drift. See
// pauseBackfillAction in ../actions for why that is the right field.
//
-// It is a REAL pause, not a stop: backfillBatch's limit() re-reads the flag at
-// dispatch and returns 0, so the pool idle-waits, the job stays alive, and
-// resuming re-derives nothing. It also survives a restart, because it is
-// persisted rather than applied to a live pool.
+// The pause button itself now lives in ./PauseBackfillButton — the monitor
+// widget's controls row wants it too, and one implementation is what keeps the
+// two surfaces honest. Everything below is the SWEEP.
//
// Stopping the SWEEP, by contrast, drains rather than cancels: the video in
// flight finishes instead of being thrown away, and the stop reaches the
@@ -98,26 +96,7 @@ export function BackfillSweepControls({
>
{sweeping ? "Stop Backfill Sweep" : "Start Backfill Sweep"}
</button>
- <button
- type="button"
- disabled={disabled}
- aria-label={laneEnabled ? "pause backfill" : "resume backfill"}
- onClick={() =>
- run(laneEnabled ? pauseBackfillAction : resumeBackfillAction)
- }
- title={
- laneEnabled
- ? "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart."
- : "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped."
- }
- className={
- laneEnabled
- ? "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
- : "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
- }
- >
- {laneEnabled ? "Pause Backfill" : "Resume Backfill"}
- </button>
+ <PauseBackfillButton paused={!laneEnabled} onChange={onChange} />
{sweeping && !laneEnabled && (
<span
aria-label="backfill lane off"
diff --git a/editor/app/jobs/components/PauseBackfillButton.tsx b/editor/app/jobs/components/PauseBackfillButton.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useEffect, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { pauseBackfillAction, resumeBackfillAction } from "../actions";
+
+// Hold the backfill lane without ending anything, in the prop shape the other
+// two pause buttons use ({ paused, disabled?, onChange? }) so all three can sit
+// side by side wherever work is being watched — the dashboard cockpit, and now
+// the monitor widget's controls row.
+//
+// Extracted from BackfillSweepControls, which still renders it: one
+// implementation, so the dashboard and the widget cannot drift.
+//
+// It is a REAL pause, not a stop. backfillBatch's limit() re-reads
+// settings.backfill.enabled at dispatch (~3s idle poll) and returns 0, so the
+// pool idle-waits, the job stays alive and keeps its place, and resuming
+// re-derives nothing. It writes THE SAME FIELD the Settings checkbox writes
+// rather than a second `backfillPaused` flag, so the two cannot disagree — which
+// is why backfill.spec asserts the settings field through this button's label
+// rather than just the label flipping.
+export function PauseBackfillButton({
+ paused,
+ disabled,
+ onChange,
+}: {
+ // settings.backfill.enabled INVERTED: `paused` is the lane held.
+ paused: boolean;
+ disabled?: boolean;
+ onChange?: () => void | Promise<void>;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ const router = useRouter();
+ // Disabled until hydrated. A click on a server-rendered button before React
+ // attaches fires NOTHING — no request, no job, no error — which is the
+ // recorded root cause of the digest pilot's "un-created job".
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+
+ function toggle() {
+ setError(null);
+ startTransition(async () => {
+ const result = await (paused ? resumeBackfillAction() : pauseBackfillAction());
+ if (!result.ok) setError(result.error ?? "Failed.");
+ if (onChange) await onChange();
+ else router.refresh();
+ });
+ }
+
+ const busy = pending || !mounted;
+ return (
+ <>
+ <button
+ type="button"
+ disabled={busy || (!paused && disabled)}
+ aria-label={paused ? "resume backfill" : "pause backfill"}
+ onClick={toggle}
+ title={
+ paused
+ ? "Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped."
+ : "Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart."
+ }
+ className={
+ paused
+ ? "px-3 py-1.5 rounded-md bg-warning text-warning-foreground text-sm font-medium hover:bg-warning/90 disabled:opacity-50"
+ : "px-3 py-1.5 rounded-md border border-border text-sm hover:bg-muted disabled:opacity-50"
+ }
+ >
+ {paused ? "Resume Backfill" : "Pause Backfill"}
+ </button>
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}
+ </>
+ );
+}
diff --git a/editor/app/widget/builder/components/LayoutBoard.tsx b/editor/app/widget/builder/components/LayoutBoard.tsx
@@ -4,27 +4,69 @@ import { Fragment, useState } from "react";
import type { WidgetConfig } from "../../lib/config";
import {
MAX_COLUMNS,
+ MAX_ROWS,
+ addCell,
insertAt,
- moveToColumn,
+ moveToCell,
+ moveToRow,
moveWithin,
- reconcileColumns,
+ reconcileLayout,
+ removeCell,
+ setCellFlow,
+ setCellSpan,
setColumnCount,
+ setRowCount,
+ setRowSize,
} 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.
+// The floorplan: a scale model of the widget, laid out in the same grid it will
+// render in — the same tracks, the same rows, the same spans. Drag a card to
+// move it; the tray underneath holds the sections that are switched off.
+//
+// The board's one bold element is the LEFT RAIL, one control per row drawn as
+// its height: a hairline box for `auto`, a tall box with a spring mark for
+// `fill`. That is where the dimension the columns model never had actually
+// lives, so it is the one thing here allowed to look like something. Everything
+// else stays hairlines on `surface` and `card`.
//
// 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.
+// Below this a track is too narrow for the strips inside it to read.
const NARROW_PX = 150;
+// Literal classes, because Tailwind reads the source. The `@max-[24rem]`
+// overrides are on the BOARD's own container query, not the viewport's, so the
+// board folds identically inside the widget's ~320px menu overlay and on the
+// builder page.
+const BOARD_COLS: Record<number, string> = {
+ 1: "grid-cols-1",
+ 2: "grid-cols-2",
+ 3: "grid-cols-3",
+ 4: "grid-cols-4",
+ 5: "grid-cols-5",
+ 6: "grid-cols-6",
+};
+
+const BOARD_SPAN: Record<number, string> = {
+ 1: "col-span-1",
+ 2: "col-span-2",
+ 3: "col-span-3",
+ 4: "col-span-4",
+ 5: "col-span-5",
+ 6: "col-span-6",
+};
+
+const STACKED = "@max-[24rem]:grid-cols-1";
+const STACKED_CELL = "@max-[24rem]:col-span-1";
+
+const CHIP =
+ "flex h-6 min-w-6 items-center justify-center rounded border border-border px-1 text-xs font-medium text-muted-foreground hover:bg-muted disabled:opacity-40";
+
export function LayoutBoard({
config,
onChange,
@@ -32,20 +74,29 @@ export function LayoutBoard({
}: {
config: WidgetConfig;
onChange: (patch: Partial<WidgetConfig>) => void;
- previewWidth: number;
+ // The width the widget will actually be. Absent inside the widget's own menu,
+ // where the px readouts are noise in a 320px overlay.
+ previewWidth?: number;
}) {
const [draggingId, setDraggingId] = useState<SectionId | null>(null);
- const [drop, setDrop] = useState<{ col: number; index: number } | null>(null);
+ const [drop, setDrop] = useState<{
+ row: number;
+ cell: number;
+ index: number;
+ } | null>(null);
const [expanded, setExpanded] = useState<SectionId[]>([]);
- const columns = config.columns;
+ const layout = config.layout;
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,
- );
+ // The widget's padding (p-2 either side) and the gaps between tracks come out
+ // of the width before it is split, so this is the real number.
+ const trackPx =
+ previewWidth === undefined
+ ? null
+ : Math.round(
+ (previewWidth - 16 - (layout.cols - 1) * 12) / layout.cols,
+ );
function toggleExpanded(id: SectionId) {
setExpanded((e) => (e.includes(id) ? e.filter((x) => x !== id) : [...e, id]));
@@ -59,19 +110,19 @@ export function LayoutBoard({
// 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) {
+ function place(id: SectionId, row: number, cell: 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) });
+ const base = reconcileLayout(layout, nextFlags);
+ onChange({ ...flagPatch, layout: insertAt(base, id, row, cell, 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 {
+ // midpoint is below the cursor, or the end of the cell.
+ function indexFromPointer(cellEl: HTMLElement, clientY: number): number {
const cards = Array.from(
- colEl.querySelectorAll<HTMLElement>("[data-section-card]"),
+ cellEl.querySelectorAll<HTMLElement>("[data-section-card]"),
);
for (let i = 0; i < cards.length; i++) {
const r = cards[i].getBoundingClientRect();
@@ -80,8 +131,11 @@ export function LayoutBoard({
return cards.length;
}
+ const lastRow = layout.rows.length - 1;
+ const lastCellOfLastRow = layout.rows[lastRow].cells.length - 1;
+
return (
- <div className="flex flex-col gap-3">
+ <div className="@container 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>
@@ -91,10 +145,10 @@ export function LayoutBoard({
key={n}
type="button"
aria-label={`${n} column${n === 1 ? "" : "s"}`}
- aria-pressed={columns.length === n}
- onClick={() => onChange({ columns: setColumnCount(columns, n) })}
+ aria-pressed={layout.cols === n}
+ onClick={() => onChange({ layout: setColumnCount(layout, n) })}
className={`h-7 w-7 rounded border text-xs font-medium ${
- columns.length === n
+ layout.cols === n
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-muted"
}`}
@@ -105,88 +159,283 @@ export function LayoutBoard({
</div>
</div>
<p className="text-xs text-muted-foreground">
- Drag a section to move it, or use the arrows on each card.
+ Drag a section to move it, or use the arrows on each card. The rail on
+ the left sets each row's height.
</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"
- }
+ {/* Top ruler: one entry per grid track, carrying the width it gets. */}
+ {trackPx !== null && (
+ <div className="mb-1.5 flex gap-1.5">
+ <div aria-hidden className="w-9 shrink-0" />
+ <div className={`grid flex-1 gap-1.5 ${BOARD_COLS[layout.cols]} ${STACKED}`}>
+ {Array.from({ length: layout.cols }, (_, i) => (
+ <div
+ key={i}
+ className="flex items-baseline justify-between gap-2 px-0.5"
>
- {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"
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+ {layout.cols === 1 ? "Width" : `Col ${i + 1}`}
+ </span>
+ <span
+ className={`text-xs tabular-nums ${
+ trackPx < NARROW_PX
+ ? "text-warning"
+ : "text-muted-foreground/70"
+ }`}
+ title={
+ trackPx < NARROW_PX
+ ? "Too narrow for these strips to read at the current preview size"
+ : "Width this track gets at the current preview size"
+ }
+ >
+ {trackPx}px
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="flex flex-col gap-1.5">
+ {layout.rows.map((row, ri) => (
+ <div key={ri} className="flex gap-1.5">
+ <RowHeightButton
+ index={ri}
+ size={row.size}
+ onToggle={() =>
+ onChange({
+ layout: setRowSize(
+ layout,
+ ri,
+ row.size === "fill" ? "auto" : "fill",
+ ),
+ })
+ }
+ />
+ <div
+ className={`grid flex-1 gap-1.5 ${BOARD_COLS[layout.cols]} ${STACKED}`}
>
- {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>
+ {row.cells.map((cell, ci) => (
+ <div
+ key={ci}
+ className={`flex min-w-0 flex-col gap-1 rounded-md border p-1 ${
+ BOARD_SPAN[cell.span] ?? "col-span-1"
+ } ${STACKED_CELL} ${
+ cell.sections.length === 0
+ ? "border-dashed border-border"
+ : "border-border/60"
+ }`}
+ >
+ <div className="flex items-center gap-1">
+ {layout.cols > 1 && (
+ <>
+ <button
+ type="button"
+ className={CHIP}
+ disabled={cell.span <= 1}
+ aria-label={`Narrow row ${ri + 1} cell ${ci + 1}`}
+ title="Narrow this cell by one track"
+ onClick={() =>
+ onChange({
+ layout: setCellSpan(layout, ri, ci, cell.span - 1),
+ })
+ }
+ >
+ ◀
+ </button>
+ <button
+ type="button"
+ className={CHIP}
+ disabled={cell.span >= layout.cols}
+ aria-label={`Widen row ${ri + 1} cell ${ci + 1}`}
+ title="Widen this cell by one track — it swallows the cell beside it"
+ onClick={() =>
+ onChange({
+ layout: setCellSpan(layout, ri, ci, cell.span + 1),
+ })
+ }
+ >
+ ▶
+ </button>
+ </>
+ )}
+ <button
+ type="button"
+ aria-label={`Row ${ri + 1} cell ${ci + 1} flow`}
+ aria-pressed={cell.flow === "row"}
+ title={
+ cell.flow === "row"
+ ? "A dense rail: one line, shared border, shortened labels"
+ : "A stack: each section on its own"
+ }
+ onClick={() =>
+ onChange({
+ layout: setCellFlow(
+ layout,
+ ri,
+ ci,
+ cell.flow === "row" ? "col" : "row",
+ ),
+ })
+ }
+ className={`${CHIP} ${
+ cell.flow === "row"
+ ? "border-border-strong bg-muted text-foreground"
+ : ""
+ }`}
+ >
+ {cell.flow === "row" ? "⇄" : "⇅"}
+ </button>
+ <span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
+ {cell.span}/{layout.cols}
+ </span>
+ {row.cells.length > 1 && (
+ <button
+ type="button"
+ className={CHIP}
+ aria-label={`Remove cell ${ci + 1} from row ${ri + 1}`}
+ title="Remove this cell; anything in it moves next door"
+ onClick={() =>
+ onChange({ layout: removeCell(layout, ri, ci) })
+ }
+ >
+ ✕
+ </button>
+ )}
+ </div>
+ <ul
+ onDragOver={(e) => {
+ if (!draggingId) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ const index = indexFromPointer(e.currentTarget, e.clientY);
+ setDrop((d) =>
+ d && d.row === ri && d.cell === ci && d.index === index
+ ? d
+ : { row: ri, cell: ci, index },
+ );
+ }}
+ onDrop={(e) => {
+ if (!draggingId) return;
+ e.preventDefault();
+ place(
+ draggingId,
+ ri,
+ ci,
+ indexFromPointer(e.currentTarget, e.clientY),
+ );
+ clearDrag();
+ }}
+ className="flex min-h-16 flex-col gap-1.5 rounded p-1"
+ >
+ {cell.sections.map((id, index) => (
+ <Fragment key={id}>
+ <DropLine
+ active={
+ drop?.row === ri &&
+ drop.cell === ci &&
+ drop.index === index
+ }
+ />
+ <SectionCard
+ def={SECTION_BY_ID[id]}
+ config={config}
+ row={ri}
+ cell={ci}
+ index={index}
+ cellLength={cell.sections.length}
+ cellCount={row.cells.length}
+ rowCount={layout.rows.length}
+ dragging={draggingId === id}
+ onChange={onChange}
+ onMove={(dir) =>
+ onChange({
+ layout: moveWithin(layout, ri, ci, index, dir),
+ })
+ }
+ onMoveCell={(dir) =>
+ onChange({
+ layout: moveToCell(layout, ri, ci, index, dir),
+ })
+ }
+ onMoveRow={(dir) =>
+ onChange({
+ layout: moveToRow(layout, ri, ci, index, dir),
+ })
+ }
+ onDragStart={() => setDraggingId(id)}
+ onDragEnd={clearDrag}
+ expanded={expanded.includes(id)}
+ onToggleExpanded={() => toggleExpanded(id)}
+ />
+ </Fragment>
+ ))}
+ <DropLine
+ active={
+ drop?.row === ri &&
+ drop.cell === ci &&
+ drop.index === cell.sections.length
+ }
+ />
+ {cell.sections.length === 0 && (
+ <li className="px-1 py-3 text-center text-xs text-muted-foreground/70">
+ Empty — drop a section here
+ </li>
+ )}
+ </ul>
+ </div>
))}
- <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>
+
+ {/* The rail's foot: the row and cell lifecycle, kept together so adding
+ a place to put something is one gesture away from the rail that
+ sizes it. */}
+ <div className="mt-2 flex flex-wrap items-center gap-1.5">
+ <button
+ type="button"
+ className={CHIP}
+ aria-label="Add a row"
+ title="Add a row below"
+ disabled={layout.rows.length >= MAX_ROWS}
+ onClick={() =>
+ onChange({ layout: setRowCount(layout, layout.rows.length + 1) })
+ }
+ >
+ + Row
+ </button>
+ <button
+ type="button"
+ className={CHIP}
+ aria-label="Remove the last row"
+ title="Remove the last row; anything in it moves up"
+ disabled={layout.rows.length <= 1}
+ onClick={() =>
+ onChange({ layout: setRowCount(layout, layout.rows.length - 1) })
+ }
+ >
+ − Row
+ </button>
+ <button
+ type="button"
+ className={CHIP}
+ aria-label={`Add a cell to row ${lastRow + 1}`}
+ title="Split the last row into another cell"
+ disabled={
+ layout.rows[lastRow].cells.length >= layout.cols ||
+ lastCellOfLastRow < 0
+ }
+ onClick={() => onChange({ layout: addCell(layout, lastRow) })}
+ >
+ + Cell
+ </button>
+ </div>
</div>
<div
@@ -232,7 +481,12 @@ export function LayoutBoard({
type="checkbox"
checked={false}
onChange={() =>
- place(s.id, columns.length - 1, columns[columns.length - 1].length)
+ place(
+ s.id,
+ lastRow,
+ lastCellOfLastRow,
+ layout.rows[lastRow].cells[lastCellOfLastRow].sections.length,
+ )
}
className="h-3.5 w-3.5"
/>
@@ -251,6 +505,49 @@ export function LayoutBoard({
);
}
+// The signature control: a row's height, drawn as its height. `auto` is a short
+// hairline box; `fill` is a tall one carrying a vertical spring mark, which is
+// the only place on the board that spends any ink.
+function RowHeightButton({
+ index,
+ size,
+ onToggle,
+}: {
+ index: number;
+ size: "auto" | "fill";
+ onToggle: () => void;
+}) {
+ const fill = size === "fill";
+ return (
+ <button
+ type="button"
+ aria-label={`Row ${index + 1} height`}
+ aria-pressed={fill}
+ onClick={onToggle}
+ title={
+ fill
+ ? "Fills the widget's leftover height; sections inside it scroll. Click for content height."
+ : "Content height, like every row before rows existed. Click to make it fill."
+ }
+ className={`flex w-9 shrink-0 flex-col items-center justify-center gap-1 self-stretch rounded-md border text-[10px] uppercase tracking-wide motion-safe:transition-colors ${
+ fill
+ ? "border-primary bg-primary/10 text-primary"
+ : "border-border text-muted-foreground/70 hover:bg-muted"
+ }`}
+ >
+ <span
+ aria-hidden
+ className={`block w-4 rounded-sm border ${
+ fill
+ ? "h-8 border-primary bg-[repeating-linear-gradient(180deg,transparent_0_3px,currentColor_3px_4px)]"
+ : "h-2 border-current"
+ }`}
+ />
+ {fill ? "fill" : "auto"}
+ </button>
+ );
+}
+
// 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 }) {
diff --git a/editor/app/widget/builder/components/SectionCard.tsx b/editor/app/widget/builder/components/SectionCard.tsx
@@ -19,14 +19,17 @@ const BTN =
export function SectionCard({
def,
config,
- col,
+ row,
+ cell,
index,
- colLength,
- columnCount,
+ cellLength,
+ cellCount,
+ rowCount,
dragging,
onChange,
onMove,
- onMoveColumn,
+ onMoveCell,
+ onMoveRow,
onDragStart,
onDragEnd,
expanded,
@@ -34,14 +37,17 @@ export function SectionCard({
}: {
def: SectionDef;
config: WidgetConfig;
- col: number;
+ row: number;
+ cell: number;
index: number;
- colLength: number;
- columnCount: number;
+ cellLength: number;
+ cellCount: number;
+ rowCount: number;
dragging: boolean;
onChange: (patch: Partial<WidgetConfig>) => void;
onMove: (dir: -1 | 1) => void;
- onMoveColumn: (dir: -1 | 1) => void;
+ onMoveCell: (dir: -1 | 1) => void;
+ onMoveRow: (dir: -1 | 1) => void;
onDragStart: () => void;
onDragEnd: () => void;
expanded: boolean;
@@ -63,8 +69,8 @@ export function SectionCard({
: "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
+ {/* Wraps rather than truncates: in a multi-track board there isn't room
+ for the name and the move 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">
@@ -104,37 +110,64 @@ export function SectionCard({
<button
type="button"
className={BTN}
- disabled={index === colLength - 1}
+ disabled={index === cellLength - 1}
onClick={() => onMove(1)}
aria-label={`Move ${def.label} down`}
title="Move down"
>
↓
</button>
- {columnCount > 1 && (
+ {cellCount > 1 && (
<>
<button
type="button"
className={BTN}
- disabled={col === 0}
- onClick={() => onMoveColumn(-1)}
+ disabled={cell === 0}
+ onClick={() => onMoveCell(-1)}
aria-label={`Move ${def.label} left`}
- title="Move to the previous column"
+ title="Move to the previous cell in this row"
>
◀
</button>
<button
type="button"
className={BTN}
- disabled={col === columnCount - 1}
- onClick={() => onMoveColumn(1)}
+ disabled={cell === cellCount - 1}
+ onClick={() => onMoveCell(1)}
aria-label={`Move ${def.label} right`}
- title="Move to the next column"
+ title="Move to the next cell in this row"
>
▶
</button>
</>
)}
+ {/* Rows are the new dimension, so they need their own pair — every
+ drag has to stay reachable from the keyboard, which is what the
+ arrows on this card have always been for. */}
+ {rowCount > 1 && (
+ <>
+ <button
+ type="button"
+ className={BTN}
+ disabled={row === 0}
+ onClick={() => onMoveRow(-1)}
+ aria-label={`Move ${def.label} to the row above`}
+ title="Move to the row above"
+ >
+ ▲
+ </button>
+ <button
+ type="button"
+ className={BTN}
+ disabled={row === rowCount - 1}
+ onClick={() => onMoveRow(1)}
+ aria-label={`Move ${def.label} to the row below`}
+ title="Move to the row below"
+ >
+ ▼
+ </button>
+ </>
+ )}
{def.options.length > 0 && (
<button
type="button"
diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx
@@ -7,18 +7,33 @@ import {
WIDGET_DEFAULTS,
type WidgetConfig,
} from "../../lib/config";
-import { GlobalOptions } from "../../components/WidgetFields";
+import { WidgetMenu } from "../../components/WidgetMenu";
+import type { PresetsPayload } from "../../components/PresetsRow";
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 }[] = [
+// the original three are all narrower than two readable columns. `Viewport` is
+// the one that isn't a pinned-window size: a fill row only shows what it does
+// when the frame has a height to leave over, so arranging one at 200px tall
+// would be arranging it blind.
+const SIZE_PRESETS: {
+ label: string;
+ width: number;
+ height: number;
+ // CSS override for the iframe box, for a size that isn't a fixed pixel pair.
+ css?: { width: string; height: string };
+}[] = [
{ 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 },
+ {
+ label: "Viewport",
+ width: 720,
+ height: 640,
+ css: { width: "100%", height: "80vh" },
+ },
];
// How long the preview waits before re-navigating. Changing an iframe's src is a
@@ -26,7 +41,11 @@ const SIZE_PRESETS: { label: string; width: number; height: number }[] = [
// field updates instantly and only the preview is debounced.
const PREVIEW_DEBOUNCE_MS = 250;
-export function WidgetBuilder() {
+export function WidgetBuilder({
+ initialPresets,
+}: {
+ initialPresets?: PresetsPayload;
+}) {
const [config, setConfig] = useState<WidgetConfig>(WIDGET_DEFAULTS);
const [size, setSize] = useState(SIZE_PRESETS[0]);
const [origin, setOrigin] = useState("");
@@ -91,11 +110,12 @@ export function WidgetBuilder() {
return (
<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>
+ <WidgetMenu
+ config={config}
+ onChange={patch}
+ initialPresets={initialPresets}
+ previewWidth={size.width}
+ />
</div>
<div className="flex flex-col gap-3 xl:shrink-0">
@@ -131,7 +151,8 @@ export function WidgetBuilder() {
<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{" "}
+ renders in the original order. A <strong>preset</strong> is just this
+ link under a name, so loading one anywhere gives the same widget. 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
@@ -166,11 +187,14 @@ export function WidgetBuilder() {
src={previewUrl}
width={size.width}
height={size.height}
+ style={size.css}
className="block"
/>
</div>
<p className="text-xs text-muted-foreground">
- Previewing at {size.width}×{size.height}px.
+ {size.css
+ ? "Previewing at the width of this panel, 80% of the window's height — enough for a fill row to show what it does."
+ : `Previewing at ${size.width}×${size.height}px.`}
</p>
</div>
</div>
diff --git a/editor/app/widget/builder/page.tsx b/editor/app/widget/builder/page.tsx
@@ -1,12 +1,27 @@
import type { Metadata } from "next";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { readWidgetPresets } from "yt-dlp-transcript-common/lib/widgetPresets";
+import { BUILT_IN_PRESETS } from "../lib/builtInPresets";
import { WidgetBuilder } from "./components/WidgetBuilder";
export const metadata: Metadata = { title: "Monitor widget" };
+// The saved presets are read per request, not baked in: without this the page
+// prerenders static at build time and seeds the preset row with whatever
+// happened to be on disk when the release was cut. (Dev renders per request
+// regardless, so only a real build catches it.)
+export const dynamic = "force-dynamic";
+
// Lives inside the normal app shell (AppFrame strips chrome only on exactly
// "/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() {
+export default async function WidgetBuilderPage() {
+ // SSR-seed the preset row so the saved list is there on first paint; the
+ // in-widget copy has no server parent and fetches /api/widget/presets instead.
+ const presets = {
+ builtIn: BUILT_IN_PRESETS,
+ saved: await readWidgetPresets(getPaths()),
+ };
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
@@ -16,11 +31,13 @@ export default function WidgetBuilderPage() {
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.
+ into the order you want to read them in, split them across rows and
+ columns if you're giving it the width, and drop the ones you
+ don't want into the tray. Give a row the leftover height with the
+ rail on its left and the sections inside it scroll under their own
+ headings. Then save it as a preset, or copy the link.
</p>
- <WidgetBuilder />
+ <WidgetBuilder initialPresets={presets} />
</div>
);
}
diff --git a/editor/app/widget/builder/widgetConfigStorage.ts b/editor/app/widget/builder/widgetConfigStorage.ts
@@ -5,32 +5,68 @@
// what actually renders (see buildWidgetQuery).
import { WIDGET_DEFAULTS, type WidgetConfig } from "../lib/config";
-import { reconcileColumns, type Columns } from "../lib/placement";
+import {
+ MAX_COLUMNS,
+ MAX_ROWS,
+ reconcileLayout,
+ type Cell,
+ type Layout,
+ type Row,
+} from "../lib/placement";
import { SECTION_BY_ID, type SectionId } from "../lib/sections";
const KEY = "ytdlp-tb:widget-config";
-// 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;
+// v3 replaces columns with rows of cells. A v1 or v2 entry resets to defaults,
+// which is right for both: each predates the dimension the next one added, and
+// has nothing to carry forward but flags that are already the defaults' shape.
+const VERSION = 3;
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);
+// number, so it needs its own validation: a track count, rows of cells, and
+// section ids we still recognize. Anything shaped wrong is discarded rather
+// than trusted — this is localStorage, which anything on the origin can write.
+function parseSections(v: unknown): SectionId[] {
+ if (!Array.isArray(v)) return [];
+ const ids: SectionId[] = [];
+ for (const id of v) {
+ if (typeof id !== "string" || !(id in SECTION_BY_ID)) continue;
+ ids.push(id as SectionId);
+ }
+ return ids;
+}
+
+function parseLayout(v: unknown): Layout | null {
+ if (!v || typeof v !== "object" || Array.isArray(v)) return null;
+ const raw = v as Record<string, unknown>;
+ const cols =
+ typeof raw.cols === "number" && Number.isFinite(raw.cols)
+ ? Math.max(1, Math.min(MAX_COLUMNS, Math.round(raw.cols)))
+ : null;
+ if (cols === null) return null;
+ if (!Array.isArray(raw.rows) || raw.rows.length === 0) return null;
+ const rows: Row[] = [];
+ for (const rowRaw of raw.rows.slice(0, MAX_ROWS)) {
+ if (!rowRaw || typeof rowRaw !== "object") return null;
+ const r = rowRaw as Record<string, unknown>;
+ if (!Array.isArray(r.cells) || r.cells.length === 0) return null;
+ const cells: Cell[] = [];
+ for (const cellRaw of r.cells) {
+ if (!cellRaw || typeof cellRaw !== "object") return null;
+ const c = cellRaw as Record<string, unknown>;
+ cells.push({
+ sections: parseSections(c.sections),
+ span:
+ typeof c.span === "number" && Number.isFinite(c.span)
+ ? Math.max(1, Math.min(cols, Math.round(c.span)))
+ : 1,
+ flow: c.flow === "row" ? "row" : "col",
+ });
}
- columns.push(ids);
+ rows.push({ size: r.size === "fill" ? "fill" : "auto", cells });
}
- return columns;
+ return { cols, rows };
}
// Overlay validated fields onto the defaults so the saved form survives the
@@ -51,9 +87,9 @@ 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;
+ if (key === "layout") {
+ const layout = parseLayout(v);
+ if (layout) next.layout = layout;
continue;
}
if (key === "channel") {
@@ -72,7 +108,10 @@ function parseStored(raw: string): WidgetConfig | null {
}
// 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) };
+ // reconcileLayout also re-imposes the grid invariants, so a hand-edited entry
+ // with spans that don't add up comes back a valid grid rather than a broken
+ // one.
+ return { ...next, layout: reconcileLayout(next.layout, 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 { Fragment, useState, type ReactNode } from "react";
+import { Fragment, useState, type CSSProperties, 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";
@@ -27,8 +27,14 @@ import {
patchWidgetConfig,
type WidgetConfig,
} from "../lib/config";
+import { hasFillRow, type Cell, type Layout, type Row } from "../lib/placement";
import type { SectionId } from "../lib/sections";
-import { WidgetConfigForm } from "./WidgetConfigForm";
+import {
+ SectionFrameProvider,
+ WidgetSection,
+ useSectionFrame,
+} from "./WidgetSection";
+import { WidgetMenu } from "./WidgetMenu";
import { WidgetControls } from "./WidgetControls";
// Read-only monitor widget. Reuses the existing ~1s poll pattern from
@@ -58,9 +64,13 @@ export function MonitorWidget({
// The controls row needs live `paused` state, so poll workers whenever either
// the Workers section or the controls are shown.
const workersEnabled = config.workers || config.controls;
+ // The DISK strip rides this payload too, so the gate can't just be
+ // `config.jobs`: `?jobs=0&disk=1` polled nothing and rendered nothing, with
+ // no clue that it was the gate rather than the disk gate being off. Same
+ // pre-existing shape as the sync payload's gate below.
const { data: jobsPayload } = usePolledPayload<ActiveJobsPayload>(
"/api/jobs/active",
- config.jobs,
+ config.jobs || config.disk,
pollMs,
initialJobs,
);
@@ -91,10 +101,15 @@ export function MonitorWidget({
// Freshness/scheduler markers move on the order of minutes, so poll no faster
// than every 15s regardless of the configured cadence. `refetchSync` lets the
// Sync button refresh the readouts the moment a sweep is queued.
+ //
+ // FOUR sections read this one payload, not two. The gate used to name only
+ // lastSync and scheduler, so `/widget?backfill=1` polled nothing and rendered
+ // nothing at all unless one of those happened to be on too. Controls is in the
+ // list because the backfill pause reads its lane state from here.
const { data: syncData, refetch: refetchSync } =
usePolledPayload<WidgetSyncPayload>(
"/api/widget/sync",
- config.lastSync || config.scheduler,
+ config.lastSync || config.scheduler || config.backfill || config.controls,
Math.max(pollMs, 15000),
null,
);
@@ -171,8 +186,11 @@ export function MonitorWidget({
confirmSyncAll={config.syncConfirm}
paused={workersPayload?.paused ?? false}
downloadsPaused={workersPayload?.downloadsPaused ?? false}
+ backfillEnabled={syncData?.backfill.enabled ?? false}
+ backfillAnyKind={syncData?.backfill.anyKind ?? false}
onWorkersChange={refetchWorkers}
onSynced={refetchSync}
+ onBackfillChange={refetchSync}
/>
);
case "lastSync":
@@ -238,40 +256,151 @@ export function MonitorWidget({
}
}
+ const layout = config.layout;
+ // One fill row anywhere changes what the widget IS: content-height (the
+ // pre-rows behaviour, and still the default) becomes exactly-its-frame, so the
+ // fill rows have a leftover to share. `h-dvh` is right for both a pinned popup
+ // and an <iframe>, which is what the widget is always embedded as.
+ const fill = hasFillRow(layout);
+ const stack = config.stackNarrow && layout.cols > 1;
+ // The one genuinely combinatorial part of the grid — N rows, each auto or 1fr
+ // — so it rides a custom property. Everything else stays a literal class,
+ // because an inline `style` would out-specify the stackNarrow overrides.
+ const rowTracks = layout.rows
+ .map((r) => (r.size === "fill" ? "minmax(0,1fr)" : "auto"))
+ .join(" ");
+
return (
// @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">
+ <div
+ className={`@container relative p-2 text-foreground ${
+ fill ? "flex h-dvh flex-col" : ""
+ }`}
+ >
{settingsLayer}
- <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
+ className={`grid grid-rows-[var(--wrows)] gap-3 ${
+ COLS_CLASS[layout.cols] ?? "grid-cols-1"
+ } ${fill ? "min-h-0 flex-1" : ""} ${
+ stack ? (STACK_GRID[layout.cols] ?? "") : ""
+ }`}
+ style={{ "--wrows": rowTracks } as CSSProperties}
+ >
+ {layout.rows.map((row, r) =>
+ row.cells.map((cell, c) => (
+ <WidgetCell
+ key={`${r}-${c}`}
+ cell={cell}
+ row={row}
+ cols={layout.cols}
+ stack={stack}
+ render={renderSection}
+ />
+ )),
+ )}
</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",
+// Tailwind needs literal class names in the source, so the track counts, the
+// spans and the stack thresholds are static maps rather than interpolated
+// `grid-cols-${n}`.
+const COLS_CLASS: Record<number, string> = {
+ 1: "grid-cols-1",
+ 2: "grid-cols-2",
+ 3: "grid-cols-3",
+ 4: "grid-cols-4",
+ 5: "grid-cols-5",
+ 6: "grid-cols-6",
+};
+
+const SPAN_CLASS: Record<number, string> = {
+ 1: "col-span-1",
+ 2: "col-span-2",
+ 3: "col-span-3",
+ 4: "col-span-4",
+ 5: "col-span-5",
+ 6: "col-span-6",
+};
+
+// Collapse back to one stack below the width at which that many tracks stop
+// being readable at all. Container queries, not viewport media queries, so an
+// embedded widget measures its own box.
+const STACK_GRID: Record<number, string> = {
+ 2: "@max-[24rem]:grid-cols-1 @max-[24rem]:grid-rows-none",
+ 3: "@max-[36rem]:grid-cols-1 @max-[36rem]:grid-rows-none",
+ 4: "@max-[48rem]:grid-cols-1 @max-[48rem]:grid-rows-none",
+ 5: "@max-[60rem]:grid-cols-1 @max-[60rem]:grid-rows-none",
+ 6: "@max-[72rem]:grid-cols-1 @max-[72rem]:grid-rows-none",
+};
+
+// The matching per-cell override: a stacked grid has one track, so nothing may
+// still claim two.
+const STACK_CELL: Record<number, string> = {
+ 2: "@max-[24rem]:col-span-1",
+ 3: "@max-[36rem]:col-span-1",
+ 4: "@max-[48rem]:col-span-1",
+ 5: "@max-[60rem]:col-span-1",
+ 6: "@max-[72rem]:col-span-1",
};
-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";
+// One grid cell: a stack of sections, or a dense rail of them.
+//
+// Both variants style their CHILDREN through `[&>section]` variants rather than
+// wrapping each one in a div. That is deliberate: a section can render nothing
+// (the backfill strip with no backfill feature on, say), and a wrapper div would
+// still take its share of a fill row's height, or draw a divider with nothing
+// beside it. Selecting the elements that actually exist can't do that.
+function WidgetCell({
+ cell,
+ row,
+ cols,
+ stack,
+ render,
+}: {
+ cell: Cell;
+ row: Row;
+ cols: Layout["cols"];
+ stack: boolean;
+ render: (id: SectionId) => ReactNode;
+}) {
+ const fillRow = row.size === "fill";
+ const place = `${SPAN_CLASS[cell.span] ?? "col-span-1"} ${
+ stack ? (STACK_CELL[cols] ?? "") : ""
+ }`;
+ const children = cell.sections.map((id) => (
+ <Fragment key={id}>{render(id)}</Fragment>
+ ));
+
+ // A rail: one shared border around the lot, hairlines between, and every
+ // strip inside told to shorten itself. Four totals as one instrument cluster.
+ if (cell.flow === "row") {
+ return (
+ <SectionFrameProvider value={{ scroll: false, dense: true }}>
+ <div
+ className={`${place} flex min-w-0 flex-wrap items-center gap-y-1 self-start rounded border border-border bg-card px-2 py-1 [&>section]:px-3 [&>section+section]:border-l [&>section+section]:border-border/60 [&>section:first-child]:pl-0 [&>section:last-child]:pr-0`}
+ >
+ {children}
+ </div>
+ </SectionFrameProvider>
+ );
+ }
+ return (
+ <SectionFrameProvider value={{ scroll: fillRow, dense: false }}>
+ <div
+ className={`${place} flex min-h-0 min-w-0 flex-col gap-3 ${
+ // In a fill row the stacked sections SPLIT the height they were given,
+ // which is what makes "two lists, half each, both scrolling" free.
+ fillRow ? "[&>section]:min-h-0 [&>section]:flex-1" : ""
+ }`}
+ >
+ {children}
+ </div>
+ </SectionFrameProvider>
+ );
}
// Compact state → dot color, mirroring stateBadge() in WorkersView.
@@ -306,22 +435,18 @@ function WorkersStrip({
}) {
const busy = workers.filter((w) => w.busy).length;
return (
- <section aria-label="Workers" className="flex flex-col gap-1.5">
- {showTitle && (
- <div className="flex items-baseline gap-2">
- <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
- Workers
- </h2>
- <span className="text-xs text-muted-foreground">
- {busy}/{workers.length} busy
+ <WidgetSection
+ label="Workers"
+ title={showTitle ? "Workers" : undefined}
+ meta={`${busy}/${workers.length} busy`}
+ extra={
+ paused && (
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-warning-soft text-warning border border-warning/30">
+ paused
</span>
- {paused && (
- <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-warning-soft text-warning border border-warning/30">
- paused
- </span>
- )}
- </div>
- )}
+ )
+ }
+ >
{workers.length === 0 ? (
<p className="text-xs text-muted-foreground">No workers configured.</p>
) : (
@@ -347,13 +472,54 @@ function WorkersStrip({
))}
</ul>
)}
- </section>
+ </WidgetSection>
);
}
+// The dense rail has room for a number and not much else, so "3m ago" becomes
+// "3m" and "in 12m" becomes "12m" — the direction is implied by the label.
+function bareTime(t: string | null): string {
+ if (t === null) return "\u2026";
+ return t.replace(/^in /, "").replace(/ ago$/, "");
+}
+
+// 1,204 → "1.2k". Only ever rendered in the dense rail, whose payload arrives on
+// the first client poll, so the locale-dependent formatting can't produce a
+// hydration mismatch.
+function compactCount(n: number): string {
+ return new Intl.NumberFormat(undefined, {
+ notation: "compact",
+ maximumFractionDigits: 1,
+ })
+ .format(n)
+ .toLowerCase();
+}
+
// Compact free-disk indicator. Green when above the floor, red when at/below it
// (downloads blocked). Only rendered when the gate is enabled.
function DiskStrip({ disk }: { disk: DiskStatusView }) {
+ const { dense } = useSectionFrame();
+ if (dense) {
+ return (
+ <section
+ aria-label="Disk space"
+ className={`flex items-center gap-1.5 text-xs ${
+ disk.low ? "text-destructive" : "text-muted-foreground"
+ }`}
+ >
+ <span
+ className={`inline-block h-2 w-2 shrink-0 rounded-full ${
+ disk.low ? "bg-destructive" : "bg-success/60"
+ }`}
+ />
+ <span className="tabular-nums">
+ {formatBytes(disk.freeBytes)} free
+ {/* The floor is why nothing is downloading; it survives the squeeze. */}
+ {disk.low && " \u00b7 downloads paused"}
+ </span>
+ </section>
+ );
+ }
return (
<section
aria-label="Disk space"
@@ -398,10 +564,23 @@ function LastSyncStrip({
now: number | null;
absolute: boolean;
}) {
+ const { dense } = useSectionFrame();
const full =
data.lastSyncAllAt === null
? "never"
: (fmtTime(data.lastSyncAllAt, now, absolute) ?? "…");
+ if (dense) {
+ return (
+ <section
+ aria-label="Last sync"
+ className="flex items-center gap-1.5 text-xs text-muted-foreground"
+ >
+ <span className="tabular-nums">
+ synced {data.lastSyncAllAt === null ? "never" : bareTime(full)}
+ </span>
+ </section>
+ );
+ }
const showChannel =
data.lastIndividualSyncAt !== null &&
(data.lastSyncAllAt === null ||
@@ -433,7 +612,32 @@ function SchedulerStrip({
now: number | null;
absolute: boolean;
}) {
+ const { dense } = useSectionFrame();
const s = data.scheduler;
+ if (dense) {
+ const short = !s.enabled
+ ? "auto off"
+ : s.overdue
+ ? "overdue"
+ : s.nextRunAt !== null
+ ? `auto ${bareTime(fmtTime(s.nextRunAt, now, absolute))}`
+ : "auto on";
+ return (
+ <section
+ aria-label="Auto-sync scheduler"
+ className={`flex items-center gap-1.5 text-xs ${
+ s.overdue ? "text-warning" : "text-muted-foreground"
+ }`}
+ >
+ <span
+ className={`inline-block h-2 w-2 shrink-0 rounded-full ${
+ s.enabled ? "bg-success" : "bg-muted-foreground/50"
+ }`}
+ />
+ <span className="tabular-nums">{short}</span>
+ </section>
+ );
+ }
const nextText = s.overdue
? "due now"
: s.nextRunAt !== null
@@ -476,9 +680,31 @@ function SchedulerStrip({
// Renders nothing when no backfill feature is on: an empty work list because a
// feature is switched off must not read as "all caught up".
function BackfillStrip({ data }: { data: WidgetSyncPayload }) {
+ const { dense } = useSectionFrame();
const b = data.backfill;
if (!b.anyKind) return null;
const done = b.reachable === 0;
+ if (dense) {
+ return (
+ <section
+ aria-label="Backfill"
+ className={`flex items-center gap-1.5 text-xs ${
+ done ? "text-muted-foreground" : "text-warning"
+ }`}
+ >
+ <span
+ className={`inline-block h-2 w-2 shrink-0 rounded-full ${
+ b.sweeping ? "bg-success" : done ? "bg-success/40" : "bg-warning"
+ }`}
+ />
+ <span className="tabular-nums">
+ {compactCount(b.reachable)} backfill
+ {/* Still a separate figure, never summed into the one beside it. */}
+ {b.needsMedia > 0 && ` \u00b7 ${compactCount(b.needsMedia)} need media`}
+ </span>
+ </section>
+ );
+ }
return (
<section
aria-label="Backfill"
@@ -504,6 +730,26 @@ function BackfillStrip({ data }: { data: WidgetSyncPayload }) {
}
function CleanableStrip({ bytes }: { bytes: number }) {
+ const { dense } = useSectionFrame();
+ if (dense) {
+ return (
+ <section
+ aria-label="Cleanable data"
+ className={`flex items-center gap-1.5 text-xs ${
+ bytes > 0 ? "text-warning" : "text-muted-foreground"
+ }`}
+ >
+ <span
+ className={`inline-block h-2 w-2 shrink-0 rounded-full ${
+ bytes > 0 ? "bg-warning" : "bg-muted-foreground/50"
+ }`}
+ />
+ <span className="tabular-nums">
+ {bytes > 0 ? `${formatBytes(bytes)} reclaimable` : "nothing to reclaim"}
+ </span>
+ </section>
+ );
+ }
return (
<section
aria-label="Cleanable data"
@@ -588,27 +834,27 @@ function ActionableStrip({
showTitle: boolean;
interactive: boolean;
}) {
+ // Given a fill row to scroll in, the cap stops being a kindness: a list you
+ // can scroll that still says "+14 more channels" defeats the point of the
+ // height it was just handed.
+ const { scroll } = useSectionFrame();
const { shown, hidden, expanded, toggle } = useExpandableList(
channels,
- ACTIONABLE_LIMIT,
+ scroll ? Number.POSITIVE_INFINITY : ACTIONABLE_LIMIT,
);
return (
- <section aria-label="Needs work" className="flex flex-col gap-1.5">
- {showTitle && (
- <div className="flex items-baseline gap-2">
- <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
- Needs work
- </h2>
- <span className="text-xs text-muted-foreground">
- {channels.length} {channels.length === 1 ? "channel" : "channels"}
- </span>
- </div>
- )}
+ <WidgetSection
+ label="Needs work"
+ title={showTitle ? "Needs work" : undefined}
+ meta={`${channels.length} ${channels.length === 1 ? "channel" : "channels"}`}
+ >
{channels.length === 0 ? (
<p className="text-xs text-muted-foreground">Everything's handled.</p>
) : (
<ul
- className={`flex flex-col gap-1.5${expanded ? " max-h-64 overflow-y-auto" : ""}`}
+ className={`flex flex-col gap-1.5${
+ expanded && !scroll ? " max-h-64 overflow-y-auto" : ""
+ }`}
>
{shown.map((c) => (
<li
@@ -659,7 +905,7 @@ function ActionableStrip({
/>
</ul>
)}
- </section>
+ </WidgetSection>
);
}
@@ -679,27 +925,24 @@ function CleanableChannelsStrip({
showTitle: boolean;
interactive: boolean;
}) {
+ const { scroll } = useSectionFrame();
const { shown, hidden, expanded, toggle } = useExpandableList(
channels,
- CLEANABLE_LIMIT,
+ scroll ? Number.POSITIVE_INFINITY : CLEANABLE_LIMIT,
);
return (
- <section aria-label="Needs cleaning" className="flex flex-col gap-1.5">
- {showTitle && (
- <div className="flex items-baseline gap-2">
- <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
- Needs cleaning
- </h2>
- <span className="text-xs text-muted-foreground">
- {channels.length} {channels.length === 1 ? "channel" : "channels"}
- </span>
- </div>
- )}
+ <WidgetSection
+ label="Needs cleaning"
+ title={showTitle ? "Needs cleaning" : undefined}
+ meta={`${channels.length} ${channels.length === 1 ? "channel" : "channels"}`}
+ >
{channels.length === 0 ? (
<p className="text-xs text-muted-foreground">Nothing to reclaim.</p>
) : (
<ul
- className={`flex flex-col gap-1.5${expanded ? " max-h-64 overflow-y-auto" : ""}`}
+ className={`flex flex-col gap-1.5${
+ expanded && !scroll ? " max-h-64 overflow-y-auto" : ""
+ }`}
>
{shown.map((c) => (
<li
@@ -737,7 +980,7 @@ function CleanableChannelsStrip({
/>
</ul>
)}
- </section>
+ </WidgetSection>
);
}
@@ -759,17 +1002,11 @@ function ActiveJobsStrip({
const running = jobs.filter((j) => j.status === "running").length;
const queued = jobs.filter((j) => j.status === "queued").length;
return (
- <section aria-label="Active jobs" className="flex flex-col gap-1.5">
- {showTitle && (
- <div className="flex items-baseline gap-2">
- <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
- Active
- </h2>
- <span className="text-xs text-muted-foreground">
- {running} running, {queued} queued
- </span>
- </div>
- )}
+ <WidgetSection
+ label="Active jobs"
+ title={showTitle ? "Active" : undefined}
+ meta={`${running} running, ${queued} queued`}
+ >
{jobs.length === 0 ? (
<p className="text-xs text-muted-foreground">No active jobs.</p>
) : (
@@ -787,7 +1024,7 @@ function ActiveJobsStrip({
))}
</ul>
)}
- </section>
+ </WidgetSection>
);
}
@@ -1061,7 +1298,11 @@ function SettingsLayer({
</button>
</div>
<form className="flex flex-col gap-4">
- <WidgetConfigForm config={config} onChange={onChange} />
+ {/* The same board the builder page shows, at the size a pinned
+ widget opens — so a preset behaves identically in both. The
+ widget itself is the preview, which is why opening the menu
+ covers it rather than sitting beside it. */}
+ <WidgetMenu config={config} onChange={onChange} compact />
</form>
<p className="mt-3 text-xs text-muted-foreground">
Changes apply live and update this widget's link — reload keeps
diff --git a/editor/app/widget/components/PresetsRow.tsx b/editor/app/widget/components/PresetsRow.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { WidgetPreset } from "yt-dlp-transcript-common/lib/widgetPresets";
+import { BUILT_IN_PRESETS, type BuiltInPreset } from "../lib/builtInPresets";
+import {
+ deleteWidgetPresetAction,
+ renameWidgetPresetAction,
+ saveWidgetPresetAction,
+} from "../presetActions";
+
+// The preset library row, in the house pattern (ProfilesRow in
+// WorkspaceSearchBar, SavedChatsRow in /ask): a grouped <select> of built-ins
+// and saved arrangements, then Save / Save as… / Rename / Delete as text
+// buttons, a dirty dot, and the browser's own prompt/confirm for naming.
+//
+// A preset is a stored /widget QUERY STRING, so "load" is just "hand this link
+// to the config parser" — which is why the same row works identically on the
+// builder page and inside the widget's own menu.
+//
+// BUILT-INS ARE LOAD-ONLY. Save on a built-in writes a new named preset rather
+// than editing it: the starting points have to stay where you left them, or
+// "Cockpit" stops meaning anything.
+
+export type PresetsPayload = { builtIn: BuiltInPreset[]; saved: WidgetPreset[] };
+
+export function PresetsRow({
+ query,
+ initial,
+ onLoad,
+}: {
+ // The query the current config serializes to — what Save would store, and
+ // what the dirty dot compares against.
+ query: string;
+ // SSR seed. The builder page has one; the in-widget menu doesn't (it is a bare
+ // client page), so it fetches on open instead.
+ initial?: PresetsPayload;
+ onLoad: (query: string) => void;
+}) {
+ const [data, setData] = useState<PresetsPayload>(
+ initial ?? { builtIn: BUILT_IN_PRESETS, saved: [] },
+ );
+ const [activeId, setActiveId] = useState<string | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const res = await fetch("/api/widget/presets", { cache: "no-store" });
+ if (res.ok) setData((await res.json()) as PresetsPayload);
+ } catch {
+ // Offline or mid-restart: keep showing what we already have rather than
+ // blanking the row.
+ }
+ }, []);
+
+ // Mounting IS opening for the in-widget menu, so this is the "fetch on open"
+ // the seeded builder doesn't need.
+ useEffect(() => {
+ if (!initial) void refresh();
+ }, [initial, refresh]);
+
+ const activeSaved = data.saved.find((p) => p.id === activeId) ?? null;
+ const activeBuiltIn = data.builtIn.find((p) => p.id === activeId) ?? null;
+ const activeQuery = activeSaved?.query ?? activeBuiltIn?.query ?? null;
+ const dirty = activeQuery !== null && activeQuery !== query;
+
+ async function run(fn: () => Promise<{ ok: boolean; error?: string }>) {
+ setBusy(true);
+ setError(null);
+ try {
+ const result = await fn();
+ if (!result.ok) setError(result.error ?? "Failed.");
+ await refresh();
+ return result.ok;
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function load(id: string) {
+ const preset =
+ data.saved.find((p) => p.id === id) ?? data.builtIn.find((p) => p.id === id);
+ if (!preset) return;
+ setActiveId(id);
+ onLoad(preset.query);
+ }
+
+ async function saveAs() {
+ const suggested = activeSaved?.name ?? activeBuiltIn?.name ?? "";
+ const name = window.prompt("Save this arrangement as:", suggested);
+ if (name === null) return;
+ const ok = await run(() => saveWidgetPresetAction(name, query));
+ if (!ok) return;
+ // Re-read so the new preset can be selected by the id the store gave it.
+ const res = await fetch("/api/widget/presets", { cache: "no-store" }).catch(
+ () => null,
+ );
+ if (!res?.ok) return;
+ const next = (await res.json()) as PresetsPayload;
+ setData(next);
+ const saved = next.saved.find((p) => p.name === name.trim());
+ if (saved) setActiveId(saved.id);
+ }
+
+ async function save() {
+ // A built-in can't be written over, so Save on one means Save as… — the
+ // action you actually wanted.
+ if (!activeSaved) return saveAs();
+ await run(() => saveWidgetPresetAction(activeSaved.name, query));
+ }
+
+ async function rename() {
+ if (!activeSaved) return;
+ const name = window.prompt("Rename this preset:", activeSaved.name);
+ if (name === null) return;
+ await run(() => renameWidgetPresetAction(activeSaved.id, name));
+ }
+
+ async function remove() {
+ if (!activeSaved) return;
+ if (!window.confirm(`Delete the preset "${activeSaved.name}"?`)) return;
+ const id = activeSaved.id;
+ const ok = await run(() => deleteWidgetPresetAction(id));
+ if (ok) setActiveId(null);
+ }
+
+ const hint = activeBuiltIn?.hint;
+
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Presets
+ </span>
+ <select
+ aria-label="preset"
+ value={activeId ?? ""}
+ onChange={(e) => e.target.value && load(e.target.value)}
+ className="rounded border border-border bg-card px-2 py-0.5 text-sm text-foreground"
+ >
+ <option value="">(custom)</option>
+ <optgroup label="Built in">
+ {data.builtIn.map((p) => (
+ <option key={p.id} value={p.id}>
+ {p.name}
+ </option>
+ ))}
+ </optgroup>
+ {data.saved.length > 0 && (
+ <optgroup label="Saved">
+ {data.saved.map((p) => (
+ <option key={p.id} value={p.id}>
+ {p.name}
+ </option>
+ ))}
+ </optgroup>
+ )}
+ </select>
+ {dirty && (
+ <span
+ role="status"
+ aria-label="preset has unsaved changes"
+ className="text-base leading-none text-warning"
+ >
+ •
+ </span>
+ )}
+ <button
+ type="button"
+ onClick={save}
+ disabled={busy || (activeSaved !== null && !dirty)}
+ className={PRESET_BTN}
+ >
+ Save
+ </button>
+ <button type="button" onClick={saveAs} disabled={busy} className={PRESET_BTN}>
+ Save as…
+ </button>
+ <button
+ type="button"
+ onClick={rename}
+ disabled={busy || !activeSaved}
+ className={PRESET_BTN}
+ >
+ Rename
+ </button>
+ <button
+ type="button"
+ onClick={remove}
+ disabled={busy || !activeSaved}
+ className="text-xs text-destructive transition-colors hover:opacity-80 disabled:opacity-50"
+ >
+ Delete
+ </button>
+ </div>
+ {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
+ {error && (
+ <p role="alert" className="text-xs text-destructive">
+ {error}
+ </p>
+ )}
+ </div>
+ );
+}
+
+const PRESET_BTN =
+ "text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50";
diff --git a/editor/app/widget/components/WidgetConfigForm.tsx b/editor/app/widget/components/WidgetConfigForm.tsx
@@ -1,181 +0,0 @@
-"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 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,
-}: {
- 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="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>
-
- {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="mb-1 text-sm font-medium">Widget</legend>
- <GlobalOptions config={config} onChange={onChange} />
- </fieldset>
- </>
- );
-}
-
-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/WidgetControls.tsx b/editor/app/widget/components/WidgetControls.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { PauseTranscriptionsButton } from "../../jobs/components/PauseTranscriptionsButton";
import { PauseDownloadsButton } from "../../jobs/components/PauseDownloadsButton";
+import { PauseBackfillButton } from "../../jobs/components/PauseBackfillButton";
import { DrainAllButton } from "../../jobs/components/DrainAllButton";
import { RetryAllFailedButton } from "../../jobs/components/RetryAllFailedButton";
import { syncAllChannelsAction, type SyncAllResult } from "../../channels/actions";
@@ -10,15 +11,17 @@ import { syncAction } from "../../channels/[slug]/pipelineActions";
// Opt-in interactive controls for the monitor widget. Two independent
// capabilities, each behind its own flag:
-// - `controls` → Pause/Resume + Drain + Retry (reused verbatim from the
-// Workers / Active Jobs pages), so a pinned widget can free the GPU, wind
-// work down, or recover failures without opening the full app.
+// - `controls` → Pause/Resume (transcriptions, downloads, backfill) + Drain +
+// Retry (reused verbatim from the Workers / Active Jobs / dashboard pages),
+// so a pinned widget can free the GPU, wind work down, hold a days-long
+// backfill, or recover failures without opening the full app.
// - `sync` → a channel-aware Sync button: pinned to one channel it syncs just
// that channel (draining the per-channel stream); otherwise it sweeps all
// channels.
// `onWorkersChange` refetches the worker payload so the pause/resume label flips
// immediately; `onSynced` refetches the last-sync/scheduler readouts so they
-// update as soon as a sweep is queued.
+// update as soon as a sweep is queued; `onBackfillChange` does the same for the
+// backfill lane, whose state rides that same payload.
export function WidgetControls({
controls,
sync,
@@ -26,8 +29,11 @@ export function WidgetControls({
confirmSyncAll,
paused,
downloadsPaused,
+ backfillEnabled,
+ backfillAnyKind,
onWorkersChange,
onSynced,
+ onBackfillChange,
}: {
controls: boolean;
sync: boolean;
@@ -35,8 +41,17 @@ export function WidgetControls({
confirmSyncAll: boolean;
paused: boolean;
downloadsPaused: boolean;
+ // settings.backfill.enabled. Persisted state, not in-memory runtime state, so
+ // the widget's 15s-floor sync poll is enough to keep the label honest between
+ // clicks — and `onBackfillChange` flips it immediately on one.
+ backfillEnabled: boolean;
+ // Whether any backfill FEATURE is registered. With none there is nothing to
+ // hold, and a dead button reads as a broken one — the same gate PipelineBand
+ // puts on the dashboard's copy.
+ backfillAnyKind: boolean;
onWorkersChange: () => void | Promise<void>;
onSynced: () => void | Promise<void>;
+ onBackfillChange: () => void | Promise<void>;
}) {
return (
<section
@@ -50,6 +65,12 @@ export function WidgetControls({
paused={downloadsPaused}
onChange={onWorkersChange}
/>
+ {backfillAnyKind && (
+ <PauseBackfillButton
+ paused={!backfillEnabled}
+ onChange={onBackfillChange}
+ />
+ )}
<DrainAllButton />
<RetryAllFailedButton />
</>
diff --git a/editor/app/widget/components/WidgetMenu.tsx b/editor/app/widget/components/WidgetMenu.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ buildWidgetQuery,
+ parseWidgetConfig,
+ type WidgetConfig,
+} from "../lib/config";
+import { LayoutBoard } from "../builder/components/LayoutBoard";
+import { GlobalOptions } from "./WidgetFields";
+import { PresetsRow, type PresetsPayload } from "./PresetsRow";
+
+// THE configuration surface. One component, two places: the /widget/builder
+// page renders it above the preview iframe, and the widget's own settings gear
+// renders it inside the overlay.
+//
+// There used to be two — a drag board for the builder and a narrow list form for
+// the gear — and the only reason for the second was that the board didn't fold.
+// It does now (its own @container, not viewport media queries, so it behaves the
+// same inside a 320px overlay as on a wide page), so the second is gone. One
+// surface is what makes a preset mean the same thing wherever you load it.
+export function WidgetMenu({
+ config,
+ onChange,
+ initialPresets,
+ previewWidth,
+ compact = false,
+}: {
+ config: WidgetConfig;
+ onChange: (patch: Partial<WidgetConfig>) => void;
+ // SSR seed for the preset row (builder page only).
+ initialPresets?: PresetsPayload;
+ // The width the widget will render at, for the board's track readouts. The
+ // builder passes its preview size; inside the widget we measure the widget.
+ previewWidth?: number;
+ // Inside the widget's own overlay: no fieldset chrome, tighter gaps.
+ compact?: boolean;
+}) {
+ const query = useMemo(() => buildWidgetQuery(config), [config]);
+ const [selfWidth, setSelfWidth] = useState<number | undefined>(undefined);
+
+ // In the gear the widget IS the preview, so the honest width is its own.
+ // Measured after mount; the overlay only ever renders client-side.
+ useEffect(() => {
+ if (!compact || previewWidth !== undefined) return;
+ const measure = () => setSelfWidth(window.innerWidth);
+ measure();
+ window.addEventListener("resize", measure);
+ return () => window.removeEventListener("resize", measure);
+ }, [compact, previewWidth]);
+
+ // Loading a preset REPLACES the config rather than merging into it: a preset
+ // is a whole link, and parseWidgetConfig returns every field, so passing it as
+ // the patch resets anything the preset didn't mention to its default. Merging
+ // would leave whatever you had switched on stuck on.
+ function loadPreset(presetQuery: string) {
+ const params = Object.fromEntries(new URLSearchParams(presetQuery));
+ onChange(parseWidgetConfig(params));
+ }
+
+ return (
+ <div className={`flex flex-col ${compact ? "gap-3" : "gap-4"}`}>
+ <PresetsRow query={query} initial={initialPresets} onLoad={loadPreset} />
+ <LayoutBoard
+ config={config}
+ onChange={onChange}
+ previewWidth={previewWidth ?? selfWidth}
+ />
+ {compact ? (
+ <div className="flex flex-col gap-2">
+ <span className="text-sm font-medium">Whole widget</span>
+ <GlobalOptions config={config} onChange={onChange} />
+ </div>
+ ) : (
+ <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={onChange} />
+ </fieldset>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/widget/components/WidgetSection.tsx b/editor/app/widget/components/WidgetSection.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { createContext, useContext, type ReactNode } from "react";
+
+// The shell every placeable widget section renders inside, and the frame flags
+// it reads.
+//
+// Four of the strips used to carry their own copy of the same header JSX; this
+// is that markup in one place, plus the one structural thing none of them could
+// do before: SCROLL UNDER A PINNED TITLE. The header has to live INSIDE the
+// scroll container for `sticky top-0` to pin it — a header outside would simply
+// scroll away with the list — which is why the shell owns both rather than the
+// caller wrapping its own children.
+
+export type SectionFrame = {
+ // This section is in a row that takes the widget's leftover height, so it gets
+ // a max height and scrolls rather than growing the page.
+ scroll: boolean;
+ // This section is in a cell laid out as a horizontal rail: drop the individual
+ // border and shorten the copy, so four totals read as one instrument cluster
+ // rather than four boxes.
+ dense: boolean;
+};
+
+const DEFAULT_FRAME: SectionFrame = { scroll: false, dense: false };
+
+const SectionFrameContext = createContext<SectionFrame>(DEFAULT_FRAME);
+
+// Provided per CELL by MonitorWidget, so renderSection() doesn't have to thread
+// two more flags through every section's props.
+export function SectionFrameProvider({
+ value,
+ children,
+}: {
+ value: SectionFrame;
+ children: ReactNode;
+}) {
+ return (
+ <SectionFrameContext.Provider value={value}>
+ {children}
+ </SectionFrameContext.Provider>
+ );
+}
+
+export function useSectionFrame(): SectionFrame {
+ return useContext(SectionFrameContext);
+}
+
+export const SECTION_TITLE_CLASS =
+ "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
+
+// `label` is the accessible name and is part of the widget's test surface — the
+// e2e suite selects sections by `getByRole("region", { name: … })`, so these
+// strings and the <h2> text are a contract, not decoration.
+export function WidgetSection({
+ label,
+ title,
+ meta,
+ extra,
+ children,
+}: {
+ label: string;
+ // Absent when the widget is configured with titles off.
+ title?: string;
+ // The count/summary that sits beside the title.
+ meta?: ReactNode;
+ // Anything after the meta — the Workers strip's "paused" pill.
+ extra?: ReactNode;
+ children: ReactNode;
+}) {
+ const { scroll } = useSectionFrame();
+ return (
+ <section aria-label={label} className="flex min-h-0 flex-col">
+ <div
+ className={
+ scroll
+ ? "flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto overscroll-contain"
+ : "flex flex-col gap-1.5"
+ }
+ >
+ {title && (
+ <div
+ className={`flex items-baseline gap-2 ${
+ scroll
+ ? "sticky top-0 z-10 border-b border-border/60 bg-background/95 pb-1 backdrop-blur"
+ : ""
+ }`}
+ >
+ <h2 className={SECTION_TITLE_CLASS}>{title}</h2>
+ {meta !== undefined && (
+ <span className="text-xs text-muted-foreground">{meta}</span>
+ )}
+ {extra}
+ </div>
+ )}
+ {children}
+ </div>
+ </section>
+ );
+}
diff --git a/editor/app/widget/lib/builtInPresets.test.ts b/editor/app/widget/lib/builtInPresets.test.ts
@@ -0,0 +1,82 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { BUILT_IN_PRESETS, builtInPresetById } from "./builtInPresets";
+import { buildWidgetQuery, parseWidgetConfig } from "./config";
+
+// Run from the repo root with:
+// ./node_modules/.bin/tsx --test editor/app/widget/lib/builtInPresets.test.ts
+//
+// A preset is a stored LINK, so the only thing that can go wrong with one is
+// that it stops meaning what it says: a renamed param, a changed default, a
+// section dropped from the registry, a layout the current placement math would
+// normalize differently. Each of those shows up as a query that does not
+// re-serialize to itself, which is what this asserts.
+
+// Parse a query string the way /widget does, then re-emit it.
+function roundTrip(query: string): string {
+ const params: Record<string, string> = {};
+ for (const [k, v] of new URLSearchParams(query)) params[k] = v;
+ return buildWidgetQuery(parseWidgetConfig(params));
+}
+
+test("every built-in preset parses and re-serializes to itself", () => {
+ for (const preset of BUILT_IN_PRESETS) {
+ assert.equal(roundTrip(preset.query), preset.query, `${preset.name} round-trips`);
+ }
+});
+
+test("built-in ids and names are unique and non-empty", () => {
+ const ids = new Set<string>();
+ const names = new Set<string>();
+ for (const p of BUILT_IN_PRESETS) {
+ assert.ok(p.id, "id is set");
+ assert.ok(p.name, "name is set");
+ assert.ok(p.hint, "hint is set");
+ assert.ok(!ids.has(p.id), `duplicate id ${p.id}`);
+ assert.ok(!names.has(p.name), `duplicate name ${p.name}`);
+ ids.add(p.id);
+ names.add(p.name);
+ }
+});
+
+test("Jobs is the way back to the default widget", () => {
+ // The "start over" entry has to be the empty query, or a bare /widget link
+ // would no longer be reachable from the menu.
+ assert.equal(builtInPresetById("jobs")?.query, "");
+ assert.equal(roundTrip(""), "");
+});
+
+test("Cockpit is the three-row layout, fill row included", () => {
+ const config = parseWidgetConfig(
+ Object.fromEntries(
+ new URLSearchParams(builtInPresetById("cockpit")?.query ?? ""),
+ ),
+ );
+ assert.equal(config.layout.rows.length, 3);
+ assert.equal(config.layout.cols, 2);
+ assert.equal(config.layout.rows[0].cells[0].span, 2);
+ assert.equal(config.layout.rows[1].cells[0].flow, "row");
+ assert.equal(config.layout.rows[2].size, "fill");
+});
+
+test("Glance is one dense rail and nothing else", () => {
+ const config = parseWidgetConfig(
+ Object.fromEntries(
+ new URLSearchParams(builtInPresetById("glance")?.query ?? ""),
+ ),
+ );
+ assert.equal(config.layout.rows.length, 1);
+ assert.equal(config.layout.rows[0].cells.length, 1);
+ assert.equal(config.layout.rows[0].cells[0].flow, "row");
+ assert.equal(config.showTitles, false);
+});
+
+test("Wall is locked down: no controls, no gear", () => {
+ const config = parseWidgetConfig(
+ Object.fromEntries(new URLSearchParams(builtInPresetById("wall")?.query ?? "")),
+ );
+ assert.equal(config.controls, false);
+ assert.equal(config.settings, false);
+ assert.equal(config.showTitles, true);
+ assert.equal(config.layout.cols, 3);
+});
diff --git a/editor/app/widget/lib/builtInPresets.ts b/editor/app/widget/lib/builtInPresets.ts
@@ -0,0 +1,63 @@
+// The arrangements that ship with the widget.
+//
+// Each is stored the same way a saved preset is — as the QUERY STRING
+// buildWidgetQuery would produce — so a built-in and something you saved
+// yourself are the same kind of thing, and loading either is just "parse this
+// link". Built-ins are load-only: Save writes a new named preset rather than
+// editing one of these, so the starting points stay where you left them.
+//
+// builtInPresets.test.ts asserts every query here parses and re-serializes to
+// ITSELF. That is the guard against a preset drifting away from the registry: a
+// renamed param, a changed default or a section that stops existing would
+// otherwise leave a preset that quietly loads as something else.
+
+export type BuiltInPreset = {
+ id: string;
+ name: string;
+ // One line: what this is for, shown beside the name.
+ hint: string;
+ // Without a leading "?". "" is the all-default widget.
+ query: string;
+};
+
+export const BUILT_IN_PRESETS: BuiltInPreset[] = [
+ {
+ id: "glance",
+ name: "Glance",
+ hint: "One dense rail of totals and a row of worker dots. A 320×120 corner pin.",
+ query:
+ "jobs=0&titles=0&clean=1&wnames=0&backfill=1&lastsync=1&g=lsync.disk.cln.bf.wk*r",
+ },
+ {
+ id: "jobs",
+ name: "Jobs",
+ hint: "Workers and active jobs in one column — the default arrangement, and the way back to it.",
+ // Deliberately empty: the default IS this preset, and an empty query is what
+ // "start over" has to mean if a bare /widget link is to keep working.
+ query: "",
+ },
+ {
+ id: "cockpit",
+ name: "Cockpit",
+ hint: "Controls across the top, a rail of totals, then the scheduler beside two scrolling worklists.",
+ query:
+ "jobs=0&workers=0&clean=1&controls=1&backfill=1&act=1&cleanlist=1&sync=1&lastsync=1&sched=1&g=ctl*2_lsync.disk.cln.bf*2r_sched*f-act.clnl",
+ },
+ {
+ id: "cleanup",
+ name: "Cleanup",
+ hint: "What you could reclaim, the controls to do it, and a full-height list of the channels holding it.",
+ query: "jobs=0&workers=0&clean=1&controls=1&cleanlist=1&g=cln.disk*r_ctl_clnl*f",
+ },
+ {
+ id: "wall",
+ name: "Wall",
+ hint: "Three columns, titles on, no controls and no gear. For a spare monitor.",
+ query:
+ "clean=1&act=1&cleanlist=1&gear=0&lastsync=1&sched=1&g=lsync.sched.disk.cln*3r_wk.jobs*f-act-clnl",
+ },
+];
+
+export function builtInPresetById(id: string): BuiltInPreset | undefined {
+ return BUILT_IN_PRESETS.find((p) => p.id === id);
+}
diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts
@@ -3,11 +3,11 @@
// serialize), so a link the builder copies always renders the way it previewed.
import {
- defaultColumns,
+ defaultLayout,
parseLayoutParam,
- reconcileColumns,
+ reconcileLayout,
serializeLayout,
- type Columns,
+ type Layout,
} from "./placement";
import type { SectionFlags } from "./sections";
@@ -75,15 +75,16 @@ 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.
+ // Where each enabled section sits: rows of cells, each cell a stack of
+ // sections (see ./placement). 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 cell
+ // in one auto row, in the widget's original order, which is why every link
+ // written before layouts existed is unchanged.
+ layout: Layout;
+ // Let the grid 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;
};
@@ -119,7 +120,7 @@ const WIDGET_FLAG_DEFAULTS: SectionFlags = {
// that could drift away from them.
export const WIDGET_DEFAULTS: WidgetConfig = {
...WIDGET_FLAG_DEFAULTS,
- columns: defaultColumns(WIDGET_FLAG_DEFAULTS),
+ layout: defaultLayout(WIDGET_FLAG_DEFAULTS),
};
// Next's searchParams give each key as string | string[] | undefined.
@@ -175,21 +176,25 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig {
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) };
+ return {
+ ...flags,
+ layout: parseLayoutParam(first(params.l), first(params.g), 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.
+// switched off leaves the board, one switched on joins the last cell of the last
+// row. Every edit surface (the builder board, the in-widget menu) goes through
+// this so neither can produce a config whose layout param disagrees with its
+// booleans. A patch carrying an explicit `layout` — 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) };
+ return { ...next, layout: reconcileLayout(next.layout, next) };
}
// Serialize a config to a query string, omitting anything left at its default so
@@ -242,8 +247,11 @@ export function buildWidgetQuery(config: WidgetConfig): string {
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);
+ // the same short link it did before layouts existed. `l` OR `g`, never both:
+ // a layout that plain columns can express keeps emitting the `l=` a
+ // pre-rows link would have carried.
+ const layout = serializeLayout(config.layout, config);
+ if (layout.l !== undefined) sp.set("l", layout.l);
+ else if (layout.g !== undefined) sp.set("g", layout.g);
return sp.toString();
}
diff --git a/editor/app/widget/lib/placement.test.ts b/editor/app/widget/lib/placement.test.ts
@@ -1,20 +1,31 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
- defaultColumns,
+ addCell,
+ defaultLayout,
+ findSection,
+ hasFillRow,
insertAt,
- moveToColumn,
+ isColumnsLayout,
+ moveToCell,
+ moveToRow,
moveWithin,
parseLayoutParam,
- reconcileColumns,
+ reconcileLayout,
+ removeCell,
removeSection,
sameLayout,
serializeLayout,
+ setCellFlow,
+ setCellSpan,
setColumnCount,
- type Columns,
+ setRowCount,
+ setRowSize,
+ type Layout,
+ type SectionPos,
} from "./placement";
import { WIDGET_DEFAULTS } from "./config";
-import type { SectionFlags } from "./sections";
+import type { SectionFlags, SectionId } from "./sections";
// Run from the repo root with:
// ./node_modules/.bin/tsx --test editor/app/widget/lib/placement.test.ts
@@ -22,158 +33,482 @@ import type { SectionFlags } from "./sections";
// 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.
+//
+// The other half is BACK-COMPATIBILITY. `l=` predates rows entirely, and every
+// already-copied widget link carries one — so each `l=` case here asserts the
+// same decode the columns model gave, and that it re-serializes to the byte-
+// identical string rather than being upgraded to a `g=`.
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"]]);
+// A layout of plain stacked columns, the shape `l=` describes: one auto row,
+// one single-track cell per column. Lets the ported cases read as they did.
+const cols = (...columns: SectionId[][]): Layout => ({
+ cols: columns.length,
+ rows: [
+ {
+ size: "auto",
+ cells: columns.map((sections) => ({ sections, span: 1, flow: "col" })),
+ },
+ ],
+});
+
+// The `l` (or `g`) a layout serializes to, for the round-trip assertions.
+const ser = (layout: Layout, config: SectionFlags = flags()) =>
+ serializeLayout(layout, config);
+
+// ---------------------------------------------------------------------------
+// Defaults
+// ---------------------------------------------------------------------------
+
+test("the default layout is one cell of the enabled sections, in render order", () => {
+ assert.deepEqual(defaultLayout(flags()), cols(["disk", "workers", "jobs"]));
+ assert.deepEqual(defaultLayout(flags({ workers: false })), cols(["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"],
- ]);
+ assert.deepEqual(
+ defaultLayout(flags({ actionable: true, lastSync: true })),
+ cols(["lastSync", "disk", "workers", "jobs", "actionable"]),
+ );
+ // One auto row, one track: the pre-rows behaviour exactly.
+ assert.equal(defaultLayout(flags()).cols, 1);
+ assert.equal(defaultLayout(flags()).rows.length, 1);
+ assert.equal(hasFillRow(defaultLayout(flags())), false);
});
test("no layout param means the default layout", () => {
- assert.deepEqual(parseLayoutParam(undefined, flags()), defaultColumns(flags()));
- assert.deepEqual(parseLayoutParam("", flags()), defaultColumns(flags()));
+ assert.deepEqual(
+ parseLayoutParam(undefined, undefined, flags()),
+ defaultLayout(flags()),
+ );
+ assert.deepEqual(parseLayoutParam("", "", flags()), defaultLayout(flags()));
});
-test("a layout param is decoded into columns", () => {
- assert.deepEqual(parseLayoutParam("wk.jobs-disk", flags()), [
- ["workers", "jobs"],
- ["disk"],
- ]);
+// ---------------------------------------------------------------------------
+// `l=` — the pre-rows contract
+// ---------------------------------------------------------------------------
+
+test("an l= param is decoded into one auto row of columns", () => {
+ assert.deepEqual(
+ parseLayoutParam("wk.jobs-disk", undefined, flags()),
+ cols(["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"],
- ]);
+ assert.deepEqual(
+ parseLayoutParam("wk.nosuch.jobs-disk", undefined, flags()),
+ cols(["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"],
- ]);
+ assert.deepEqual(
+ parseLayoutParam("wk.jobs-wk.disk", undefined, flags()),
+ cols(["workers", "jobs"], ["disk"]),
+ );
});
-test("a section that is enabled but unlisted joins the last column", () => {
+test("a section that is enabled but unlisted joins the last cell", () => {
// `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"],
- ]);
+ assert.deepEqual(
+ parseLayoutParam("wk-jobs", undefined, flags()),
+ cols(["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"],
- [],
- ]);
+ assert.deepEqual(
+ parseLayoutParam("wk.jobs-disk", undefined, flags({ disk: false })),
+ cols(["workers", "jobs"], []),
+ );
});
test("a layout that survives nothing falls back to the default", () => {
- assert.deepEqual(parseLayoutParam("nope.alsonope", flags()), defaultColumns(flags()));
+ assert.deepEqual(
+ parseLayoutParam("nope.alsonope", undefined, flags()),
+ defaultLayout(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"],
- ]);
+ // Six tracks is the cap now, so a seven-column link merges the tail rather
+ // than being rejected.
+ const on = flags({ cleanable: true, actionable: true, cleanChannels: true, lastSync: true });
+ assert.deepEqual(
+ parseLayoutParam("wk-jobs-disk-cln-act-clnl-lsync", undefined, on),
+ cols(
+ ["workers"],
+ ["jobs"],
+ ["disk"],
+ ["cleanable"],
+ ["actionable"],
+ ["cleanChannels", "lastSync"],
+ ),
+ );
});
-test("serializing a default layout gives the empty string", () => {
+test("serializing a default layout gives nothing at all", () => {
// This is what keeps an all-default config serializing to a bare /widget.
- assert.equal(serializeLayout(defaultColumns(flags()), flags()), "");
+ assert.deepEqual(ser(defaultLayout(flags())), {});
const off = flags({ workers: false });
- assert.equal(serializeLayout(defaultColumns(off), off), "");
+ assert.deepEqual(ser(defaultLayout(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("every columns layout still round-trips through l=, byte-identical", () => {
+ for (const raw of [
+ "wk.jobs-disk",
+ "disk.jobs.wk",
+ "wk.jobs.disk-",
+ "disk-wk-jobs",
+ ]) {
+ const layout = parseLayoutParam(raw, undefined, flags());
+ assert.deepEqual(ser(layout), { l: raw }, `round-trip of ${raw}`);
+ // ...and never as a g=, which no older client would understand.
+ assert.equal(ser(layout).g, undefined);
+ }
});
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);
+ const layout = cols(["workers", "jobs", "disk"], []);
+ assert.deepEqual(ser(layout), { l: "wk.jobs.disk-" });
+ assert.deepEqual(parseLayoutParam("wk.jobs.disk-", undefined, flags()), layout);
+});
+
+// ---------------------------------------------------------------------------
+// `g=` — rows, spans, flow, fill
+// ---------------------------------------------------------------------------
+
+// The Cockpit arrangement: controls across the top, a dense rail of totals under
+// it, then a fill row with the scheduler beside the two scrolling lists.
+const COCKPIT = "ctl*2_lsync.disk.cln.bf*2r_sched*f-act.clnl";
+const cockpitFlags = flags({
+ controls: true,
+ lastSync: true,
+ scheduler: true,
+ cleanable: true,
+ backfill: true,
+ actionable: true,
+ cleanChannels: true,
+ jobs: false,
+ workers: false,
+ disk: true,
+});
+
+test("g= decodes rows, spans, flow and the fill marker", () => {
+ const layout = parseLayoutParam(undefined, COCKPIT, cockpitFlags);
+ assert.deepEqual(layout, {
+ cols: 2,
+ rows: [
+ {
+ size: "auto",
+ cells: [{ sections: ["controls"], span: 2, flow: "col" }],
+ },
+ {
+ size: "auto",
+ cells: [
+ {
+ sections: ["lastSync", "disk", "cleanable", "backfill"],
+ span: 2,
+ flow: "row",
+ },
+ ],
+ },
+ {
+ size: "fill",
+ cells: [
+ { sections: ["scheduler"], span: 1, flow: "col" },
+ { sections: ["actionable", "cleanChannels"], span: 1, flow: "col" },
+ ],
+ },
+ ],
+ });
+ assert.ok(hasFillRow(layout));
+ assert.ok(!isColumnsLayout(layout));
+});
+
+test("g= re-serializes byte-identically", () => {
+ const layout = parseLayoutParam(undefined, COCKPIT, cockpitFlags);
+ assert.deepEqual(ser(layout, cockpitFlags), { g: COCKPIT });
+ // ...and never alongside an l=, which would be two contradicting layouts.
+ assert.equal(ser(layout, cockpitFlags).l, undefined);
+});
+
+test("g= wins over l= when both are present", () => {
+ const layout = parseLayoutParam("wk.jobs-disk", COCKPIT, cockpitFlags);
+ assert.equal(layout.rows.length, 3);
+});
+
+test("the fill marker survives on any cell, and normalizes onto the first", () => {
+ // A hand-edited link that marks the SECOND cell still fills the row, and
+ // re-serializes with the marker on the first cell.
+ const layout = parseLayoutParam(undefined, "wk-jobs*f", flags());
+ assert.equal(layout.rows[0].size, "fill");
+ assert.deepEqual(ser(layout), { g: "wk*f-jobs.disk" });
+});
+
+test("an out-of-range span is clamped, and the row still sums to the tracks", () => {
+ const layout = parseLayoutParam(undefined, "wk*9-jobs", flags());
+ const row = layout.rows[0];
+ assert.equal(
+ row.cells.reduce((s, c) => s + c.span, 0),
+ layout.cols,
+ );
+ assert.ok(row.cells.every((c) => c.span >= 1 && c.span <= layout.cols));
});
-test("reconcile drops what is off and appends what is on", () => {
+test("an unknown modifier letter is ignored rather than fatal", () => {
+ assert.deepEqual(
+ parseLayoutParam(undefined, "wk*2z-jobs.disk", flags()),
+ {
+ cols: 3,
+ rows: [
+ {
+ size: "auto",
+ cells: [
+ { sections: ["workers"], span: 2, flow: "col" },
+ { sections: ["jobs", "disk"], span: 1, flow: "col" },
+ ],
+ },
+ ],
+ },
+ );
+});
+
+test("more rows than the maximum are merged into the last", () => {
+ const layout = parseLayoutParam(
+ undefined,
+ "wk_jobs_disk_cln_act_clnl_lsync",
+ flags({ cleanable: true, actionable: true, cleanChannels: true, lastSync: true }),
+ );
+ assert.equal(layout.rows.length, 6);
+ assert.deepEqual(layout.rows[5].cells[0].sections, ["cleanChannels", "lastSync"]);
+});
+
+test("a g= that survives nothing falls back to the default", () => {
+ assert.deepEqual(
+ parseLayoutParam(undefined, "nope_alsonope", flags()),
+ defaultLayout(flags()),
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Reconcile
+// ---------------------------------------------------------------------------
+
+test("reconcile drops what is off and appends to the last cell of the last row", () => {
assert.deepEqual(
- reconcileColumns([["workers", "jobs"], ["disk"]], flags({ disk: false })),
- [["workers", "jobs"], []],
+ reconcileLayout(cols(["workers", "jobs"], ["disk"]), flags({ disk: false })),
+ cols(["workers", "jobs"], []),
);
assert.deepEqual(
- reconcileColumns([["workers"], ["jobs"]], flags({ actionable: true })),
- [["workers"], ["jobs", "disk", "actionable"]],
+ reconcileLayout(cols(["workers"], ["jobs"]), flags({ actionable: true })),
+ cols(["workers"], ["jobs", "disk", "actionable"]),
);
+ // Multi-row: the newly enabled section lands in the LAST cell of the LAST
+ // row, not back at the top where it would push the arrangement around.
+ const grid = parseLayoutParam(undefined, "wk_jobs-disk", flags());
+ const next = reconcileLayout(grid, flags({ actionable: true }));
+ assert.deepEqual(next.rows[1].cells[1].sections, ["disk", "actionable"]);
});
+// ---------------------------------------------------------------------------
+// Movement
+// ---------------------------------------------------------------------------
+
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);
+ const layout = cols(["workers", "jobs", "disk"]);
+ assert.deepEqual(
+ moveWithin(layout, 0, 0, 2, -1),
+ cols(["workers", "disk", "jobs"]),
+ );
+ assert.equal(moveWithin(layout, 0, 0, 0, -1), layout);
+ assert.equal(moveWithin(layout, 0, 0, 2, 1), layout);
});
-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("moveToCell lands at the same height, or the end of a shorter cell", () => {
+ assert.deepEqual(
+ moveToCell(cols(["workers", "jobs"], ["disk"]), 0, 0, 1, 1),
+ cols(["workers"], ["disk", "jobs"]),
+ );
+ assert.deepEqual(
+ moveToCell(cols(["workers", "jobs", "disk"], []), 0, 0, 2, 1),
+ cols(["workers", "jobs"], ["disk"]),
+ );
+ // No neighbouring cell → unchanged.
+ const one = cols(["workers", "jobs"]);
+ assert.equal(moveToCell(one, 0, 0, 0, 1), one);
});
-test("insertAt accounts for the removal when moving down within a column", () => {
+test("moveToRow carries a section into the row above or below", () => {
+ const layout = parseLayoutParam(undefined, "wk.jobs_disk", flags());
+ const down = moveToRow(layout, 0, 0, 0, 1);
+ assert.deepEqual(down.rows[0].cells[0].sections, ["jobs"]);
+ assert.deepEqual(down.rows[1].cells[0].sections, ["disk", "workers"]);
+ // Off the end → unchanged.
+ assert.equal(moveToRow(layout, 0, 0, 0, -1), layout);
+});
+
+test("insertAt accounts for the removal when moving down within a cell", () => {
// 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"],
- [],
- [],
- ]);
+ assert.deepEqual(
+ insertAt(cols(["workers", "jobs", "disk"]), "workers", 0, 0, 2),
+ cols(["jobs", "workers", "disk"]),
+ );
+ assert.deepEqual(
+ insertAt(cols(["workers", "jobs"], ["disk"]), "disk", 0, 0, 0),
+ cols(["disk", "workers", "jobs"], []),
+ );
+ // Across rows: pulled out of wherever it was, wherever that was.
+ const grid = parseLayoutParam(undefined, "wk.jobs_disk", flags());
+ const moved = insertAt(grid, "disk", 0, 0, 0);
+ assert.deepEqual(moved.rows[0].cells[0].sections, ["disk", "workers", "jobs"]);
+ assert.deepEqual(moved.rows[1].cells[0].sections, []);
});
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"]]));
+ assert.deepEqual(
+ removeSection(cols(["workers", "jobs"], ["disk"]), "jobs"),
+ cols(["workers"], ["disk"]),
+ );
+ assert.ok(sameLayout(cols(["workers"]), cols(["workers"])));
+ assert.ok(!sameLayout(cols(["workers"]), cols(["workers"], [])));
+ assert.ok(!sameLayout(cols(["workers", "jobs"]), cols(["jobs", "workers"])));
+ // Structure counts too, not just the section order.
+ const filled = setRowSize(cols(["workers"]), 0, "fill");
+ assert.ok(!sameLayout(filled, cols(["workers"])));
+});
+
+// ---------------------------------------------------------------------------
+// Structure
+// ---------------------------------------------------------------------------
+
+test("setColumnCount merges trailing cells rather than dropping them", () => {
+ assert.deepEqual(
+ setColumnCount(cols(["workers"], ["jobs"], ["disk"]), 2),
+ cols(["workers"], ["jobs", "disk"]),
+ );
+ assert.deepEqual(
+ setColumnCount(cols(["workers", "jobs"]), 3),
+ cols(["workers", "jobs"], [], []),
+ );
+});
+
+test("setColumnCount keeps every row summing to the new track count", () => {
+ const grid = parseLayoutParam(undefined, "wk*2_jobs-disk", flags());
+ for (const n of [1, 2, 3, 4, 5, 6]) {
+ const next = setColumnCount(grid, n);
+ assert.equal(next.cols, n);
+ for (const row of next.rows) {
+ assert.equal(
+ row.cells.reduce((s, c) => s + c.span, 0),
+ n,
+ `row sums to ${n}`,
+ );
+ }
+ }
+});
+
+test("setRowCount merges trailing rows rather than dropping them", () => {
+ const three = parseLayoutParam(undefined, "wk_jobs_disk", flags());
+ const two = setRowCount(three, 2);
+ assert.equal(two.rows.length, 2);
+ assert.deepEqual(two.rows[1].cells[0].sections, ["jobs", "disk"]);
+ // Growing appends an empty row, so there is somewhere to drop things.
+ const four = setRowCount(three, 4);
+ assert.equal(four.rows.length, 4);
+ assert.deepEqual(four.rows[3].cells[0].sections, []);
+});
+
+test("setCellSpan takes the tracks from the neighbours, merging what it swallows", () => {
+ const grid = parseLayoutParam(undefined, "wk-jobs-disk", flags());
+ assert.equal(grid.cols, 3);
+
+ // Widening by one swallows the cell beside it — its sections move in rather
+ // than being dropped — and the row still sums to the track count.
+ const wide = setCellSpan(grid, 0, 0, 2);
+ assert.deepEqual(
+ wide.rows[0].cells.map((c) => c.span),
+ [2, 1],
+ );
+ assert.deepEqual(wide.rows[0].cells[0].sections, ["workers", "jobs"]);
+ assert.deepEqual(wide.rows[0].cells[1].sections, ["disk"]);
+
+ // Asking for the whole row gives the whole row, everything merged in order.
+ const max = setCellSpan(grid, 0, 0, 6);
+ assert.equal(max.rows[0].cells.length, 1);
+ assert.equal(max.rows[0].cells[0].span, 3);
+ assert.deepEqual(max.rows[0].cells[0].sections, ["workers", "jobs", "disk"]);
+
+ // Narrowing the last cell in a row frees its tracks into a new empty cell —
+ // the exact inverse, so a widen is undoable.
+ const back = setCellSpan(max, 0, 0, 1);
+ assert.deepEqual(
+ back.rows[0].cells.map((c) => c.span),
+ [1, 2],
+ );
+ assert.deepEqual(back.rows[0].cells[1].sections, []);
+});
+
+test("setCellFlow, setRowSize and hasFillRow", () => {
+ const grid = parseLayoutParam(undefined, "lsync.disk-jobs", flags({ lastSync: true }));
+ const rail = setCellFlow(grid, 0, 0, "row");
+ assert.equal(rail.rows[0].cells[0].flow, "row");
+ assert.equal(setCellFlow(rail, 0, 0, "row"), rail);
+ assert.ok(!hasFillRow(rail));
+ const filled = setRowSize(rail, 0, "fill");
+ assert.ok(hasFillRow(filled));
+ assert.equal(setRowSize(filled, 0, "fill"), filled);
+});
+
+test("addCell splits a row, removeCell merges one back", () => {
+ const one = parseLayoutParam(undefined, "wk.jobs.disk", flags());
+ const two = addCell(one, 0);
+ assert.equal(two.rows[0].cells.length, 1, "a one-track row has no room");
+
+ const wide = setColumnCount(one, 2);
+ assert.equal(wide.rows[0].cells.length, 2);
+ const three = addCell(setColumnCount(one, 3), 0);
+ assert.equal(three.rows[0].cells.length, 3);
+
+ const merged = removeCell(wide, 0, 1);
+ assert.equal(merged.rows[0].cells.length, 1);
+ assert.deepEqual(merged.rows[0].cells[0].sections, ["workers", "jobs", "disk"]);
+ // The last cell of a row can't be removed — a row always has one.
+ assert.equal(removeCell(merged, 0, 0), merged);
+});
+
+test("the board's round trip: add a row, fill it, widen a cell, and back", () => {
+ // The exact sequence the builder e2e drives, asserted on the params it writes.
+ let layout = parseLayoutParam(undefined, undefined, flags());
+ layout = setColumnCount(layout, 2);
+ assert.deepEqual(ser(layout), { l: "disk.wk.jobs-" });
+
+ layout = setRowCount(layout, 2);
+ layout = insertAt(layout, "jobs", 1, 0, 0);
+ assert.deepEqual(ser(layout), { g: "disk.wk-_jobs-" });
+
+ layout = setRowSize(layout, 1, "fill");
+ assert.deepEqual(ser(layout), { g: "disk.wk-_jobs*f-" });
+
+ layout = setCellSpan(layout, 0, 0, 2);
+ assert.deepEqual(ser(layout), { g: "disk.wk*2_jobs*f-" });
+
+ // Narrowing it back and dropping the extra row falls all the way back to `l=`.
+ layout = setCellSpan(layout, 0, 0, 1);
+ layout = setRowSize(layout, 1, "auto");
+ layout = setRowCount(layout, 1);
+ assert.deepEqual(ser(layout), { l: "disk.wk-jobs" });
+});
+
+test("findSection reports the row, cell and index", () => {
+ const grid = parseLayoutParam(undefined, "wk.jobs_disk", flags());
+ const at: SectionPos = { row: 0, cell: 0, index: 1 };
+ assert.deepEqual(findSection(grid, "jobs"), at);
+ assert.deepEqual(findSection(grid, "disk"), { row: 1, cell: 0, index: 0 });
+ assert.equal(findSection(grid, "cleanable"), null);
});
diff --git a/editor/app/widget/lib/placement.ts b/editor/app/widget/lib/placement.ts
@@ -1,15 +1,37 @@
-// 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.
+// Placement math for the monitor widget: which section sits in which grid cell,
+// in what order, and how tall its row is. Pure functions over a `Layout` — no
+// React, no DOM — so the floorplan board, the in-widget menu 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 —
+// THE MODEL IS ROWS OF CELLS, each cell a vertical stack of sections. It
+// replaces the old `SectionId[][]` columns, which had no rows, no spans and no
+// height control — so a section could never span the full width, four one-line
+// totals could never share a rail, and nothing could be given the leftover
+// height to scroll inside. Rendered as CSS Grid: `cols` tracks wide, one grid
+// row per Row, each cell placed with a col-span.
//
-// /widget?l=ctl.disk.wk.jobs-cln.act
+// URL shape. `l=` is still an INPUT format and is still what gets emitted
+// whenever a layout is expressible as plain columns, so every widget link copied
+// before rows existed keeps rendering identically:
//
-// Both are unreserved characters URLSearchParams leaves literal, unlike `,`
-// which would come back as %2C and make a hand-shared link unreadable.
+// /widget?l=ctl.disk.wk.jobs-cln.act (one auto row, N cells, span 1)
+//
+// Anything richer serializes to `g=`:
+//
+// rows separated by _
+// cells separated by -
+// sections separated by .
+// modifiers after * digits = span, r = flow row, f = row fills
+//
+// /widget?g=ctl*2_lsync.disk.cln.bf*2r_sched*f-act.clnl
+//
+// `_ - . *` are the only separators URLSearchParams leaves literal (`~` and `!`
+// come back percent-encoded), which is the same reason the original picked `-`
+// and `.`. Row height rides on a cell modifier rather than a second param: a row
+// is `fill` if ANY of its cells carries `f`, and serialization writes `f` on the
+// row's first cell.
import {
DEFAULT_ORDER,
@@ -20,175 +42,564 @@ import {
type SectionId,
} from "./sections";
-export const COLUMN_SEP = "-";
+export const ROW_SEP = "_";
+export const CELL_SEP = "-";
export const SECTION_SEP = ".";
+export const MOD_SEP = "*";
+// The old name for CELL_SEP, from when a cell was a whole column.
+export const COLUMN_SEP = CELL_SEP;
+
+// Six is where Tailwind's static `grid-cols-N` / `col-span-N` literals stop
+// being worth generating, and well past the point where a 320px widget has
+// anything useful to show. A hand-written layout with more is merged down rather
+// than rejected.
+export const MAX_COLUMNS = 6;
+export const MAX_ROWS = 6;
+
+// A row is `auto` (its content's height, the pre-rows behaviour) or `fill` (it
+// shares the leftover height of the widget's frame). One fill row anywhere is
+// what turns the widget from content-height into frame-height.
+export type RowSize = "auto" | "fill";
+// How a cell arranges the sections inside it: a vertical stack, or one dense
+// horizontal rail of totals.
+export type CellFlow = "col" | "row";
+
+export type Cell = { sections: SectionId[]; span: number; flow: CellFlow };
+export type Row = { size: RowSize; cells: Cell[] };
+export type Layout = { cols: number; rows: Row[] };
+
+export type CellPos = { row: number; cell: number };
+export type SectionPos = { row: number; cell: number; index: number };
+
+function clampInt(n: number, lo: number, hi: number): number {
+ const v = Math.round(Number.isFinite(n) ? n : lo);
+ return Math.max(lo, Math.min(hi, v));
+}
+
+function cloneCell(c: Cell): Cell {
+ return { sections: c.sections.slice(), span: c.span, flow: c.flow };
+}
+
+function cloneLayout(l: Layout): Layout {
+ return {
+ cols: l.cols,
+ rows: l.rows.map((r) => ({ size: r.size, cells: r.cells.map(cloneCell) })),
+ };
+}
-// 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 function emptyCell(span = 1): Cell {
+ return { sections: [], span, flow: "col" };
+}
-export type Columns = SectionId[][];
+// INVARIANT: every row's cell spans sum to exactly `cols`, and no row has more
+// cells than there are columns. Keeping it exact is what makes `cols` derivable
+// from a `g=` string (no separate param) and what keeps the grid gapless.
+//
+// `keep` is the one cell whose span the caller just set on purpose; everything
+// else absorbs the difference, shrinking from the tail first so the cell you
+// dragged is the one that wins.
+function fitRow(cells: Cell[], cols: number, keep = -1): Cell[] {
+ const next = cells.map(cloneCell);
+ if (next.length === 0) return [emptyCell(cols)];
+ // Too many cells for the track count: merge the tail rather than drop it.
+ while (next.length > cols) {
+ const last = next.pop() as Cell;
+ next[next.length - 1].sections.push(...last.sections);
+ }
+ for (const c of next) c.span = clampInt(c.span, 1, cols);
+ const n = next.length;
+ if (keep >= 0 && keep < n) {
+ // Leave every other cell at least one track.
+ next[keep].span = clampInt(next[keep].span, 1, cols - (n - 1));
+ }
+ let sum = next.reduce((s, c) => s + c.span, 0);
+ for (let i = n - 1; sum > cols && i >= 0; i--) {
+ if (i === keep) continue;
+ const take = Math.min(next[i].span - 1, sum - cols);
+ next[i].span -= take;
+ sum -= take;
+ }
+ // Only reachable for a malformed `keep`; clamp rather than emit a bad grid.
+ if (sum > cols && keep >= 0 && keep < n) {
+ next[keep].span -= sum - cols;
+ sum = cols;
+ }
+ if (sum < cols) {
+ let idx = n - 1;
+ if (idx === keep && n > 1) idx = n - 2;
+ next[idx].span += cols - sum;
+ }
+ return next;
+}
-function clone(columns: Columns): Columns {
- return columns.map((col) => col.slice());
+// Bring a whole layout back to the invariants: 1..MAX_COLUMNS tracks,
+// 1..MAX_ROWS rows (extras merged into the last, never dropped), spans summing
+// to `cols` per row.
+export function normalizeLayout(layout: Layout): Layout {
+ const cols = clampInt(layout.cols, 1, MAX_COLUMNS);
+ let rows = layout.rows.map((r) => ({
+ size: r.size === "fill" ? ("fill" as const) : ("auto" as const),
+ cells: r.cells.map(cloneCell),
+ }));
+ if (rows.length === 0) rows = [{ size: "auto", cells: [emptyCell(cols)] }];
+ if (rows.length > MAX_ROWS) {
+ const keep = rows.slice(0, MAX_ROWS);
+ const last = keep[MAX_ROWS - 1];
+ for (const extra of rows.slice(MAX_ROWS)) {
+ for (const cell of extra.cells) {
+ last.cells[last.cells.length - 1].sections.push(...cell.sections);
+ }
+ }
+ rows = keep;
+ }
+ return { cols, rows: rows.map((r) => ({ ...r, cells: fitRow(r.cells, cols) })) };
}
// One column holding every enabled section in the widget's original render
-// order — the layout a link with no `l=` gets, and the baseline
+// order — the layout a link with no `l=`/`g=` 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 defaultLayout(config: SectionFlags): Layout {
+ return {
+ cols: 1,
+ rows: [
+ {
+ size: "auto",
+ cells: [{ sections: enabledSections(config), span: 1, flow: "col" }],
+ },
+ ],
+ };
}
-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]))
- );
+export function sameLayout(a: Layout, b: Layout): boolean {
+ if (a.cols !== b.cols || a.rows.length !== b.rows.length) return false;
+ return a.rows.every((row, r) => {
+ const other = b.rows[r];
+ if (row.size !== other.size || row.cells.length !== other.cells.length) {
+ return false;
+ }
+ return row.cells.every((cell, c) => {
+ const oc = other.cells[c];
+ return (
+ cell.span === oc.span &&
+ cell.flow === oc.flow &&
+ cell.sections.length === oc.sections.length &&
+ cell.sections.every((id, i) => id === oc.sections[i])
+ );
+ });
+ });
}
// 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 {
+// now off, and append ones that are on but unplaced to the LAST CELL OF THE LAST
+// ROW, in DEFAULT_ORDER. The append rule is what keeps a baked link working when
+// a new section is enabled from the in-widget menu (or added to the registry in
+// a later release) — it shows up rather than silently vanishing.
+export function reconcileLayout(layout: Layout, config: SectionFlags): Layout {
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 next = normalizeLayout(layout);
+ for (const row of next.rows) {
+ for (const cell of row.cells) {
+ cell.sections = cell.sections.filter((id) => {
+ if (!on.has(id) || seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ });
+ }
+ }
const missing = DEFAULT_ORDER.filter((id) => on.has(id) && !seen.has(id));
- if (missing.length > 0) next[next.length - 1].push(...missing);
+ if (missing.length > 0) {
+ const lastRow = next.rows[next.rows.length - 1];
+ lastRow.cells[lastRow.cells.length - 1].sections.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.
+// ---------------------------------------------------------------------------
+// Decode
+// ---------------------------------------------------------------------------
+
+type ParsedCell = { cell: Cell; fill: boolean };
+
+// "lsync.disk.cln.bf*2r" → the cell it describes plus whether it marked its row
+// as filling. Unknown modifier letters and out-of-range spans are dropped or
+// clamped, the same tolerance parseLayoutParam has always had for a hand-edited
+// link.
+function parseCell(raw: string, seen: Set<SectionId>): ParsedCell {
+ const star = raw.indexOf(MOD_SEP);
+ const body = star === -1 ? raw : raw.slice(0, star);
+ const mods = star === -1 ? "" : raw.slice(star + 1);
+ const sections: SectionId[] = [];
+ for (const code of body.split(SECTION_SEP)) {
+ const def = sectionByCode(code.trim());
+ if (!def || seen.has(def.id)) continue;
+ seen.add(def.id);
+ sections.push(def.id);
+ }
+ const digits = mods.replace(/[^0-9]/g, "");
+ const span = digits ? clampInt(Number(digits), 1, MAX_COLUMNS) : 1;
+ return {
+ cell: { sections, span, flow: mods.includes("r") ? "row" : "col" },
+ fill: mods.includes("f"),
+ };
+}
+
+function parseGrid(raw: string): Layout {
+ const seen = new Set<SectionId>();
+ const rows: Row[] = [];
+ for (const rowRaw of raw.split(ROW_SEP)) {
+ const parsed = rowRaw.split(CELL_SEP).map((c) => parseCell(c, seen));
+ rows.push({
+ size: parsed.some((p) => p.fill) ? "fill" : "auto",
+ cells: parsed.map((p) => p.cell),
+ });
+ }
+ // `cols` is derived, not carried: with spans summing to the track count per
+ // row (the invariant fitRow enforces), the widest row IS the column count.
+ const cols = rows.reduce(
+ (m, r) => Math.max(m, r.cells.reduce((s, c) => s + c.span, 0)),
+ 1,
+ );
+ return normalizeLayout({ cols, rows });
+}
+
+function parseColumns(raw: string): Layout {
+ const seen = new Set<SectionId>();
+ const cells = raw
+ .split(CELL_SEP)
+ .map((c) => parseCell(c.split(MOD_SEP)[0], seen).cell);
+ return normalizeLayout({ cols: cells.length, rows: [{ size: "auto", cells }] });
+}
+
+function isEmptyLayout(layout: Layout): boolean {
+ return layout.rows.every((r) => r.cells.every((c) => c.sections.length === 0));
+}
+
+// Decode + normalize the layout params. `g` wins when present; `l` decodes into
+// a single auto row of plain stacked cells, which is exactly what it always
+// meant. Unknown codes and repeats are dropped (first placement wins), disabled
+// sections are dropped, and anything enabled but unlisted is appended by
+// reconcileLayout. A value that survives none of that falls back to the default
+// single column rather than rendering nothing.
export function parseLayoutParam(
- raw: string | undefined,
+ l: string | undefined,
+ g: 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);
+): Layout {
+ const raw = g || l;
+ if (!raw) return defaultLayout(config);
+ const layout = g ? parseGrid(g) : parseColumns(l as string);
+ if (isEmptyLayout(layout)) return defaultLayout(config);
+ return reconcileLayout(layout, config);
+}
+
+// ---------------------------------------------------------------------------
+// Encode
+// ---------------------------------------------------------------------------
+
+// True when the layout says nothing a plain `l=` can't: one auto row of
+// single-track stacked cells. This is what keeps every pre-rows link
+// round-tripping to the identical string.
+export function isColumnsLayout(layout: Layout): boolean {
+ return (
+ layout.rows.length === 1 &&
+ layout.rows[0].size === "auto" &&
+ layout.rows[0].cells.every((c) => c.span === 1 && c.flow === "col")
+ );
+}
+
+function cellCode(cell: Cell, fill: boolean): string {
+ const body = cell.sections.map((id) => SECTION_BY_ID[id].code).join(SECTION_SEP);
+ let mods = "";
+ if (cell.span > 1) mods += String(cell.span);
+ if (cell.flow === "row") mods += "r";
+ if (fill) mods += "f";
+ return mods ? `${body}${MOD_SEP}${mods}` : body;
+}
+
+// `{}` when the layout is what a link with no params would already produce, so
+// an all-default config still serializes to a bare /widget. Returns `l` OR `g`,
+// never both — see buildWidgetQuery.
+export function serializeLayout(
+ layout: Layout,
+ config: SectionFlags,
+): { l?: string; g?: string } {
+ if (sameLayout(layout, defaultLayout(config))) return {};
+ if (isColumnsLayout(layout)) {
+ return {
+ l: layout.rows[0].cells
+ .map((c) => c.sections.map((id) => SECTION_BY_ID[id].code).join(SECTION_SEP))
+ .join(CELL_SEP),
+ };
}
- // 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);
+ return {
+ g: layout.rows
+ .map((row) =>
+ row.cells
+ .map((cell, i) => cellCode(cell, row.size === "fill" && i === 0))
+ .join(CELL_SEP),
+ )
+ .join(ROW_SEP),
+ };
}
-// "" 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);
+// ---------------------------------------------------------------------------
+// Movement
+// ---------------------------------------------------------------------------
+
+function cellAt(layout: Layout, row: number, cell: number): Cell | null {
+ return layout.rows[row]?.cells[cell] ?? null;
+}
+
+export function findSection(layout: Layout, id: SectionId): SectionPos | null {
+ for (let row = 0; row < layout.rows.length; row++) {
+ const cells = layout.rows[row].cells;
+ for (let cell = 0; cell < cells.length; cell++) {
+ const index = cells[cell].sections.indexOf(id);
+ if (index !== -1) return { row, cell, index };
+ }
+ }
+ return null;
}
-// Swap with the neighbour above/below inside one column. Same idiom as the
+// Swap with the neighbour above/below inside one cell. Same idiom as the
// reorder in SocialLinksField.
export function moveWithin(
- columns: Columns,
- col: number,
+ layout: Layout,
+ row: number,
+ cell: number,
idx: number,
dir: -1 | 1,
-): Columns {
- const next = clone(columns);
- const target = next[col];
- if (!target) return columns;
+): Layout {
+ const next = cloneLayout(layout);
+ const target = cellAt(next, row, cell);
+ if (!target) return layout;
const j = idx + dir;
- if (j < 0 || j >= target.length) return columns;
- [target[idx], target[j]] = [target[j], target[idx]];
+ if (j < 0 || j >= target.sections.length) return layout;
+ const s = target.sections;
+ [s[idx], s[j]] = [s[j], s[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,
+// Move a section to the adjacent cell in the same row, landing at the same
+// height where that cell is deep enough and at the end where it isn't.
+export function moveToCell(
+ layout: Layout,
+ row: number,
+ cell: 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);
+): Layout {
+ const to = cell + dir;
+ const from = cellAt(layout, row, cell);
+ if (!from || to < 0 || to >= layout.rows[row].cells.length) return layout;
+ const next = cloneLayout(layout);
+ const [id] = next.rows[row].cells[cell].sections.splice(idx, 1);
+ if (id === undefined) return layout;
+ const dest = next.rows[row].cells[to].sections;
+ dest.splice(Math.min(idx, dest.length), 0, id);
+ return next;
+}
+
+// Move a section to the adjacent row, into the cell at the same horizontal
+// position (or the last one, where that row is narrower).
+export function moveToRow(
+ layout: Layout,
+ row: number,
+ cell: number,
+ idx: number,
+ dir: -1 | 1,
+): Layout {
+ const to = row + dir;
+ if (to < 0 || to >= layout.rows.length) return layout;
+ const next = cloneLayout(layout);
+ const [id] = next.rows[row].cells[cell]?.sections.splice(idx, 1) ?? [];
+ if (id === undefined) return layout;
+ const destCells = next.rows[to].cells;
+ const destCell = destCells[Math.min(cell, destCells.length - 1)];
+ destCell.sections.push(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.
+// sits first. `index` is read against the cell as the user sees it, so a move
+// down within one cell has to account for the removal shifting things up.
export function insertAt(
- columns: Columns,
+ layout: Layout,
id: SectionId,
- col: number,
+ row: number,
+ cell: 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);
+): Layout {
+ const next = cloneLayout(layout);
+ const target = cellAt(next, row, cell);
+ if (!target) return layout;
+ let at = index;
+ const found = findSection(next, id);
+ if (found) {
+ next.rows[found.row].cells[found.cell].sections.splice(found.index, 1);
+ if (found.row === row && found.cell === cell && found.index < index) at -= 1;
+ }
+ target.sections.splice(Math.max(0, Math.min(at, target.sections.length)), 0, id);
return next;
}
-export function removeSection(columns: Columns, id: SectionId): Columns {
- return columns.map((col) => col.filter((x) => x !== id));
+export function removeSection(layout: Layout, id: SectionId): Layout {
+ const next = cloneLayout(layout);
+ for (const row of next.rows) {
+ for (const cell of row.cells) {
+ cell.sections = cell.sections.filter((x) => x !== id);
+ }
+ }
+ return next;
}
-// 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;
+// ---------------------------------------------------------------------------
+// Structure
+// ---------------------------------------------------------------------------
+
+// Grow by appending an empty cell per row; shrink by merging every trailing cell
+// into the last one that survives, so nothing is ever silently dropped.
+export function setColumnCount(layout: Layout, n: number): Layout {
+ const cols = clampInt(n, 1, MAX_COLUMNS);
+ if (cols === layout.cols) return layout;
+ const next = cloneLayout(layout);
+ next.cols = cols;
+ if (cols > layout.cols) {
+ for (const row of next.rows) {
+ for (let i = layout.cols; i < cols; i++) row.cells.push(emptyCell(1));
+ }
}
- const next = clone(columns.slice(0, count));
- for (const col of columns.slice(count)) next[count - 1].push(...col);
+ return normalizeLayout(next);
+}
+
+// Grow by appending a row of empty single-track cells — one per column, so a new
+// row is a place to drop things rather than one undivided band; shrink by
+// merging every trailing row's sections into the last surviving row's last cell.
+export function setRowCount(layout: Layout, n: number): Layout {
+ const count = clampInt(n, 1, MAX_ROWS);
+ if (count === layout.rows.length) return layout;
+ const next = cloneLayout(layout);
+ if (count > next.rows.length) {
+ while (next.rows.length < count) {
+ next.rows.push({
+ size: "auto",
+ cells: Array.from({ length: next.cols }, () => emptyCell(1)),
+ });
+ }
+ return normalizeLayout(next);
+ }
+ const keep = next.rows.slice(0, count);
+ const last = keep[count - 1];
+ for (const row of next.rows.slice(count)) {
+ for (const cell of row.cells) {
+ last.cells[last.cells.length - 1].sections.push(...cell.sections);
+ }
+ }
+ next.rows = keep;
+ return normalizeLayout(next);
+}
+
+export function setRowSize(layout: Layout, row: number, size: RowSize): Layout {
+ if (!layout.rows[row] || layout.rows[row].size === size) return layout;
+ const next = cloneLayout(layout);
+ next.rows[row].size = size;
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 };
+// Widen/narrow one cell. The row keeps summing to the track count, so the
+// neighbours give up (or take back) what this one gains — the cell you dragged
+// is the one that wins.
+//
+// A neighbour squeezed to nothing is REMOVED and its sections merged into the
+// cell that took its tracks, rather than the widen being refused: "make this
+// span the whole row" is the commonest thing anyone asks a grid for, and
+// refusing it because some other cell still holds a track would be the wrong
+// answer. Narrowing the last cell in a row is the exact inverse — the freed
+// tracks become a new empty cell you can drop into.
+function applySpan(cells: Cell[], cols: number, keep: number, want: number): Cell[] {
+ const next = cells.map(cloneCell);
+ next[keep].span = clampInt(want, 1, cols);
+ let sum = next.reduce((s, c) => s + c.span, 0);
+ // Nearest first: the cell you are growing into is the one beside you.
+ const order: number[] = [];
+ for (let i = keep + 1; i < next.length; i++) order.push(i);
+ for (let i = keep - 1; i >= 0; i--) order.push(i);
+ for (const i of order) {
+ if (sum <= cols) break;
+ const take = Math.min(next[i].span, sum - cols);
+ next[i].span -= take;
+ sum -= take;
}
- return null;
+ const merged: Cell[] = [];
+ for (let i = 0; i < next.length; i++) {
+ if (i !== keep && next[i].span <= 0) {
+ next[keep].sections.push(...next[i].sections);
+ continue;
+ }
+ merged.push(next[i]);
+ }
+ sum = merged.reduce((s, c) => s + c.span, 0);
+ if (sum < cols) {
+ const k = merged.indexOf(next[keep]);
+ let idx = merged.length - 1;
+ if (idx === k) idx = merged.length - 2;
+ if (idx < 0) merged.push(emptyCell(cols - sum));
+ else merged[idx].span += cols - sum;
+ }
+ return merged;
+}
+
+export function setCellSpan(
+ layout: Layout,
+ row: number,
+ cell: number,
+ span: number,
+): Layout {
+ const target = cellAt(layout, row, cell);
+ if (!target) return layout;
+ const next = cloneLayout(layout);
+ next.rows[row].cells = applySpan(next.rows[row].cells, next.cols, cell, span);
+ return sameLayout(next, layout) ? layout : next;
+}
+
+export function setCellFlow(
+ layout: Layout,
+ row: number,
+ cell: number,
+ flow: CellFlow,
+): Layout {
+ const target = cellAt(layout, row, cell);
+ if (!target || target.flow === flow) return layout;
+ const next = cloneLayout(layout);
+ next.rows[row].cells[cell].flow = flow;
+ return next;
+}
+
+export function addCell(layout: Layout, row: number): Layout {
+ const target = layout.rows[row];
+ if (!target || target.cells.length >= layout.cols) return layout;
+ const next = cloneLayout(layout);
+ next.rows[row].cells.push(emptyCell(1));
+ next.rows[row].cells = fitRow(next.rows[row].cells, next.cols);
+ return next;
+}
+
+// Remove a cell, merging its sections into its neighbour rather than dropping
+// them. The last cell in a row can't be removed — a row always has one.
+export function removeCell(layout: Layout, row: number, cell: number): Layout {
+ const target = layout.rows[row];
+ if (!target || target.cells.length <= 1 || !target.cells[cell]) return layout;
+ const next = cloneLayout(layout);
+ const [gone] = next.rows[row].cells.splice(cell, 1);
+ const into = next.rows[row].cells[Math.max(0, cell - 1)];
+ into.sections.push(...gone.sections);
+ next.rows[row].cells = fitRow(next.rows[row].cells, next.cols);
+ return next;
+}
+
+// Whether any row asks for the leftover height — the one thing that changes the
+// widget from content-height to frame-height.
+export function hasFillRow(layout: Layout): boolean {
+ return layout.rows.some((r) => r.size === "fill");
}
diff --git a/editor/app/widget/lib/sections.ts b/editor/app/widget/lib/sections.ts
@@ -31,7 +31,7 @@ export type SectionId =
// 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">;
+export type SectionFlags = Omit<WidgetConfig, "layout">;
// 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
diff --git a/editor/app/widget/page.tsx b/editor/app/widget/page.tsx
@@ -1,6 +1,12 @@
import type { Metadata } from "next";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ findWidgetPreset,
+ readWidgetPresets,
+} from "yt-dlp-transcript-common/lib/widgetPresets";
import { buildActiveJobsPayload } from "../jobs/active/buildActiveJobs";
import { buildWorkersPayload } from "../workers/buildWorkers";
+import { BUILT_IN_PRESETS } from "./lib/builtInPresets";
import { MonitorWidget } from "./components/MonitorWidget";
import { parseWidgetConfig } from "./lib/config";
@@ -8,18 +14,52 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Monitor" };
+type RawParams = Record<string, string | string[] | undefined>;
+
+function first(v: string | string[] | undefined): string | undefined {
+ return Array.isArray(v) ? v[0] : v;
+}
+
+// Expand `?preset=<id|name>` into the params it stands for. A preset stores a
+// query string, so this is a merge of two links — and ANY param written
+// explicitly in the URL WINS, so `?preset=cockpit&titles=0` is the preset with
+// its titles off rather than a contradiction. Built-ins resolve too, and an
+// unknown reference is simply the rest of the URL.
+async function resolvePreset(params: RawParams): Promise<RawParams> {
+ const ref = first(params.preset)?.trim();
+ if (!ref) return params;
+ const saved = await readWidgetPresets(getPaths());
+ const preset =
+ findWidgetPreset(saved, ref) ??
+ BUILT_IN_PRESETS.find(
+ (p) => p.id === ref || p.name.toLowerCase() === ref.toLowerCase(),
+ ) ??
+ null;
+ if (!preset) return params;
+ const merged: RawParams = {};
+ for (const [k, v] of new URLSearchParams(preset.query)) merged[k] = v;
+ for (const [k, v] of Object.entries(params)) {
+ if (k !== "preset") merged[k] = v;
+ }
+ return merged;
+}
+
// Bare, read-only monitor. The root layout strips its chrome (see AppFrame) so
// it can be embedded in a small pinned window or iframe. What it shows is driven
// entirely by GET params (see lib/config); the /widget/builder page composes
-// those links. Initial payloads are built server-side here for a flicker-free
-// first paint; MonitorWidget then polls the same /api endpoints for live data.
+// those links, and `?preset=` names one that was saved. Initial payloads are
+// built server-side here for a flicker-free first paint; MonitorWidget then
+// polls the same /api endpoints for live data.
export default async function WidgetPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
- const config = parseWidgetConfig((await searchParams) ?? {});
- const initialJobs = config.jobs ? await buildActiveJobsPayload() : null;
+ const config = parseWidgetConfig(await resolvePreset((await searchParams) ?? {}));
+ // The disk strip reads its numbers off this payload as well, so a
+ // disk-without-jobs widget needs it built too (see MonitorWidget's poll gate).
+ const initialJobs =
+ config.jobs || config.disk ? await buildActiveJobsPayload() : null;
// Controls need the workers payload (for `paused`) even if the Workers section
// itself is hidden.
const initialWorkers =
diff --git a/editor/app/widget/presetActions.ts b/editor/app/widget/presetActions.ts
@@ -0,0 +1,61 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ addWidgetPreset,
+ moveWidgetPreset,
+ removeWidgetPreset,
+ renameWidgetPreset,
+} from "yt-dlp-transcript-common/lib/widgetPresets";
+
+// Server actions behind the preset row, in the same shape and with the same
+// revalidation as editor/app/jobs/bookmarkActions.ts. The in-widget menu reads
+// back through /api/widget/presets (it is a client component with no server
+// parent to revalidate into); the builder page SSR-seeds and this
+// revalidatePath refreshes it.
+
+export type PresetActionResult = { ok: true } | { ok: false; error: string };
+
+function refreshBuilder(): void {
+ revalidatePath("/widget/builder");
+}
+
+// Save `query` under `name`. Saving over an existing name overwrites it — a
+// named slot, not an ever-growing list of same-named entries.
+export async function saveWidgetPresetAction(
+ name: string,
+ query: string,
+): Promise<PresetActionResult> {
+ const preset = await addWidgetPreset(getPaths(), name, query);
+ if (!preset) return { ok: false, error: "A preset needs a name." };
+ refreshBuilder();
+ return { ok: true };
+}
+
+export async function renameWidgetPresetAction(
+ id: string,
+ name: string,
+): Promise<PresetActionResult> {
+ const ok = await renameWidgetPreset(getPaths(), id, name);
+ if (!ok) return { ok: false, error: "A preset needs a name." };
+ refreshBuilder();
+ return { ok: true };
+}
+
+export async function deleteWidgetPresetAction(
+ id: string,
+): Promise<PresetActionResult> {
+ await removeWidgetPreset(getPaths(), id);
+ refreshBuilder();
+ return { ok: true };
+}
+
+export async function moveWidgetPresetAction(
+ id: string,
+ dir: -1 | 1,
+): Promise<PresetActionResult> {
+ await moveWidgetPreset(getPaths(), id, dir);
+ refreshBuilder();
+ return { ok: true };
+}
diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts
@@ -11,6 +11,7 @@ import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { test, expect } from "@playwright/test";
import {
+ readJson,
resetData,
resolvePath,
writeSettings,
@@ -58,6 +59,31 @@ async function seedCleanableChannel(
await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
}
+// Settings with a backfill KIND registered (diarization), so the widget's
+// backfill strip has something to report and its pause has something to hold.
+// Same shape as backfill.spec.ts's — `anyKind` is what gates both.
+const BACKFILL_SETTINGS = {
+ diarization: {
+ enabled: true,
+ inlineAfterTranscribe: false,
+ threshold: 0.5,
+ threads: 1,
+ python: "python3",
+ segModel: "/dev/null",
+ embModel: "/dev/null",
+ concurrency: 1,
+ },
+ backfill: {
+ enabled: true,
+ weight: 1,
+ concurrency: 1,
+ sweepEnabled: false,
+ sweepKinds: [],
+ sweepChannels: [],
+ allowRedownload: false,
+ },
+};
+
const TWO_WORKERS = {
workers: [
{ id: "gpu", name: "GPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: {} },
@@ -530,3 +556,260 @@ test("a link written before layouts existed renders in the original order", asyn
"Needs work",
]);
});
+
+// ---------------------------------------------------------------------------
+// Rows, spans, fill and the dense rail
+// ---------------------------------------------------------------------------
+//
+// The layout model went from N columns of stacked sections to ROWS OF CELLS —
+// which is what makes a full-width section, a rail of totals and a scrolling
+// section possible at all. `l=` stayed an input format and is still what gets
+// emitted for anything plain columns can express, so every assertion above this
+// line is the back-compatibility proof and none of it changed.
+
+test("a g= layout renders as a grid: rows, and a section spanning both tracks", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 900, height: 700 });
+ // The Cockpit arrangement, reached by name — which also covers ?preset=
+ // resolving server-side.
+ await page.goto("/widget?preset=cockpit");
+
+ const controls = page.getByRole("region", { name: "Controls" });
+ const sched = page.getByRole("region", { name: "Auto-sync scheduler" });
+ const work = page.getByRole("region", { name: "Needs work" });
+ await expect(work).toBeVisible({ timeout: 10_000 });
+
+ const cb = (await controls.boundingBox())!;
+ const sb = (await sched.boundingBox())!;
+ const wb = (await work.boundingBox())!;
+
+ // Controls spans BOTH tracks: wider than either of the cells below it.
+ expect(cb.width).toBeGreaterThan(sb.width * 1.5);
+ // Three rows, top to bottom...
+ expect(sb.y).toBeGreaterThan(cb.y);
+ // ...and the last row's two cells sit side by side.
+ expect(wb.x).toBeGreaterThan(sb.x);
+});
+
+test("a row-flow cell puts the totals on one line inside one rail", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 900, height: 400 });
+ await page.goto(
+ "/widget?jobs=0&workers=0&clean=1&lastsync=1&sched=1&g=lsync.sched.disk.cln*r",
+ );
+
+ const sync = page.getByRole("region", { name: "Last sync" });
+ const sched = page.getByRole("region", { name: "Auto-sync scheduler" });
+ const disk = page.getByRole("region", { name: "Disk space" });
+ const clean = page.getByRole("region", { name: "Cleanable data" });
+ await expect(clean).toBeVisible({ timeout: 10_000 });
+
+ // One line: every total shares a baseline.
+ const boxes = await Promise.all(
+ [sync, sched, disk, clean].map((l) => l.boundingBox()),
+ );
+ for (const b of boxes) expect(Math.abs(b!.y - boxes[0]!.y)).toBeLessThan(4);
+ // ...in order, left to right.
+ expect(boxes[3]!.x).toBeGreaterThan(boxes[0]!.x);
+
+ // ONE container: the rail carries the border, the strips inside carry none.
+ expect(
+ await sync.evaluate((el) => getComputedStyle(el).borderTopWidth),
+ ).toBe("0px");
+
+ // And the copy shortens rather than wrapping.
+ await expect(sync).toContainText("synced");
+ await expect(sync).not.toContainText("Last full sync");
+});
+
+test("a fill row scrolls its sections under their own headings, uncapped", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ // Twelve channels: twice the six-row cap the non-scrolling list uses, and
+ // more rows than the short frame below can hold — so there is something to
+ // scroll rather than a box that merely could.
+ for (let i = 0; i < 12; i += 1) {
+ const slug = `bulk-clean-${i}`;
+ await writeChannelConfig(slug);
+ await seedCleanableChannel(slug, 1_048_576 * (i + 1));
+ }
+
+ await page.setViewportSize({ width: 900, height: 300 });
+ await page.goto("/widget?jobs=0&workers=0&cleanlist=1&g=clnl*f");
+
+ const list = page.getByRole("region", { name: "Needs cleaning" });
+ await expect(list).toBeVisible({ timeout: 10_000 });
+
+ // Given the height to scroll in, the cap is lifted: all eight rows are there
+ // and there is no "+N more" control left to defeat the point.
+ const rows = list
+ .getByRole("listitem")
+ .and(page.locator("[aria-label^='needs cleaning ']"));
+ await expect(rows).toHaveCount(12, { timeout: 10_000 });
+ await expect(list.getByRole("button", { name: /more channels/ })).toHaveCount(0);
+
+ // The heading is pinned: scrolling the list does not move it.
+ const heading = list.getByRole("heading", { name: "Needs cleaning" });
+ const before = (await heading.boundingBox())!;
+ const scrolled = await list.evaluate((el) => {
+ const box = el.firstElementChild as HTMLElement;
+ box.scrollTop = 300;
+ return box.scrollTop;
+ });
+ expect(scrolled).toBeGreaterThan(0);
+ const after = (await heading.boundingBox())!;
+ expect(Math.abs(after.y - before.y)).toBeLessThan(2);
+ await expect(heading).toBeInViewport();
+});
+
+test("the board writes rows, fill and spans into the link, and falls back to l=", async ({
+ page,
+}) => {
+ await page.goto("/widget/builder");
+ const url = page.getByLabel("widget URL");
+ await expect(url).toHaveValue(/\/widget$/);
+
+ await page.getByRole("button", { name: "2 columns" }).click();
+ await expect(url).toHaveValue(/[?&]l=disk\.wk\.jobs-(&|$)/);
+
+ // A second row, and something in it.
+ await page.getByRole("button", { name: "Add a row" }).click();
+ await page
+ .getByRole("button", { name: "Move Active jobs to the row below" })
+ .click();
+ await expect(url).toHaveValue(/[?&]g=disk\.wk-_jobs-(&|$)/);
+
+ // The left rail: give the second row the leftover height.
+ await page.getByRole("button", { name: "Row 2 height" }).click();
+ await expect(url).toHaveValue(/[?&]g=disk\.wk-_jobs\*f-(&|$)/);
+
+ // Widening swallows the empty cell beside it.
+ await page.getByRole("button", { name: "Widen row 1 cell 1" }).click();
+ await expect(url).toHaveValue(/[?&]g=disk\.wk\*2_jobs\*f-(&|$)/);
+
+ // ...and undoing all three falls back to a plain columns link, which is what
+ // keeps a link shareable with anything that only ever understood `l=`.
+ await page.getByRole("button", { name: "Narrow row 1 cell 1" }).click();
+ await page.getByRole("button", { name: "Row 2 height" }).click();
+ await page.getByRole("button", { name: "Remove the last row" }).click();
+ await expect(url).toHaveValue(/[?&]l=disk\.wk-jobs(&|$)/);
+ await expect(url).not.toHaveValue(/[?&]g=/);
+});
+
+// ---------------------------------------------------------------------------
+// Presets
+// ---------------------------------------------------------------------------
+
+test("a preset saves, loads and deletes, in the builder and in the widget", async ({
+ page,
+}) => {
+ await page.goto("/widget/builder");
+ const url = page.getByLabel("widget URL");
+
+ // Arrange something worth keeping.
+ await page.getByRole("checkbox", { name: "Workers" }).uncheck();
+ await expect(url).toHaveValue(/\/widget\?workers=0$/);
+
+ page.once("dialog", (d) => d.accept("Corner pin"));
+ await page.getByRole("button", { name: "Save as…" }).click();
+
+ const select = page.getByRole("combobox", { name: "preset" });
+ await expect(select.locator("option", { hasText: "Corner pin" })).toHaveCount(
+ 1,
+ { timeout: 10_000 },
+ );
+
+ // A built-in loads as a whole link, layout included.
+ await select.selectOption({ label: "Glance" });
+ await expect(url).toHaveValue(/[?&]g=lsync\.disk\.cln\.bf\.wk\*r(&|$)/);
+
+ // ...and the saved one comes back exactly as it was saved.
+ await select.selectOption({ label: "Corner pin" });
+ await expect(url).toHaveValue(/\/widget\?workers=0$/);
+
+ // The same preset is there inside the widget's own menu — one store, two
+ // surfaces — and loading it there rewrites the widget's address bar.
+ await page.goto("/widget");
+ await page.getByRole("button", { name: "Settings" }).click();
+ const dialog = page.getByRole("dialog", { name: "Widget settings" });
+ const inWidget = dialog.getByRole("combobox", { name: "preset" });
+ await expect(inWidget.locator("option", { hasText: "Corner pin" })).toHaveCount(
+ 1,
+ { timeout: 10_000 },
+ );
+ await inWidget.selectOption({ label: "Corner pin" });
+ await expect(page).toHaveURL(/[?&]workers=0(&|$)/);
+
+ page.once("dialog", (d) => d.accept());
+ await dialog.getByRole("button", { name: "Delete", exact: true }).click();
+ await expect(inWidget.locator("option", { hasText: "Corner pin" })).toHaveCount(
+ 0,
+ { timeout: 10_000 },
+ );
+});
+
+test("the in-widget menu is the same board, and its moves reach the URL", async ({
+ page,
+}) => {
+ await page.goto("/widget");
+ await page.getByRole("button", { name: "Settings" }).click();
+ const dialog = page.getByRole("dialog", { name: "Widget settings" });
+
+ // The board, not the list form that used to live here: the same move buttons
+ // the builder page has, writing the same param.
+ await dialog.getByRole("button", { name: "Move Active jobs up" }).click();
+ await expect(page).toHaveURL(/[?&]l=disk\.jobs\.wk(&|$)/);
+
+ // ...and the row rail too, which is what a second, narrower form could never
+ // have offered.
+ await dialog.getByRole("button", { name: "Row 1 height" }).click();
+ await expect(page).toHaveURL(/[?&]g=disk\.jobs\.wk\*f(&|$)/);
+});
+
+// ---------------------------------------------------------------------------
+// Backfill in the widget
+// ---------------------------------------------------------------------------
+
+test("backfill=1 alone renders the Backfill strip", async ({ page }) => {
+ // REGRESSION: the sync poll used to be gated on lastSync || scheduler only,
+ // so a widget asking for just the backfill strip fetched nothing and rendered
+ // nothing — with no clue that it was the gate rather than the corpus.
+ await writeSettings({ ...TWO_WORKERS, ...BACKFILL_SETTINGS });
+ await page.goto("/widget?backfill=1");
+ await expect(page.getByRole("region", { name: "Backfill" })).toBeVisible({
+ timeout: 15_000,
+ });
+});
+
+test("the widget's controls hold and release the backfill lane", async ({
+ page,
+}) => {
+ await writeSettings({ ...TWO_WORKERS, ...BACKFILL_SETTINGS });
+
+ // Assert the FIELD, not just the label: the button writes the same
+ // settings.backfill.enabled the Settings checkbox does, and the point of that
+ // choice is that the two cannot drift.
+ const laneEnabled = async () =>
+ (
+ await readJson<{ backfill?: { enabled?: boolean } }>(
+ "test-settings.json",
+ ).catch(() => ({}) as { backfill?: { enabled?: boolean } })
+ ).backfill?.enabled ?? false;
+
+ await page.goto("/widget?controls=1&backfill=1");
+ const pause = page.getByRole("button", { name: "pause backfill" });
+ await expect(pause).toBeEnabled({ timeout: 30_000 });
+ await pause.click();
+
+ await expect.poll(laneEnabled, { timeout: 30_000 }).toBe(false);
+ // The label flips immediately, because the click refetches the payload it
+ // reads rather than waiting out the 15s poll floor.
+ const resume = page.getByRole("button", { name: "resume backfill" });
+ await expect(resume).toBeVisible({ timeout: 15_000 });
+
+ await resume.click();
+ await expect.poll(laneEnabled, { timeout: 30_000 }).toBe(true);
+});