feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts

`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up
(lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own
files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide,
which features/ may read too; a page is still opened lazily by import(). Its contract test probes
every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red.

Six modules got that face; the shared channel UI (about, tags, tag manager) moved to
features/channel, the filter chips and the saved-views context to features/filters, and the prefs
store's controller type moved next to the store instead of importing it from its own page. The
header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each
naming why: they go when the header gets slots and every rail loads lazily.

`chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the
entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its
import() a silent no-op. Proved on a build with that mutant; today's build has none.
This commit is contained in:
2026-10-22 23:30:27 +02:00
parent 63a0fecc8b
commit d5867bacca
66 changed files with 453 additions and 109 deletions
+13 -8
View File
@@ -73,14 +73,19 @@ only along a declared edge and writes them only through that unit's service; cor
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
[`backend/app/README.md`](backend/app/README.md) with the conventions.
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
app-free by a contract test) → `shell/moduleRegistry` route-chunks → **`modules/<page>/`** (one folder per page: the page,
its rail, its store, its tests) → **`features/<name>/`** (what several pages share: video cards, filters,
downloads, the player, navigation) → the **platform layer `components/ui/`** (domain-free primitives: the
popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
…) → `lib/`. The direction is one-way and **lint-enforced**, not just described (`local/layers`, with
its own test): an import only goes down; another module is reached only through its **`index.ts`** (its
public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or
lazily by `import()`; a platform component may not import a domain API slice, and `lib/` nothing above
it, not even a type. A lazily imported page, rail or dialog that a static import drags onto the boot
path fails the build (`chunkGuard.ts`). A new generic component goes in `ui/`; see
[`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
prefs; API through `lib/api/*` slices over `core.req<T>`.