19 KiB
ChicoryTV issue-queue handoff (living document)
Paste the prompt below into a fresh session to work the next item. Each session ends by
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
filed backend gap issues #100–#111; a 7–9-way parallel workflow build once exhausted RAM, so
builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all landed by
2026-07-04 (PRs #113–#119); merge pass PR #120; live-data screens: #109 Dashboard (PR #123),
#84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127), #88 Libraries
(PR #128), #85 Guide/EPG (PR #129); #62 prerequisites: #65 (PR #130), #64 (PR #133), #63
(PR #134) — epic #62 COMPLETE; #89 Channel Builder (PR #136); #93 Settings (PR #138) —
first screen through the full design-first workflow; #92 design-sync round-trip verified and
documented (docs/design-sync.md).
PROCESS (2026-07-06, binding — supersedes 07-05): Claude Code ONLY — Codex is retired
(usage exhausted). Fable is the orchestrator in the main session and is EXPENSIVE — use it
SPARINGLY: delegate implementation to the best-fitting subagent models (haiku for mechanical
churn, sonnet for standard components/tests, opus for judgment-heavy logic/orchestration
code; fable only for the hardest design calls and the final review fork). Reviews stay
multi-lens via subagents (fable correctness fork + cheaper contract/tests lens + a
design-system lens for frontend work), plus a fork verification pass over any fix diff.
Review fixes are applied by fitting subagents, never inline. npm ci in each fresh worktree
before web/ verification. Merges need explicit user consent per PR — NOTE: the permission
classifier requires consent IN-CONVERSATION; the standing consent written here does not
satisfy it, so ask a quick merge question each time (learned #134). Subagents killed by
transient API errors CAN be resumed via SendMessage with their agentId — resume instead of
relaunching (their edits are saved; learned #89). NEW (#93): LIVE-E2E the new screen against
a real fresh local server BEFORE the review lenses — it caught two ship-blockers jsdom
can't see (see Lessons: local-run recipe).
RELEASE CHECKPOINT (standing, added 2026-07-06): prod cutover to the fork is DONE —
prod container ersatztv on bumblebee runs 192.168.1.95:3000/timothy/ersatztv:prod
(= v26.3.1, app-identical to upstream 26.3.0); ersatztv-test tracks :latest (main).
Prod only advances on v* tags. At every milestone merge, FLAG THE USER: is this slice
worth tagging v26.4.0 (reserved for the first app-change release)? Latest sensible tag
point is #91 (cutover); earlier if a stable API slice should reach prod sooner. Tagging
needs explicit user consent; NEVER [skip ci] a commit you'll tag. (Flagged again at the
#93 merge, 2026-07-07 — builder + settings + full API is a very plausible v26.4.0 slice;
#90 rebrand would make it present as ChicoryTV.)
Session state (2026-07-07, post-#93): main = 17f49304 (PR #138 merged): Settings is
live at /app/settings/<section> — backend GET/PUT /api/settings/{ffmpeg,playout,xmltv, scanner,logging,ui,hdhr} + /api/settings/resolutions CRUD (SettingsController, DTOs in
ErsatzTV.Core/Api/Settings), frontend web/src/screens/SettingsScreen.tsx +
web/src/api/settings.ts (tiered loader: settings groups gate the screen, reference data
via allSettled with per-resource failure notes). App.tsx ScreenRoute gained an opt-in
allowSubPaths flag (settings only); the screen parses its own URL suffix. Design-first
workflow proven end-to-end: prototype design-system/templates/chicorytv-admin/Settings.jsx
- handoff bundle
design-system/design_handoff_settings/synced both directions with the DesignSync MCP (main session only) — workflow documented indocs/design-sync.md, #92 CLOSED. Also this session: PR #137 — Scriban.Signed 6.5.2→7.2.5 (GHSA-5wr9-m6jw-xx44 sandbox escape; the fresh advisory made NuGetAudit fail EVERY CI restore, merged first to unblock); media-sources fresh-DB 500 fixed (Dapper→EF, see Lessons). Review: 3 lenses → 11 findings (2 backend contract clusters + frontend silent-failure/partial-save cluster), fixed by 2 parallel subagents, fork-verified SHIP. Baselines: ErsatzTV.Tests 495, Core.Tests 493 (+1 skip), web tests 145 (was 112). All #93 worktrees removed; main checkout still sits on docs/59-ui-redesign-brief — do NOT touch it.
Lessons for all remaining prompts (accumulated):
- NEW (#93) — Local live-E2E recipe:
npm run build(outputs to gitignoredErsatzTV/wwwroot/app/), thenln -sfn <worktree>/ErsatzTV/wwwroot/app ErsatzTV/bin/Debug/net10.0/wwwroot/app(Program.cs sets ContentRoot to the ASSEMBLY dir, so the SPA static-file provider reads bin's wwwroot — publish/Docker copy it,dotnet rundoesn't), thenETV_CONFIG_FOLDER=<scratch> ASPNETCORE_URLS=http://127.0.0.1:8409 dotnet run --project ErsatzTV. Fresh DB migrates in seconds; ffmpeg autodetected from PATH. update-openapi.sh FAILS while an instance runs (single-instance mutex) — kill it first. - NEW (#93) — Dapper + Microsoft.Data.Sqlite infers expression columns (COUNT(*)) as BLOB when the result set is EMPTY → incompatible deserializer → 500 on every fresh DB. Prefer EF LINQ GroupBy for aggregates in Api handlers; regression-test the empty-DB path.
- NEW (#93) — Screen hooks must TIER their loads: the screen's own resources gate loading/error; reference data (pickers, sources, health, version) settles per-resource (allSettled) with inline "Couldn't load X" notes. Precedent: settings.ts. Also: render the error branch BEFORE the loading branch — a draft-null loading guard ahead of the error check made the error state unreachable (infinite spinner).
- NEW (#93) — ApiResults maps ONLY NotFoundError→404; plain BaseError→422. Handlers that
collapse "missing" and "invalid state" into one query filter can't 404 — split the lookup
(precedent: DeleteCustomResolutionHandler). Request DTOs in Controllers/Api/Requests
deliberately have NO
#nullable enable(only RESPONSE DTOs get it). Startup's UseStringEnumSchemas registers non-Core enums (OutputFormatKind, LogEventLevel) INDIVIDUALLY — assembly-wide reflection over ErsatzTV.FFmpeg throws ReflectionTypeLoadException (optional NvEncSharp natives). Core↔Application enum twins bridge via exhaustive switch expressions, never int casts. - NEW (#93) —
npm run check:apidiff-guards v1.d.ts against the LAST COMMIT — it fails mid-branch after a backend OpenAPI change until the regenerated file is committed; regen-idempotence (running generate:api twice → no diff) is the real sync check. - NEW (#93) — OSV advisories can break ALL CI overnight: NuGetAudit + warnings-as-errors turns a fresh critical advisory into NU1904 restore failures on every branch. Fix = a one-line central bump PR straight off main, merged before feature PRs (PR #137, Scriban 6→7 validated by both suites + unchanged XMLTV goldens).
- NEW (#89) — Dialog/portal components: key open-effects on
[open]ONLY and read callbacks through a latest-ref; an effect depending on an inlineonClosere-runs (and re-focuses) on every parent render — the focus-steal makes dialog inputs untypeable, and jsdom tests can't catch it (fireEvent.change needs no focus). - NEW (#89) — before offering a "None"/clear affordance for any field the backend resolves
with
x ?? fallback, check whether null actually MEANS clear — for from-lineup advanced overrides null = INHERIT (see #135), so honest UI is "Inherit from template", not "None". - NEW (#89) —
<label onClick={...}>wrapping a labelable control (button/input) double-fires in real browsers (label activation forwarding + bubble); jsdom does not emulate it, tests stay green. Use a<div>row with the control as the single accessible element. - NEW (#89) —
/api/library/browsemediaTypeis single-valued: a Collections-style picker needs 5 typed parallel calls (Collection/Smart/Multi/Rerun/Playlist) merged client-side. ApiResults 422 title is ALWAYS "Validation failed" — fixtures must not invent titles. - Multiple Dynamic-start Flood schedule items are NON-VIABLE (#134): PlayoutModeSchedulerFlood
only yields to a next item with StartType.Fixed (
PlayoutModeSchedulerFlood.cs:50-53) and never advances on the hard stop — an ordered multi-source lineup must be ONE generatedIsSystemPlaylist (PlayAll=true per entry, entries in Index order) behind a single Flood item. PlaylistItem supports Movie/Show/Season/Artist/Collection/Smart/Multi but has NO RerunCollectionId and NO nested-playlist support (CollectionKey.ForPlaylistItem + MediaCollectionRepository.GetPlaylistItemMap are the two switches that define support). - Validation must see the SAME data the build path uses (#134): normalizing on a
with {}copy inside the validator let raw request values reach persistence (FK violation → opaque 422). Normalize the whole input once up front; both validation and build consume the normalized form. - Any handler that SYNTHESIZES names into unique-indexed columns needs de-collision (" 2", " 3"…, max-length-safe) — deleting a channel doesn't cascade its generated schedule/playlist, so recreate-after-delete is a routine path, not an edge case (#134).
SelectOneAsyncre-applies.OrderBy(keySelector)INTERNALLY, which REPLACES any ordering the caller composed before it (#133) — never pre-OrderByinto SelectOneAsync; write the explicit.Where(...).OrderBy(...).FirstOrDefaultAsync(...)when ordering matters.- Normalize user input ONCE (#133): validate uniqueness/lengths against the SAME normalized (e.g. trimmed) value you persist, or a whitespace variant slips past validation and dies on the unique index as an unhandled 500.
- Application command/query records + handlers live in
<Domain>/Commands/and<Domain>/Queries/subfolders (contributing §2); namespace staysErsatzTV.Application.<Domain>regardless of subfolder (#133). - Deferral wording must ENUMERATE what is deferred (#130): "aggregate collection metadata is deferred" quietly swallowed manual collections, which are a cheap direct join — the review had to split the deferral. Cheap-vs-expensive is per collection kind, not per feature.
- Merged-source paging pattern (#130): Lucene supplies media ids+total, EF supplies
collection-likes; page = media first, then a skip cascade through each collection type
(
remainingSkip/takethreading). Stale Lucene entries can drift collection paging for a scan window — accepted, documented in-code. Any similar dual-source endpoint should copy the GetLibraryBrowseItemsHandler pattern AND its multi-type-overflow paging test. - User text into BOTH Lucene and SQL needs per-side treatment (#130): raw query text is the
established Lucene idiom (parser falls back to escaped-literal on ParseException — malformed
input degrades to empty/literal results, never throws), but the same text in EF LIKE needs
%/_/escape-char escaping or semantics diverge between the two halves. - Direct
*MetadataDbSet queries need a deterministic winner (#130): items can carry >1 metadata row; either go through the navigation + HeadOrNone() idiom or GroupBy(itemId).OrderBy(Id).First(). - NULL FIELDS ARE OMITTED ON THE WIRE (#129): Startup.cs sets Newtonsoft
NullValueHandling.Ignoreglobally, so any null DTO property is ABSENT from the JSON →undefinedin the browser, even though generated types say| null. Frontend guards must use truthiness (!x), NEVER=== null; fixtures for null cases must OMIT the key (test precedent: "renders the Guide screen when an on-air channel omits nowPlaying"). - Cross-endpoint correlation needs shared ids (#129): /api/guide titles are show-only (ChannelGuideMetadata.GetTitle) while /api/channels/state nowPlaying uses GetDisplayTitle ("Show - s01e01 - Ep") — string matching across endpoints can never work for episodes. Live match is now timestamps-only; the real fix is a shared programme/playout-item id on both endpoints (backlog).
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
returns UNDER THE QUERY THE CLIENT SENDS.
percentis a 0–1 fraction despite its name. Enum-with-None fields are never truthiness-checked. Verify UNITS/scale of numeric wire fields against the producing code, not the field name. - Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; poll while pending∪active nonempty with a grace window; drain grace on persistent errors.
- setState updaters must be PURE — no fetches (#127), no ref mutations (#128); StrictMode double-invokes updaters and the test renderer doesn't, so reviewers must catch it.
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback = the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't; #93: handoff said --status-warn-soft, the SPA token is --ctv-warn-soft — Toast precedent).
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped.
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based double-submit guards; mutations never refetch the world (#125/#127).
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures are BARE 4xx/5xx unless the controller returns ProblemDetails); mount call-counts assert the DELTA across navigation; status-dot state never color-only (StatusDot has a label).
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need
#nullable enable; Application has NO nullable context. NSubstitute+ConfigElementKey:Arg.Any<ConfigElementKey>()+<T>.Option<T>.ToNullable()→MatchUnsafe. update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check. Validation.Apply ERASES NotFoundError subtypes (#44 gotcha) — multi-check validation that must 404 stays early-return. - Backlog nits (unfiled): unclamped pageSize (browse is clamped; older endpoints aren't);
PlayoutController Create/Delete lack Name=; heavy GetItems pre-check; >30 MB uploads →
bare 413; artwork content-type trusted (#66); schedule estimator materializes collections
per GET; /api/health TTL cache;
LibraryScanStatusResponseModel.percent0–1 under a percent name; 1 pre-existing --text-faint usage in shell.css. From #129: shared programme/playout-item id on /api/guide + /api/channels/state; extract the duplicated channel-state poll loop into a shared helper; EPG grid re-renders unmemoized on every tick; /api/guide 21-include eager-load untrimmed. From #130: the two manual-collection metadata helpers each fetch CollectionItems (share one fetch); very large manual collections make the browse duration sum heavy. From #134: pre-existing non-system PlaylistGroup named "Channel Lineups" breaks multi-item creates with a generic 422; non-DbUpdateException create failures surface as bare 500. From #89: undefined-vs-null lineup keys in the create body; reduced-motion block lists a now-no-op .ctv-builder-libcard. From #93: enumeration endpoints missing for MPEG-TS scripts / audio language codes / UI cultures (settings fields are free-text meanwhile); media sources have no status/reachability signal in the API (Settings omits the StatusDot); full FFmpeg profile editor screen; edits made to an already-saved group DURING an in-flight save can be overwritten by the returned DTO (narrow race, noted by the verification fork). Filed: #126, #135.
PROMPT — #90: ChicoryTV rebrand (assets + naming)
You are Fable, the ORCHESTRATOR in the main Claude Code session (Codex is retired — Claude Code only). Fable is EXPENSIVE: delegate implementation to fitting subagents (this issue is mostly mechanical — haiku/sonnet territory; fable only for the review fork). Read CLAUDE.md and the PROCESS + Lessons sections of this file first.
HARD CONSTRAINTS:
git worktree add .worktrees/issue-90-rebrand -b feat/90-rebrand origin/main; never touch the main checkout (it sits on docs/59-ui-redesign-brief).cd web && npm cifirst.- Max 2–3 concurrent builds; ONE dotnet build at a time. Frontend-only expected — if backend strings turn out to be in scope, they're NOT (deeper product rebrand is epic #59).
- NEVER set ETV_UPDATE_GOLDENS.
- Review before PR: this is a small mechanical issue — a single fable correctness/design fork over the diff suffices (skip the 3-lens panel unless the diff grows); fixes via subagents. Merge needs an in-conversation consent question.
Task
Issue #90: apply the ChicoryTV rebrand across the SPA surface. Naming (ErsatzTV → ChicoryTV
in user-facing UI copy), favicon/app icon/wordmark from design-system/assets/
(chicory-mark.svg, chicorytv-icon.svg, chicorytv-wordmark.svg), page title/meta.
Acceptance: the SPA presents consistently as ChicoryTV. (Product-wide rebrand = epic #59.)
Approach notes
- Inventory first (delegate to Explore): every user-facing "ErsatzTV" in web/ (index.html title/meta/favicon, shell header, empty states, aria-labels, App.test.tsx copy assertions), what web/index.html currently ships as favicon, how the shell renders the brand mark today (the admin template uses chicory-mark.svg + "ChicoryTV" — mirror that), and whether vite needs assets in web/public/ vs imported.
- Do NOT rename API strings, C# namespaces, Docker images, or docs — SPA surface only. The legacy Blazor UI stays ErsatzTV.
- Favicon: derive from chicorytv-icon.svg (SVG favicon is fine for modern browsers; add a PNG fallback only if trivial).
- Tests: update copy assertions; baseline 145 web tests must stay green (some assert brand strings). dotnet suites should be untouched (frontend-only): ErsatzTV.Tests 495, Core.Tests 493 (+1 skip).
- PR → main: "feat(web): ChicoryTV rebrand (#90)",
closes #90; poll CI by head SHA; ask "merge?"; verify main post-merge run. - RELEASE CHECKPOINT: after #90 merges, the SPA is feature-complete-enough AND branded —
this is the strongest v26.4.0 tag point before #91. Ask the user explicitly whether to
tag v26.4.0 now (never
[skip ci]the tagged commit). - Update THIS handoff: pop #90, next = #91 cutover; record PR + main SHA + baselines. Commit to main. Print the next prompt in a fenced block.
Issue queue (work top-down)
- HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126 (OpenAPI polymorphism) + #135 (advanced clear-to-none) are backend slot-fillers between screens. Renovate/dep PRs (#21, #48, #49, #61, #131 security, #132) — cheap batch-merge pass when convenient (note: Scriban already bumped to 7.2.5 by PR #137). MCP PR #76 (#58) still needs its rebase/refresh pass — good parallel track.
- #90 rebrand ← PROMPT above (small, mechanical; strongest v26.4.0 tag point at its merge).
- #91 cutover (+ tag v26.4.0 at the latest here — see RELEASE CHECKPOINT). Cross-refs: #66/#67 remain open image-pipeline nice-to-haves; #68 unblocked-independent. Done recently: PR #136 (#89 Channel Builder), PR #138 (#93 Settings — closed 2026-07-07, main 17f49304; web tests 112 → 145, ErsatzTV.Tests 447 → 495), PR #137 (Scriban GHSA CI-unblock), #92 design-sync round-trip closed (docs/design-sync.md).