commit f45aa9be0cd817525f831a193a5029ea5a8a36ca
parent 4188248664b77aaad2d55807ee9c42d110792b84
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 02:49:33 -0400
plans: release 16 slice FK, as shipped — a form that fails keeps what was typed; FACTS (the reset, which elements survive it, the values contract, the checkbox rule); the editor changelog
The record: the three more parts of the cause the slice found and fixed (an
uncontrolled select keeps its mount-time option, a success included; a focused
number input misses its new default; a controlled checkbox, radio or select
shows its mount-time state until the next re-render), what was built, the
commits, the gates (tsc, common 2,404, editor unit 109, the capped editor build,
eight e2e runs incl. two against main's code where every case fails), what was
left and the decisions to overturn. The slices table's FK row names the files
touched beyond it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 199 insertions(+), 1 deletion(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -12,6 +12,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/plans/FACTS.md b/plans/FACTS.md
@@ -8027,3 +8027,64 @@ 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 get `defaultSelected` only at mount (`:22444`). After any submit the DOM shows
+ what it mounted with 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,142 @@ 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 **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` / `defaultSelected` are set only at mount |
+| 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 |
+| this commit | `plans:` this section; FACTS; the editor changelog |
+
+#### 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 the
+ next render shows it (ChannelForm create, switching a URL between a social and a video source after
+ a refusal: `fetchPlaylist` comes back unticked). A text field keeps its initial value.
+- **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.
+- **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/*` |
+
## Rollout
Both slices are export- and homepage-side; the editor and umtool are not rebuilt for this release.