Adds metadata blocks (key/status/since/supersedes/superseded-by + Rule/Signals/Mechanics) to all 26 legacy-unmigrated ## records across optimistic-concurrency.md (7, concurrency.*), api-auth-security.md (9, security.*), release-ci-governance.md (7 remaining legacy records, release.*), and spa-modularization.md (3, spa.*). Heading text and rationale prose are byte-identical; only metadata was inserted, per the key-table assignments. security.session-auth-dual-credential (#295 PR1) gets a one-line cross-reference noting it narrows the OIDC-inert sub-claim of security.blazor-removal-auth-posture (#206), per orchestrator adjudication — both stay status: active with supersedes/superseded-by: none. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.4 KiB
SPA modularization — App.tsx screen/shell extraction epic (#243)
Why the ChicoryTV SPA was decomposed out of a monolithic App.tsx, and the conventions the
extraction froze (single-file screens, screen-owned route wrappers, explicit primary-action
ownership). Pure structural moves — no API/route/CSS/behavior change — so the rationale is
about boundaries, relocated from the append-only docs/decisions.md at the v26.9.0
consolidation. Cross-links: docs/spa-conventions.md §2/§6/§10.
Issue trail: epic #243 — phase 1 #244 (Channels), phase 2 #245 (Playouts), phase 4 #247 (shell/routing + primary actions). Refs #238 #230.
Contents
- 2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)
- 2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)
- 2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)
2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)
key: spa.channels-screen-extraction · status: active · since: 2026-07-11 · supersedes: none · superseded-by: none
Rule: The Channels domain is a single-file zero-prop screen (web/src/screens/ChannelsScreen.tsx) with a colocated test file and no sibling helper directory, since its pure logic is too small (~30 lines) to justify a separate business-rule layer like Schedules' itemRules.ts.
Signals: screen extraction, single-file screen, no helper dir · paths: web/src/screens/ChannelsScreen.tsx · issues: #244, #243, #238
Mechanics: docs/spa-conventions.md §6
First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved
verbatim out of web/src/App.tsx into web/src/screens/ChannelsScreen.tsx (zero-prop, self-sufficient,
mirroring the SchedulesScreen extraction), with its behavior tests moved to a colocated
ChannelsScreen.test.tsx that owns its own scoped fetch mock (spa-conventions §6). Pure structural
move: no API/route/CSS/visual change; App.tsx retains only the import + the <ChannelsScreen />
dispatch clause.
Decision — no web/src/channels/ sibling helper directory (unlike Schedules' web/src/schedules/).
Channels' pure logic (stateByChannelId, sortedChannels, groupedChannels, progressFromChannelState,
formatChannelNumber) totals ~30 lines with no independent business-rule layer comparable to Schedules'
itemRules.ts (~470 lines, separately unit-tested). Keeping it inside the single screen file matches the
Blocks/Decos/Templates precedent. Revisit only if a later #243 phase adds substantial pure Channels logic
worth isolating.
One cross-domain helper inlined, not shared: the Dashboard-owned progressFromNowPlaying (still in
App.tsx, used by OnAirCard) was structurally reused by the Channels progressFromChannelState. Rather
than export it from App or create a shared module, its ~5-line start/finish/now percentage math was inlined
into the moved progressFromChannelState (behavior-identical — ChannelState.nowPlaying carries the same
startUtc/finishUtc shape), so ChannelsScreen.tsx has no import back into App.tsx.
#238 (TopBar primaryAction dead button) left untouched — the channels route's inert ctv:primary-action
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
is deferred to #247 (epic phase 4).
2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)
key: spa.playouts-screen-extraction · status: active · since: 2026-07-14 · supersedes: none · superseded-by: none
Rule: The Playouts domain (including its unguarded PlayoutsRouteScreen route wrapper with local pathname/popstate state) moved as one unit into web/src/screens/PlayoutsScreen.tsx, keeping its screen-specific sub-path route ownership colocated with the base screen; a pure structural move with no API/route/CSS/behavior change.
Signals: screen extraction, route wrapper ownership, sub-path routing · paths: web/src/screens/PlayoutsScreen.tsx · issues: #245, #243
Mechanics: docs/spa-conventions.md §2
Second bounded extraction under the App.tsx modularization epic (#243): the Playouts domain moved from
web/src/App.tsx into web/src/screens/PlayoutsScreen.tsx, including its loading/error/empty states,
dialogs, mutations, timeline/filter helpers, and the existing PlayoutsRouteScreen. The dedicated
PlayoutScheduleEditors.tsx modules remain separate. This is a pure structural move: no API, route, CSS,
or runtime behavior changed; App.tsx retains only the import and <PlayoutsRouteScreen /> dispatch.
The unguarded route wrapper moves with the screen. Playouts owns two sibling sub-path editors and,
per spa-conventions.md §2, must keep its local pathname state plus popstate listener because App's
allow-sub-path route object is reference-stable. Colocating the wrapper keeps that screen-specific route
ownership beside the base screen while App-level tests retain the cross-route navigation assertions.
Temporal mutation behavior stays verbatim. mutatingRef, runMutation, and the explicit
query.refresh() after a 409 moved as one unit. The extraction deliberately does not add mount/current
guards to these pre-existing promise completions; changing those semantics belongs to a separate issue.
Detailed behavior tests now render PlayoutsScreen directly with a scoped fetch mock, including the
zero-playout Add Playout affordance, lock/409 handling, refresh/poll ownership, action and kind gates, and
dialog flows. Refs #245 #243.
2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)
key: spa.app-shell-extraction · status: active · since: 2026-07-15 · supersedes: none · superseded-by: none
Rule: App.tsx is only the composition root over web/src/app/routes.tsx (stable route-object identity), app/AppShell.tsx (shell chrome), and app/ScreenContent.tsx (exhaustive screen dispatch); primary actions are one explicit PrimaryActionProvider registration per screen, replacing the old global ctv:primary-action window event.
Signals: app shell extraction, route identity, primary-action ownership · paths: web/src/app/routes.tsx, web/src/app/AppShell.tsx, web/src/app/ScreenContent.tsx · issues: #247, #243, #238, #230
Mechanics: docs/spa-conventions.md §10; App.test.tsx
Final phase of the App.tsx modularization epic (#243). web/src/App.tsx is now only the composition
root: it owns activeRoute, currentPathRef, the navigate/popstate pair that consults the dirty
guard, the approved librariesSubPath, and the global theme/health inputs, then composes the shell with
the matched screen. Route metadata/matching/sidebar groups moved to web/src/app/routes.tsx; shell chrome
(Sidebar, TopBar, Connect menu, version and theme controls) moved to app/AppShell.tsx; exhaustive screen
dispatch plus the Media/Libraries wrappers moved to app/ScreenContent.tsx. No route, styling, API, or
dependency changed.
Route identity is deliberate infrastructure. routes.tsx contains the ONE stable module-level array
of shared ScreenRoute objects. routeFromLocation() returns those same references for an
allowSubPaths base path and every owned sub-path, so React's Object.is state bailout remains part of
the contract rather than an accidental implementation detail. The three existing sub-path mechanisms stay
distinct: Playouts and Media self-own pathname/popstate; guarded Libraries receives only the path App
approved after its dirty guard; the remaining route screens retain their established self-owned or keyed
remount behavior. The extraction does not unify them into a speculative router/framework. ScreenContent
uses an exhaustive ScreenId switch, so a future route added without a dispatch branch fails typecheck
instead of silently falling through to a placeholder.
Primary actions are one explicit screen-owned registration, not a global event. The old
ctv:primary-action window CustomEvent and string-keyed dispatcher are removed. PrimaryActionProvider
holds exactly one { routeId, handler, owner } registration shared by the active screen and TopBar;
usePrimaryAction registers it, keeps the latest handler behind a ref without ownership churn, and clears
it only when the same opaque owner unmounts. A stale cleanup therefore cannot erase a newer screen's
action. TopBar renders the Plus action only when the route has a non-empty label AND that active route owns
a matching registration, so metadata alone can no longer create a dead button. This is intentionally not a
generic action bus: one declared action per screen is the whole contract.
Tests pin stable route-object identity (including sibling sub-paths and query-only URLs), composition-level
sub-path rendering and dirty-popstate restoration, and primary-action matching/latest-handler/route-change/
cleanup/stale-owner behavior. Detailed Builder and Settings behavior is colocated with those screens while
App.test.tsx remains shell/routing/composition coverage. Refs #247 #243 #238 #230.