fix(721,740): align Auto-Tune proposal rows on a grid; guard AddItemsDialog's async searches #831

Merged
timothy merged 1 commits from fix/721-740-spa-guards into main 2026-08-25 23:55:24 +02:00
Owner

fixes #721
fixes #740

Two frontend fixes swept in one session — disjoint files, one shared component change.

#721 — Auto-Tune proposal rows

The channel number started wherever the preceding text ended: the row was a flex line whose first child was <Checkbox label={displayName}>, so minWidth: 42 reserved width for the number without being able to align it. The name then rendered a second time in the detail column, and the group header above the rows had the same duplicate.

The row is a grid now. What makes the column line up is that everything left of the number is fixed-width — a label-less Checkbox renders one fixed-size control — plus a fixed number track, because each row is its own grid container and an auto/max-content track resolves per row.

No fixed track bounds the value, and the code says so rather than pretending otherwise: AutoTuneNumberAllocator.Allocate emits int.ToString() from a StartingNumber that PreviewAutoTuneChannelsHandler only floors at 1, and a per-proposal override is free text checked against Channel.NumberValidator (^[0-9]+(\.[0-9]{1,2})?$). So the cell clips, with the full value on hover. That is a real trade — the flex box it replaces grew instead of truncating — so the title fallback is part of the fix and is pinned by a test.

Checkbox gains hideLabel, mirroring the prop Switch in the same file already has. label still supplies the accessible name, so every existing getByRole('checkbox', { name }) assertion is untouched.

#740 — AddItemsDialog async guards

Neither guard §3b mandates was present, and their triggers differ:

  • runSearch is reachable twice concurrently — a form submit fanning out over DEFAULT_SEARCH_KINDS, a kind-chip click issuing one request. A superseded response settling later repainted the list with every kind under an active "Movies" chip, wiped a newer successful result with a superseded search's error, or cleared searching while the other request was still in flight.
  • submit is unmount-reachable mid-request: its Add button is disabled while adding, but Dialog closes on Escape, backdrop click and the header close button, none of which consult adding.

onAdded/onClose are deliberately not guarded — they belong to the parent, which owns its own guarding, and dropping the list reload after a write that succeeded would leave the collection stale.

Verification

Every clause ships with a test that reddens when that clause alone is removed — 14/14, each mutation applied to pristine source, asserted to match exactly once, read back, then re-run.

Two of those were green until the tests were strengthened, and both are the kind of green that means nothing:

  • The two submit guards masked each other — removing either alone left the whole suite green, because the survivor still read the ref. That test counts the reads now.
  • The failed-search test asserted queryByText('Unable to search library'), which messageFromCollectionError only produces for a non-Error throw — null-vs-null, passing with the guard removed. It matches role="alert" instead, verified to fail on its own with the test's other assertions removed.

Web suite 115 files / 1206 tests green; npm run typecheck and npm run lint clean; repo guard suite scripts/tests 956 passed / 2 skipped.

Live-E2E not required — docs/e2e-local.md scopes it to API write-path handler changes; this is pure-SPA and read-only. No .cs files touched.

Review

Independent cold adversarial review (worktree-isolated, review-only brief), three rounds: BLOCKED → BLOCKED (narrowly) → MERGEABLE. Round 1 caught a false mutation-proof claim in the commit message plus a genuinely unpinned catch-path guard whose removal reintroduced a worse bug than #740 itself; round 2 caught the vacuous error assertion and two unpinned style clauses. Both rounds' findings were taken in full, none argued away.

Docs

docs/spa-conventions.md §3b — the AddItemsDialog caveat naming #740 as outstanding is replaced by the shipped rule, with the two guards' different triggers stated separately (concurrent reachability vs unmount reachability) and the own-state-only rule spelled out.

Deferred

#830 — a failed add is silently swallowed when the dialog is closed mid-request. Pre-existing; the guard here makes the drop deliberate rather than accidental, but fixing the UX needs a parent-owned surface or a change to the shared Dialog, which is its own blast radius.

Design system

The prototypes already had a label-less proposal checkbox — the SPA had drifted from them — so the mirrored delta is the number's right-align. The prototypes keep their flex row; converting them to the grid is a layout change to a mockup, not part of this fix.

🤖 Generated with Claude Code

