commit 0f881094bdf05bbf12764a41e112b3f2667915f4 parent 27f7d3a7494943e086d1cd9eafd9dff0b03e70a9 Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Thu, 1 Oct 2026 03:14:45 -0400 Merge r16/forms-keep-input (release 16 slice FK) — a form that fails keeps what was typed: every refusing action returns the submitted values, the shared field helpers seed from them, controlled choices are put back after React's reset, selects re-seed on success; all fifteen forms and the four structured widgets; reviewed SHIP Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Diffstat:
38 files changed, 2040 insertions(+), 297 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -13,6 +13,7 @@ - **`/jobs` names the hub's and the homepage's jobs.** They show as **Build hub**, **Deploy hub**, **Build & deploy hub**, **Build homepage**, **Deploy homepage** and **Build & deploy homepage**, not as `build-hub`, `build-homepage` and so on. - **A job cancelled before it started now stays cancelled.** Its record on disk kept saying "queued", so a restart could put a job you had just cancelled back in its queue, and a clip fetch cancelled while waiting could be reported as still queued. Jobs still waiting when the editor shuts down are handled as before: the next start settles or re-queues them. - **A site's Charts tab gives a sixth series its own colour.** The dashboard's charts coloured their series from five colours and started again at the sixth, so a chart broken down by six or more channels drew the sixth in the first one's colour. The sixth now takes the palette's sixth colour, and each from the seventh on a hue of its own; the first five are unchanged. The published sites' charts get the same change with their next build. +- **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default). ## [0.11.0] - 2026-09-30 - **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites. diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/DeleteVideoDirSection.tsx @@ -5,6 +5,7 @@ import { deleteVideoDirAction, type DeleteDirActionResult, } from "../../videoActions"; +import { SeededInput } from "../../../../../../components/forms/Field"; export function DeleteVideoDirSection({ slug, @@ -29,8 +30,10 @@ export function DeleteVideoDirSection({ aria-label={`delete video directory ${videoId}`} className="flex flex-wrap items-start gap-2" > - <input + <SeededInput + state={state} name="confirmId" + initial="" required placeholder={videoId} aria-label="confirm video id" diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -83,6 +83,7 @@ import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotSc import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; import { fixIncompleteTranscriptOne } from "../../lib/fixIncompleteTranscript"; import { writeFileAtomic, writeJsonAtomic } from "yt-dlp-transcript-common/lib/jsonFile-server"; +import { formValues, type FormErrorState } from "../../../../lib/formState"; function videoQueueKey(config: ChannelConfig, override: string | undefined): string { return resolveQueueKey(downloadQueueKey(config), override); @@ -544,7 +545,8 @@ export async function setPrimaryTranscriptAction( return { ok: true }; } -export type DeleteDirActionResult = { error: string } | undefined; +// A refusal carries what was submitted (lib/formState.ts). +export type DeleteDirActionResult = FormErrorState; // Validated, non-redirecting directory delete shared by the single-video form // action and the bulk action. Pure filesystem op — queues no job, so it never @@ -604,14 +606,16 @@ export async function deleteVideoDirAction( _prev: DeleteDirActionResult, formData: FormData, ): Promise<DeleteDirActionResult> { + const values = formValues(formData); const confirm = String(formData.get("confirmId") ?? "").trim(); if (confirm !== videoId) { return { error: `Type the video id "${videoId}" exactly to confirm deletion`, + values, }; } const result = await deleteOneVideoDir(slug, videoId); - if (!result.ok) return { error: result.error }; + if (!result.ok) return { error: result.error, values }; revalidatePath(`/channels/${slug}`); requestChannelSnapshot(getPaths(), slug); redirect(`/channels/${slug}`); diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -80,8 +80,11 @@ import { import { queueForSlugs, type QueueOutcome } from "./lib/queueForSlugs"; import { storePlaylistAction, syncAction } from "./[slug]/pipelineActions"; import { fetchPostsAction } from "./[slug]/socialActions"; +import { formValues, type FormErrorState } from "../lib/formState"; -export type ActionResult = { error: string } | undefined; +// `undefined` is success; a refusal says why, and a form's refusal carries +// what was submitted so the form keeps what was typed (lib/formState.ts). +export type ActionResult = FormErrorState; // Import-for-side-effect, deferred to call time so the heavy fetcher modules // never enter the module graph a client component imports. @@ -182,20 +185,22 @@ export async function createChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); let parsed; try { parsed = parseChannelForm(formData); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } const { name, config } = parsed; const slug = parsed.slug || slugify(name); if (!slug) { - return { error: "Could not derive a slug from the name" }; + return { error: "Could not derive a slug from the name", values }; } if (!isValidChannelSlug(slug)) { return { error: `"${slug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`, + values, }; } const paths = getPaths(); @@ -212,10 +217,10 @@ export async function createChannelAction( siteWrites = planSiteMembershipWrites(paths, slug, requests); } } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } if (await channelExists(paths, slug)) { - return { error: `Channel "${slug}" already exists` }; + return { error: `Channel "${slug}" already exists`, values }; } await createChannel(paths, slug, config); // THE COMPILED TREES NAME THEIR CHANNELS. A channel created since the last @@ -234,6 +239,7 @@ export async function createChannelAction( // The channel itself was created; don't redirect as if nothing happened. return { error: `Channel "${slug}" was created, but updating site memberships failed: ${(e as Error).message}. Open its Configure panel to retry.`, + values, }; } // URL-first onboarding: unless opted out ("Fetch playlist now", default on), @@ -284,15 +290,16 @@ export async function updateChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); let parsed; try { parsed = parseChannelForm(formData); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } const paths = getPaths(); const existing = await readChannelConfig(paths, slug); - if (!existing) return { error: `Channel "${slug}" not found` }; + if (!existing) return { error: `Channel "${slug}" not found`, values }; // Plan the site-membership writes (Sites section) before touching anything, // so bad input errors out with no partial write. Null = field absent // (zero sites configured / legacy submit) → leave memberships alone. @@ -305,7 +312,7 @@ export async function updateChannelAction( siteWrites = planSiteMembershipWrites(paths, slug, requests); } } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // The form parser only emits keys whose form value is meaningful, so a // cleared input is absent from `parsed.config`. A plain spread would keep @@ -317,11 +324,11 @@ export async function updateChannelAction( const written = await patchChannelConfig(paths, slug, parsed.config, { unset: CHANNEL_FORM_FIELDS, }); - if (!written) return { error: `Channel "${slug}" not found` }; + if (!written) return { error: `Channel "${slug}" not found`, values }; try { await applySiteWrites(siteWrites, paths); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // Config changes (e.g. audioFormat / handling) feed snapshot buckets, so // refresh the report through the global debounced scheduler. @@ -584,10 +591,12 @@ export async function deleteChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); const confirm = String(formData.get("confirmSlug") ?? "").trim(); if (confirm !== slug) { return { error: `Type the channel slug "${slug}" exactly to confirm deletion`, + values, }; } // THE RENAME'S GUARD, AND DELETE NEEDED IT MORE. Renaming while a job runs @@ -598,7 +607,7 @@ export async function deleteChannelAction( // races the writer for the tree and whichever loses reports an ENOENT nobody // asked about. const busy = channelMediaBusyReason(slug, "deleting it"); - if (busy) return { error: busy }; + if (busy) return { error: busy, values }; // THE OTHER REFUSAL REACHES THE FORM THE SAME WAY. `deleteChannel` THROWS // when `.relocating.json` is present — media in transition is not a channel // anyone may delete — and an uncaught throw from a server action is a @@ -609,7 +618,7 @@ export async function deleteChannelAction( try { await deleteChannel(getPaths(), slug); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // Same reason as createChannelAction: the deleted channel keeps a leaf in // every compiled tree until something recompiles. A leaf matching nothing is @@ -635,29 +644,32 @@ export async function renameChannelAction( _prev: ActionResult, formData: FormData, ): Promise<ActionResult> { + const values = formValues(formData); const confirm = String(formData.get("confirmSlug") ?? "").trim(); if (confirm !== oldSlug) { return { error: `Type the channel slug "${oldSlug}" exactly to confirm the rename`, + values, }; } const newSlug = String(formData.get("newSlug") ?? "").trim(); if (!newSlug) { - return { error: "Enter a new slug" }; + return { error: "Enter a new slug", values }; } if (newSlug === oldSlug) { - return { error: "The new slug is the same as the current one" }; + return { error: "The new slug is the same as the current one", values }; } if (!isValidChannelSlug(newSlug)) { return { error: `"${newSlug}" is not a valid slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`, + values, }; } const paths = getPaths(); const config = await readChannelConfig(paths, oldSlug); - if (!config) return { error: `Channel "${oldSlug}" not found` }; + if (!config) return { error: `Channel "${oldSlug}" not found`, values }; if (await channelExists(paths, newSlug)) { - return { error: `Channel "${newSlug}" already exists` }; + return { error: `Channel "${newSlug}" already exists`, values }; } // THE REGISTRY IS HALF THE TRUTH, and this check used to be the other half's @@ -668,13 +680,13 @@ export async function renameChannelAction( // under it. One question, one answer, the same sentence the Storage panel // and the bulk move say. const busy = channelMediaBusyReason(oldSlug, "renaming it"); - if (busy) return { error: busy }; + if (busy) return { error: busy, values }; let result; try { result = await renameChannel(paths, oldSlug, newSlug, config); } catch (e) { - return { error: (e as Error).message }; + return { error: (e as Error).message, values }; } // THE PRIORITY DOCUMENT KEYS BY SLUG, so it has to follow the rename or the // channel's tier, rank and per-operation overrides stay under a slug that no diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx @@ -34,6 +34,21 @@ import { DurationField } from "../../components/DurationField"; import { handleFromAccountUrl } from "yt-dlp-transcript-common/social/fetchers"; import { probeChannelUrlAction } from "../actions"; import { + Field, + SeededInput, + SeededSelect, +} from "../../components/forms/Field"; +import { + ControlledCheck, + ControlledSelect, +} from "../../components/forms/Controlled"; +import { + failedValues, + seedChecked, + seedValue, + type SeedSource, +} from "../../lib/formState"; +import { SiteMembershipsSection, type InitialMembership, type SiteMembershipOption, @@ -92,6 +107,10 @@ type Props = { initial?: { slug: string; config: ChannelConfig }; submitLabel: string; errorMessage?: string; + // The form's action state (ChannelFormClient's useActionState): after a + // refused submit, every uncontrolled field seeds from what was submitted + // (lib/formState.ts). + state?: SeedSource; // All configured sites, for the Sites membership section. sites: SiteMembershipOption[]; // Edit mode: the channel's current site memberships. @@ -106,12 +125,30 @@ export function ChannelForm({ initial, submitLabel, errorMessage, + state, sites, initialMemberships, activeSiteId, }: Props) { const isEdit = !!initial; const c = initial?.config; + // Edit mode's handling radios are uncontrolled: a radio group seeds from the + // VALUE it posted, not from whether its name was there. + const editHandling = seedValue( + state, + "handling", + c?.handling === "transcribe" ? "transcribe" : "youtube", + ); + // Create mode's "On create" boxes are drawn per KIND of source (Fetch posts + // now for a social account; Fetch playlist now and Add to top for a video + // channel), so each kind seeds only from a refused submit of its own kind. + // A refused video submit carries no `fetchPostsNow`, and switching the URL + // to a social account afterwards must show that box at its default + // (ticked), not "absent, so unticked". The social branch always posts the + // hidden `sourceKind=social`. + const postedSocial = failedValues(state)?.sourceKind === "social"; + const socialSeed = postedSocial ? state : undefined; + const videoSeed = postedSocial ? undefined : state; // Controlled, so a surface that has to compare what is TYPED against what a // stored scan was run under can read them. (Nothing does yet; the metadata @@ -232,6 +269,7 @@ export function ChannelForm({ <Field label="Name" name="name" + state={state} required defaultValue={c?.name ?? ""} placeholder="Display name (e.g., TheQuartering)" @@ -254,6 +292,7 @@ export function ChannelForm({ <Field label="Slug" name="slug" + state={state} readOnly defaultValue={initial!.slug} hint="To change the slug, use Rename in the Danger zone below." @@ -292,7 +331,7 @@ export function ChannelForm({ type="radio" name="handling" value="youtube" - defaultChecked={c?.handling !== "transcribe"} + defaultChecked={editHandling === "youtube"} /> YouTube (auto-subtitles via yt-dlp) </label> @@ -301,7 +340,7 @@ export function ChannelForm({ type="radio" name="handling" value="transcribe" - defaultChecked={c?.handling === "transcribe"} + defaultChecked={editHandling === "transcribe"} /> Transcribe (download audio, run whisper-cpp) </label> @@ -309,15 +348,17 @@ export function ChannelForm({ <Field label="URL" name="url" + state={state} defaultValue={c?.url ?? ""} placeholder="https://www.youtube.com/@channel/videos" hint="Channel or playlist URL passed to yt-dlp." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Platform</span> - <select + <SeededSelect + state={state} name="platform" - defaultValue={c?.platform ?? ""} + initial={c?.platform ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Auto (detect from URL)</option> @@ -328,7 +369,7 @@ export function ChannelForm({ <option value="kick">Kick</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Used as the default job queue, so all channels on the same platform run sequentially. @@ -400,7 +441,7 @@ export function ChannelForm({ <fieldset className="flex flex-col gap-2"> <legend className="text-sm font-medium">Handling</legend> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="radio" name="handling" value="youtube" @@ -413,7 +454,7 @@ export function ChannelForm({ YouTube (auto-subtitles via yt-dlp) </label> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="radio" name="handling" value="transcribe" @@ -429,7 +470,7 @@ export function ChannelForm({ )} <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Platform</span> - <select + <ControlledSelect name="platform" value={platform} onChange={(e) => { @@ -447,7 +488,7 @@ export function ChannelForm({ <option value="kick">Kick</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> - </select> + </ControlledSelect> <span className="text-xs text-muted-foreground"> Used as the default job queue, so all channels on the same platform run sequentially. Leave on Auto for a host yt-dlp knows @@ -463,7 +504,11 @@ export function ChannelForm({ <input type="checkbox" name="fetchPostsNow" - defaultChecked + defaultChecked={seedChecked( + socialSeed, + "fetchPostsNow", + true, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -480,7 +525,7 @@ export function ChannelForm({ <input type="checkbox" name="fetchPlaylist" - defaultChecked + defaultChecked={seedChecked(videoSeed, "fetchPlaylist", true)} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -496,6 +541,11 @@ export function ChannelForm({ <input type="checkbox" name="prioritizeDownload" + defaultChecked={seedChecked( + videoSeed, + "prioritizeDownload", + false, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -557,22 +607,24 @@ export function ChannelForm({ Audio format{" "} <span className="text-muted-foreground font-normal">(transcribe only)</span> </span> - <select + <SeededSelect + state={state} name="audioFormat" - defaultValue={c?.audioFormat ?? ""} + initial={c?.audioFormat ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Default (mp3)</option> <option value="m4a">m4a</option> <option value="mp3">mp3</option> <option value="opus">opus</option> - </select> + </SeededSelect> </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Download format</span> - <select + <SeededSelect + state={state} name="downloadFormat" - defaultValue={c?.downloadFormat ?? ""} + initial={c?.downloadFormat ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Inherit (global default)</option> @@ -581,7 +633,7 @@ export function ChannelForm({ {DOWNLOAD_FORMAT_LABELS[p]} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> The yt-dlp <code>-f</code> selector for this channel's downloads. Leave on Inherit to use the global default; choose{" "} @@ -593,7 +645,11 @@ export function ChannelForm({ <input type="checkbox" name="keepSourceVideo" - defaultChecked={c?.keepSourceVideo === true} + defaultChecked={seedChecked( + state, + "keepSourceVideo", + c?.keepSourceVideo === true, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -610,15 +666,16 @@ export function ChannelForm({ </span> </span> </label> - <AudioCheckFields config={c} /> + <AudioCheckFields config={c} state={state} /> </Section> <Section title="Retention & persistence"> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Keep latest (source videos)</span> - <input + <SeededInput + state={state} type="number" name="keepLatest" - defaultValue={c?.keepLatest != null ? String(c.keepLatest) : ""} + initial={c?.keepLatest != null ? String(c.keepLatest) : ""} min={0} max={100000} placeholder="0 (off)" @@ -633,16 +690,17 @@ export function ChannelForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Extraction mode</span> - <select + <SeededSelect + state={state} name="extractionMode" - defaultValue={c?.extractionMode ?? ""} + initial={c?.extractionMode ?? ""} aria-label="extraction mode" className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Default (yt-dlp extracts)</option> <option value="ytdlp">yt-dlp (-x postprocessor)</option> <option value="app">App (download container, ffmpeg extracts)</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Who extracts audio for transcribe-handling downloads. App mode keeps the source container in hand so it can be persisted; the keep-latest @@ -652,6 +710,7 @@ export function ChannelForm({ <Field label="Saved-video store dir" name="savedVideosDir" + state={state} defaultValue={c?.savedVideosDir ?? ""} placeholder="(global default)" hint="Per-channel override for where this channel's persisted source videos live (e.g. a larger disk). Blank uses the global SAVED_VIDEOS_DIR default." @@ -694,7 +753,11 @@ export function ChannelForm({ </span> <textarea name="ytdlpExtraArgs" - defaultValue={(c?.ytdlpExtraArgs ?? []).join("\n")} + defaultValue={seedValue( + state, + "ytdlpExtraArgs", + (c?.ytdlpExtraArgs ?? []).join("\n"), + )} rows={4} className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" /> @@ -719,7 +782,11 @@ export function ChannelForm({ <input type="checkbox" name="downloadFilterIncludeLivestreams" - defaultChecked={c?.downloadFilter?.includeLivestreams ?? false} + defaultChecked={seedChecked( + state, + "downloadFilterIncludeLivestreams", + c?.downloadFilter?.includeLivestreams ?? false, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -734,9 +801,10 @@ export function ChannelForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Filtered-out livestreams</span> - <select + <SeededSelect + state={state} name="downloadFilterRejectedLivestreams" - defaultValue={c?.downloadFilter?.rejectedLivestreams ?? "skip"} + initial={c?.downloadFilter?.rejectedLivestreams ?? "skip"} aria-label="filtered-out livestreams" className="rounded border border-border bg-card px-2 py-1 text-sm" > @@ -744,7 +812,7 @@ export function ChannelForm({ <option value="chat-only"> Keep the live chat — no audio, no transcript </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> What to do with a livestream the filter above rejected. A multi-hour stream whose title says nothing is rarely worth its audio, but its @@ -756,15 +824,17 @@ export function ChannelForm({ <Field label="Cookies from browser" name="cookiesFromBrowser" + state={state} defaultValue={c?.cookiesFromBrowser ?? ""} placeholder="(inherit global)" hint="Per-channel override of the global yt-dlp --cookies-from-browser browser spec (e.g. firefox, chrome:Default). Blank inherits the global value from /settings." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Cookie mode</span> - <select + <SeededSelect + state={state} name="cookieMode" - defaultValue={c?.cookieMode ?? ""} + initial={c?.cookieMode ?? ""} aria-label="cookie mode" className="rounded border border-border bg-card px-2 py-1 text-sm" > @@ -778,7 +848,7 @@ export function ChannelForm({ <option value="defer"> Defer — never in normal runs; collect into "Needs cookies" </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> When this channel's downloads use browser cookies. Leave on Inherit to use the global mode from /settings. Defer excludes @@ -794,10 +864,11 @@ export function ChannelForm({ (seconds, optional) </span> </span> - <input + <SeededInput + state={state} type="number" name="sleepBetweenDownloadsSeconds" - defaultValue={ + initial={ c?.sleepBetweenDownloadsSeconds != null ? String(c.sleepBetweenDownloadsSeconds) : "" @@ -931,7 +1002,13 @@ function SyncIntervalField({ ); } -function AudioCheckFields({ config }: { config?: ChannelConfig }) { +function AudioCheckFields({ + config, + state, +}: { + config?: ChannelConfig; + state?: SeedSource; +}) { const ac = config?.audioCheck; const enabled = ac?.enabled === true; return ( @@ -940,7 +1017,7 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <input type="checkbox" name="audioCheckEnabled" - defaultChecked={enabled} + defaultChecked={seedChecked(state, "audioCheckEnabled", enabled)} aria-label="audio-check enabled" className="mt-1" /> @@ -964,10 +1041,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <span className="font-medium"> Check interval (seconds) </span> - <input + <SeededInput + state={state} type="number" name="audioCheckIntervalSeconds" - defaultValue={ac?.intervalSeconds ?? ""} + initial={String(ac?.intervalSeconds ?? "")} placeholder={String(AUDIO_CHECK_INTERVAL_DEFAULT_SECONDS)} min={AUDIO_CHECK_INTERVAL_MIN_SECONDS} max={AUDIO_CHECK_INTERVAL_MAX_SECONDS} @@ -979,10 +1057,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <span className="font-medium"> Max consecutive rollbacks </span> - <input + <SeededInput + state={state} type="number" name="audioCheckMaxRollbacks" - defaultValue={ac?.maxRollbacks ?? ""} + initial={String(ac?.maxRollbacks ?? "")} placeholder={String(AUDIO_CHECK_MAX_ROLLBACKS_DEFAULT)} min={AUDIO_CHECK_MAX_ROLLBACKS_MIN} max={AUDIO_CHECK_MAX_ROLLBACKS_MAX} @@ -998,10 +1077,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <span className="font-medium"> Copy timeout (seconds) </span> - <input + <SeededInput + state={state} type="number" name="audioCheckCopyTimeoutSeconds" - defaultValue={ac?.copyTimeoutSeconds ?? ""} + initial={String(ac?.copyTimeoutSeconds ?? "")} placeholder={String(AUDIO_CHECK_COPY_TIMEOUT_DEFAULT_SECONDS)} min={AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS} max={AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS} @@ -1018,7 +1098,11 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { <input type="checkbox" name="audioCheckResumeDuringProbe" - defaultChecked={ac?.resumeDuringProbe ?? false} + defaultChecked={seedChecked( + state, + "audioCheckResumeDuringProbe", + ac?.resumeDuringProbe ?? false, + )} aria-label="audio-check resume during probe" className="mt-1" /> @@ -1036,46 +1120,3 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) { </div> ); } - -// `value` + `onChange` make the field CONTROLLED (the download-filter patterns -// are, so their live values can be read without touching the DOM). Omit both -// for the ordinary uncontrolled field. -function Field({ - label, - name, - defaultValue, - value, - onChange, - placeholder, - hint, - required, - readOnly, -}: { - label: string; - name: string; - defaultValue?: string; - value?: string; - onChange?: (v: string) => void; - placeholder?: string; - hint?: string; - required?: boolean; - readOnly?: boolean; -}) { - return ( - <label className="flex flex-col gap-1 text-sm"> - <span className="font-medium">{label}</span> - <input - type="text" - name={name} - {...(value === undefined - ? { defaultValue } - : { value, onChange: (e) => onChange?.(e.target.value) })} - placeholder={placeholder} - required={required} - readOnly={readOnly} - className={`rounded border border-border bg-card px-2 py-1 text-sm ${readOnly ? "opacity-60" : ""}`} - /> - {hint && <span className="text-xs text-muted-foreground">{hint}</span>} - </label> - ); -} diff --git a/editor/app/channels/components/ChannelFormClient.tsx b/editor/app/channels/components/ChannelFormClient.tsx @@ -52,6 +52,7 @@ export function ChannelFormClient({ initial={initial} submitLabel={submitLabel} errorMessage={state?.error} + state={state} sites={sites} initialMemberships={initialMemberships} activeSiteId={activeSiteId} diff --git a/editor/app/channels/components/DeleteChannelForm.tsx b/editor/app/channels/components/DeleteChannelForm.tsx @@ -2,6 +2,7 @@ import { useActionState } from "react"; import type { ActionResult } from "../actions"; +import { SeededInput } from "../../components/forms/Field"; type Props = { slug: string; @@ -35,8 +36,10 @@ export function DeleteChannelForm({ slug, action, busyReason = null }: Props) { </p> )} <div className="flex gap-2 items-start"> - <input + <SeededInput + state={state} name="confirmSlug" + initial="" required placeholder={slug} aria-label="confirm slug to delete" diff --git a/editor/app/channels/components/RenameChannelForm.tsx b/editor/app/channels/components/RenameChannelForm.tsx @@ -2,6 +2,7 @@ import { useActionState } from "react"; import type { ActionResult } from "../actions"; +import { SeededInput } from "../../components/forms/Field"; type Props = { slug: string; @@ -27,8 +28,10 @@ export function RenameChannelForm({ slug, action, busyReason = null }: Props) { </p> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">New slug</span> - <input + <SeededInput + state={state} name="newSlug" + initial="" required placeholder="new-channel-slug" aria-label="new slug" @@ -45,8 +48,10 @@ export function RenameChannelForm({ slug, action, busyReason = null }: Props) { </p> )} <div className="flex gap-2 items-start"> - <input + <SeededInput + state={state} name="confirmSlug" + initial="" required placeholder={slug} aria-label="confirm current slug" diff --git a/editor/app/channels/components/SiteMembershipsSection.tsx b/editor/app/channels/components/SiteMembershipsSection.tsx @@ -1,6 +1,12 @@ "use client"; import { useState } from "react"; +// Its controlled checkboxes and selects sit inside action forms, whose reset they would not +// survive alone (forms/Controlled.tsx). +import { + ControlledCheck, + ControlledSelect, +} from "../../components/forms/Controlled"; // The channel form's per-site membership picker: every configured site with a // checkbox (member or not) plus a group dropdown, including a "+ New group…" @@ -137,7 +143,7 @@ export function SiteMembershipsSection({ > <div className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 flex-1 min-w-0"> - <input + <ControlledCheck type="checkbox" checked={on} onChange={(e) => toggle(s.siteId, e.target.checked)} @@ -151,7 +157,7 @@ export function SiteMembershipsSection({ </span> </label> {on && ( - <select + <ControlledSelect value={row.groupId} onChange={(e) => setGroup(s.siteId, e.target.value)} aria-label={`Group for ${label}`} @@ -164,7 +170,7 @@ export function SiteMembershipsSection({ </option> ))} <option value={NEW_GROUP}>+ New group…</option> - </select> + </ControlledSelect> )} </div> {on && row.groupId === NEW_GROUP && ( diff --git a/editor/app/components/DurationField.tsx b/editor/app/components/DurationField.tsx @@ -11,6 +11,9 @@ import { unitLabel, type DurationUnit, } from "yt-dlp-transcript-common/lib/duration"; +// Its controlled selects sit inside action forms, whose reset they would not +// survive alone (forms/Controlled.tsx). +import { ControlledSelect } from "./forms/Controlled"; // A cadence control: pick a preset, or type an amount and choose a unit. // @@ -163,7 +166,7 @@ export function DurationField({ {!hideLabel && <span className="font-medium">{label}</span>} <div className="flex flex-wrap items-center gap-2"> {hasModeSelect && ( - <select + <ControlledSelect aria-label={label} value={mode} onChange={(e) => setMode(e.target.value)} @@ -182,7 +185,7 @@ export function DurationField({ </option> ))} <option value={DURATION_CUSTOM}>Custom…</option> - </select> + </ControlledSelect> )} {isCustom && ( <> @@ -197,7 +200,7 @@ export function DurationField({ min={1} className={`${inputClass} w-24`} /> - <select + <ControlledSelect aria-label={`${label} unit`} value={unit} onChange={(e) => setUnit(e.target.value as DurationUnit)} @@ -208,7 +211,7 @@ export function DurationField({ {unitLabel(u, parsedAmount || 2)} </option> ))} - </select> + </ControlledSelect> </> )} </div> diff --git a/editor/app/components/SocialLinksField.tsx b/editor/app/components/SocialLinksField.tsx @@ -2,6 +2,9 @@ import type { SocialLink } from "yt-dlp-transcript-common/lib/settings"; import { socialLinksJson, type SocialRow } from "./socialLinksJson"; +// Its controlled checkboxes sit inside action forms, whose reset they would not +// survive alone (forms/Controlled.tsx). +import { ControlledCheck } from "./forms/Controlled"; export type { SocialRow }; @@ -101,7 +104,7 @@ export function SocialLinksField({ value, onChange, name }: Props) { kept first (common/lib/socialLinks.ts headerSocialLinks). */} <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="checkbox" checked={s.featured === true} onChange={(e) => update(idx, { featured: e.target.checked })} diff --git a/editor/app/components/forms/Controlled.tsx b/editor/app/components/forms/Controlled.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { + useEffect, + useLayoutEffect, + useRef, + type InputHTMLAttributes, + type RefObject, + type SelectHTMLAttributes, +} from "react"; + +// A CONTROLLED CHECKBOX, RADIO OR SELECT INSIDE AN ACTION FORM (release 16, +// slice FK). +// +// React resets a `<form>` when its action's transition commits (see +// lib/formState.ts). A controlled TEXT input survives that: React keeps its +// `defaultValue` in step with `value`, so `form.reset()` puts the same text +// back. It does not do the same for the other two kinds: +// - a checkbox or radio's `defaultChecked` is set only when it MOUNTS +// (react-dom `initInput`; `updateInput` sets `checked` alone); +// - a controlled select's options never get `defaultSelected` +// (`updateOptions` is called with `setDefaultSelected` false at mount and +// on every update), so the reset selects the option the SERVER rendered +// `selected`, or the first option when the select mounted on the client. +// So after ANY submit — failure or success — the reset returns a checkbox or +// radio to what it mounted with, and a select to that option, while React's +// state still says otherwise, until the next re-render that reaches it +// re-asserts the prop (the editor's auto-refresh, 5 s by default; never, with +// it off). A NAMED one submitted in that window posts the stale choice. +// +// These put the controlled value back right after the form resets: a `reset` +// listener on the element's form, deferred a microtask so it runs after the +// reset (React calls `form.reset()` inside its commit). They touch the DOM +// ONLY then — never at mount, so a choice made before hydration (which React +// replays as a change event) is not overwritten. Same props, same markup as +// the bare element. +function useValueAfterReset<E extends HTMLInputElement | HTMLSelectElement, V>( + ref: RefObject<E | null>, + value: V, + put: (el: E, value: V) => void, +) { + // The value of the LATEST commit, read when the microtask runs: a value that + // changes in the very commit that resets (a form re-reading what was stored + // after a save) is the one put back. + const latest = useRef(value); + useLayoutEffect(() => { + latest.current = value; + }); + useEffect(() => { + const el = ref.current; + const form = el?.form; + if (!el || !form) return; + const onReset = () => queueMicrotask(() => put(el, latest.current)); + form.addEventListener("reset", onReset); + return () => form.removeEventListener("reset", onReset); + }, [ref, put]); +} + +const putChecked = (el: HTMLInputElement, checked: boolean) => { + el.checked = checked; +}; +const putValue = (el: HTMLSelectElement, value: string) => { + el.value = value; +}; + +export function ControlledCheck( + props: InputHTMLAttributes<HTMLInputElement> & { + type: "checkbox" | "radio"; + checked: boolean; + }, +) { + const ref = useRef<HTMLInputElement>(null); + useValueAfterReset(ref, props.checked, putChecked); + return <input ref={ref} {...props} />; +} + +export function ControlledSelect( + props: SelectHTMLAttributes<HTMLSelectElement> & { value: string }, +) { + const ref = useRef<HTMLSelectElement>(null); + useValueAfterReset(ref, props.value, putValue); + return <select ref={ref} {...props} />; +} diff --git a/editor/app/components/forms/Field.tsx b/editor/app/components/forms/Field.tsx @@ -1,24 +1,47 @@ -// The labelled text/number input the long settings forms are built out of, -// lifted out of SettingsForm when slice 3 moved four of its fieldsets onto the -// operation pages and two files needed the same helper. +import type { + InputHTMLAttributes, + SelectHTMLAttributes, +} from "react"; +import { seedValue, type SeedSource } from "../../lib/formState"; + +// The labelled text/number input the editor's forms are built out of. // -// DELIBERATELY NOT A DEDUPLICATION SWEEP. Three other files still define their -// own `Field` — ChannelForm.tsx, SiteForm.tsx and OverviewPanel.tsx — and they -// are NOT this one: they differ in props (no `type`/`step`) and in markup, so -// folding them in is a behaviour change to three forms that slice 3 does not -// touch. Leave them until something actually needs them to be one. +// ONE FIELD SINCE RELEASE 16 (slice FK). Slice 3 lifted this out of +// SettingsForm and left SiteForm's and ChannelForm's own copies alone on +// purpose ("leave them until something actually needs them to be one"). Slice +// FK is that something: all three had to learn to seed from a failed action's +// values, and they were the same label → input → hint, differing only in props +// (`step` here, `min` in SiteForm's, `placeholder`/`readOnly`/a controlled mode +// in ChannelForm's). Those props are all here now. OverviewPanel's `Field` is a +// different thing (a read-only figure, no input) and stays its own. +// +// `state` is the form's `useActionState` state. When the last submit failed, +// the input's default is what was submitted (lib/formState.ts), so the reset +// React runs after the action puts the operator's text back instead of the +// stored value. `defaultValue` is the INITIAL value — the stored one. +// +// `value` + `onChange` make the field CONTROLLED (ChannelForm's download-filter +// patterns are, so their live values can be read without touching the DOM); a +// controlled field survives the reset on its own and ignores `state`. export function Field({ label, name, defaultValue, + state, hint, required, type = "text", step, + min, + placeholder, + readOnly, + value, + onChange, }: { label: string; name: string; defaultValue?: string; + state?: SeedSource; hint?: string; required?: boolean; type?: string; @@ -27,19 +50,95 @@ export function Field({ // error, no request, the save just never happens. Any numeric field that // accepts fractions must pass a step. step?: string; + min?: string; + placeholder?: string; + readOnly?: boolean; + value?: string; + onChange?: (v: string) => void; }) { + const className = `rounded border border-border bg-card px-2 py-1 text-sm${readOnly ? " opacity-60" : ""}`; + const shared = { type, required, step, min, placeholder, readOnly, className }; return ( <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">{label}</span> - <input - type={type} - name={name} - defaultValue={defaultValue} - required={required} - step={step} - className="rounded border border-border bg-card px-2 py-1 text-sm" - /> + {value === undefined ? ( + <SeededInput + {...shared} + name={name} + state={state} + initial={defaultValue ?? ""} + /> + ) : ( + <input + {...shared} + name={name} + value={value} + onChange={(e) => onChange?.(e.target.value)} + /> + )} {hint && <span className="text-xs text-muted-foreground">{hint}</span>} </label> ); } + +// An UNCONTROLLED input whose default is the failed submit's value, else +// `initial`. For the inputs that are not a `Field` (their own label markup). +// +// A NUMBER INPUT IS RE-MOUNTED when its default changes. React does not write a +// new `defaultValue` into a number input that has focus (react-dom +// `setDefaultValue` skips it, so typing "1." is not rewritten under the cursor) +// — and pressing Enter in one submits the form with the focus still there, so +// the reset would put the OLD default back. A new element takes its default at +// mount, focused or not. The key is the default itself: when it does not +// change there is nothing to re-apply. +export function SeededInput({ + state, + name, + initial, + type = "text", + ...rest +}: Omit< + InputHTMLAttributes<HTMLInputElement>, + "defaultValue" | "value" | "name" +> & { + state: SeedSource; + name: string; + initial: string; +}) { + const seeded = seedValue(state, name, initial); + return ( + <input + key={type === "number" ? seeded : undefined} + {...rest} + type={type} + name={name} + defaultValue={seeded} + /> + ); +} + +// An UNCONTROLLED select whose default is the failed submit's value, else +// `initial`. +// +// RE-MOUNTED WHENEVER ITS DEFAULT CHANGES: React applies a select's +// `defaultValue` only when it mounts (react-dom: an update re-applies it only +// when `multiple` toggles), so a new `defaultValue` never reaches the DOM and +// the reset puts back the option the select MOUNTED with — after a failure, and +// after a success too (the stored value changed, the select did not). The key +// is the default itself. +export function SeededSelect({ + state, + name, + initial, + ...rest +}: Omit< + SelectHTMLAttributes<HTMLSelectElement>, + "defaultValue" | "value" | "name" +> & { + state: SeedSource; + name: string; + initial: string; +}) { + const seeded = seedValue(state, name, initial); + return <select key={seeded} {...rest} name={name} defaultValue={seeded} />; +} diff --git a/editor/app/lib/formState.test.ts b/editor/app/lib/formState.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + failedValues, + formValues, + seedChecked, + seedValue, + type FormErrorState, + type FormState, +} from "./formState"; + +// Run with: pnpm --filter editor test + +test("formValues keeps each name's first value and skips files", () => { + const fd = new FormData(); + fd.append("siteTitle", "Typed title"); + fd.append("digestSections", "chapters"); + fd.append("digestSections", "tags"); + fd.append("upload", new File(["x"], "x.txt")); + fd.append("empty", ""); + assert.deepEqual(formValues(fd), { + siteTitle: "Typed title", + digestSections: "chapters", + empty: "", + }); +}); + +test("formValues keeps a field named like an Object.prototype key", () => { + const fd = new FormData(); + fd.append("__proto__", "a"); + fd.append("constructor", "b"); + const values = formValues(fd); + assert.equal(Object.getPrototypeOf(values), Object.prototype); + assert.equal(Object.hasOwn(values, "__proto__"), true); + assert.equal(values.constructor, "b"); + assert.deepEqual(Object.keys(values), ["__proto__", "constructor"]); +}); + +test("seedValue: no state, a success, or a failure without values → initial", () => { + const ok: FormState<{ siteId: string }> = { ok: true, siteId: "x" }; + const bare: FormState = { ok: false, error: "no" }; + for (const state of [undefined, null, ok, bare]) { + assert.equal(seedValue(state, "siteTitle", "Stored"), "Stored"); + } +}); + +test("seedValue: a failure with values → what was submitted, even empty", () => { + const state: FormState = { + ok: false, + error: "Site ID must be …", + values: { siteTitle: "Typed", wordmarkLead: "" }, + }; + assert.equal(seedValue(state, "siteTitle", "Stored"), "Typed"); + // Cleared on purpose is kept cleared: "" is a value, not an absence. + assert.equal(seedValue(state, "wordmarkLead", "Jer"), ""); +}); + +test("seedValue: a name the failed submit did not carry keeps its initial", () => { + // A disabled field, or one not rendered at the time, posts nothing. + const state: FormState = { ok: false, error: "no", values: { a: "1" } }; + assert.equal(seedValue(state, "notPosted", "Stored"), "Stored"); + // Not fooled by Object.prototype. + assert.equal(seedValue(state, "toString", "Stored"), "Stored"); +}); + +test("seedChecked: no state, a success, or no values → initial", () => { + const ok: FormState = { ok: true }; + const bare: FormState = { ok: false, error: "no" }; + for (const state of [undefined, null, ok, bare]) { + assert.equal(seedChecked(state, "pwa", true), true); + assert.equal(seedChecked(state, "pwa", false), false); + } +}); + +test("seedChecked: with values, present is ticked and ABSENT is unticked", () => { + // An unticked box sends nothing, so absence is the answer, not "unknown". + const state: FormState = { + ok: false, + error: "no", + values: { pwa: "on", commit: "true" }, + }; + assert.equal(seedChecked(state, "pwa", false), true); + assert.equal(seedChecked(state, "commit", false), true); + assert.equal(seedChecked(state, "archives", true), false); + assert.equal(seedChecked(state, "hasOwnProperty", true), false); +}); + +test("the channels flavour reads through the same helpers", () => { + const failed: FormErrorState = { + error: "already exists", + values: { name: "Typed", fetchPlaylist: "on" }, + }; + const success: FormErrorState = undefined; + assert.deepEqual(failedValues(failed), { + name: "Typed", + fetchPlaylist: "on", + }); + assert.equal(seedValue(failed, "name", "Stored"), "Typed"); + assert.equal(seedChecked(failed, "fetchPlaylist", false), true); + assert.equal(seedChecked(failed, "prioritizeDownload", true), false); + assert.equal(failedValues(success), undefined); + assert.equal(seedValue(success, "name", "Stored"), "Stored"); + // An error with no values (an action that returns none) seeds from initial. + assert.equal(seedValue({ error: "no" }, "name", "Stored"), "Stored"); +}); diff --git a/editor/app/lib/formState.ts b/editor/app/lib/formState.ts @@ -0,0 +1,91 @@ +// A FORM THAT FAILS KEEPS WHAT WAS TYPED (release 16, slice FK). +// +// React resets a `<form>` when its action's transition commits — success or +// failure alike. `startHostTransition` calls `requestFormReset` beside the +// action itself, and the commit ends with a native `form.reset()` (react-dom, +// `recursivelyResetForms`). An UNCONTROLLED field (`defaultValue`, +// `defaultChecked`) goes back to its default; so before this, every editor form +// wiped what the operator typed the moment its action returned an error. +// +// The fix is a data contract, not a widget: an action that can fail captures +// what was submitted at its top (`formValues`) and returns it beside the error, +// and every uncontrolled field seeds its default from it (`seedValue`, +// `seedChecked`). React writes the new default in the same commit, before the +// reset runs, so the reset puts the typed value back. A success returns no +// values, so a field seeds from its initial value — the stored one — exactly +// as before. +// +// PURE, AND IMPORTED BY BOTH SIDES: the server actions build `values` with it +// and the client forms read it. No React, no node: — a "use server" module and +// a client bundle both import this file. + +// What a form posted, as a failed action hands it back: each name's FIRST +// value, files skipped (no editor form posts one, and a File does not survive +// the trip back to the client). +export type FormValues = Record<string, string>; + +// The `{ ok }` flavour, which most save actions return. `T` is what a success +// carries (`{ siteId }`, `{ note }`, …). +export type FormFailure = { ok: false; error: string; values?: FormValues }; +export type FormState<T extends object = object> = ({ ok: true } & T) | FormFailure; + +// The channels flavour: `undefined` is success (the action redirects, or there +// is nothing to say), an object is the refusal. +export type FormErrorState = { error: string; values?: FormValues } | undefined; + +// EITHER FLAVOUR, as the seeding helpers read it — one implementation for both. +// A state is a failure unless it says `ok: true`; the channels flavour has no +// `ok` at all, and its success is `undefined`. +export type SeedSource = + | { ok?: boolean; error?: string; values?: FormValues } + | null + | undefined; + +export function formValues(formData: FormData): FormValues { + const values: FormValues = {}; + for (const [name, value] of formData.entries()) { + if (typeof value !== "string") continue; + if (Object.hasOwn(values, name)) continue; + // defineProperty, not assignment: a field named `__proto__` would otherwise + // hit the prototype setter and vanish. + Object.defineProperty(values, name, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + return values; +} + +// The values a failed submit carried back, or undefined (no submit yet, a +// success, or an action that returned no values). +export function failedValues(state: SeedSource): FormValues | undefined { + if (!state || state.ok === true) return undefined; + return state.values; +} + +// A text, number, textarea, select or radio-group field's default: what was +// submitted when the last submit failed, else `initial`. A name the failed +// submit did not carry was not submitted at all (a disabled field, or one not +// rendered then), so it keeps `initial` rather than going blank. +export function seedValue( + state: SeedSource, + name: string, + initial: string, +): string { + const values = failedValues(state); + return values && Object.hasOwn(values, name) ? values[name] : initial; +} + +// A checkbox's default: ticked when the last failed submit carried its name, +// else `initial`. AN UNTICKED BOX SENDS NOTHING, so once `values` is present +// absence means unchecked — not "unknown, use the initial". +export function seedChecked( + state: SeedSource, + name: string, + initial: boolean, +): boolean { + const values = failedValues(state); + return values ? Object.hasOwn(values, name) : initial; +} diff --git a/editor/app/operations/components/settings/AttributionSettingsForm.tsx b/editor/app/operations/components/settings/AttributionSettingsForm.tsx @@ -3,7 +3,8 @@ import { useActionState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; import type { DigestAppDescriptor } from "yt-dlp-transcript-common/lib/digestApps"; -import { Field } from "../../../components/forms/Field"; +import { Field, SeededSelect } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { saveAttributionSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -41,7 +42,11 @@ export function AttributionSettingsForm({ <input type="checkbox" name="attributionEnabled" - defaultChecked={initial.enabled} + defaultChecked={seedChecked( + state, + "attributionEnabled", + initial.enabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -56,7 +61,11 @@ export function AttributionSettingsForm({ <input type="checkbox" name="attributionDiarized" - defaultChecked={initial.diarizedEnabled} + defaultChecked={seedChecked( + state, + "attributionDiarized", + initial.diarizedEnabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -75,7 +84,11 @@ export function AttributionSettingsForm({ <input type="checkbox" name="attributionTextOnly" - defaultChecked={initial.textOnlyEnabled} + defaultChecked={seedChecked( + state, + "attributionTextOnly", + initial.textOnlyEnabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -95,9 +108,10 @@ export function AttributionSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Engine</span> - <select + <SeededSelect + state={state} name="attributionAppId" - defaultValue={initial.appId} + initial={initial.appId} className="rounded border border-border bg-card px-2 py-1 text-sm" > {digestApps.map((a) => ( @@ -106,7 +120,7 @@ export function AttributionSettingsForm({ {a.metered ? " (metered)" : ""} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Uses the digest engines and their configuration, set on the Digest operation's page — same constrained-JSON workload, so there is @@ -116,12 +130,14 @@ export function AttributionSettingsForm({ <Field label="Model override" name="attributionModel" + state={state} defaultValue={initial.model} hint="Empty means the engine's own configured model. Set separately from the digest's so a digest bake-off does not invalidate every attribution record as a side effect — this value is part of what makes a record fresh." /> <Field label="Prompt generation" name="attributionPromptVersion" + state={state} defaultValue={String(initial.promptVersion)} type="number" hint="A record generated at an older number is stale and will be redone. Raise it to force a corpus-wide regeneration after changing the prompt; it cannot be set below the shipped value, because pinning it lower would freeze superseded output into the archive looking current." diff --git a/editor/app/operations/components/settings/DiarizationSettingsForm.tsx b/editor/app/operations/components/settings/DiarizationSettingsForm.tsx @@ -2,7 +2,8 @@ import { useActionState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; -import { Field } from "../../../components/forms/Field"; +import { Field, SeededSelect } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { saveDiarizationSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -38,7 +39,11 @@ export function DiarizationSettingsForm({ <input type="checkbox" name="diarizationEnabled" - defaultChecked={initial.enabled} + defaultChecked={seedChecked( + state, + "diarizationEnabled", + initial.enabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -57,7 +62,11 @@ export function DiarizationSettingsForm({ <input type="checkbox" name="diarizationInlineAfterTranscribe" - defaultChecked={initial.inlineAfterTranscribe} + defaultChecked={seedChecked( + state, + "diarizationInlineAfterTranscribe", + initial.inlineAfterTranscribe, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -80,9 +89,10 @@ export function DiarizationSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Engine</span> - <select + <SeededSelect + state={state} name="diarizationEngine" - defaultValue={initial.engine} + initial={initial.engine} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="sherpa-onnx"> @@ -91,7 +101,7 @@ export function DiarizationSettingsForm({ <option value="sortformer"> Sortformer — end-to-end, GPU or CPU </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> <strong>sherpa-onnx</strong> groups voices that sound alike, and it splits far too eagerly: on a 13-minute video with one host it finds @@ -113,14 +123,15 @@ export function DiarizationSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Sortformer device</span> - <select + <SeededSelect + state={state} name="diarizationBackend" - defaultValue={initial.backend} + initial={initial.backend} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="vulkan">Vulkan — the graphics card</option> <option value="cpu">CPU</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> Ignored unless the engine above is Sortformer. Both produce{" "} <strong>identical</strong> speaker turns, so this only trades one @@ -134,36 +145,42 @@ export function DiarizationSettingsForm({ <Field label="Sortformer engine binary" name="diarizationSortformerBin" + state={state} defaultValue={initial.sortformerBin} hint="Absolute path to the diarize-file binary built by scripts/build-sortformer.sh. Empty means the Sortformer engine is not configured, and every run reports a skip rather than a failure." /> <Field label="Sortformer model (GGUF)" name="diarizationSortformerModel" + state={state} defaultValue={initial.sortformerModel} hint="Absolute path to the .gguf downloaded by scripts/build-sortformer.sh. Its filename is recorded in every sidecar, so changing the model is what marks earlier captures worth redoing." /> <Field label="Segmentation model (ONNX)" name="diarizationSegModel" + state={state} defaultValue={initial.segModel} hint="Absolute path to a pyannote segmentation-3.0 ONNX model. Empty means the lane is not configured and every run reports a skip rather than a failure." /> <Field label="Speaker-embedding model (ONNX)" name="diarizationEmbModel" + state={state} defaultValue={initial.embModel} hint="Absolute path to a speaker-embedding ONNX model (e.g. NeMo TitaNet)." /> <Field label="Python interpreter" name="diarizationPython" + state={state} defaultValue={initial.python} hint="Interpreter with sherpa-onnx installed. sherpa-onnx ships wheels only up to CPython 3.13, so on a 3.14 system this must point at a dedicated venv." /> <Field label="Clustering threshold" name="diarizationThreshold" + state={state} defaultValue={String(initial.threshold)} type="number" step="0.01" @@ -172,6 +189,7 @@ export function DiarizationSettingsForm({ <Field label="Engine threads" name="diarizationThreads" + state={state} defaultValue={String(initial.threads)} type="number" hint="Threads per diarize run." @@ -179,6 +197,7 @@ export function DiarizationSettingsForm({ <Field label="Max audio hours" name="diarizationMaxAudioHours" + state={state} defaultValue={String(initial.maxAudioHours)} type="number" step="0.5" @@ -187,6 +206,7 @@ export function DiarizationSettingsForm({ <Field label="Diarization concurrency" name="diarizationConcurrency" + state={state} defaultValue={String(initial.concurrency)} type="number" hint="Videos diarized at once during a backfill. Default 1: this is CPU-bound work competing with GPU feeding and the digest sweep for the same 8 threads." diff --git a/editor/app/operations/components/settings/DigestSettingsForm.tsx b/editor/app/operations/components/settings/DigestSettingsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; // From the CLIENT-SAFE digest module, NOT settings.ts's re-exports of them: // settings.ts opens with `import fs from "node:fs"`, so pulling the option @@ -12,7 +12,9 @@ import { DIGEST_TIMESTAMP_MODES as DIGEST_TIMESTAMP_MODE_OPTIONS, } from "yt-dlp-transcript-common/lib/digest"; import type { DigestAppDescriptor } from "yt-dlp-transcript-common/lib/digestApps"; -import { Field } from "../../../components/forms/Field"; +import { Field, SeededSelect } from "../../../components/forms/Field"; +import { ControlledCheck } from "../../../components/forms/Controlled"; +import { seedChecked } from "../../../lib/formState"; import { DigestAppsField } from "./DigestAppsField"; import { saveDigestSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -34,6 +36,24 @@ export function DigestSettingsForm({ SaveResult | undefined, FormData >(saveDigestSettingsAction, undefined); + // THE ONE NAME POSTED MORE THAN ONCE in the editor's forms — a box per + // section, all named `digestSections` — so a failed submit's `values` (each + // name's first value) cannot say which were ticked. Controlled instead: + // what the operator ticked lives here, and ControlledCheck keeps it through + // the reset. The posted shape is unchanged. + const [sections, setSections] = useState<string[]>(() => [ + ...initial.sections, + ]); + // AFTER A SUCCESS THE BOXES SHOW WHAT WAS STORED, as the uncontrolled boxes + // did after their reset: unticking every box saves the stored list again, so + // what was ticked is not always what was saved. Re-read on a new result or a + // new stored list, unless the result is a failure (then the ticks stay). + const stored = initial.sections.join(","); + const [shown, setShown] = useState({ state, stored }); + if (shown.state !== state || shown.stored !== stored) { + setShown({ state, stored }); + if (state?.ok !== false) setSections([...initial.sections]); + } return ( <form @@ -54,9 +74,10 @@ export function DigestSettingsForm({ </p> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Local engine</span> - <select + <SeededSelect + state={state} name="digestLocalAppId" - defaultValue={initial.localAppId} + initial={initial.localAppId} className="rounded border border-border bg-card px-2 py-1 text-sm" > {digestApps @@ -66,7 +87,7 @@ export function DigestSettingsForm({ {a.label} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> The lane that carries the corpus. Runs on your own hardware; nothing leaves the machine. @@ -77,11 +98,18 @@ export function DigestSettingsForm({ <span className="flex flex-wrap gap-3"> {DIGEST_SECTION_OPTIONS.map((section) => ( <label key={section} className="flex items-center gap-1 text-sm"> - <input + <ControlledCheck type="checkbox" name="digestSections" value={section} - defaultChecked={initial.sections.includes(section)} + checked={sections.includes(section)} + onChange={(e) => + setSections((prev) => + e.target.checked + ? [...prev, section] + : prev.filter((s) => s !== section), + ) + } /> {section} </label> @@ -100,9 +128,10 @@ export function DigestSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Timestamp mode</span> - <select + <SeededSelect + state={state} name="digestTimestampMode" - defaultValue={initial.timestampMode} + initial={initial.timestampMode} className="rounded border border-border bg-card px-2 py-1 text-sm" > {DIGEST_TIMESTAMP_MODE_OPTIONS.map((mode) => ( @@ -111,7 +140,7 @@ export function DigestSettingsForm({ {mode === DEFAULT_DIGEST_TIMESTAMP_MODE ? " (recommended)" : ""} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> How each chunk's transcript markers are numbered.{" "} <strong>chunk-local</strong> re-bases every chunk to 00:00:00 and @@ -126,6 +155,7 @@ export function DigestSettingsForm({ <Field label="Prompt variant label" name="digestPromptVariant" + state={state} defaultValue={initial.promptVariant} hint="Free-text label for a non-default prompt shape, recorded in every section's provenance (trimmed, max 40 chars). Setting or changing it invalidates digests generated under a different label — which is exactly what makes a bake-off round re-run its sample instead of skipping it as fresh. Leave blank unless you are running one." /> @@ -133,7 +163,11 @@ export function DigestSettingsForm({ <input type="checkbox" name="digestYieldToCpuWorkers" - defaultChecked={initial.yieldToCpuWorkers} + defaultChecked={seedChecked( + state, + "digestYieldToCpuWorkers", + initial.yieldToCpuWorkers, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -168,7 +202,11 @@ export function DigestSettingsForm({ <input type="checkbox" name="digestRemoteEnabled" - defaultChecked={initial.remoteEnabled} + defaultChecked={seedChecked( + state, + "digestRemoteEnabled", + initial.remoteEnabled, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -186,9 +224,10 @@ export function DigestSettingsForm({ </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Metered engine</span> - <select + <SeededSelect + state={state} name="digestRemoteAppId" - defaultValue={initial.remoteAppId} + initial={initial.remoteAppId} className="rounded border border-border bg-card px-2 py-1 text-sm" > {digestApps @@ -198,11 +237,12 @@ export function DigestSettingsForm({ {a.label} </option> ))} - </select> + </SeededSelect> </label> <Field label="Long-tail cutoff (seconds)" name="digestLongTailSeconds" + state={state} defaultValue={String(initial.longTailSeconds)} type="number" hint="Videos at least this long are what the metered lane takes when you run it. Default 14400 (4 h)." @@ -210,6 +250,7 @@ export function DigestSettingsForm({ <Field label="Spend cap (USD per job)" name="digestSpendCapUsd" + state={state} defaultValue={String(initial.spendCapUsd)} type="number" step="0.01" diff --git a/editor/app/operations/components/settings/LaneSettingsForm.tsx b/editor/app/operations/components/settings/LaneSettingsForm.tsx @@ -4,6 +4,7 @@ import { useActionState } from "react"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { Field } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { saveBackfillLaneSettingsAction } from "../../settingsActions"; import type { SaveResult } from "../../../settings/actions"; @@ -105,7 +106,7 @@ export function LaneSettingsForm({ <input type="checkbox" name="backfillEnabled" - defaultChecked={!held} + defaultChecked={seedChecked(state, "backfillEnabled", !held)} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -121,6 +122,7 @@ export function LaneSettingsForm({ <Field label="Lane concurrency" name="backfillConcurrency" + state={state} defaultValue={String(initial.concurrency)} type="number" hint="Slots the lane may use when it is not standing aside. Default 1 — this is CPU-bound work competing with GPU feeding and the digest lane for the same threads." @@ -129,7 +131,11 @@ export function LaneSettingsForm({ <input type="checkbox" name="backfillAllowRedownload" - defaultChecked={initial.allowRedownload} + defaultChecked={seedChecked( + state, + "backfillAllowRedownload", + initial.allowRedownload, + )} className="mt-1" /> <span className="flex flex-col gap-1"> diff --git a/editor/app/operations/components/sync/SchedulerSettingsForm.tsx b/editor/app/operations/components/sync/SchedulerSettingsForm.tsx @@ -12,6 +12,7 @@ import { } from "yt-dlp-transcript-common/lib/channelConfig"; import { DurationField } from "../../../components/DurationField"; import { Field } from "../../../components/forms/Field"; +import { seedChecked } from "../../../lib/formState"; import { FULL_SWEEP_PRESETS } from "../../../scheduler/intervalPresets"; // THE SYNC OPERATION'S SETTINGS BLOCK, whole. It is the form the descriptor's @@ -51,7 +52,11 @@ export function SchedulerSettingsForm({ <input type="checkbox" name="syncSchedulerEnabled" - defaultChecked={scheduler.enabled} + defaultChecked={seedChecked( + state, + "syncSchedulerEnabled", + scheduler.enabled, + )} className="mt-1" /> <span className="flex flex-col gap-0.5"> @@ -111,6 +116,7 @@ export function SchedulerSettingsForm({ <Field label="Full-sweep auto-confirm cap" name="syncSchedulerFullSweepConfirmMaxSuspects" + state={state} defaultValue={String(scheduler.fullSweepConfirmMaxSuspects)} type="number" hint="When a sweep finds at most this many videos missing from the listing, it probes each one upstream to confirm; above the cap it only flags them and leaves the probing to 'Check maybe-missing'. 0 = never auto-confirm. This is a count of videos, not a duration." @@ -118,6 +124,7 @@ export function SchedulerSettingsForm({ <Field label="Full-sweep shrink guard" name="syncSchedulerFullSweepShrinkGuardPercent" + state={state} defaultValue={String(scheduler.fullSweepShrinkGuardPercent)} type="number" hint="If a fresh listing comes back smaller than the stored one by more than this percentage (and by more than 25 entries), it is treated as suspect: the stored video list and the missing-video flags are left alone and the sweep is retried on the next sync rather than after the full cadence. A second enumeration reporting a similar count confirms it and is accepted, so a genuine mass deletion still lands. 0 = off; an empty listing is always refused. This is a percentage, not a duration." @@ -125,6 +132,7 @@ export function SchedulerSettingsForm({ <Field label="Max concurrent syncs" name="syncSchedulerMaxConcurrentSyncs" + state={state} defaultValue={String(scheduler.maxConcurrentSyncs)} type="number" hint="A tick queues at most (this − already-running) channels, most-overdue first; the rest roll to the next tick. Bounds load and staggers big batches." @@ -145,6 +153,7 @@ export function SchedulerSettingsForm({ <Field label="Quiet hours start (0–23)" name="syncSchedulerQuietHoursStart" + state={state} defaultValue={ scheduler.quietHoursStart != null ? String(scheduler.quietHoursStart) @@ -155,6 +164,7 @@ export function SchedulerSettingsForm({ <Field label="Quiet hours end (0–23)" name="syncSchedulerQuietHoursEnd" + state={state} defaultValue={ scheduler.quietHoursEnd != null ? String(scheduler.quietHoursEnd) diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts @@ -25,6 +25,7 @@ import { } from "yt-dlp-transcript-common/lib/digest"; import { withGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; import type { SaveResult } from "../settings/actions"; +import { formValues } from "../lib/formState"; // Same helper as operations/actions.ts, and for the same reason — a "use server" // module may only EXPORT async functions, so this cannot be shared from there. @@ -53,6 +54,7 @@ export async function saveDigestSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dD = current.digest; const digestAppsRaw = String(formData.get("digestAppsJson") ?? "").trim(); @@ -61,7 +63,11 @@ export async function saveDigestSettingsAction( try { digestApps = JSON.parse(digestAppsRaw); } catch { - return { ok: false, error: "Digest app config payload is malformed" }; + return { + ok: false, + error: "Digest app config payload is malformed", + values, + }; } } // Filtered to KNOWN kinds here rather than leaning on the settings schema's sanitizer. @@ -122,7 +128,7 @@ export async function saveDigestSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; @@ -138,6 +144,7 @@ export async function saveDiarizationSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dDiar = current.diarization; const next: Partial<SiteSettings> = { @@ -181,7 +188,7 @@ export async function saveDiarizationSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; @@ -208,6 +215,7 @@ export async function saveBackfillLaneSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dBack = current.backfill; const next: Partial<SiteSettings> = { @@ -225,7 +233,7 @@ export async function saveBackfillLaneSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; @@ -243,6 +251,7 @@ export async function saveAttributionSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); const dAttr = current.attribution; const next: Partial<SiteSettings> = { @@ -266,7 +275,7 @@ export async function saveAttributionSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidateOperations(); return { ok: true }; diff --git a/editor/app/saved-videos/backupActions.ts b/editor/app/saved-videos/backupActions.ts @@ -6,6 +6,7 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { SYNC_INTERVAL_MAX_MINUTES } from "yt-dlp-transcript-common/lib/channelConfig"; +import { formValues, type FormState } from "../lib/formState"; import { backupSavedVideos, verifySavedVideoBackup, @@ -82,7 +83,8 @@ export async function verifySavedVideoBackupAction( }); } -export type SaveBackupConfigResult = { ok: true } | { ok: false; error: string }; +// A failure carries what was submitted (lib/formState.ts). +export type SaveBackupConfigResult = FormState; // Persist just the saved-video backup block (dest / enabled / interval) from the // Saved Videos page, leaving the rest of SiteSettings untouched. writeSettings @@ -91,6 +93,7 @@ export async function saveSavedVideoBackupAction( _prev: SaveBackupConfigResult | undefined, formData: FormData, ): Promise<SaveBackupConfigResult> { + const values = formValues(formData); const dest = String(formData.get("backupDest") ?? "").trim(); const enabled = formData.get("backupEnabled") === "on"; const intervalRaw = String(formData.get("backupIntervalMinutes") ?? "").trim(); @@ -99,6 +102,7 @@ export async function saveSavedVideoBackupAction( return { ok: false, error: `Backup interval must be 1–${SYNC_INTERVAL_MAX_MINUTES} minutes`, + values, }; } const settings = getSettings(); @@ -113,7 +117,7 @@ export async function saveSavedVideoBackupAction( }, }); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidatePath("/saved-videos"); return { ok: true }; diff --git a/editor/app/saved-videos/components/SavedVideosControls.tsx b/editor/app/saved-videos/components/SavedVideosControls.tsx @@ -11,6 +11,7 @@ import { verifySavedVideoBackupAction, type SaveBackupConfigResult, } from "../backupActions"; +import { seedChecked } from "../../lib/formState"; type Props = { enabled: boolean; @@ -76,7 +77,7 @@ export function SavedVideosControls({ enabled, dest, intervalMinutes }: Props) { <input type="checkbox" name="backupEnabled" - defaultChecked={enabled} + defaultChecked={seedChecked(state, "backupEnabled", enabled)} aria-label="scheduled backup enabled" className="mt-1" /> diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts @@ -15,8 +15,10 @@ import { } from "yt-dlp-transcript-common/lib/settings"; import { saveSettings } from "../settings/saveSettings"; import { DURATION_KEEP } from "yt-dlp-transcript-common/lib/duration"; +import { formValues, type FormState } from "../lib/formState"; -export type SaveResult = { ok: true } | { ok: false; error: string }; +// A failure carries what was submitted (lib/formState.ts). +export type SaveResult = FormState; // One cadence field, as posted by DurationField. Four outcomes: // "keep" -> leave the stored value alone (bulk edits skipping this field) @@ -60,6 +62,7 @@ export async function setChannelCadencesAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); let sync: CadenceEdit; let sweep: CadenceEdit; try { @@ -74,9 +77,11 @@ export async function setChannelCadencesAction( "Full sweep interval", ); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; + } + if (slugs.length === 0) { + return { ok: false, error: "No channels selected", values }; } - if (slugs.length === 0) return { ok: false, error: "No channels selected" }; if (sync === "keep" && sweep === "keep") return { ok: true }; // nothing to do const paths = getPaths(); @@ -93,7 +98,9 @@ export async function setChannelCadencesAction( else patch.fullSweepIntervalMinutes = sweep.minutes; } const written = await patchChannelConfig(paths, slug, patch, { unset }); - if (!written) return { ok: false, error: `Channel "${slug}" not found` }; + if (!written) { + return { ok: false, error: `Channel "${slug}" not found`, values }; + } // Keep the channel report/badges in sync with the config edit. requestChannelSnapshot(paths, slug); revalidatePath(`/channels/${slug}`); @@ -119,6 +126,7 @@ export async function saveSchedulerSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const current = getSettings(); // An absent or blank field keeps the saved value rather than coercing to NaN // and resetting it to the default. It is the whole block's contract now, not @@ -189,7 +197,7 @@ export async function saveSchedulerSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidatePath("/operations/sync"); revalidatePath("/settings"); diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -26,13 +26,18 @@ import { isCookieMode, } from "yt-dlp-transcript-common/lib/cookiePolicy"; import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; +import { formValues, type FormState } from "../lib/formState"; -export type SaveResult = { ok: true } | { ok: false; error: string }; +// THE SHARED SAVE RESULT of /settings and the four operation-settings forms. A +// failure carries what was submitted, so the form keeps what was typed +// (lib/formState.ts). +export type SaveResult = FormState; export async function saveSettingsAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const adminTitle = String(formData.get("adminTitle") ?? "").trim(); const homepageUrl = String(formData.get("homepageUrl") ?? "").trim(); const maxBytesRaw = String( @@ -76,11 +81,17 @@ export async function saveSettingsAction( ? downloadFormatRaw : "auto"; - if (!adminTitle) return { ok: false, error: "Admin title is required" }; + if (!adminTitle) { + return { ok: false, error: "Admin title is required", values }; + } const parsed = Number.parseInt(maxBytesRaw, 10); if (!Number.isFinite(parsed)) { - return { ok: false, error: "maxTranscriptPageBytes must be a number" }; + return { + ok: false, + error: "maxTranscriptPageBytes must be a number", + values, + }; } if ( parsed < TRANSCRIPT_PAGE_MIN_BYTES || @@ -89,6 +100,7 @@ export async function saveSettingsAction( return { ok: false, error: `maxTranscriptPageBytes must be between ${TRANSCRIPT_PAGE_MIN_BYTES} and ${TRANSCRIPT_PAGE_HARD_CAP_BYTES}`, + values, }; } @@ -97,19 +109,25 @@ export async function saveSettingsAction( return { ok: false, error: "sleepBetweenDownloadsSeconds must be a number", + values, }; } if (sleepParsed < 0 || sleepParsed > SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS) { return { ok: false, error: `sleepBetweenDownloadsSeconds must be between 0 and ${SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS}`, + values, }; } // 0 disables passive refresh; any other value must land in the allowed window. const autoRefreshParsed = Number.parseInt(autoRefreshRaw, 10); if (!Number.isFinite(autoRefreshParsed)) { - return { ok: false, error: "autoRefreshIntervalSeconds must be a number" }; + return { + ok: false, + error: "autoRefreshIntervalSeconds must be a number", + values, + }; } if ( autoRefreshParsed !== 0 && @@ -119,17 +137,19 @@ export async function saveSettingsAction( return { ok: false, error: `autoRefreshIntervalSeconds must be 0 (off) or between ${AUTO_REFRESH_INTERVAL_MIN_SECONDS} and ${AUTO_REFRESH_INTERVAL_MAX_SECONDS}`, + values, }; } const minFreeDiskParsed = Number.parseInt(minFreeDiskRaw, 10); if (!Number.isFinite(minFreeDiskParsed)) { - return { ok: false, error: "minFreeDiskGB must be a number" }; + return { ok: false, error: "minFreeDiskGB must be a number", values }; } if (minFreeDiskParsed < 0 || minFreeDiskParsed > MIN_FREE_DISK_GB_MAX) { return { ok: false, error: `minFreeDiskGB must be between 0 and ${MIN_FREE_DISK_GB_MAX}`, + values, }; } @@ -141,12 +161,13 @@ export async function saveSettingsAction( ? RESUME_MARGIN_GB_DEFAULT : Number.parseInt(resumeMarginRaw, 10); if (!Number.isFinite(resumeMarginParsed)) { - return { ok: false, error: "resumeMarginGB must be a number" }; + return { ok: false, error: "resumeMarginGB must be a number", values }; } if (resumeMarginParsed < 0 || resumeMarginParsed > RESUME_MARGIN_GB_MAX) { return { ok: false, error: `resumeMarginGB must be between 0 and ${RESUME_MARGIN_GB_MAX}`, + values, }; } @@ -154,7 +175,7 @@ export async function saveSettingsAction( try { socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); } catch { - return { ok: false, error: "Social links payload is malformed" }; + return { ok: false, error: "Social links payload is malformed", values }; } const socialParsed = parseSocialLinks(socialInput); if ( @@ -165,6 +186,7 @@ export async function saveSettingsAction( ok: false, error: "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", + values, }; } // A link whose SVG is unchanged from settings.json is kept as it is; a new @@ -174,6 +196,7 @@ export async function saveSettingsAction( return { ok: false, error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, + values, }; } const socialLinks: SocialLink[] = checked.links; @@ -226,7 +249,7 @@ export async function saveSettingsAction( try { await saveSettings(next); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } revalidatePath("/settings"); return { ok: true }; diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -11,7 +11,12 @@ import { DOWNLOAD_FORMAT_LABELS, DOWNLOAD_FORMAT_PRESETS, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; -import { Field } from "../../components/forms/Field"; +import { + Field, + SeededInput, + SeededSelect, +} from "../../components/forms/Field"; +import { seedChecked } from "../../lib/formState"; import { SocialLinksField, toSocialRow, @@ -36,6 +41,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Admin title" name="adminTitle" + state={state} defaultValue={initial.adminTitle} required hint="Title for this editor's own shell. Public sites get their titles from each site's config." @@ -43,6 +49,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Family hub URL" name="homepageUrl" + state={state} defaultValue={initial.homepageUrl} type="url" hint="Absolute URL of the family hub (e.g. https://archilyzer-hub.pages.dev). Each site that names no hub of its own publishes it in its /site.json and /corpus.json, so the hub can tell its member sites; no page links to it. Leave blank to publish none." @@ -50,6 +57,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Max transcript page bytes" name="maxTranscriptPageBytes" + state={state} defaultValue={String(initial.maxTranscriptPageBytes)} hint="Bytes per generated transcript page (256 KB – 20 MB). Affects pagination next build." type="number" @@ -57,14 +65,16 @@ export function SettingsForm({ initial }: Props) { <Field label="Cookies from browser" name="cookiesFromBrowser" + state={state} defaultValue={initial.cookiesFromBrowser} hint="Browser spec passed to yt-dlp --cookies-from-browser (e.g. firefox, chrome:Default) for age-restricted, members-only, and private videos. When it is used is set by the cookie mode below. Leave blank for no cookies. Each channel can override both in its Advanced settings." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Cookie mode</span> - <select + <SeededSelect + state={state} name="cookieMode" - defaultValue={initial.cookieMode} + initial={initial.cookieMode} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="when-required"> @@ -76,7 +86,7 @@ export function SettingsForm({ initial }: Props) { <option value="defer"> Defer — never in normal runs; collect into "Needs cookies" </option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> <strong>When required</strong> (default) runs cookie-free and retries only the attempts that fail with an auth/age error.{" "} @@ -92,15 +102,17 @@ export function SettingsForm({ initial }: Props) { <Field label="Sleep between managed downloads (seconds)" name="sleepBetweenDownloadsSeconds" + state={state} defaultValue={String(initial.sleepBetweenDownloadsSeconds)} type="number" hint="Pause inserted between per-video yt-dlp invocations in managed batch downloads (download-from-playlist, download-missing). 0 disables. Default 10s. Each channel can override this in its Advanced settings." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Default download format</span> - <select + <SeededSelect + state={state} name="downloadFormat" - defaultValue={initial.downloadFormat} + initial={initial.downloadFormat} className="rounded border border-border bg-card px-2 py-1 text-sm" > {DOWNLOAD_FORMAT_PRESETS.map((p) => ( @@ -108,7 +120,7 @@ export function SettingsForm({ initial }: Props) { {DOWNLOAD_FORMAT_LABELS[p]} </option> ))} - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> The yt-dlp <code>-f</code> selector for managed downloads.{" "} <strong>Auto</strong> picks per-source: Odysee/LBRY downloads its @@ -121,6 +133,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Minimum free disk space (GB)" name="minFreeDiskGB" + state={state} defaultValue={String(initial.minFreeDiskGB)} type="number" hint="Downloads are prevented from starting, and a running batch stops launching new videos, when free space on the transcripts directory falls below this floor. Default 5 GB. Set to 0 to disable the gate." @@ -128,6 +141,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Resume margin (GB)" name="resumeMarginGB" + state={state} defaultValue={String(initial.resumeMarginGB)} type="number" hint="Extra headroom above the floor that a disk-stopped pipeline must see before it starts writing again. Without it the first resumed download drops free space back under the floor and the pipeline flaps. Default 2 GB. Set to 0 to resume at the floor." @@ -153,6 +167,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Auto-refresh interval (seconds)" name="autoRefreshIntervalSeconds" + state={state} defaultValue={String(initial.autoRefreshIntervalSeconds)} type="number" hint="How often the editor passively re-fetches the current page so sidebar badges and reports stay live without a manual reload. Pauses automatically while the tab is hidden and refreshes once when you return. Default 5s (range 1–600). Set to 0 to disable." @@ -161,7 +176,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="inlineTranscribeOnFallback" - defaultChecked={initial.inlineTranscribeOnFallback} + defaultChecked={seedChecked( + state, + "inlineTranscribeOnFallback", + initial.inlineTranscribeOnFallback, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -181,7 +200,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="skipLiveDownloads" - defaultChecked={initial.skipLiveDownloads} + defaultChecked={seedChecked( + state, + "skipLiveDownloads", + initial.skipLiveDownloads, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -202,7 +225,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="verifyAvailabilityBeforeClean" - defaultChecked={initial.verifyAvailabilityBeforeClean} + defaultChecked={seedChecked( + state, + "verifyAvailabilityBeforeClean", + initial.verifyAvailabilityBeforeClean, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -226,7 +253,11 @@ export function SettingsForm({ initial }: Props) { <input type="checkbox" name="buildArchives" - defaultChecked={initial.buildArchives} + defaultChecked={seedChecked( + state, + "buildArchives", + initial.buildArchives, + )} className="mt-1" /> <span className="flex flex-col gap-1"> @@ -243,10 +274,11 @@ export function SettingsForm({ initial }: Props) { </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Archive overflow storage (R2 bucket)</span> - <input + <SeededInput + state={state} type="text" name="archiveStorageBucket" - defaultValue={initial.archiveStorage?.bucket ?? ""} + initial={initial.archiveStorage?.bucket ?? ""} placeholder="my-archives-bucket" className="rounded border border-border bg-card px-2 py-1 text-sm" /> @@ -259,10 +291,11 @@ export function SettingsForm({ initial }: Props) { </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Archive overflow public URL</span> - <input + <SeededInput + state={state} type="text" name="archiveStoragePublicBaseUrl" - defaultValue={initial.archiveStorage?.publicBaseUrl ?? ""} + initial={initial.archiveStorage?.publicBaseUrl ?? ""} placeholder="https://archives.example.com" className="rounded border border-border bg-card px-2 py-1 text-sm" /> @@ -277,9 +310,10 @@ export function SettingsForm({ initial }: Props) { </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Report refresh debounce</span> - <select + <SeededSelect + state={state} name="reportDebouncePreset" - defaultValue={initial.reportDebouncePreset} + initial={initial.reportDebouncePreset} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="fast">Fast — ~1s after activity, no cap</option> @@ -287,7 +321,7 @@ export function SettingsForm({ initial }: Props) { Balanced — ~3s after activity, 30s cap </option> <option value="lazy">Lazy — ~10s after activity, 60s cap</option> - </select> + </SeededSelect> <span className="text-xs text-muted-foreground"> After any action that changes a channel report (download, transcribe, cleanup, file edits…), the report is regenerated automatically. This @@ -307,6 +341,7 @@ export function SettingsForm({ initial }: Props) { <Field label="Max parallel builds" name="maxParallelBuilds" + state={state} defaultValue={String(initial.buildPipeline.maxParallelBuilds)} type="number" hint="Cap on concurrent per-site container builds (1–16) when Build all sites runs in containers — whenever a container engine answers." @@ -314,12 +349,14 @@ export function SettingsForm({ initial }: Props) { <Field label="Docker image tag" name="dockerImage" + state={state} defaultValue={initial.buildPipeline.dockerImage} hint="Tag of the reusable build image, built once and reused for every site." /> <Field label="Dockerfile path" name="dockerfile" + state={state} defaultValue={initial.buildPipeline.dockerfile} hint="Path (relative to the repo root) used to build the image." /> diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -30,24 +30,33 @@ import { } from "yt-dlp-transcript-common/lib/channelGroups"; import { migrateToSites } from "yt-dlp-transcript-common/controller/migrateToSites"; import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks"; +import { formValues, type FormState } from "../lib/formState"; -export type SaveResult = { ok: true; siteId: string } | { ok: false; error: string }; +// A failure carries what was submitted, so SiteForm keeps what was typed +// (lib/formState.ts). +export type SaveResult = FormState<{ siteId: string }>; export async function saveSiteAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const siteId = String(formData.get("siteId") ?? "").trim(); if (!isValidSiteId(siteId)) { return { ok: false, error: "Site ID must be lowercase letters, numbers, and hyphens.", + values, }; } const siteTitle = String(formData.get("siteTitle") ?? "").trim(); const headerTitle = String(formData.get("headerTitle") ?? "").trim(); - if (!siteTitle) return { ok: false, error: "Site title is required" }; - if (!headerTitle) return { ok: false, error: "Header title is required" }; + if (!siteTitle) { + return { ok: false, error: "Site title is required", values }; + } + if (!headerTitle) { + return { ok: false, error: "Header title is required", values }; + } const siteDescription = String(formData.get("siteDescription") ?? "").trim(); const homeTagline = String(formData.get("homeTagline") ?? "").trim(); // The wordmark's heavy lead must be a proper prefix of the header title; @@ -57,6 +66,7 @@ export async function saveSiteAction( return { ok: false, error: `Wordmark lead must be how the header title starts, same case, and shorter than it (e.g. "Jer" for "Jeralyzer") — or left blank.`, + values, }; } // Brand accent: a named id from the radio group, or "custom" + its hex @@ -72,12 +82,17 @@ export async function saveSiteAction( return { ok: false, error: "Custom accent must be a hex color like #cc3366.", + values, }; } } else if (accentChoice) { accent = parseAccentSetting(accentChoice); if (!accent) { - return { ok: false, error: `Unknown brand accent "${accentChoice}".` }; + return { + ok: false, + error: `Unknown brand accent "${accentChoice}".`, + values, + }; } if (accent === DEFAULT_ACCENT) accent = undefined; } @@ -89,6 +104,7 @@ export async function saveSiteAction( return { ok: false, error: "Public URL must be an absolute http(s):// URL, or left blank.", + values, }; } const siteUrl = parseSiteUrl(siteUrlRaw); @@ -102,6 +118,7 @@ export async function saveSiteAction( return { ok: false, error: "Hub URL must be an absolute http(s):// URL, or left blank.", + values, }; } const hubUrl = parseSiteUrl(hubUrlRaw); @@ -121,6 +138,7 @@ export async function saveSiteAction( return { ok: false, error: "Archive size cap must be a non-negative number of MB, or blank.", + values, }; } archiveMaxBytes = Math.floor(mb * 1024 * 1024); @@ -132,7 +150,7 @@ export async function saveSiteAction( String(formData.get("relatedSitesJson") ?? "[]"), ); } catch { - return { ok: false, error: "Related sites payload is malformed" }; + return { ok: false, error: "Related sites payload is malformed", values }; } const relatedSites = parseRelatedSites(relatedSitesInput); @@ -140,13 +158,14 @@ export async function saveSiteAction( try { groupsInput = JSON.parse(String(formData.get("groupsJson") ?? "[]")); } catch { - return { ok: false, error: "Channel groups payload is malformed" }; + return { ok: false, error: "Channel groups payload is malformed", values }; } const groups = parseChannelGroups(groupsInput); if (groups.length === 0) { return { ok: false, error: "At least one channel group is required (check ID/name fields)", + values, }; } const defaultGroupIdRaw = String(formData.get("defaultGroupId") ?? "").trim(); @@ -155,6 +174,7 @@ export async function saveSiteAction( return { ok: false, error: `Default group "${defaultGroupIdRaw}" is not among the configured group IDs`, + values, }; } @@ -167,7 +187,7 @@ export async function saveSiteAction( try { socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); } catch { - return { ok: false, error: "Social links payload is malformed" }; + return { ok: false, error: "Social links payload is malformed", values }; } const socialParsed = parseSocialLinks(socialInput); if ( @@ -178,6 +198,7 @@ export async function saveSiteAction( ok: false, error: "Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.", + values, }; } // A link whose SVG is unchanged from the site's file is kept as it is; a @@ -193,6 +214,7 @@ export async function saveSiteAction( return { ok: false, error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`, + values, }; } socialLinks = checked.links; @@ -202,7 +224,11 @@ export async function saveSiteAction( try { channelsInput = JSON.parse(String(formData.get("channelsJson") ?? "[]")); } catch { - return { ok: false, error: "Channel membership payload is malformed" }; + return { + ok: false, + error: "Channel membership payload is malformed", + values, + }; } const channels = parseSiteChannels(channelsInput); @@ -233,7 +259,11 @@ export async function saveSiteAction( try { await writeSite(site, getPaths()); } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + values, + }; } revalidatePath("/sites"); // "layout": the h1 above every tab reads siteTitle, and a literal path would diff --git a/editor/app/sites/components/CutReleaseForm.tsx b/editor/app/sites/components/CutReleaseForm.tsx @@ -5,6 +5,8 @@ import { cutReleaseAction, type CutReleaseState, } from "../lib/cutReleaseAction"; +import { SeededInput } from "../../components/forms/Field"; +import { seedChecked } from "../../lib/formState"; type Props = { workspace: "editor" | "export"; @@ -36,12 +38,13 @@ export function CutReleaseForm({ <label className="sr-only" htmlFor={`version-${workspace}`}> New version </label> - <input + <SeededInput + state={state} id={`version-${workspace}`} name="version" type="text" placeholder={suggestedVersion} - defaultValue={suggestedVersion} + initial={suggestedVersion} disabled={!hasUnreleased || pending} className="w-40 px-3 py-2 rounded-md border border-border bg-card text-sm font-mono" aria-describedby={`hint-${workspace}`} @@ -58,7 +61,7 @@ export function CutReleaseForm({ type="checkbox" name="commit" value="true" - defaultChecked + defaultChecked={seedChecked(state, "commit", true)} disabled={!hasUnreleased || pending} /> <span>Commit changelog</span> diff --git a/editor/app/sites/components/HomepageConfigForm.tsx b/editor/app/sites/components/HomepageConfigForm.tsx @@ -3,6 +3,8 @@ import { useActionState } from "react"; import type { HomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; import { saveHomepageConfigAction, type SaveResult } from "../lib/homepageActions"; +import { SeededInput } from "../../components/forms/Field"; +import { seedChecked } from "../../lib/formState"; const input = "rounded-md border border-border bg-card px-3 py-1.5 text-sm"; @@ -18,55 +20,69 @@ export function HomepageConfigForm({ config }: { config: HomepageConfig }) { <form action={formAction} className="flex flex-col gap-3 max-w-xl"> <label className={label}> Site title - <input className={input} name="siteTitle" defaultValue={config.siteTitle} /> + <SeededInput + state={state} + className={input} + name="siteTitle" + initial={config.siteTitle} + /> </label> <label className={label}> Header title - <input + <SeededInput + state={state} className={input} name="headerTitle" - defaultValue={config.headerTitle} + initial={config.headerTitle} /> </label> <label className={label}> Description - <input + <SeededInput + state={state} className={input} name="siteDescription" - defaultValue={config.siteDescription} + initial={config.siteDescription} /> </label> <label className={label}> Home tagline - <input + <SeededInput + state={state} className={input} name="homeTagline" - defaultValue={config.homeTagline} + initial={config.homeTagline} /> </label> <label className={label}> Public URL - <input + <SeededInput + state={state} className={input} name="siteUrl" placeholder="https://archilyzer-hub.pages.dev" - defaultValue={config.siteUrl ?? ""} + initial={config.siteUrl ?? ""} /> </label> <label className={label}> Cloudflare Pages project - <input + <SeededInput + state={state} className={input} name="cloudflareProject" placeholder="archilyzer-hub" - defaultValue={config.cloudflareProject ?? ""} + initial={config.cloudflareProject ?? ""} /> </label> <label className="flex items-center gap-2 text-sm"> <input type="checkbox" name="transcriptDownloads" - defaultChecked={config.transcriptDownloads !== false} + defaultChecked={seedChecked( + state, + "transcriptDownloads", + config.transcriptDownloads !== false, + )} className="accent-brand" /> Per-video transcript downloads (Download menu, Copy Markdown) diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -16,6 +16,12 @@ import { toSocialRow, type SocialRow, } from "../../components/SocialLinksField"; +import { Field } from "../../components/forms/Field"; +import { + ControlledCheck, + ControlledSelect, +} from "../../components/forms/Controlled"; +import { seedChecked } from "../../lib/formState"; export type ChannelOption = { slug: string; name: string }; export type SiteOption = { siteId: string; siteTitle: string }; @@ -208,6 +214,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { {isNew ? ( <Field label="Site ID" + state={state} name="siteId" defaultValue={initial.siteId} required @@ -224,29 +231,34 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { )} <Field label="Site title" + state={state} name="siteTitle" defaultValue={initial.siteTitle} required /> <Field label="Header title" + state={state} name="headerTitle" defaultValue={initial.headerTitle} required /> <Field label="Wordmark lead" + state={state} name="wordmarkLead" defaultValue={initial.wordmarkLead ?? ""} hint={'The heavy start of the wordmark — the subject\'s name, e.g. "Jer" for Jeralyzer; the rest is set light. It must be how the header text begins (same case) and shorter than it. Leave blank to set the whole name heavy.'} /> <Field label="Site description" + state={state} name="siteDescription" defaultValue={initial.siteDescription} /> <Field label="Home tagline" + state={state} name="homeTagline" defaultValue={initial.homeTagline} /> @@ -260,7 +272,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <div className="flex flex-wrap gap-x-4 gap-y-2"> {ACCENT_IDS.map((id) => ( <label key={id} className="flex items-center gap-1.5"> - <input + <ControlledCheck type="radio" name="accent" value={id} @@ -283,7 +295,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </label> ))} <label className="flex items-center gap-1.5"> - <input + <ControlledCheck type="radio" name="accent" value="custom" @@ -317,12 +329,14 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </fieldset> <Field label="Cloudflare Pages project" + state={state} name="cloudflareProject" defaultValue={initial.cloudflareProject ?? ""} hint="Project name this site deploys to (wrangler --project-name). Leave blank to set later." /> <Field label="Public URL" + state={state} name="siteUrl" defaultValue={initial.siteUrl ?? ""} hint="Absolute URL this site is served at (e.g. https://jeralyzer.com). Used so other sites can link to it in their footer. Leave blank to omit this site from cross-site lists." @@ -331,7 +345,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="listed" - defaultChecked={initial.listed !== false} + defaultChecked={seedChecked( + state, + "listed", + initial.listed !== false, + )} className="accent-brand" /> List on the Archilyzer homepage and hub @@ -345,6 +363,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </p> <Field label="Hub URL" + state={state} name="hubUrl" defaultValue={initial.hubUrl ?? ""} hint="The hub this site belongs under (e.g. https://archilyzer-hub.pages.dev), published in this site's /site.json and /corpus.json so the hub can tell it is a member; the header does not link to it. Leave blank to inherit the family default from Settings." @@ -353,7 +372,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="pwa" - defaultChecked={initial.pwa === true} + defaultChecked={seedChecked(state, "pwa", initial.pwa === true)} className="accent-brand" /> Ship as an installable PWA @@ -368,7 +387,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="archives" - defaultChecked={initial.archives !== false} + defaultChecked={seedChecked( + state, + "archives", + initial.archives !== false, + )} className="accent-brand" /> Generate downloadable archive zips on build @@ -380,8 +403,10 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </p> <Field label="Archive size cap (MB)" + state={state} name="archiveMaxMB" type="number" + min="0" defaultValue={ typeof initial.archiveMaxBytes === "number" ? String(Math.round(initial.archiveMaxBytes / (1024 * 1024))) @@ -393,7 +418,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="transcriptDownloads" - defaultChecked={initial.transcriptDownloads !== false} + defaultChecked={seedChecked( + state, + "transcriptDownloads", + initial.transcriptDownloads !== false, + )} className="accent-brand" /> Per-video transcript downloads (Download menu, Copy Markdown) @@ -409,7 +438,11 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <input type="checkbox" name="duplicates" - defaultChecked={initial.duplicates !== false} + defaultChecked={seedChecked( + state, + "duplicates", + initial.duplicates !== false, + )} className="accent-brand" /> Show the Duplicates page @@ -438,7 +471,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { className="flex items-center gap-2 text-sm py-1 border-b border-border" > <label className="flex items-center gap-2 flex-1 min-w-0"> - <input + <ControlledCheck type="checkbox" checked={on} onChange={(e) => toggleMember(c.slug, e.target.checked)} @@ -450,7 +483,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </span> </label> {on && groupOptions.length > 0 && ( - <select + <ControlledSelect value={members.get(c.slug) ?? ""} onChange={(e) => setMemberGroup(c.slug, e.target.value)} aria-label={`Group for ${c.name || c.slug}`} @@ -462,7 +495,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { {g.label} </option> ))} - </select> + </ControlledSelect> )} </div> ); @@ -486,7 +519,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { > <div className="flex items-center justify-between gap-2"> <label className="flex items-center gap-1.5 text-xs select-none"> - <input + <ControlledCheck type="radio" checked={isDefault} onChange={() => setDefaultGroupId(idTrim)} @@ -574,7 +607,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { /> </label> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="checkbox" checked={g.selectedByDefault} onChange={(e) => @@ -591,7 +624,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { Selected by default </label> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="checkbox" checked={g.inline} onChange={(e) => @@ -631,7 +664,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { value={inheritSocial ? "on" : ""} /> <label className="flex items-center gap-2 text-sm"> - <input + <ControlledCheck type="checkbox" checked={inheritSocial} onChange={(e) => setInheritSocial(e.target.checked)} @@ -718,7 +751,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { key={s.siteId} className="flex items-center gap-2 text-sm" > - <input + <ControlledCheck type="checkbox" checked={g.siteIds.includes(s.siteId)} onChange={(e) => @@ -775,34 +808,3 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { </form> ); } - -function Field({ - label, - name, - defaultValue, - hint, - required, - type = "text", -}: { - label: string; - name: string; - defaultValue?: string; - hint?: string; - required?: boolean; - type?: string; -}) { - return ( - <label className="flex flex-col gap-1 text-sm"> - <span className="font-medium">{label}</span> - <input - type={type} - name={name} - defaultValue={defaultValue} - required={required} - min={type === "number" ? 0 : undefined} - className="rounded border border-border bg-card px-2 py-1 text-sm" - /> - {hint && <span className="text-xs text-muted-foreground">{hint}</span>} - </label> - ); -} diff --git a/editor/app/sites/lib/cutReleaseAction.ts b/editor/app/sites/lib/cutReleaseAction.ts @@ -2,6 +2,7 @@ import { cutReleaseForWorkspace } from "yt-dlp-transcript-common/controller/cutRelease"; import { revalidateAfterReleaseCut } from "./revalidateAfterReleaseCut"; +import { formValues, type FormState } from "../../lib/formState"; // The "Cut release" form's server action: a FormData adapter over the one // writer, `cutReleaseForWorkspace` (common/controller/cutRelease.ts), which the @@ -9,14 +10,15 @@ import { revalidateAfterReleaseCut } from "./revalidateAfterReleaseCut"; // dirty-tree guard, the cut, the atomic write, the commit and every refusal's // sentence live there. -export type CutReleaseState = - | { ok: true; version: string; committed: boolean } - | { ok: false; error: string }; +// A failure carries what was submitted, so the form keeps the typed version +// and the commit box (lib/formState.ts). +export type CutReleaseState = FormState<{ version: string; committed: boolean }>; export async function cutReleaseAction( _prev: CutReleaseState | undefined, formData: FormData, ): Promise<CutReleaseState> { + const values = formValues(formData); const workspaceRaw = formData.get("workspace"); const versionRaw = formData.get("version"); const commitRaw = formData.get("commit"); @@ -28,10 +30,10 @@ export async function cutReleaseAction( typeof versionRaw === "string" ? versionRaw.trim() : ""; const shouldCommit = commitRaw === "true" || commitRaw === "on"; if (!workspace) { - return { ok: false, error: "Invalid workspace." }; + return { ok: false, error: "Invalid workspace.", values }; } if (!version) { - return { ok: false, error: "Version is required." }; + return { ok: false, error: "Version is required.", values }; } const result = await cutReleaseForWorkspace({ workspace, @@ -43,7 +45,7 @@ export async function cutReleaseAction( // slice O3, release 10 review L3): the pages that render this changelog // are stale all the same. The error already says the file changed. if (result.written) revalidateAfterReleaseCut(); - return { ok: false, error: result.error }; + return { ok: false, error: result.error, values }; } revalidateAfterReleaseCut(); return { ok: true, version: result.version, committed: result.committed }; diff --git a/editor/app/sites/lib/homepageActions.ts b/editor/app/sites/lib/homepageActions.ts @@ -8,8 +8,10 @@ import { parseHomepageUrl, type HomepageConfig, } from "yt-dlp-transcript-common/lib/homepage"; +import { formValues, type FormState } from "../../lib/formState"; -export type SaveResult = { ok: true } | { ok: false; error: string }; +// A failure carries what was submitted (lib/formState.ts). +export type SaveResult = FormState; // Branding/config for the hub landing site. The hub is a single cross-site // landing page (no authored Markdown pages), so this is the only homepage action. @@ -17,17 +19,23 @@ export async function saveHomepageConfigAction( _prev: SaveResult | undefined, formData: FormData, ): Promise<SaveResult> { + const values = formValues(formData); const paths = getPaths(); const current = getHomepageConfig(paths); const siteTitle = String(formData.get("siteTitle") ?? "").trim(); const headerTitle = String(formData.get("headerTitle") ?? "").trim(); - if (!siteTitle) return { ok: false, error: "Site title is required" }; - if (!headerTitle) return { ok: false, error: "Header title is required" }; + if (!siteTitle) { + return { ok: false, error: "Site title is required", values }; + } + if (!headerTitle) { + return { ok: false, error: "Header title is required", values }; + } const siteUrlRaw = String(formData.get("siteUrl") ?? "").trim(); if (siteUrlRaw && !parseHomepageUrl(siteUrlRaw)) { return { ok: false, error: "Public URL must be an absolute http(s):// URL, or left blank.", + values, }; } const cloudflareProject = String( @@ -48,7 +56,11 @@ export async function saveHomepageConfigAction( try { await writeHomepageConfig(next, paths); } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + values, + }; } revalidatePath("/sites"); return { ok: true }; diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts @@ -46,6 +46,7 @@ import { secondsText, } from "yt-dlp-transcript-common/lib/storageHealthTimings"; import { parseHealthTimingsForm } from "./lib/healthTimingsForm"; +import { formValues, type FormState } from "../lib/formState"; // THE SIX THINGS AN OPERATOR MAY DO TO A STORAGE LOCATION (and, at the end, // the drive-health timings every location is judged by). @@ -491,7 +492,9 @@ export async function evictClipWindowsAction(opts: { // The drive-health timings // --------------------------------------------------------------------------- -export type HealthTimingsResult = { ok: true; note: string } | { ok: false; error: string }; +// A failure carries what was submitted, so HealthTimingForm keeps the five +// fields as typed (lib/formState.ts). +export type HealthTimingsResult = FormState<{ note: string }>; // SAVE `settings.storage.health` FROM THE /storage FORM (HealthTimingForm). // @@ -508,13 +511,14 @@ export async function saveHealthTimingsAction( _prev: HealthTimingsResult | undefined, formData: FormData, ): Promise<HealthTimingsResult> { + const values = formValues(formData); const parsed = parseHealthTimingsForm(formData); - if (!parsed.ok) return parsed; + if (!parsed.ok) return { ...parsed, values }; const settings = getSettings(); try { await saveSettings({ storage: { ...settings.storage, health: parsed.health } }); } catch (e) { - return { ok: false, error: (e as Error).message }; + return { ok: false, error: (e as Error).message, values }; } const t = applyHealthTimings(getSettings().storage.health); revalidatePath("/storage"); diff --git a/editor/app/storage/components/HealthTimingForm.tsx b/editor/app/storage/components/HealthTimingForm.tsx @@ -9,6 +9,7 @@ import { } from "yt-dlp-transcript-common/lib/storageHealthTimings"; import { HEALTH_TIMING_FIELDS } from "../lib/healthTimingsForm"; import { saveHealthTimingsAction, type HealthTimingsResult } from "../actions"; +import { SeededInput } from "../../components/forms/Field"; // THE DRIVE HEALTH TIMING — `settings.storage.health`, the five numbers the // editor decides "this drive is mounted and not answering" by @@ -70,12 +71,13 @@ export function HealthTimingForm({ stored }: { stored: StorageHealthSettings }) {f.label} {f.unit && <span className="font-normal text-muted-foreground"> ({f.unit})</span>} </span> - <input + <SeededInput + state={state} type="text" inputMode="numeric" name={f.key} aria-label={f.ariaLabel} - defaultValue={stored[f.key] === undefined ? "" : String(stored[f.key])} + initial={stored[f.key] === undefined ? "" : String(stored[f.key])} placeholder={String(HEALTH_TIMING_DEFAULTS[f.key])} className="rounded border border-border bg-background px-2 py-1 text-sm font-mono tabular-nums" /> diff --git a/editor/app/workers/components/WorkersField.tsx b/editor/app/workers/components/WorkersField.tsx @@ -3,6 +3,12 @@ import { useId, useState } from "react"; import type { Worker } from "yt-dlp-transcript-common/lib/workers"; import type { TranscriptionAppDescriptor } from "yt-dlp-transcript-common/lib/transcriptionApps"; +// Its controlled checkboxes and selects sit inside action forms, whose reset they would not +// survive alone (forms/Controlled.tsx). +import { + ControlledCheck, + ControlledSelect, +} from "../../components/forms/Controlled"; // Editor for the transcription worker list. Manages an ordered array of workers // (list order == priority, top = highest) and serializes it to a hidden JSON @@ -248,7 +254,7 @@ function WorkerCard({ className="flex-1 min-w-40 rounded border border-border bg-card px-2 py-1 text-sm" /> <label className="flex items-center gap-1 text-xs"> - <input + <ControlledCheck type="checkbox" checked={row.enabled} onChange={(e) => onPatch({ enabled: e.target.checked })} @@ -299,7 +305,7 @@ function WorkerCard({ {row.kind === "local" && ( <label className="flex flex-col gap-1 text-xs"> <span className="font-medium">Engine</span> - <select + <ControlledSelect value={row.appId ?? ""} onChange={(e) => onPatch({ appId: e.target.value })} aria-label={`worker ${index + 1} engine`} @@ -310,7 +316,7 @@ function WorkerCard({ {a.label} </option> ))} - </select> + </ControlledSelect> </label> )} <span className="text-xs text-muted-foreground"> @@ -460,7 +466,7 @@ function WorkerCard({ id={`${uid}-slots`} /> <label className="flex items-center gap-2 text-xs"> - <input + <ControlledCheck type="checkbox" checked={remote.sharedFs === true} onChange={(e) => onPatchRemote({ sharedFs: e.target.checked })} diff --git a/editor/e2e/forms-keep-input.spec.ts b/editor/e2e/forms-keep-input.spec.ts @@ -0,0 +1,791 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { test, expect, type Locator, type Page } from "@playwright/test"; +import { + channelStage, + generateReport, + readJson, + resetData, + resolvePath, + writeSettings, + writeSite, +} from "./helpers"; + +// A FORM THAT FAILS KEEPS WHAT WAS TYPED (release 16, slice FK). +// +// React resets a <form> when its action's transition commits, success or +// failure, and an uncontrolled field goes back to its default. Every action +// that can refuse now returns what was submitted and the fields seed from it +// (app/lib/formState.ts). One case per form the slice changed: type, submit +// something the action refuses, assert the refusal AND that every field still +// holds what was typed. +// +// Fields are found by NAME where a label would be ambiguous: a Field's +// accessible name includes its hint, and getByLabel matches substrings. +// +// THE FOUR OPERATION FORMS, THE SYNC FORM AND THE BACKUP FORM CANNOT BE REFUSED +// FROM THE PAGE — their actions only fail when settings.json cannot be written. +// `withSettingsUnwritable` makes that happen for real: the settings file is put +// aside and a directory takes its path, so the atomic write's rename fails +// (EISDIR) and the action returns its error. The file is back before the test +// ends, whatever happens. + +const SETTINGS = resolvePath("test-settings.json"); + +async function withSettingsUnwritable(fn: () => Promise<void>): Promise<void> { + const aside = `${SETTINGS}.fk-aside`; + await rename(SETTINGS, aside); + await mkdir(SETTINGS); + try { + await fn(); + } finally { + await rm(SETTINGS, { recursive: true, force: true }); + await rename(aside, SETTINGS); + } +} + +// A click before React hydrates submits nothing a client action sees. +async function hydrated(form: Locator): Promise<void> { + await expect(async () => { + const ok = await form.evaluate((el) => + Object.keys(el).some((k) => k.startsWith("__reactProps")), + ); + expect(ok).toBe(true); + }).toPass({ timeout: 15_000 }); +} + +const named = (scope: Locator | Page, name: string) => + scope.locator(`[name="${name}"]`); + +// Next's route announcer is a second (empty) role=alert, so filter by text. +const alert = (page: Page, text: RegExp) => + page.getByRole("alert").filter({ hasText: text }); + +// A CONTROLLED checkbox, radio or select is read ONCE, as the refusal shows, +// not polled. Without forms/Controlled.tsx the reset leaves it showing what it +// mounted with only until the next re-render that reaches it — the editor's +// auto-refresh, 5 s by default — so a polled assertion passes on code without +// the fix. The refusal appears in the commit the reset ran in. +const checkedNow = (loc: Locator) => loc.isChecked(); +const valueNow = (loc: Locator) => loc.inputValue(); + +test("SiteForm create: a refused site id keeps every field", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + await page.goto("/sites/new"); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: "Create site" }), + }); + await hydrated(form); + + await named(form, "siteId").fill("Not A Valid Id!"); + await named(form, "siteTitle").fill("Typed Title"); + await named(form, "headerTitle").fill("Typedalyzer"); + await named(form, "wordmarkLead").fill("Typed"); + await named(form, "siteDescription").fill("A typed description"); + await named(form, "homeTagline").fill("A typed tagline"); + await named(form, "cloudflareProject").fill("typed-project"); + await named(form, "archiveMaxMB").fill("12"); + // Two checkboxes toggled from their defaults (pwa off, archives on). + await named(form, "pwa").check(); + await named(form, "archives").uncheck(); + // Controlled, posted: the accent radio; controlled, not posted: membership. + await form.locator('input[name="accent"][value="brass"]').check(); + await form + .getByRole("checkbox", { name: /include test youtube channel/i }) + .check(); + + await form.getByRole("button", { name: "Create site" }).click(); + await expect(alert(page, /site id must be/i)).toBeVisible(); + expect( + await checkedNow(form.locator('input[name="accent"][value="brass"]')), + ).toBe(true); + expect( + await checkedNow( + form.getByRole("checkbox", { name: /include test youtube channel/i }), + ), + ).toBe(true); + + await expect(named(form, "siteId")).toHaveValue("Not A Valid Id!"); + await expect(named(form, "siteTitle")).toHaveValue("Typed Title"); + await expect(named(form, "headerTitle")).toHaveValue("Typedalyzer"); + await expect(named(form, "wordmarkLead")).toHaveValue("Typed"); + await expect(named(form, "siteDescription")).toHaveValue( + "A typed description", + ); + await expect(named(form, "homeTagline")).toHaveValue("A typed tagline"); + await expect(named(form, "cloudflareProject")).toHaveValue("typed-project"); + await expect(named(form, "archiveMaxMB")).toHaveValue("12"); + await expect(named(form, "pwa")).toBeChecked(); + await expect(named(form, "archives")).not.toBeChecked(); + await expect(named(form, "listed")).toBeChecked(); +}); + +test("SiteForm edit: a refused Public URL keeps every field", async ({ + page, +}) => { + await resetData("empty"); + await writeSite("alpha", { siteTitle: "Alpha Site" }); + await page.goto("/sites/alpha"); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: "Save site" }), + }); + await hydrated(form); + + await named(form, "siteTitle").fill("Alpha Typed"); + await named(form, "headerTitle").fill("Alphalyzer"); + await named(form, "wordmarkLead").fill("Alpha"); + await named(form, "siteDescription").fill("Typed description"); + await named(form, "siteUrl").fill("not a url"); + await named(form, "hubUrl").fill("https://hub.example"); + await named(form, "duplicates").uncheck(); + await named(form, "transcriptDownloads").uncheck(); + await form.locator('input[name="accent"][value="violet"]').check(); + + await form.getByRole("button", { name: "Save site" }).click(); + await expect(alert(page, /public url must be/i)).toBeVisible(); + expect( + await checkedNow(form.locator('input[name="accent"][value="violet"]')), + ).toBe(true); + + await expect(named(form, "siteTitle")).toHaveValue("Alpha Typed"); + await expect(named(form, "headerTitle")).toHaveValue("Alphalyzer"); + await expect(named(form, "wordmarkLead")).toHaveValue("Alpha"); + await expect(named(form, "siteDescription")).toHaveValue( + "Typed description", + ); + await expect(named(form, "siteUrl")).toHaveValue("not a url"); + await expect(named(form, "hubUrl")).toHaveValue("https://hub.example"); + await expect(named(form, "duplicates")).not.toBeChecked(); + await expect(named(form, "transcriptDownloads")).not.toBeChecked(); + // Nothing was written. + const site = await readJson<{ siteTitle: string }>( + "test-transcripts/sites/alpha/site.json", + ); + expect(site.siteTitle).toBe("Alpha Site"); +}); + +test("SettingsForm: a refused page size keeps every field, and a save after it shows what was stored", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/settings"); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: "Save settings" }), + }); + await hydrated(form); + + await named(form, "adminTitle").fill("Typed Admin"); + await named(form, "maxTranscriptPageBytes").fill("42"); + await named(form, "cookiesFromBrowser").fill("firefox:Typed"); + await named(form, "cookieMode").selectOption("defer"); + await named(form, "sleepBetweenDownloadsSeconds").fill("17"); + await named(form, "downloadFormat").selectOption("original"); + await named(form, "minFreeDiskGB").fill("3"); + await named(form, "skipLiveDownloads").uncheck(); + await named(form, "inlineTranscribeOnFallback").check(); + await named(form, "archiveStorageBucket").fill("typed-bucket"); + await named(form, "reportDebouncePreset").selectOption("lazy"); + await named(form, "maxParallelBuilds").fill("7"); + await named(form, "dockerImage").fill("typed-image"); + // A social link with its controlled "featured" box ticked. + await form.getByRole("button", { name: /add link/i }).click(); + await form.getByPlaceholder(/label \(e\.g\. github\)/i).fill("GitHub"); + const featured = form.getByRole("checkbox", { + name: "Keep in header on small screens", + }); + await featured.check(); + + await form.getByRole("button", { name: "Save settings" }).click(); + await expect(alert(page, /between/)).toBeVisible(); + expect(await checkedNow(featured)).toBe(true); + + await expect(named(form, "adminTitle")).toHaveValue("Typed Admin"); + await expect(named(form, "maxTranscriptPageBytes")).toHaveValue("42"); + await expect(named(form, "cookiesFromBrowser")).toHaveValue("firefox:Typed"); + await expect(named(form, "cookieMode")).toHaveValue("defer"); + await expect(named(form, "sleepBetweenDownloadsSeconds")).toHaveValue("17"); + await expect(named(form, "downloadFormat")).toHaveValue("original"); + await expect(named(form, "minFreeDiskGB")).toHaveValue("3"); + await expect(named(form, "skipLiveDownloads")).not.toBeChecked(); + await expect(named(form, "inlineTranscribeOnFallback")).toBeChecked(); + await expect(named(form, "archiveStorageBucket")).toHaveValue("typed-bucket"); + await expect(named(form, "reportDebouncePreset")).toHaveValue("lazy"); + await expect(named(form, "maxParallelBuilds")).toHaveValue("7"); + await expect(named(form, "dockerImage")).toHaveValue("typed-image"); + + // THE SUCCESS PATH: fix the one bad field and save. The selects show the + // stored choice afterwards (a select's default reached the DOM only at mount + // before this slice, so the reset put back the option it mounted with). + await form.getByRole("button", { name: /remove/i }).first().click(); + await named(form, "maxTranscriptPageBytes").fill("8388608"); + await form.getByRole("button", { name: "Save settings" }).click(); + await expect( + form.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + await expect(async () => { + const saved = await readJson<{ cookieMode: string; adminTitle: string }>( + "test-settings.json", + ); + expect(saved.cookieMode).toBe("defer"); + expect(saved.adminTitle).toBe("Typed Admin"); + }).toPass({ timeout: 10_000 }); + await expect(named(form, "cookieMode")).toHaveValue("defer"); + await expect(named(form, "reportDebouncePreset")).toHaveValue("lazy"); + await expect(named(form, "adminTitle")).toHaveValue("Typed Admin"); +}); + +test("ChannelForm create: a refused slug keeps every field", async ({ + page, +}) => { + await resetData("one-youtube-channel"); + await writeSite("alpha", { siteTitle: "Alpha Site" }); + await generateReport(page, "new"); + await page.goto("/channels/new"); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: /create channel/i }), + }); + await hydrated(form); + + await named(form, "name").fill("Typed Channel"); + await named(form, "slug").fill("test-youtube"); // already exists + await named(form, "url").fill("https://www.youtube.com/@typed/videos"); + // Create mode's platform select and handling radios are controlled AND + // posted: a stale one would be sent by the next submit. + await named(form, "platform").selectOption("rumble"); + await form.locator('input[name="handling"][value="transcribe"]').check(); + const member = form.getByRole("checkbox", { name: "Include on Alpha Site" }); + const memberWas = await member.isChecked(); + await member.setChecked(!memberWas); + await named(form, "fetchPlaylist").uncheck(); + await named(form, "prioritizeDownload").check(); + await named(form, "audioFormat").selectOption("opus"); + await named(form, "downloadFormat").selectOption("original"); + await named(form, "keepSourceVideo").check(); + await named(form, "audioCheckEnabled").check(); + await named(form, "audioCheckIntervalSeconds").fill("45"); + await named(form, "keepLatest").fill("5"); + await named(form, "extractionMode").selectOption("app"); + await named(form, "savedVideosDir").fill("/tmp/typed-dir"); + await form.getByText("Advanced", { exact: true }).click(); + await named(form, "ytdlpExtraArgs").fill("--typed\n--args"); + await named(form, "downloadFilterInclude").fill("typed-include"); + await named(form, "downloadFilterIncludeLivestreams").check(); + await named(form, "downloadFilterRejectedLivestreams").selectOption( + "chat-only", + ); + await named(form, "cookiesFromBrowser").fill("firefox"); + await named(form, "cookieMode").selectOption("always"); + await named(form, "sleepBetweenDownloadsSeconds").fill("9"); + + await form.getByRole("button", { name: /create channel/i }).click(); + await expect(form.getByText(/already exists/i)).toBeVisible(); + expect(await valueNow(named(form, "platform"))).toBe("rumble"); + expect( + await checkedNow(form.locator('input[name="handling"][value="transcribe"]')), + ).toBe(true); + expect(await checkedNow(member)).toBe(!memberWas); + + await expect(named(form, "name")).toHaveValue("Typed Channel"); + await expect(named(form, "slug")).toHaveValue("test-youtube"); + await expect(named(form, "url")).toHaveValue( + "https://www.youtube.com/@typed/videos", + ); + await expect(named(form, "fetchPlaylist")).not.toBeChecked(); + await expect(named(form, "prioritizeDownload")).toBeChecked(); + await expect(named(form, "audioFormat")).toHaveValue("opus"); + await expect(named(form, "downloadFormat")).toHaveValue("original"); + await expect(named(form, "keepSourceVideo")).toBeChecked(); + await expect(named(form, "audioCheckEnabled")).toBeChecked(); + await expect(named(form, "audioCheckIntervalSeconds")).toHaveValue("45"); + await expect(named(form, "keepLatest")).toHaveValue("5"); + await expect(named(form, "extractionMode")).toHaveValue("app"); + await expect(named(form, "savedVideosDir")).toHaveValue("/tmp/typed-dir"); + await expect(named(form, "ytdlpExtraArgs")).toHaveValue("--typed\n--args"); + await expect(named(form, "downloadFilterInclude")).toHaveValue( + "typed-include", + ); + await expect(named(form, "downloadFilterIncludeLivestreams")).toBeChecked(); + await expect(named(form, "downloadFilterRejectedLivestreams")).toHaveValue( + "chat-only", + ); + await expect(named(form, "cookiesFromBrowser")).toHaveValue("firefox"); + await expect(named(form, "cookieMode")).toHaveValue("always"); + await expect(named(form, "sleepBetweenDownloadsSeconds")).toHaveValue("9"); +}); + +// Create mode draws its "On create" boxes per KIND of source: Fetch posts now +// for a social account, Fetch playlist now for a video channel. A box of the +// kind that was NOT posted must come back at its default (ticked) when the URL +// switches kind after a refusal — not read as "absent from values, unticked". +test("ChannelForm create: switching the source's kind after a refusal shows the other kind's box at its default", async ({ + page, +}) => { + const BSKY_URL = "https://bsky.app/profile/someone.bsky.social"; + const VIDEO_URL = "https://www.youtube.com/@kindswitch/videos"; + await resetData("one-youtube-channel"); + await generateReport(page, "new"); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: /create channel/i }), + }); + const posts = named(form, "fetchPostsNow"); + const playlist = named(form, "fetchPlaylist"); + const refuse = async () => { + await form.getByRole("button", { name: /create channel/i }).click(); + await expect(form.getByText(/already exists/i)).toBeVisible(); + }; + + // A refused VIDEO submit, then a social URL. + await page.goto("/channels/new"); + await hydrated(form); + await named(form, "name").fill("Kind Switch"); + await named(form, "slug").fill("test-youtube"); // already exists + await named(form, "url").fill(VIDEO_URL); + await playlist.uncheck(); + await refuse(); + await expect(playlist).not.toBeChecked(); + await named(form, "url").fill(BSKY_URL); + await expect(posts).toBeChecked(); + + // The reverse: a refused SOCIAL submit, then a video URL. + await page.goto("/channels/new"); + await hydrated(form); + await named(form, "name").fill("Kind Switch"); + await named(form, "slug").fill("test-youtube"); + await named(form, "url").fill(BSKY_URL); + await posts.uncheck(); + await refuse(); + await expect(posts).not.toBeChecked(); + await named(form, "url").fill(VIDEO_URL); + await expect(playlist).toBeChecked(); +}); + +test("ChannelForm edit: a refused filter keeps every field", async ({ + page, +}) => { + await resetData("one-youtube-channel"); + await generateReport(page, "test-youtube"); + await page.goto(channelStage("test-youtube", "configure")); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: /save changes/i }), + }); + await hydrated(form); + + await named(form, "name").fill("Edited Name"); + await named(form, "url").fill("https://www.youtube.com/@edited/videos"); + await named(form, "platform").selectOption("rumble"); + await form.locator('input[name="handling"][value="transcribe"]').check(); + await named(form, "audioFormat").selectOption("m4a"); + await named(form, "keepLatest").fill("7"); + await named(form, "keepSourceVideo").check(); + await form.getByText("Advanced", { exact: true }).click(); + await named(form, "ytdlpExtraArgs").fill("--edited"); + await named(form, "downloadFilterExclude").fill("(unclosed"); + await named(form, "cookieMode").selectOption("defer"); + + await form.getByRole("button", { name: /save changes/i }).click(); + await expect( + form.getByText(/not a valid regular expression/i), + ).toBeVisible(); + + await expect(named(form, "name")).toHaveValue("Edited Name"); + await expect(named(form, "url")).toHaveValue( + "https://www.youtube.com/@edited/videos", + ); + await expect(named(form, "platform")).toHaveValue("rumble"); + await expect( + form.locator('input[name="handling"][value="transcribe"]'), + ).toBeChecked(); + await expect(named(form, "audioFormat")).toHaveValue("m4a"); + await expect(named(form, "keepLatest")).toHaveValue("7"); + await expect(named(form, "keepSourceVideo")).toBeChecked(); + await expect(named(form, "ytdlpExtraArgs")).toHaveValue("--edited"); + await expect(named(form, "downloadFilterExclude")).toHaveValue("(unclosed"); + await expect(named(form, "cookieMode")).toHaveValue("defer"); + const config = await readJson<{ name: string }>( + "test-transcripts/channels/test-youtube/config.json", + ); + expect(config.name).not.toBe("Edited Name"); +}); + +test("RenameChannelForm: a mismatched confirm keeps both boxes", async ({ + page, +}) => { + await resetData("one-youtube-channel"); + await generateReport(page, "test-youtube"); + await page.goto(channelStage("test-youtube", "danger")); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: "Rename channel" }), + }); + await hydrated(form); + + await form.getByLabel("new slug").fill("renamed-typed"); + await form.getByLabel("confirm current slug").fill("not-the-slug"); + await form.getByRole("button", { name: "Rename channel" }).click(); + await expect(form.getByText(/exactly to confirm the rename/i)).toBeVisible(); + + await expect(form.getByLabel("new slug")).toHaveValue("renamed-typed"); + await expect(form.getByLabel("confirm current slug")).toHaveValue( + "not-the-slug", + ); +}); + +test("DeleteChannelForm: a mismatched confirm keeps the box", async ({ + page, +}) => { + await resetData("one-youtube-channel"); + await generateReport(page, "test-youtube"); + await page.goto(channelStage("test-youtube", "danger")); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: "Delete channel" }), + }); + await hydrated(form); + + await form.getByLabel("confirm slug to delete").fill("wrong-slug"); + await form.getByRole("button", { name: "Delete channel" }).click(); + await expect(form.getByText(/exactly to confirm deletion/i)).toBeVisible(); + await expect(form.getByLabel("confirm slug to delete")).toHaveValue( + "wrong-slug", + ); +}); + +test("DeleteVideoDirSection: a mismatched id keeps the box", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + await page.goto("/channels/test-youtube/videos/20240101_test1234567"); + const form = page.getByLabel("delete video directory 20240101_test1234567"); + await hydrated(form); + + await form.getByLabel("confirm video id").fill("nope-typed"); + await form.getByRole("button", { name: "Delete directory" }).click(); + await expect(page.getByLabel("delete directory error")).toContainText( + "Type the video id", + ); + await expect(form.getByLabel("confirm video id")).toHaveValue("nope-typed"); +}); + +test("HealthTimingForm: an out-of-range value keeps the five fields", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0 }); + await page.goto("/storage"); + const block = page.getByLabel("drive health timing"); + const form = block.locator("form"); + await hydrated(form); + await block.locator("summary").click(); + + await block.getByLabel("read budget").fill("200"); + await block.getByLabel("health check interval").fill("20000"); + await block.getByLabel("health check timeout").fill("4000"); + await block.getByLabel("clean checks to clear").fill("3"); + await block.getByLabel("reads at once per drive").fill("2"); + await block.getByLabel("save timing").click(); + await expect(block.getByLabel("timing error")).toContainText( + "Read budget must be between", + ); + + await expect(block.getByLabel("read budget")).toHaveValue("200"); + await expect(block.getByLabel("health check interval")).toHaveValue("20000"); + await expect(block.getByLabel("health check timeout")).toHaveValue("4000"); + await expect(block.getByLabel("clean checks to clear")).toHaveValue("3"); + await expect(block.getByLabel("reads at once per drive")).toHaveValue("2"); +}); + +test("HomepageConfigForm: a refused Public URL keeps every field", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/sites"); + const form = page.locator("form").filter({ + has: page.getByRole("button", { name: "Save config" }), + }); + await hydrated(form); + + await named(form, "siteTitle").fill("Typed Hub"); + await named(form, "headerTitle").fill("Typed Header"); + await named(form, "siteDescription").fill("Typed hub description"); + await named(form, "homeTagline").fill("Typed hub tagline"); + await named(form, "siteUrl").fill("not a url"); + await named(form, "cloudflareProject").fill("typed-hub"); + await named(form, "transcriptDownloads").uncheck(); + await form.getByRole("button", { name: "Save config" }).click(); + await expect(form.getByText(/public url must be/i)).toBeVisible(); + + await expect(named(form, "siteTitle")).toHaveValue("Typed Hub"); + await expect(named(form, "headerTitle")).toHaveValue("Typed Header"); + await expect(named(form, "siteDescription")).toHaveValue( + "Typed hub description", + ); + await expect(named(form, "homeTagline")).toHaveValue("Typed hub tagline"); + await expect(named(form, "siteUrl")).toHaveValue("not a url"); + await expect(named(form, "cloudflareProject")).toHaveValue("typed-hub"); + await expect(named(form, "transcriptDownloads")).not.toBeChecked(); +}); + +test("CutReleaseForm: a bad version keeps it and the commit box", async ({ + page, +}) => { + // The disposable test changelog cut-release.spec.ts drives (never the + // repo's own); left with no [Unreleased] heading, as that spec leaves it. + const changelog = resolvePath("test-changelog.md"); + const before = await readFile(changelog, "utf8").catch(() => null); + await writeFile( + changelog, + "# Changelog\n\n## [Unreleased]\n- a pending bullet\n\n" + + "## [9.9.9] - 2024-01-01\n- old\n", + ); + try { + await page.goto("/changelog"); + const form = page.locator("form").filter({ + has: page.locator("#version-editor"), + }); + await hydrated(form); + + await page.locator("#version-editor").fill("not-semver"); + await form.getByRole("checkbox", { name: "Commit changelog" }).uncheck(); + await form.getByRole("button", { name: /^cut release$/i }).click(); + await expect(alert(page, /not a valid semver/i)).toBeVisible(); + + await expect(page.locator("#version-editor")).toHaveValue("not-semver"); + await expect( + form.getByRole("checkbox", { name: "Commit changelog" }), + ).not.toBeChecked(); + // Refused before anything was written. + expect(await readFile(changelog, "utf8")).toContain("## [Unreleased]"); + } finally { + await writeFile( + changelog, + before ?? + "# Changelog\n\n## [9.9.9] - 2024-01-01\n- old released bullet\n", + ); + } +}); + +test("AttributionSettingsForm: a refused write keeps every field", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/operations/attribution-text"); + const form = page.locator('form[data-settings-block="attribution"]'); + await hydrated(form); + + const enabled = await named(form, "attributionEnabled").isChecked(); + await named(form, "attributionEnabled").setChecked(!enabled); + await named(form, "attributionTextOnly").check(); + const app = named(form, "attributionAppId"); + await app.selectOption({ index: 1 }); + const appValue = await app.inputValue(); + await named(form, "attributionModel").fill("typed-model:7b"); + await named(form, "attributionPromptVersion").fill("99"); + + await withSettingsUnwritable(async () => { + await form + .getByRole("button", { name: "Save attribution settings" }) + .click(); + await expect(form.getByRole("alert")).toBeVisible(); + }); + + await expect(named(form, "attributionEnabled")).toBeChecked({ + checked: !enabled, + }); + await expect(named(form, "attributionTextOnly")).toBeChecked(); + await expect(app).toHaveValue(appValue); + await expect(named(form, "attributionModel")).toHaveValue("typed-model:7b"); + await expect(named(form, "attributionPromptVersion")).toHaveValue("99"); +}); + +test("DiarizationSettingsForm: a refused write keeps every field", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/operations/diarization"); + const form = page.locator('form[data-settings-block="diarization"]'); + await hydrated(form); + + await named(form, "diarizationEnabled").check(); + await named(form, "diarizationInlineAfterTranscribe").check(); + await named(form, "diarizationEngine").selectOption("sortformer"); + await named(form, "diarizationBackend").selectOption("cpu"); + await named(form, "diarizationSortformerBin").fill("/opt/typed/diarize"); + await named(form, "diarizationPython").fill("/opt/typed/python"); + await named(form, "diarizationThreshold").fill("0.42"); + await named(form, "diarizationThreads").fill("3"); + await named(form, "diarizationMaxAudioHours").fill("2.5"); + + await withSettingsUnwritable(async () => { + await form + .getByRole("button", { name: "Save diarization settings" }) + .click(); + await expect(form.getByRole("alert")).toBeVisible(); + }); + + await expect(named(form, "diarizationEnabled")).toBeChecked(); + await expect(named(form, "diarizationInlineAfterTranscribe")).toBeChecked(); + await expect(named(form, "diarizationEngine")).toHaveValue("sortformer"); + await expect(named(form, "diarizationBackend")).toHaveValue("cpu"); + await expect(named(form, "diarizationSortformerBin")).toHaveValue( + "/opt/typed/diarize", + ); + await expect(named(form, "diarizationPython")).toHaveValue( + "/opt/typed/python", + ); + await expect(named(form, "diarizationThreshold")).toHaveValue("0.42"); + await expect(named(form, "diarizationThreads")).toHaveValue("3"); + await expect(named(form, "diarizationMaxAudioHours")).toHaveValue("2.5"); +}); + +test("DigestSettingsForm: a refused write keeps every field, the section boxes too", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/operations/digest"); + const form = page.locator('form[data-settings-block="digest"]'); + await hydrated(form); + + const box = (section: string) => + form.locator(`input[name="digestSections"][value="${section}"]`); + await box("tags").check(); + await box("chapters").uncheck(); + await named(form, "digestTimestampMode").selectOption("absolute"); + await named(form, "digestPromptVariant").fill("typed-variant"); + await named(form, "digestYieldToCpuWorkers").check(); + await named(form, "digestRemoteEnabled").check(); + await named(form, "digestLongTailSeconds").fill("7200"); + await named(form, "digestSpendCapUsd").fill("3.25"); + + await withSettingsUnwritable(async () => { + await form.getByRole("button", { name: "Save digest settings" }).click(); + await expect(form.getByRole("alert")).toBeVisible(); + expect(await checkedNow(box("tags"))).toBe(true); + expect(await checkedNow(box("chapters"))).toBe(false); + }); + + await expect(named(form, "digestTimestampMode")).toHaveValue("absolute"); + await expect(named(form, "digestPromptVariant")).toHaveValue("typed-variant"); + await expect(named(form, "digestYieldToCpuWorkers")).toBeChecked(); + await expect(named(form, "digestRemoteEnabled")).toBeChecked(); + await expect(named(form, "digestLongTailSeconds")).toHaveValue("7200"); + await expect(named(form, "digestSpendCapUsd")).toHaveValue("3.25"); +}); + +test("LaneSettingsForm: a refused write keeps every field", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/operations/diarization"); + const form = page.locator('form[data-settings-block="backfill"]'); + await hydrated(form); + + const run = await named(form, "backfillEnabled").isChecked(); + await named(form, "backfillEnabled").setChecked(!run); + await named(form, "backfillConcurrency").fill("3"); + await named(form, "backfillAllowRedownload").check(); + + await withSettingsUnwritable(async () => { + await form.getByRole("button", { name: "Save lane settings" }).click(); + await expect(form.getByRole("alert")).toBeVisible(); + }); + + await expect(named(form, "backfillEnabled")).toBeChecked({ checked: !run }); + await expect(named(form, "backfillConcurrency")).toHaveValue("3"); + await expect(named(form, "backfillAllowRedownload")).toBeChecked(); +}); + +test("SchedulerSettingsForm: a refused write keeps every field, a cadence select too", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/operations/sync"); + const form = page.locator('form[data-settings-block="syncScheduler"]'); + await hydrated(form); + + const on = await named(form, "syncSchedulerEnabled").isChecked(); + await named(form, "syncSchedulerEnabled").setChecked(!on); + await named(form, "syncSchedulerFullSweepConfirmMaxSuspects").fill("6"); + await named(form, "syncSchedulerFullSweepShrinkGuardPercent").fill("33"); + await named(form, "syncSchedulerMaxConcurrentSyncs").fill("4"); + await named(form, "syncSchedulerQuietHoursStart").fill("22"); + await named(form, "syncSchedulerQuietHoursEnd").fill("6"); + // A DurationField: a controlled select inside the action form. + const sweep = form.getByLabel("Default full sweep interval", { exact: true }); + await sweep.selectOption("4320"); + + await withSettingsUnwritable(async () => { + await form.getByRole("button", { name: "Save controls" }).click(); + await expect(form.getByRole("alert")).toBeVisible(); + expect(await valueNow(sweep)).toBe("4320"); + }); + + await expect(named(form, "syncSchedulerEnabled")).toBeChecked({ + checked: !on, + }); + await expect( + named(form, "syncSchedulerFullSweepConfirmMaxSuspects"), + ).toHaveValue("6"); + await expect( + named(form, "syncSchedulerFullSweepShrinkGuardPercent"), + ).toHaveValue("33"); + await expect(named(form, "syncSchedulerMaxConcurrentSyncs")).toHaveValue("4"); + await expect(named(form, "syncSchedulerQuietHoursStart")).toHaveValue("22"); + await expect(named(form, "syncSchedulerQuietHoursEnd")).toHaveValue("6"); +}); + +test("SavedVideosControls: a refused write keeps the box and the cadence", async ({ + page, +}) => { + await resetData("empty"); + await page.goto("/saved-videos"); + const form = page.getByLabel("backup config"); + await hydrated(form); + + await page.getByLabel("backup destination").fill("/tmp/ttb-fk-dest"); + await page.getByLabel("scheduled backup enabled").check(); + await form.getByLabel("Backup interval amount").fill("3"); + await form.getByLabel("Backup interval unit").selectOption("days"); + + await withSettingsUnwritable(async () => { + await form.getByRole("button", { name: "Save backup config" }).click(); + await expect(form.getByRole("alert")).toBeVisible(); + expect(await valueNow(form.getByLabel("Backup interval unit"))).toBe( + "days", + ); + }); + + await expect(page.getByLabel("backup destination")).toHaveValue( + "/tmp/ttb-fk-dest", + ); + await expect(page.getByLabel("scheduled backup enabled")).toBeChecked(); + await expect(form.getByLabel("Backup interval amount")).toHaveValue("3"); +}); + +test("WorkersConfigForm: a refused list keeps the Enabled boxes as set", async ({ + page, +}) => { + await resetData("empty"); + await writeSettings({ + workers: ["gpu", "cpu"].map((id, priority) => ({ + id, + name: id.toUpperCase(), + kind: "local", + enabled: true, + priority, + appId: "whisper-cpp", + config: {}, + })), + }); + await page.goto("/workers"); + const form = page.locator('form[data-settings-block="workers"]'); + await hydrated(form); + + await form.getByLabel("worker 1 enabled").uncheck(); + await form.getByLabel("worker 2 enabled").uncheck(); + await form.getByRole("button", { name: "Save workers" }).click(); + await expect(alert(page, /at least one worker must be enabled/i)).toBeVisible(); + + // Controlled boxes: before slice FK the reset put them back to ticked, while + // the list still said off, until the next re-render. + expect(await checkedNow(form.getByLabel("worker 1 enabled"))).toBe(false); + expect(await checkedNow(form.getByLabel("worker 2 enabled"))).toBe(false); +}); diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -8027,3 +8027,66 @@ source mirror (homepage)". Anchors are at the branch. `.tsx` with and without its entities (`next/dist/build/swc` `transform`): 22 texts in 19 files differ, 1 in `homepage/app/downloads/page.tsx` and 21 in `editor/app/**`; none in `export/app` or `common/components`. + +## A form that fails keeps what was typed (verified 2026-10-01, branch `r16/forms-keep-input`) + +- **React resets a `<form>` whose `action` is a function when the action's transition commits — + success or failure alike.** In the react-dom the editor runs (Next 16.2.3's bundled + `19.3.0-canary-3f0b9e61-20260317`, `next/dist/compiled/react-dom/cjs/react-dom-client.development.js`): + `startHostTransition` calls `requestFormReset$1(formFiber)` beside `action(formData)` (`:9222`); + the commit marks the form (`needsFormReset`, `:15963`) and, after every mutation of the root, + calls `recursivelyResetForms` (`:16017-16018`), which calls the native `form.reset()` (`:16223`). + (The standalone `react-dom@19.2.4` under `editor/node_modules` has the same code at `:8955`, + `:14848`, `:14900-14901`.) The reset comes AFTER the commit's prop updates, so a new + `defaultValue`/`defaultChecked` written in that commit is what the reset restores. +- **Which elements survive the reset, and why** (measured in Chromium against react-dom 19.2.4, + `fk-exp` in the slice's scratch, whose code for these paths is the canary's; then probed in the + editor itself on `main`'s code): + - an uncontrolled text input, textarea or checkbox goes back to its default — and takes a NEW + default written in the same commit (`updateInput` / `updateTextarea`); + - a controlled TEXT input survives: `updateInput` keeps its `defaultValue` in step with `value`; + - a **focused number input** is the exception: `setDefaultValue` skips it (`:1857`), so a submit by + Enter in one restores its OLD default; + - an uncontrolled `<select>` restores the option it MOUNTED with: a `defaultValue` change after + mount is applied only when `multiple` toggles (`:22447`); + - a **controlled checkbox, radio or select does not survive**: `defaultChecked` is set only at + mount (`initInput`, `:1849`; `updateInput` writes `checked` alone, `:1797-1798`), and a controlled + select's options NEVER get `defaultSelected` (`updateOptions(…, false)` at mount, `:22121`, and on + update, `:22444`), so the reset selects the server-rendered `selected` option, or the FIRST + option for a select mounted on the client (a membership's group select that appears on a tick). + After any submit the DOM shows that while state holds the choice, **until the next re-render that reaches the + element** re-asserts the prop (React 19 calls `updateInput` with every prop on any update) — in + the editor 0.3 to 7 s, the auto-refresh being the usual one; never, with it off. A NAMED one + submitted in that window posts the stale choice. +- **The `values` contract** (`editor/app/lib/formState.ts`, pure — imported by the `"use server"` + actions and the client forms both). An action that can refuse captures `const values = + formValues(formData)` at its top — each name's FIRST value, files skipped — and returns it with + every refusal: `{ ok: false, error, values }` (`FormState<T>`), or `{ error, values }` for the + channels flavour (`FormErrorState`; success is `undefined`). A success carries no `values`. + `seedValue(state, name, initial)` is the submitted value when the last submit failed and carried + the name, else `initial` (a name not posted — disabled, or not rendered then — keeps `initial`). + `seedChecked(state, name, initial)`: **when `values` is present, a checkbox is ticked iff its name + is in it** — an unticked box sends nothing, so absence means unchecked. One reading of both + flavours: a state is a failure unless it says `ok: true`. +- **The seeding primitives** (`editor/app/components/forms/`): `Field` (the one labelled input; it + takes `state`), `SeededInput` (re-mounts a number input when its default changes — the focused + case), `SeededSelect` (always re-mounts on a new default — the select case); both key on the + default itself, so an unchanged default re-mounts nothing. `ControlledCheck` / `ControlledSelect` + (`Controlled.tsx`) put a controlled element's latest committed value back a microtask after its + form's native `reset` event (React calls `form.reset()` inside the commit, so the microtask runs + after it), and touch the DOM at no other time. **Never write a controlled element's DOM value at + mount:** a choice made before hydration is replayed by React as a change event once it hydrates + the element, and a mount-time write overwrites the DOM first, so the replay reads the old value + (a layout-effect version of these lost `cadence-ui`'s and `channel-site-membership`'s + straight-after-`goto` selections). A `key` on the whole FORM is not a fix: it would discard the + typed values with the stale ones. +- **A name posted more than once cannot be seeded from `values`** (each name's first value). + The one in the editor is DigestSettingsForm's `digestSections` (one box per section): its boxes + are controlled (`ControlledCheck`) and re-read from the stored list after a success or a new + stored list. +- **A form action that only fails on a settings write is refused for real in e2e by putting a + directory at the settings path**: `editor/test-settings.json` is set aside, `mkdir` takes its + name, the atomic write's `rename` fails `EISDIR`, and `getSettings()` reads defaults meanwhile + (a read never throws). `forms-keep-input.spec.ts` `withSettingsUnwritable` restores the file in a + `finally`. A background writer in that window fails the same way, so the spec keeps it to one + submit. diff --git a/plans/release-16.md b/plans/release-16.md @@ -23,7 +23,7 @@ slice's prompt carries its ruling, and this record carries what was built. Rules |---|---|---|---| | CK | `r16/search-in` | A "Search in" row — Transcripts, Posts, Live chat — on the export and hub search, Transcripts and Posts on by default | `common/components/{FiltersPanel,SearchSessionContext,SearchResults,SearchBar,exportFilterStorage}.tsx/.ts`, `common/lib/searchQuery.ts` and `common/lib/search/*` as its prompt names, `export/e2e/search-in.spec.ts` (new) and the specs its prompt names, `export/e2e/helpers.ts`; records: `plans/FACTS.md` | | DX | `r16/research-setup` | The research-only setup (source → `pnpm install` → `claude mcp add archilyzer` → `/ask`) in one place, the homepage's AI and MCP doc; the sites' and the hub's Use-with-AI page removed and its links pointed at the doc; `README.md` §1/§4 and `mcp/README.md` their own copies (as amended) | `homepage/content/docs/ai-and-mcp.md`, `export/app/use-with-ai/` (removed), the Use with AI links (`export/app/components/{Header,MobileMenu,Footer}.tsx`, `export/app/(workspace)/ask/page.tsx`), `common/lib/{project,corpus}.ts` + `common/bin/compose-site.ts` (what named the page), `mcp/README.md`, `README.md` §1/§4 (wording only), `homepage/e2e/docs.spec.ts`, `export/e2e{,-hub}/use-with-ai-link.spec.ts` and the specs that visited the page | -| FK | `r16/forms-keep-input` | Every editor form keeps what was typed when its action fails: actions return the submitted values with the error, the shared field helpers seed from them | new `editor/app/lib/formState.ts` + test; `editor/app/components/forms/Field.tsx` and the local `Field`s in `SiteForm.tsx`, `ChannelForm.tsx`; every action that returns `{ok:false,error}`/`{error}` (sites, settings, operations/settingsActions, scheduler, storage, homepageActions, cutReleaseAction, channels, videoActions); the 15 forms the ruling lists; e2e `forms-keep-input.spec.ts` (new) + the existing `sites-crud`, `settings`, `channels` specs; records: `plans/FACTS.md` | +| FK | `r16/forms-keep-input` | Every editor form keeps what was typed when its action fails: actions return the submitted values with the error, the shared field helpers seed from them | new `editor/app/lib/formState.ts` + test; `editor/app/components/forms/Field.tsx` and the local `Field`s in `SiteForm.tsx`, `ChannelForm.tsx`; every action that returns `{ok:false,error}`/`{error}` (sites, settings, operations/settingsActions, scheduler, storage, homepageActions, cutReleaseAction, channels, videoActions); the 15 forms the ruling lists; e2e `forms-keep-input.spec.ts` (new) + the existing `sites-crud`, `settings`, `channels` specs; records: `plans/FACTS.md`. As shipped, also `editor/app/components/forms/Controlled.tsx` (new) and one tag swap each in `DurationField`, `SocialLinksField`, `SiteMembershipsSection`, `WorkersField` ("Slice FK, as shipped") | ## Slice CK — the ruling (2026-09-30) @@ -557,6 +557,183 @@ drop the `test.fail`); the JSX entity/whitespace sweep (22 texts in 19 files, FA passed**, 19 s; export `use-with-ai-link.spec.ts` **4 passed**, 12 s; the hub's **3 passed**, 10 s. The full suites were not re-run, as the parent directed. +### Slice FK, as shipped — a form that fails keeps what was typed (2026-10-01) + +Branch `r16/forms-keep-input` off `main` `57d982bb`, worktree `~/Projects/r12-paths-fix` (editor 5001, +test 5011, export 5010), one Opus implementer. Scratch files `fk-*` in the job's `tmp`. The ruling +is above ("Slice FK — the ruling"); the inventory that found the cause is `fk-inventory.md`. + +**The cause had three more parts than the inventory found, and the slice fixed all of them** (the +operator's "fix it everywhere", relayed mid-slice). Measured first in Chromium against +`react-dom@19.2.4` (a bundled test page, `fk-exp/`), then in the editor itself on `main`'s code with +a throwaway probe spec (`fk-probe-main.keep.log`), each read at 0 / 0.3 / 1.5 / 7 s after the +refusal showed: + +| Element | On `main` after a submit | Why (react-dom; lines in FACTS) | +|---|---|---| +| Uncontrolled text input, textarea, checkbox | back to the stored value, for good | the reset; no action returned what was typed (the inventory's finding) | +| Uncontrolled `<select>` | back to the option it MOUNTED with, **after a success too**: `cookieMode` saved as `defer` still read `when-required` 7 s later, and a second save would send that | a `defaultValue` change after mount is never applied | +| A focused number input (Enter submits) | back to its old default | `setDefaultValue` skips a focused number input | +| Controlled checkbox, radio, select | back to what it mounted with (a select: its server-rendered option, or its first when it mounted on the client) **until the next re-render reaches it**: the workers' Enabled boxes between 0.3 and 1.5 s; a site's accent and membership, the sync form's sweep select, a new channel's platform and handling between 1.5 and 7 s (the auto-refresh, 5 s by default; never, with it off). A named one posts the stale choice if submitted in that window | `defaultChecked` is set only at mount; a controlled select's options never get `defaultSelected` | +| Controlled text input | kept | React keeps its `defaultValue` in step with `value` | + +**What it does.** +- **`editor/app/lib/formState.ts`** (pure; the actions and the forms both import it): `FormState<T>` + (`{ ok: true } & T | { ok: false; error; values? }`), `FormErrorState` (the channels flavour, + `{ error; values? } | undefined`), `formValues(formData)` (each name's first value, files skipped, + a `__proto__` name kept), and one reading of both flavours — a state is a failure unless it says + `ok: true` — behind `seedValue` and `seedChecked`. `formState.test.ts`, 8 cases. +- **Every action that can refuse returns what was submitted**, captured at its top, with every + refusal, validation and caught failure alike; a success returns what it did: `saveSiteAction`, + `saveSettingsAction`, the four in `operations/settingsActions.ts`, `saveSchedulerSettingsAction` + and `setChannelCadencesAction`, `saveHealthTimingsAction`, `saveHomepageConfigAction`, + `cutReleaseAction`, `saveSavedVideoBackupAction`, `createChannelAction`, `updateChannelAction`, + `renameChannelAction`, `deleteChannelAction`, `deleteVideoDirAction`. Their result types are + `FormState<…>` / `FormErrorState`. +- **`editor/app/components/forms/Field.tsx`: one `Field`.** SiteForm's and ChannelForm's own copies + were the same label → input → hint with other props (`min`; `placeholder`, `readOnly`, a controlled + mode); they fold in, and `Field` takes `state`. `SeededInput` (an uncontrolled input seeded from + `state`; a number input re-mounts when its default changes) and `SeededSelect` (re-mounts whenever + its default changes). Both key on the default itself. OverviewPanel's `Field` is a read-only + figure, not an input, and stays. +- **`editor/app/components/forms/Controlled.tsx`: `ControlledCheck`, `ControlledSelect`** — the bare + element plus a `reset` listener on its form that puts the latest committed value back a microtask + after the reset. They touch the DOM at no other time: the first cut wrote the value in a layout + effect on every commit, mount included, and that overwrote a choice made before hydration + (`19f8e867`, below). +- **The forms**, all of the ruling's fifteen: SiteForm (create and edit; the accent radios, the + membership and group controls are `ControlledCheck`/`ControlledSelect`), SettingsForm, + ChannelForm (create and edit; create mode's handling radios and platform select are controlled + and posted), AttributionSettingsForm, DiarizationSettingsForm, DigestSettingsForm, + LaneSettingsForm, SchedulerSettingsForm, HealthTimingForm, HomepageConfigForm, CutReleaseForm, + RenameChannelForm, DeleteChannelForm, DeleteVideoDirSection and SavedVideosControls' checkbox. Every + `defaultValue`/`defaultChecked`/`<select defaultValue>` in them goes through the helpers; + `state` comes from each form's `useActionState` (ChannelForm's through `ChannelFormClient`). + - **Shaped differently, and said where it is:** DigestSettingsForm's `digestSections` is one name + posted once per ticked box, which `values` (first value per name) cannot carry; its boxes are + controlled (`ControlledCheck`) and re-read from the stored list after a success or a new stored + list, posting what they always posted. ChannelForm's edit-mode handling radios seed from the + VALUE posted, not the name's presence. +- **The shared widgets' checkboxes and selects** (`ae5a212f`, its own commit): the ruling named + `DurationField`, `SocialLinksField`, `SiteMembershipsSection` and `WorkersField` as the model that + already survives. Their text inputs do; their checkboxes and selects are the last row of the table + above. One tag swap each to `ControlledCheck`/`ControlledSelect`, nothing else changed in them. + `DigestAppsField`, `LocationForm`, `EditorTagsClient`, `BulkCadenceBar` and `ChannelCadenceEditor` + are untouched (the last two get the fix through `DurationField`). +- **Inventory check** (the operator's "plus any form the inventory missed"): every + `defaultValue=`/`defaultChecked=` under `editor/app` is seeded, and every `<form action>` with a + field was looked at. The one not changed is the Diagnostics stage's **Go to video** (its action + always redirects; there is no refusal to keep anything through). + +**A success path is unchanged except where it was showing the wrong thing:** a select, and a +controlled checkbox or select, now show what was saved after a save; each used to go back to what +the page mounted with (the uncontrolled select for good). The `[Unreleased]` bullet says so. + +**Commits** + +| Commit | What | +|---|---| +| `353f3219` | `editor:` `formState.ts` + test, the one `Field`, `SeededInput`/`SeededSelect`, `Controlled.tsx`; SiteForm and SettingsForm with their actions | +| `06984433` | `editor:` the other thirteen forms and their actions | +| `ae5a212f` | `editor:` `DurationField`, `SocialLinksField`, `SiteMembershipsSection`, `WorkersField` — controlled checkboxes and selects | +| `716f58bb` | `editor(e2e):` `forms-keep-input.spec.ts`, 18 cases | +| `798cf7ec` | `editor(e2e):` controlled elements read once, as the refusal shows | +| `19f8e867` | `editor:` `ControlledCheck`/`ControlledSelect` put the value back after the reset only — never at mount | +| `657a6949` | `plans:` this section; FACTS; the editor changelog | +| `6cf51b24` | `editor:` review L1 — ChannelForm create's **On create** boxes seed only from a refusal of their own kind; the spec's 19th case; `Controlled.tsx`'s comment (I2) | +| `0520ec75` | `plans:` review L1's bullet, I1, I2, I3 | +| `9487c74b` | merge of `main` `458f4fcd` (below) | +| this commit | `plans:` the review, the gates after it, the merge | + +#### Gates (logs `$T/fk-*.log`) + +- **tsc** (all workspaces) clean, 46 s, at `19f8e867` — after deleting the worktree's + `export/.next/dev/types`, left by an earlier slice's dev server and still naming the removed + `/use-with-ai` page (DX's note). +- **common:** 2,404/2,404, 78 s (nothing under `common/` changed). **Editor unit:** 109/109 (`main`'s + 101 + `formState.test.ts`'s 8). +- **Build:** the capped editor build with the corpus linked (`ln -sT` the primary's `transcripts`, + `systemd-run --scope -p MemoryMax=5G`, `pnpm --filter editor exec next build`, the link removed + after): exit 0, 43 s, 1.65 GB peak, at `19f8e867` (48 s, 1.6 GB at `798cf7ec`). +- **Numbers tool:** none. + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 | `716f58bb` | `forms-keep-input` + `sites-crud`, `settings`, `channels`, `storage-locations`, `site-scope` | **75 passed**, 0 failed, 5.2 min | + | 2 | `main`'s `editor/app` (`57d982bb`), the spec at `716f58bb` | `forms-keep-input` | 17 failed, 1 passed, 3.4 min — the worker list's boxes were re-synced by a re-render inside the polled assertion's 5 s (so run 4) | + | 3 | `main`'s `editor/app`, then the slice's | a throwaway probe (5 cases, not committed) | the table above (`fk-probe-main.keep.log`); on the slice's code every read is right at every moment (`fk-probe-slice.log`) | + | 4 | `main`'s `editor/app`, the spec at `798cf7ec` | `forms-keep-input` | **18 failed**, 0 passed, 2.5 min — every case fails on the code before the slice | + | 5 | `798cf7ec` | the full editor suite | stopped at 203 of 694: `cadence-ui` (3) and `channel-site-membership`'s create case failed — the first `Controlled.tsx` overwrote a choice made before hydration; fixed in `19f8e867` | + | 6 | `19f8e867` | `forms-keep-input`, `cadence-ui`, `channel-site-membership`, `workers`, `operation-settings`, `saved-videos`, `new-channel-onboarding` + the five the prompt named | **109 passed**, 0 failed, 6.9 min | + | 7 | `19f8e867` | the full editor suite | **681 passed**, 1 failed, 12 skipped (the rack-audit shots), 38.1 min — `export-search` "Advanced reset does not touch filter checkboxes": its `getByRole('checkbox', { name: 'Deleted' })` also met a result's "Select "Deleted platypus chronicles" for AI" box | + | 8 | `19f8e867` | `export-search.spec.ts` alone | **19 passed**, 0 failed, 50 s. Nothing under `export/` or `common/` differs from `main`; run 7's failure is that locator's race with the results | + +#### Found and left + +- **A controlled NUMBER input focused when Enter submits** (`DurationField`'s amount, the number + boxes in `WorkersField` and `DigestAppsField`) shows its old value after the reset until its next + change: React writes its `value` on an update but nothing updates it. What posts is from state (a + hidden input), so nothing saved is wrong. A `ControlledInput` would close it; not built. +- **An action that throws past its own `try`** (`createChannel` itself, for one) still reaches the + form as Next's error, not as `{ error, values }`. Every caught failure carries `values`; + converting the uncaught ones is a behaviour change of its own. +- **A field not rendered at the submit** is seeded as not posted: a checkbox reads unticked when a + later render shows it, a text field keeps its initial value. The one place a form switches what it + renders is ChannelForm create (a social or a video source), and its **On create** boxes seed only + from a refused submit of their own kind (review L1, below); every other box a social submit leaves + out starts unticked in create mode anyway. +- **A seeded select or number input re-mounts when its stored value changes under it** (a save from + another tab), and an in-progress edit of THAT field is lost; every other field keeps its edit. + DigestSettingsForm's section boxes do the same: they re-read the stored list whenever it changes + and the last result was not a refusal, before any submit too, so unsaved ticks give way to another + tab's save (on `main` a touched box kept its tick). +- **`ControlledSelect` puts back a value no option has as a blank select** (`selectedIndex` −1); + React's own update selects the first enabled option instead. Reachable for `WorkersField`'s engine + select (`row.appId ?? ""`, no `""` option) and a membership whose group was deleted. Visual only: + both post from state. +- **The other way to do it**, for the record and not tried: `onReset={(e) => e.preventDefault()}` on + each action form should cancel React's reset outright, so nothing is wiped and nothing needs seeding, but the + success path would keep what was typed instead of re-reading the stored values. The ruling chose + the values contract; this was not built. + +#### Decisions the operator could overturn + +| What I did | The alternative | +|---|---| +| `seedValue` keeps `initial` for a name the failed submit did not carry | The prompt's `values[name] ?? ""`: a disabled or not-yet-rendered field would go blank (the ruling's "else from the initial value" read per field) | +| Fixed the controlled checkboxes/selects too, including in the four widgets the ruling named as the model (`ae5a212f`, revertable alone) | Leave them: their desync lasts until the next re-render, and the unnamed ones post from state | +| `SeededSelect` re-mounts on every change of its default, a success included | Re-mount only out of a failure: the select would keep showing its mount-time option after a plain save | +| DigestSettingsForm's sections controlled | Extend `FormValues` to carry every value of a repeated name | +| The e2e refuses the settings-only forms by putting a directory at `test-settings.json` | A test-only failure switch in `saveSettings` behind `/api/test/*` | + +#### Review + +**Verdict: SHIP** (`fk-review.md` in the job's scratch): one Low, five infos. + +| Finding | Where | +|---|---| +| L1: in ChannelForm create, after a refusal from one kind of source (video or social), switching the URL to the other kind mounted that kind's **On create** box seeded from `values`, where its name was absent, so unticked; on `main` it mounted ticked | `6cf51b24`: `fetchPostsNow` seeds only from a refused social submit (it posts the hidden `sourceKind=social`), `fetchPlaylist` and `prioritizeDownload` only from a refused video one. A 19th case refuses a video submit and switches to a social URL (**Fetch posts now** ticked), and the reverse; against `657a6949`'s ChannelForm it fails ("Received: unchecked") | +| I1: the digest section boxes re-read a new stored list before any submit too, so another tab's save replaces unsaved ticks | `0520ec75`: added to the cross-tab bullet in "Found and left" | +| I2: a controlled select never gets `defaultSelected` (mount passes `false` too, `:22121`); the reset selects the server-rendered option, or the first for a client-mounted select — not "what it mounted with" | `6cf51b24` (`Controlled.tsx`'s comment), `0520ec75` (FACTS, the table above) | +| I3: `putValue` leaves a blank select for a value no option has | `0520ec75`: recorded in "Found and left" | +| I4: the microtask deferral relies on React's script-initiated reset; a native `<button type="reset">` would undo the put (the editor has none) | No action | +| I5: the full suite's `export-search` failure is pre-existing — `{ name: 'Deleted' }` matches by substring the result card's "Select "Deleted platypus chronicles" for AI" box (since `9a5029ec`) | No action here; `{ exact: true }` in six places is a follow-up | + +#### Brought to main (2026-10-01) + +`9487c74b` merges `main` `458f4fcd` (the deck's posts: `umtool/` only, plus `plans/` and two +`[Unreleased]` bullets) into `0520ec75`. No conflict. `editor/CHANGELOG.md` keeps one +`## [Unreleased]`: the deck's two bullets at its top, this slice's at its end. `plans/release-16.md` +keeps both sides: `main`'s deploy note under "## Rollout" and this section with the slices table's +FK row. + +**Gates after the review and the merge** (at `9487c74b`; logs `$T/fk-tsc3.log`, `fk-unit3.log`, +`fk-build3.log`, `fk-e2e3.log`): tsc (all workspaces) clean, 34 s; editor unit **109/109** +(`formState.test.ts` alone 8/8); the capped editor build with the corpus linked exit 0, 37 s, +1.66 GB, the link removed; e2e `forms-keep-input`, `channels`, `channel-site-membership`, +`new-channel-onboarding`, `social-channel`, `sites-crud`, `settings`: **70 passed**, 0 failed, +5.6 min. The full suite was not re-run, as the parent directed. + ## Rollout Both slices are export- and homepage-side; the editor and umtool are not rebuilt for this release.