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>
111 lines
9.4 KiB
Markdown
111 lines
9.4 KiB
Markdown
# 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-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-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-15--app-shellrouting-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.
|