fixes #721 fixes #740 Two `frontend` fixes swept in one session — disjoint files, one shared component change. ## #721 — Auto-Tune proposal rows The channel number started wherever the preceding text ended: the row was a flex line whose first child was `<Checkbox label={displayName}>`, so `minWidth: 42` reserved width for the number without being able to align it. The name then rendered a second time in the detail column, and the group header above the rows had the same duplicate. The row is a grid now. What makes the column line up is that everything **left of** the number is fixed-width — a label-less `Checkbox` renders one fixed-size control — plus a fixed number track, because each row is its own grid container and an `auto`/`max-content` track resolves per row. **No fixed track bounds the value**, and the code says so rather than pretending otherwise: `AutoTuneNumberAllocator.Allocate` emits `int.ToString()` from a `StartingNumber` that `PreviewAutoTuneChannelsHandler` only floors at 1, and a per-proposal override is free text checked against `Channel.NumberValidator` (`^[0-9]+(\.[0-9]{1,2})?$`). So the cell clips, with the full value on hover. That is a real trade — the flex box it replaces *grew* instead of truncating — so the `title` fallback is part of the fix and is pinned by a test. `Checkbox` gains `hideLabel`, mirroring the prop `Switch` in the same file already has. `label` still supplies the accessible name, so every existing `getByRole('checkbox', { name })` assertion is untouched. ## #740 — AddItemsDialog async guards Neither guard §3b mandates was present, and **their triggers differ**: - `runSearch` is reachable **twice concurrently** — a form submit fanning out over `DEFAULT_SEARCH_KINDS`, a kind-chip click issuing one request. A superseded response settling later repainted the list with every kind under an active "Movies" chip, wiped a newer successful result with a superseded search's *error*, or cleared `searching` while the other request was still in flight. - `submit` is **unmount-reachable** mid-request: its Add button is disabled while adding, but `Dialog` closes on Escape, backdrop click and the header close button, none of which consult `adding`. `onAdded`/`onClose` are deliberately **not** guarded — they belong to the parent, which owns its own guarding, and dropping the list reload after a write that succeeded would leave the collection stale. ## Verification Every clause ships with a test that reddens when **that clause alone** is removed — 14/14, each mutation applied to pristine source, asserted to match exactly once, read back, then re-run. Two of those were green until the tests were strengthened, and both are the kind of green that means nothing: - The two `submit` guards **masked each other** — removing either alone left the whole suite green, because the survivor still read the ref. That test counts the reads now. - The failed-search test asserted `queryByText('Unable to search library')`, which `messageFromCollectionError` only produces for a **non-`Error`** throw — null-vs-null, passing with the guard removed. It matches `role="alert"` instead, verified to fail on its own with the test's other assertions removed. Web suite 115 files / 1206 tests green; `npm run typecheck` and `npm run lint` clean; repo guard suite `scripts/tests` 956 passed / 2 skipped. **Live-E2E not required** — `docs/e2e-local.md` scopes it to API write-path handler changes; this is pure-SPA and read-only. No `.cs` files touched. ## Review Independent cold adversarial review (worktree-isolated, review-only brief), three rounds: **BLOCKED** → **BLOCKED (narrowly)** → **MERGEABLE**. Round 1 caught a false mutation-proof claim in the commit message plus a genuinely unpinned catch-path guard whose removal reintroduced a *worse* bug than #740 itself; round 2 caught the vacuous error assertion and two unpinned style clauses. Both rounds' findings were taken in full, none argued away. ## Docs `docs/spa-conventions.md` §3b — the `AddItemsDialog` caveat naming #740 as outstanding is replaced by the shipped rule, with the two guards' **different triggers** stated separately (concurrent reachability vs unmount reachability) and the own-state-only rule spelled out. ## Deferred **#830** — a failed add is silently swallowed when the dialog is closed mid-request. Pre-existing; the guard here makes the drop deliberate rather than accidental, but fixing the UX needs a parent-owned surface or a change to the shared `Dialog`, which is its own blast radius. ## Design system The prototypes already had a label-less proposal checkbox — the SPA had drifted from them — so the mirrored delta is the number's right-align. The prototypes keep their flex row; converting them to the grid is a layout change to a mockup, not part of this fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-08-25 23:29:06 +02:00
fix(721,740): align Auto-Tune proposal rows on a grid; guard AddItemsDialog's async searches
review-verdict/h10 Review-verdict: MERGEABLE @ 4d33462 (base: main)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 47s
PR Gates / Docs update reminder (pull_request) Successful in 48s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 49s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 16s
Review verdict / Set review-verdict status (pull_request_target) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 35s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m54s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
4d33462732
#721 — the Auto-Tune proposal list was hard to scan because the channel number started
wherever the preceding text ended: the row was a flex line whose first child was a
`<Checkbox label={displayName}>`, so `minWidth: 42` reserved width for the number without
being able to align it. The same name then rendered a second time in the detail column, and
the group header above the rows had the same duplicate (`Select all TV Shows` immediately
followed by `TV Shows`).

The row is now a grid. What makes the column line up is that everything to the LEFT of the
number is fixed-width — a label-less Checkbox renders one fixed-size control — and the number
track is fixed rather than content-sized, because each row is its own grid container and an
`auto`/`max-content` track would resolve per row.

