diff --git a/CHANGELOG.md b/CHANGELOG.md index aa822cb1..5cd2a984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.109.1 — 2026-10-23 + +### New + +- Development: the frontend is one folder per page (its page, rail, store and definition), with the layers held by lint and the shared /watch page held apart by a contract test; the header knows no page, and the feed and Plex each keep their own saved views. + +### Fixed + +- Saved views: a view you applied and then changed is still active after a visit to another page, in the feed and in Plex. + ## 0.109.0 — 2026-10-21 ### New diff --git a/CLAUDE.md b/CLAUDE.md index ae1732bf..223ce247 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,14 +73,22 @@ 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) → **`modules//`** (one folder per page: the page, its rail, its store, its tests, +and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by +`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in +`lib/urlState` and its entry there, plus its panel in `lib/panelLayout` if it has a rail) → +**`features//`** (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`. @@ -107,6 +115,9 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals | | FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `` | +| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it | +| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` | +| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it | | FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `` hand-painted like a button | | FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals | | FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached | diff --git a/VERSION b/VERSION index 8b27ad70..ced503bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.109.0 +0.109.1 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8ea9f4c0..05543621 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -275,7 +275,9 @@ Also in force: - **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6] - Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller. The band + Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; the floating + header over it is its header slot (`PageHeader`: the page's own pills, the palette's Jump-to pill where a + page has none, `PageHeaderDefault`). The band holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a diff --git a/docs/development.md b/docs/development.md index 842f3cef..0bb8d445 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,6 +36,32 @@ The dev server also serves the **design lab** at `http://localhost:5173/design-l decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build (see [`DESIGN.md`](DESIGN.md)). +## Adding a page + +A frontend page is one folder, `frontend/src/modules//` (the layering is in +[`CLAUDE.md`](../CLAUDE.md) § Architecture shape): + +1. The page itself, default-exported, and anything only it uses. Header content (a search box, a toggle) the + page renders into the floating header itself through `PageHeader`; the shell knows no page. +2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a + `lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically + only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. +3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's + hooks). Another folder reaches the module through this file or not at all. +4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in + nav-rail order. The rail, the palette, the `g` chords, the tab title and the root's stores all read from there. +5. Its strings in both locales (`src/i18n/locales`). +6. A page with a side rail also names its panel below the module, in `lib/` (rails share one layout + and collapse machinery, which lib may not learn from a module): the `PanelId` and its default group + order, storage and pref keys in `lib/panelLayout.ts`, its collapse flag in `lib/useRail.ts` + (`COLLAPSE`), and those keys in `lib/storage.ts`. The rail calls `useRail("")` and hands what it + returns to `ui/FilterRail`; tsc names each table still missing the new id. + +What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), +`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go +down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the +i18n parity test. + ## The gate A change is done when this is green, from the repo root: @@ -105,7 +131,7 @@ npm run lint # eslint (typed rules included; warnings fail) npm run format:check # prettier npm run knip # unused files/exports/deps npm run test # vitest (incl. the compiler and gate-register contracts) -npx vite build # the production bundle (devOnlyGuard fails it on dev-only code) +npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot) npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above ``` diff --git a/docs/gates.md b/docs/gates.md index effe4bcc..b0e23f5f 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -61,7 +61,8 @@ them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formattin | src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw input as a focus target, an untranslated label) that is not product UI. `local/button-primitive` stays on: in a test file (and in a control primitive) it only refuses a raw `