No fixed track is a true upper bound on the value: `AutoTuneNumberAllocator.Allocate` emits
`int.ToString()` from a `StartingNumber` that `PreviewAutoTuneChannelsHandler` only floors at
1, and a per-proposal override is free text checked against `Channel.NumberValidator`
(`^[0-9]+(\.[0-9]{1,2})?$`) — unbounded integer part plus up to two decimals. So the cell
clips: an extreme value degrades to an ellipsis with the full value on hover instead of
overflowing into the checkbox beside it. That is a real trade — the flex box it replaces grew
instead of truncating — so the `title` fallback is part of the fix, not decoration, and is
pinned. The number is right-aligned and tabular so 2-, 3- and 4-digit numbers agree on their
last digit.

`Checkbox` gains `hideLabel`, mirroring the prop `Switch` in the same file already has: the
name lives in its own column and the control keeps `label` as its accessible name, so the
existing `getByRole('checkbox', { name })` assertions are untouched.

#740 — `AddItemsDialog` had neither of the two guards spa-conventions.md §3b mandates for
asynchronous pickers, and their triggers differ. `runSearch` is reachable twice concurrently
(a form submit fanning out over DEFAULT_SEARCH_KINDS, a kind-chip click issuing one request),
so a superseded response could settle after a newer one — repainting the list with every kind
under an active "Movies" chip, wiping a newer successful result with a superseded search's
error, or clearing `searching` while the other request was still in flight. Separately, the
dialog is unmount-reachable mid-request through Escape, a backdrop click and the header close
button, none of which consult the in-flight flag — so `submit`'s post-await writes to its own
state need the is-mounted guard even though its Add button is disabled and nothing can
supersede it. `onAdded`/`onClose` are deliberately NOT guarded: they belong to the parent,
which owns its own guarding, and dropping the list reload after a write that succeeded would
leave the collection stale.

Every clause listed below ships with a test that reddens when THAT clause alone is removed.
Verified by applying each mutation to the pristine source, asserting it matched exactly once,
reading it back, and re-running — 14/14 red:

  runSearch success guard   -> repaint test        submit catch guard    -> failed-add test
  runSearch catch guard     -> failed-search test  submit finally guard  -> failed-add test
  runSearch finally guard   -> spinner test        row grid track list   -> #721 test
  isCurrent mounted half    -> unmount test        textAlign / overflow  -> #721 test
  isCurrent seq half        -> 3 search tests      whiteSpace / title    -> #721 test
  row + group-header hideLabel -> #721 test

Two of those needed the test strengthened before they were pinned at all, and both are the
kind of green that means nothing:

- The two `submit` guards MASKED each other — removing either alone left the suite green,
  because the survivor still read the ref. That test now counts the reads rather than
  asserting merely that one of them saw `false`.
- The failed-search test asserted `queryByText('Unable to search library')`, which
  `messageFromCollectionError` only produces for a non-`Error` throw — so it was null-vs-null
  and passed with the guard removed. It matches `role="alert"` instead, verified to fail on
  its own with the other assertions in that test removed.

The unmount tests record what the component read out of the is-mounted ref because an
unmounted tree renders nothing either way and no DOM assertion can tell "the guard stopped it"
from "React discarded it"; same construction as schedules/pickers.test.tsx.

The design-system prototypes already had a label-less proposal checkbox — the SPA had drifted
from them — so the mirrored delta is the number's right-align. The prototypes keep their flex
row; converting them to the grid is a layout change to a mockup, not part of this fix.

Verification: web suite 115 files / 1206 tests green; `npm run typecheck` and `npm run lint`
clean; repo guard suite `scripts/tests` 956 passed / 2 skipped. Live-E2E not required —
`docs/e2e-local.md` scopes it to API write-path handler changes and this is pure-SPA and
read-only. No .cs files touched.

fixes #721
fixes #740

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: MERGEABLE @ 4d33462

Independent cold adversarial review, 3 rounds (BLOCKED, BLOCKED-narrowly, MERGEABLE). Round 1 found a false mutation-proof claim and an unpinned catch-path guard whose removal reintroduced a worse defect than #740; round 2 found a vacuous error assertion and 2 unpinned style clauses. All taken in full. Final: 14/14 clauses redden individually, 0 unpinned, reproduced independently by the reviewer. Verdict posted by the AUTHOR on the reviewer's behalf.

Review-verdict: MERGEABLE @ 4d33462 Independent cold adversarial review, 3 rounds (BLOCKED, BLOCKED-narrowly, MERGEABLE). Round 1 found a false mutation-proof claim and an unpinned catch-path guard whose removal reintroduced a worse defect than #740; round 2 found a vacuous error assertion and 2 unpinned style clauses. All taken in full. Final: 14/14 clauses redden individually, 0 unpinned, reproduced independently by the reviewer. Verdict posted by the AUTHOR on the reviewer's behalf.
timothy merged commit 7453dd3a82 into main 2026-08-25 23:55:24 +02:00
timothy deleted branch fix/721-740-spa-guards 2026-08-25 23:55:25 +02:00
Sign in to join this conversation.