diff --git a/CLAUDE.md b/CLAUDE.md index ae1732bf..4f6fceec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`** (one folder per page: the page, +its rail, its store, its tests) → **`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`. diff --git a/docs/development.md b/docs/development.md index 842f3cef..285ae0b1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -105,7 +105,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/frontend/chunkGuard.ts b/frontend/chunkGuard.ts new file mode 100644 index 00000000..92fd575f --- /dev/null +++ b/frontend/chunkGuard.ts @@ -0,0 +1,40 @@ +import type { Plugin } from "vite"; +import { bootLeaks, type ChunkLike } from "./src/lib/chunkLeaks"; + +// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot (the +// entry, the shell and what they import): one static import of it on the boot path — a module's +// index.ts re-exporting it is the easy way — makes its import() a silent no-op. That rule used to be +// a hope; this makes it a build failure, naming each module and the boot chunk it landed in. +const SHELL = "src/shell/App.tsx"; + +export function chunkGuard(): Plugin { + return { + name: "siftlode-chunk-guard", + apply: "build", + generateBundle(_options, bundle) { + const lazy = new Set( + [...this.getModuleIds()].filter( + (id) => (this.getModuleInfo(id)?.dynamicImporters.length ?? 0) > 0, + ), + ); + const chunks: ChunkLike[] = Object.values(bundle).flatMap((o) => + o.type === "chunk" + ? [ + { + fileName: o.fileName, + isEntry: o.isEntry, + imports: o.imports, + moduleIds: o.moduleIds, + }, + ] + : [], + ); + const leaks = bootLeaks(chunks, lazy, SHELL); + if (leaks.length) + this.error( + `lazily imported, yet loaded at boot: ${leaks.join(", ")}. ` + + "Something on the boot path imports it statically (an index.ts re-export?): import it lazily there too, or not at all.", + ); + }, + }; +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1cb8a807..58f9320c 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -7,6 +7,8 @@ import globals from "globals"; import eslintComments from "@eslint-community/eslint-plugin-eslint-comments"; import vitest from "@vitest/eslint-plugin"; import playwright from "eslint-plugin-playwright"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; // Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the // hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this @@ -419,8 +421,89 @@ const glyphLink = { }; }, }; +// The app's layers (CLAUDE.md "Architecture shape"), bottom up: lib/ → components/ui/ → features/ → +// modules/ → shell/. An import may go down, never up — with one door: a module's index.ts, its public +// face, which features/ may read too (the module stores are mounted at the root and read app-wide). +// Anything else of ANOTHER module is reached through that face, or lazily by import(), which is a +// chunk boundary and so the way a page is opened. Measured on the import, resolved to its file, so +// every spelling of a path is the same path. lib/ and ui/ keep their own `no-restricted-imports` +// blocks below; this rule adds the folders above them. +const SRC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "src"); +const RANK = { lib: 0, ui: 1, features: 2, modules: 3, shell: 4 }; +function placeOf(abs) { + const rel = path.relative(SRC_DIR, abs).split(path.sep).join("/"); + if (rel.startsWith("..")) return null; + const parts = rel.replace(/\.(tsx?|jsx?)$/, "").split("/"); + const [top, unit] = parts; + if (top === "lib") return { layer: "lib" }; + if (top === "components") return unit === "ui" ? { layer: "ui" } : { layer: "other" }; + if (top === "features" || top === "modules") { + // `modules/plex` and `modules/plex/index` are the unit's public face. + const face = parts.length === 2 || (parts.length === 3 && parts[2] === "index"); + return { layer: top, unit, face }; + } + if (top === "shell") return { layer: "shell" }; + // main.tsx, watch/, i18n/, content/, design-lab/, the app-wide tests at the root + return { layer: "other", top: parts.length === 1 ? "root" : top }; +} +const layers = { + meta: { + type: "problem", + messages: { + up: "{{from}} sits below {{to}}: it may not import from it (`{{spec}}`). Move the shared piece down, or take it as a prop.", + face: "`{{spec}}` is one of {{unit}}'s own files: reach another unit through its index.ts (its public face), or lazily with import().", + }, + }, + create(context) { + const file = context.filename; + const from = placeOf(file); + // The dev-only lab and the app-wide tests at the root look at everything on purpose. + const free = + !from || + (from.layer === "other" && from.top === "design-lab") || + (from.layer === "other" && from.top === "root" && /\.test(kit)?\.|\.testkit\./.test(file)); + const check = (node, spec, lazy) => { + if (free || typeof spec !== "string" || !spec.startsWith(".")) return; + const to = placeOf(path.resolve(path.dirname(file), spec)); + if (!to || to.layer === "other") return; + const fromRank = from.layer === "other" ? 5 : RANK[from.layer]; + if ( + to.layer === "shell" && + from.layer !== "shell" && + !(from.layer === "other" && from.top === "root") + ) + return context.report({ + node, + messageId: "up", + data: { from: from.layer, to: "shell", spec }, + }); + const door = to.layer === "modules" && (to.face || lazy) && from.layer === "features"; + if (RANK[to.layer] > fromRank && !door) + return context.report({ + node, + messageId: "up", + data: { from: from.layer, to: to.layer, spec }, + }); + const sameUnit = to.layer === from.layer && to.unit === from.unit; + if (to.layer === "modules" && !sameUnit && !to.face && !lazy) + context.report({ node, messageId: "face", data: { spec, unit: `modules/${to.unit}` } }); + }; + return { + ImportDeclaration: (n) => check(n.source, n.source.value, false), + ExportNamedDeclaration: (n) => n.source && check(n.source, n.source.value, false), + ExportAllDeclaration: (n) => check(n.source, n.source.value, false), + ImportExpression: (n) => n.source.type === "Literal" && check(n.source, n.source.value, true), + TSImportType: (n) => { + const lit = n.argument?.literal ?? n.argument; + if (lit?.type === "Literal") check(lit, lit.value, false); + }, + }; + }, +}; + const localRules = { rules: { + layers, "glyph-link": glyphLink, "sortable-a11y": sortableA11y, "jsx-comment-text": jsxCommentText, @@ -567,6 +650,7 @@ export default tseslint.config( "local/literal-a11y-text": "error", "local/glyph-link": "error", "local/sortable-a11y": "error", + "local/layers": "error", "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], // tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a // finding is reported once, by one tool, and this config stays about hooks + refresh safety. @@ -761,7 +845,7 @@ export default tseslint.config( // Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the // flat config named only src/**, so `eslint .` skipped them without a word. The same base and // typed rules as the app; Node globals, since none of it runs in the browser. - files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"], + files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts", "chunkGuard.ts"], extends: [js.configs.recommended, ...tseslint.configs.recommended], languageOptions: { ecmaVersion: 2022, diff --git a/frontend/src/PlaylistEditFailures.dom.test.tsx b/frontend/src/PlaylistEditFailures.dom.test.tsx index ce2d7c23..8a2f42be 100644 --- a/frontend/src/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/PlaylistEditFailures.dom.test.tsx @@ -32,7 +32,7 @@ vi.mock("./lib/notifications", async (importOriginal) => ({ import PlexPlaylistView from "./modules/plex/PlexPlaylistView"; import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist"; import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd"; -import TagManager from "./modules/channels/TagManager"; +import TagManager from "./features/channel/TagManager"; import { api, HttpError, type PlexCard } from "./lib/api"; beforeAll(() => { diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index b92cf515..646f6c58 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -1,7 +1,7 @@ # `components/ui/` — the platform layer Everything in this directory is a **platform component**: generic, reusable, and **domain-free**. -Everything in `components/` above it is a **module component**: it knows about videos, channels, +Everything above it — `features/`, `modules/` and `shell/` under `src/` — knows about videos, channels, playlists, Plex or downloads. That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among @@ -18,9 +18,9 @@ A component belongs here **iff its props and imports name no domain concept**. I - generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …), - i18n, React, and third-party UI libraries. -It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is -enforced by `no-restricted-imports` in `eslint.config.js` — not by this README, which is only the -explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module +It may **not** import anything from `features/`, `modules/` or `shell/`, or a domain API slice +(`lib/api/*`). This is enforced in `eslint.config.js` (`no-restricted-imports` and `local/layers`) — not +by this README, which is only the explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module supply it. The same goes for its words: a primitive translates only from the platform i18n namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` — `uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index 69385f6a..a16a7936 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -51,7 +51,7 @@ const PLAIN: Record = { "modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"], "modules/settings/SettingsPanel.tsx": [1, "a colour swatch"], "modules/channels/SyncStatus.tsx": [1, "the status pill trigger"], - "modules/channels/TagManager.tsx": [1, "a suggestion in a picker"], + "features/channel/TagManager.tsx": [1, "a suggestion in a picker"], "modules/files/UploadDialog.tsx": [1, "the drop zone"], "features/player/VideoEditor.tsx": [2, "timeline glyphs"], "shell/Welcome.tsx": [1, "a screenshot (zoom)"], diff --git a/frontend/src/modules/channels/ChannelAbout.tsx b/frontend/src/features/channel/ChannelAbout.tsx similarity index 100% rename from frontend/src/modules/channels/ChannelAbout.tsx rename to frontend/src/features/channel/ChannelAbout.tsx diff --git a/frontend/src/modules/channels/ChannelTags.editor.dom.test.tsx b/frontend/src/features/channel/ChannelTags.editor.dom.test.tsx similarity index 100% rename from frontend/src/modules/channels/ChannelTags.editor.dom.test.tsx rename to frontend/src/features/channel/ChannelTags.editor.dom.test.tsx diff --git a/frontend/src/modules/channels/ChannelTags.suggest.dom.test.tsx b/frontend/src/features/channel/ChannelTags.suggest.dom.test.tsx similarity index 100% rename from frontend/src/modules/channels/ChannelTags.suggest.dom.test.tsx rename to frontend/src/features/channel/ChannelTags.suggest.dom.test.tsx diff --git a/frontend/src/modules/channels/ChannelTags.tsx b/frontend/src/features/channel/ChannelTags.tsx similarity index 100% rename from frontend/src/modules/channels/ChannelTags.tsx rename to frontend/src/features/channel/ChannelTags.tsx diff --git a/frontend/src/modules/channels/TagManager.tsx b/frontend/src/features/channel/TagManager.tsx similarity index 100% rename from frontend/src/modules/channels/TagManager.tsx rename to frontend/src/features/channel/TagManager.tsx diff --git a/frontend/src/modules/channels/useTagSuggestions.dom.test.tsx b/frontend/src/features/channel/useTagSuggestions.dom.test.tsx similarity index 100% rename from frontend/src/modules/channels/useTagSuggestions.dom.test.tsx rename to frontend/src/features/channel/useTagSuggestions.dom.test.tsx diff --git a/frontend/src/modules/channels/useTagSuggestions.ts b/frontend/src/features/channel/useTagSuggestions.ts similarity index 100% rename from frontend/src/modules/channels/useTagSuggestions.ts rename to frontend/src/features/channel/useTagSuggestions.ts diff --git a/frontend/src/features/downloads/DownloadTray.tsx b/frontend/src/features/downloads/DownloadTray.tsx index abf825b0..d4422f58 100644 --- a/frontend/src/features/downloads/DownloadTray.tsx +++ b/frontend/src/features/downloads/DownloadTray.tsx @@ -37,8 +37,7 @@ import { useLiveQuery } from "../../lib/useLiveQuery"; import { usePref } from "../../lib/usePref"; import { ErrorModal } from "./downloadModals"; import { jobTitle } from "./downloadTitle"; -import { sourceLabel } from "../../modules/files/fileFolders"; -import { useFilesActions } from "../../modules/files/filesContext"; +import { sourceLabel, useFilesActions } from "../../modules/files"; import { useDownloadActions } from "./useDownloadActions"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { setCornerLift } from "../../components/ui/backToTopState"; diff --git a/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx b/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx index 7180e1bd..4589fb23 100644 --- a/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx +++ b/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx @@ -63,7 +63,7 @@ const CTX = { toggleDefault: vi.fn(async () => {}), }; -vi.mock("../../modules/feed/savedViewsContext", () => ({ +vi.mock("./savedViewsContext", () => ({ useSavedViews: () => ({ ...CTX, activeViewId: h.activeViewId, diff --git a/frontend/src/features/filters/PersonalViewsModal.tsx b/frontend/src/features/filters/PersonalViewsModal.tsx index e1e5e52a..d0abed22 100644 --- a/frontend/src/features/filters/PersonalViewsModal.tsx +++ b/frontend/src/features/filters/PersonalViewsModal.tsx @@ -26,9 +26,9 @@ import { import Modal from "../../components/ui/Modal"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; -import { useSavedViews, asFilters } from "../../modules/feed/savedViewsContext"; +import { useSavedViews, asFilters } from "./savedViewsContext"; import { useActiveFilters } from "../../lib/useActiveFilters"; -import { buildPlexActiveFilters } from "../../modules/plex/usePlexActiveFilters"; +import { buildPlexActiveFilters } from "../../modules/plex"; import { useUndoable } from "../../lib/useUndoable"; import UndoToolbar from "../../components/ui/UndoToolbar"; import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y"; diff --git a/frontend/src/features/filters/SavedViewsPopover.dom.test.tsx b/frontend/src/features/filters/SavedViewsPopover.dom.test.tsx index 81c76217..97bf282b 100644 --- a/frontend/src/features/filters/SavedViewsPopover.dom.test.tsx +++ b/frontend/src/features/filters/SavedViewsPopover.dom.test.tsx @@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); -vi.mock("../../modules/feed/savedViewsContext", () => ({ +vi.mock("./savedViewsContext", () => ({ useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }), })); diff --git a/frontend/src/features/filters/SavedViewsPopover.tsx b/frontend/src/features/filters/SavedViewsPopover.tsx index 8d04147b..24d8aec5 100644 --- a/frontend/src/features/filters/SavedViewsPopover.tsx +++ b/frontend/src/features/filters/SavedViewsPopover.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Settings2 } from "lucide-react"; import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; -import { useSavedViews } from "../../modules/feed/savedViewsContext"; +import { useSavedViews } from "./savedViewsContext"; import { IconButton } from "../../components/ui/IconButton"; import { MenuCheck } from "../../components/ui/MenuCheck"; import { SURFACE } from "../../components/ui/surfaces"; diff --git a/frontend/src/features/filters/SelectedViewChip.tsx b/frontend/src/features/filters/SelectedViewChip.tsx index dd89be80..4f8ba066 100644 --- a/frontend/src/features/filters/SelectedViewChip.tsx +++ b/frontend/src/features/filters/SelectedViewChip.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Bookmark } from "lucide-react"; import SavedViewsPopover from "./SavedViewsPopover"; -import { useSavedViews } from "../../modules/feed/savedViewsContext"; +import { useSavedViews } from "./savedViewsContext"; // The active personal view, shown as the leading pill before the active-filter chip row. Styled like // the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged diff --git a/frontend/src/modules/feed/TagFilterChips.dom.test.tsx b/frontend/src/features/filters/TagFilterChips.dom.test.tsx similarity index 100% rename from frontend/src/modules/feed/TagFilterChips.dom.test.tsx rename to frontend/src/features/filters/TagFilterChips.dom.test.tsx diff --git a/frontend/src/modules/feed/TagFilterChips.tsx b/frontend/src/features/filters/TagFilterChips.tsx similarity index 100% rename from frontend/src/modules/feed/TagFilterChips.tsx rename to frontend/src/features/filters/TagFilterChips.tsx diff --git a/frontend/src/modules/feed/savedViewsContext.ts b/frontend/src/features/filters/savedViewsContext.ts similarity index 100% rename from frontend/src/modules/feed/savedViewsContext.ts rename to frontend/src/features/filters/savedViewsContext.ts diff --git a/frontend/src/features/prefs/PrefsProvider.tsx b/frontend/src/features/prefs/PrefsProvider.tsx index 109f70e4..9abc6331 100644 --- a/frontend/src/features/prefs/PrefsProvider.tsx +++ b/frontend/src/features/prefs/PrefsProvider.tsx @@ -24,8 +24,7 @@ import { } from "../../lib/notifications"; import { hintsEnabled, setHintsEnabled } from "../../lib/hints"; import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage"; -import type { PrefsController } from "../../modules/settings/SettingsPanel"; -import { PrefsContext } from "./prefsContext"; +import { PrefsContext, type PrefsController } from "./prefsContext"; const PERF_KEY = LS.perfMode; diff --git a/frontend/src/features/prefs/prefsContext.ts b/frontend/src/features/prefs/prefsContext.ts index adea2d68..8f9c13ee 100644 --- a/frontend/src/features/prefs/prefsContext.ts +++ b/frontend/src/features/prefs/prefsContext.ts @@ -1,11 +1,29 @@ import { createContext, useContext } from "react"; -import { DEFAULT_THEME } from "../../lib/theme"; -import { getNotifSettings } from "../../lib/notifications"; -import type { PrefsController } from "../../modules/settings/SettingsPanel"; +import { DEFAULT_THEME, type ThemePrefs } from "../../lib/theme"; +import { getNotifSettings, type NotifSettings } from "../../lib/notifications"; +import type { SaveState } from "../../components/ui/DraftSaveBar"; // The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only // components (Fast Refresh). +// The Settings page edits server-persisted preferences. Each change applies locally for instant +// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the +// outcome. PrefsProvider owns the state + the save; this controller is how the page reads/writes it. +// It lives with the store rather than with the page, so the store does not depend on its page. +export interface PrefsController { + theme: ThemePrefs; + setTheme: (t: ThemePrefs) => void; + perf: boolean; + setPerf: (v: boolean) => void; + hints: boolean; + setHints: (v: boolean) => void; + railLabels: boolean; + setRailLabels: (v: boolean) => void; + notif: NotifSettings; + setNotif: (n: NotifSettings) => void; + saveState: SaveState; +} + export const PrefsContext = createContext({ theme: DEFAULT_THEME, setTheme: () => {}, diff --git a/frontend/src/lib/chunkLeaks.test.ts b/frontend/src/lib/chunkLeaks.test.ts new file mode 100644 index 00000000..ca7e75b2 --- /dev/null +++ b/frontend/src/lib/chunkLeaks.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { bootLeaks, type ChunkLike } from "./chunkLeaks"; + +const APP = "/r/src/shell/App.tsx"; +const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) => + ({ fileName, moduleIds, imports, isEntry }) satisfies ChunkLike; + +describe("a lazily imported module", () => { + const lazy = new Set([APP, "/r/src/modules/plex/PlexBrowse.tsx"]); + + it("is a leak in the entry chunk, in a chunk the entry imports, or in the App chunk", () => { + const P = "/r/src/modules/plex/PlexBrowse.tsx"; + for (const chunks of [ + [chunk("index.js", ["/r/src/main.tsx", P], [], true)], + [chunk("index.js", ["/r/src/main.tsx"], ["shared.js"], true), chunk("shared.js", [P])], + [chunk("index.js", ["/r/src/main.tsx"], [], true), chunk("App.js", [APP, P])], + [ + chunk("index.js", ["/r/src/main.tsx"], [], true), + chunk("App.js", [APP], ["s.js"]), + chunk("s.js", [P]), + ], + ]) + expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([ + `${P} (${chunks[chunks.length - 1]!.fileName})`, + ]); + }); + + it("is not a leak in its own chunk, or in one only another lazy chunk loads — and App itself is lazy by design", () => { + const chunks = [ + chunk("index.js", ["/r/src/main.tsx"], ["react.js"], true), + chunk("react.js", ["/r/node_modules/react/index.js"]), + chunk("App.js", [APP], ["react.js"]), + chunk("PlexBrowse.js", ["/r/src/modules/plex/PlexBrowse.tsx"], ["react.js"]), + ]; + expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([]); + }); + + it("is checked on Windows paths too", () => { + const win = "D:\\r\\src\\modules\\plex\\PlexBrowse.tsx"; + const chunks = [chunk("index.js", ["D:\\r\\src\\main.tsx", win], [], true)]; + expect(bootLeaks(chunks, new Set([win]), "src/shell/App.tsx")).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/chunkLeaks.ts b/frontend/src/lib/chunkLeaks.ts new file mode 100644 index 00000000..bcaa1b0a --- /dev/null +++ b/frontend/src/lib/chunkLeaks.ts @@ -0,0 +1,40 @@ +// Which lazily imported modules a production build loads at boot anyway — the logic behind the +// chunkGuard build plugin (frontend/chunkGuard.ts), kept free of Vite so a unit test can drive it. +// +// A page, a rail, a dialog or a player is imported with import()/lazy() so it is fetched only when it +// is opened. Rollup honours that only while nothing on the boot path imports the same module +// statically: one static import — a re-export from a module's index.ts is the easy way to add one — +// and the module joins a boot chunk, the import() becomes a no-op, and nothing says so. The boot path +// is the entry chunks and what they import, plus the shell (App, itself loaded lazily but fetched +// at boot alongside the identity probe) and what it imports. + +export interface ChunkLike { + fileName: string; + isEntry: boolean; + /** The chunks this one imports statically. */ + imports: string[]; + moduleIds: string[]; +} + +const posix = (id: string) => id.split("\\").join("/"); + +/** Every lazily imported module (`lazy`: module ids) that sits in a boot chunk, as "id (chunk)". The + * shell's own module (`shell`, a path under the project, e.g. "src/shell/App.tsx") is exempt: it is + * lazy so that the public pages never load it, and its chunk is the shell. */ +export function bootLeaks(chunks: ChunkLike[], lazy: Set, shell: string): string[] { + const byName = new Map(chunks.map((c) => [c.fileName, c])); + const isShell = (id: string) => posix(id).endsWith(`/${shell}`); + const roots = chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell)); + const boot = new Set(); + const visit = (c: ChunkLike | undefined) => { + if (!c || boot.has(c)) return; + boot.add(c); + c.imports.forEach((name) => visit(byName.get(name))); + }; + roots.forEach(visit); + const leaks: string[] = []; + for (const c of boot) + for (const id of c.moduleIds) + if (lazy.has(id) && !isShell(id)) leaks.push(`${posix(id)} (${c.fileName})`); + return leaks.sort(); +} diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 0676c44d..371b4c8a 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -471,6 +471,8 @@ describe("the gate register (docs/gates.md)", () => { "vitest.config.ts", "reactPlugin.ts", "devOnlyGuard.ts", + "chunkGuard.ts", + "src/lib/chunkLeaks.ts", "playwright.config.ts", "postcss.config.js", ], diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index f6a147c5..8b02e7bd 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -35,6 +35,7 @@ "local/glyph-link", "local/input-primitive", "local/jsx-comment-text", + "local/layers", "local/literal-a11y-text", "local/locale-format", "local/sortable-a11y", @@ -117,6 +118,7 @@ "local/glyph-link", "local/input-primitive", "local/jsx-comment-text", + "local/layers", "local/literal-a11y-text", "local/locale-format", "local/sortable-a11y", @@ -642,6 +644,7 @@ "local/glyph-link": [2], "local/input-primitive": [2], "local/jsx-comment-text": [2], + "local/layers": [2], "local/literal-a11y-text": [2], "local/locale-format": [2], "local/sortable-a11y": [2], @@ -1055,6 +1058,7 @@ "local/glyph-link", "local/input-primitive", "local/jsx-comment-text", + "local/layers", "local/literal-a11y-text", "local/locale-format", "local/sortable-a11y", diff --git a/frontend/src/lib/layers.contract.test.ts b/frontend/src/lib/layers.contract.test.ts new file mode 100644 index 00000000..6f6135be --- /dev/null +++ b/frontend/src/lib/layers.contract.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { eslint } from "./lintProbe.testkit"; + +// The layer rule (local/layers in eslint.config.js) is what makes the folders a structure rather +// than a filing habit: lib/ → components/ui/ → features/ → modules/ → shell/, an import only goes +// down, and a module's own files are reached from outside only through its index.ts or lazily. A +// rule that matched nothing would look exactly like a clean codebase, so each edge is probed here. +async function refused(source: string, filePath: string): Promise { + const [result] = await eslint.lintText(source, { filePath }); + return (result?.messages ?? []).filter((m) => m.ruleId === "local/layers").map((m) => m.message); +} +const imp = (...specs: string[]) => + specs.map((s, i) => `import x${i} from "${s}";\n`).join("") + + `export default [${specs.map((_, i) => `x${i}`).join(", ")}];\n`; + +const IN_FEED = "src/modules/feed/__probe.tsx"; +const IN_FEATURE = "src/features/video/__probe.tsx"; + +describe("a module", () => { + it("reaches another module only through its index.ts", async () => { + expect(await refused(imp("../plex/PlexBrowse"), IN_FEED)).toHaveLength(1); + expect(await refused(imp("../plex", "../plex/index"), IN_FEED)).toEqual([]); + }); + + it("by every spelling: a type, a re-export, a type-only import", async () => { + const src = + 'import type { P } from "../plex/plexContext";\n' + + 'export { usePlex } from "../plex/plexContext";\n' + + 'export * from "../channels/channelsContext";\n' + + "export type Q = P;\n"; + expect(await refused(src, IN_FEED)).toHaveLength(3); + }); + + it("may open another module's page lazily, and use its own files and anything below", async () => { + const src = + 'const P = () => import("../plex/PlexBrowse");\n' + + 'import F from "./Feed";\nimport V from "../../features/video/VideoCard";\n' + + 'import B from "../../components/ui/Button";\nimport { qk } from "../../lib/queryKeys";\n' + + "export default [P, F, V, B, qk];\n"; + expect(await refused(src, IN_FEED)).toEqual([]); + }); + + it("never imports the shell", async () => { + expect(await refused(imp("../../shell/Header"), IN_FEED)).toHaveLength(1); + }); +}); + +describe("a feature", () => { + it("reads a module only through its face, and never the shell", async () => { + expect(await refused(imp("../../modules/plex"), IN_FEATURE)).toEqual([]); + const up = await refused(imp("../../modules/plex/plexContext", "../../shell/App"), IN_FEATURE); + expect(up).toHaveLength(2); + expect(up.every((m) => m.includes("sits below"))).toBe(true); + }); + + it("uses other features freely", async () => { + expect(await refused(imp("../player/ClipPanel", "../filters/SearchBar"), IN_FEATURE)).toEqual( + [], + ); + }); +}); + +describe("the platform and lib layers", () => { + it("import nothing above them — not even a module's face", async () => { + const ui = await refused( + imp("../../features/video/VideoCard", "../../modules/feed", "../../shell/App"), + "src/components/ui/__layers_probe.tsx", + ); + expect(ui).toHaveLength(3); + const lib = await refused( + imp("../features/navigation/navigationContext", "../modules/feed", "../shell/App"), + "src/lib/__layers_probe.ts", + ); + expect(lib).toHaveLength(3); + }); +}); + +describe("the shell and the entry", () => { + it("reach a module through its face, or lazily", async () => { + const src = + imp("../modules/feed/Feed", "../modules/feed") + + 'export const L = () => import("../modules/feed/Feed");\n'; + expect(await refused(src, "src/shell/__layers_probe.tsx")).toHaveLength(1); + }); + + it("only the root entry mounts the shell", async () => { + expect(await refused(imp("./shell/App"), "src/__layers_probe.tsx")).toEqual([]); + expect(await refused(imp("./modules/feed/Feed"), "src/__layers_probe.tsx")).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/lintProbe.testkit.ts b/frontend/src/lib/lintProbe.testkit.ts index 40b433cc..3d38cd2c 100644 --- a/frontend/src/lib/lintProbe.testkit.ts +++ b/frontend/src/lib/lintProbe.testkit.ts @@ -15,8 +15,11 @@ const FRONTEND = fileURLToPath(new URL("../../", import.meta.url)); // file no tsconfig includes, so those names are let into TypeScript's default project here — the // probe still runs the real config, typed rules included, rather than a copy with them switched off. const PROBE_FILES = [ + "src/__*probe.tsx", "src/*/__*probe.ts", "src/*/__*probe.tsx", + // a file inside one module or feature (src/modules/feed/…), for the layer rule + "src/*/*/__*probe.tsx", "src/components/ui/__*probe.tsx", "src/lib/api/__*probe.ts", "src/components/Button.tsx", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e0831afb..161fae9f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,14 +7,11 @@ import ErrorBoundary from "./shell/ErrorBoundary"; import { ConfirmProvider } from "./components/ui/ConfirmProvider"; import BootGate, { BootScreen } from "./shell/BootGate"; import { NavigationProvider } from "./features/navigation/NavigationProvider"; -import { FeedFiltersProvider } from "./modules/feed/FeedFiltersProvider"; -import { SavedViewsProvider } from "./modules/feed/SavedViewsProvider"; -import { PlaylistsProvider } from "./modules/playlists/PlaylistsProvider"; -import { FilesProvider } from "./modules/files/FilesProvider"; -import { PlexProvider } from "./modules/plex/PlexProvider"; -import { PlexViewBridge } from "./modules/plex/PlexViewBridge"; -import { ChannelsProvider } from "./modules/channels/ChannelsProvider"; -import { FeedViewProvider } from "./modules/feed/FeedViewProvider"; +import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed"; +import { PlaylistsProvider } from "./modules/playlists"; +import { FilesProvider } from "./modules/files"; +import { PlexProvider, PlexViewBridge } from "./modules/plex"; +import { ChannelsProvider } from "./modules/channels"; import { PrefsProvider } from "./features/prefs/PrefsProvider"; import { WizardProvider } from "./features/wizard/WizardProvider"; import "./i18n"; diff --git a/frontend/src/modules/activity/index.ts b/frontend/src/modules/activity/index.ts new file mode 100644 index 00000000..c327c99f --- /dev/null +++ b/frontend/src/modules/activity/index.ts @@ -0,0 +1,2 @@ +// The activity page's public face: what another unit may use of it. +export { StatusDot, type DotKey } from "./RunStatusDot"; diff --git a/frontend/src/modules/channels/AboutCell.tsx b/frontend/src/modules/channels/AboutCell.tsx index 7404104a..955bdc57 100644 --- a/frontend/src/modules/channels/AboutCell.tsx +++ b/frontend/src/modules/channels/AboutCell.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; import { api } from "../../lib/api"; -import ChannelAboutContent from "./ChannelAbout"; +import ChannelAboutContent from "../../features/channel/ChannelAbout"; import { PopoverSurface } from "../../components/ui/AnchoredPopover"; import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; import { SURFACE } from "../../components/ui/surfaces"; diff --git a/frontend/src/modules/channels/Channels.tsx b/frontend/src/modules/channels/Channels.tsx index 4cace19b..4ff00a18 100644 --- a/frontend/src/modules/channels/Channels.tsx +++ b/frontend/src/modules/channels/Channels.tsx @@ -33,14 +33,14 @@ import { useChannelActions } from "./useChannelActions"; import { useContextMenu } from "../../components/ui/useContextMenu"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { useChannelTagActions } from "../../lib/useChannelTagActions"; -import { useTagSuggestions } from "./useTagSuggestions"; +import { useTagSuggestions } from "../../features/channel/useTagSuggestions"; import { useChannelPriority } from "../../lib/useChannelPriority"; import { placeByPriority } from "../../lib/channelOrder"; import { focusNavItem } from "../../lib/listNav"; import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher"; import ColumnSortControl from "../../components/ui/ColumnSortControl"; import FilterChipGroup from "../../components/ui/FilterChipGroup"; -import TagFilterChips from "../feed/TagFilterChips"; +import TagFilterChips from "../../features/filters/TagFilterChips"; import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu"; import ChannelStatsPopover from "./ChannelStatsPopover"; import Pager from "../../components/ui/Pager"; @@ -57,10 +57,10 @@ import { menuGroups } from "../../components/ui/menuGroups"; import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems"; import { optionsFromColumns } from "../../lib/sortOptions"; import ChannelDiscovery from "./ChannelDiscovery"; -import TagManager from "./TagManager"; +import TagManager from "../../features/channel/TagManager"; import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext"; import { useNavigationActions } from "../../features/navigation/navigationContext"; -import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext"; +import { useFeedFilters, useFeedFiltersActions } from "../feed"; import { useWizard } from "../../features/wizard/wizardContext"; import { useMe } from "../../lib/useMe"; import { useTags } from "../../lib/useTags"; diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx index ceb062e7..afdedb46 100644 --- a/frontend/src/modules/channels/channelColumns.tsx +++ b/frontend/src/modules/channels/channelColumns.tsx @@ -7,11 +7,11 @@ import type { Column } from "../../components/ui/DataTable"; import { PriorityStepper } from "../../components/ui/PriorityStepper"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; -import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags"; +import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/ChannelTags"; import { StoredCell } from "./channelCells"; import { isFullySynced } from "./channelSync"; import { RelativeTime } from "../../components/ui/RelativeTime"; -import type { FilterChip } from "../feed/TagFilterChips"; +import type { FilterChip } from "../../features/filters/TagFilterChips"; /** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical * in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different diff --git a/frontend/src/modules/channels/index.ts b/frontend/src/modules/channels/index.ts new file mode 100644 index 00000000..73033bbb --- /dev/null +++ b/frontend/src/modules/channels/index.ts @@ -0,0 +1,3 @@ +// The channel manager's public face: its store, mounted at the root and read app-wide. +export { ChannelsProvider } from "./ChannelsProvider"; +export { useChannels, useChannelsActions } from "./channelsContext"; diff --git a/frontend/src/modules/feed/ChannelPage.backfill.dom.test.tsx b/frontend/src/modules/feed/ChannelPage.backfill.dom.test.tsx index 4d8dd2df..16ee1552 100644 --- a/frontend/src/modules/feed/ChannelPage.backfill.dom.test.tsx +++ b/frontend/src/modules/feed/ChannelPage.backfill.dom.test.tsx @@ -24,7 +24,7 @@ vi.mock("./feedFiltersContext", () => ({ useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), })); vi.mock("./Feed", () => ({ default: () => null })); -vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); +vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null })); import ChannelPage from "./ChannelPage"; import { api, type ChannelDetail, type FeedFilters, type Me } from "../../lib/api"; diff --git a/frontend/src/modules/feed/ChannelPage.banner.dom.test.tsx b/frontend/src/modules/feed/ChannelPage.banner.dom.test.tsx index df8b273d..285986a1 100644 --- a/frontend/src/modules/feed/ChannelPage.banner.dom.test.tsx +++ b/frontend/src/modules/feed/ChannelPage.banner.dom.test.tsx @@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({ useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), })); vi.mock("./Feed", () => ({ default: () => null })); -vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); +vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null })); // The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for. vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/frontend/src/modules/feed/ChannelPage.busyFocus.dom.test.tsx b/frontend/src/modules/feed/ChannelPage.busyFocus.dom.test.tsx index 3d63c499..2669f886 100644 --- a/frontend/src/modules/feed/ChannelPage.busyFocus.dom.test.tsx +++ b/frontend/src/modules/feed/ChannelPage.busyFocus.dom.test.tsx @@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({ useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), })); vi.mock("./Feed", () => ({ default: () => null })); -vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); +vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null })); // The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for. vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/frontend/src/modules/feed/ChannelPage.fold.dom.test.tsx b/frontend/src/modules/feed/ChannelPage.fold.dom.test.tsx index bfef7f59..dedc0434 100644 --- a/frontend/src/modules/feed/ChannelPage.fold.dom.test.tsx +++ b/frontend/src/modules/feed/ChannelPage.fold.dom.test.tsx @@ -21,7 +21,7 @@ vi.mock("./feedFiltersContext", () => ({ useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), })); vi.mock("./Feed", () => ({ default: () => null })); -vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); +vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null })); vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index f232e214..df517b35 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -26,7 +26,7 @@ import Tabs from "../../components/ui/Tabs"; import { useTabPanel } from "../../components/ui/tabPanel"; import { useScrollFold } from "../../components/ui/scrollFold"; import Feed from "./Feed"; -import ChannelAboutContent from "../channels/ChannelAbout"; +import ChannelAboutContent from "../../features/channel/ChannelAbout"; import { PageToolbar } from "../../components/ui/PageShell"; import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot"; import { useFeedFilters } from "./feedFiltersContext"; @@ -46,7 +46,7 @@ import { } from "../../lib/api"; import type { FeedView } from "../../lib/feedView"; import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format"; -import { ChannelTagsControl } from "../channels/ChannelTags"; +import { ChannelTagsControl } from "../../features/channel/ChannelTags"; import { useChannelTagActions } from "../../lib/useChannelTagActions"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; diff --git a/frontend/src/modules/feed/ChannelPage.watchState.dom.test.tsx b/frontend/src/modules/feed/ChannelPage.watchState.dom.test.tsx index 5a60289a..39536ee2 100644 --- a/frontend/src/modules/feed/ChannelPage.watchState.dom.test.tsx +++ b/frontend/src/modules/feed/ChannelPage.watchState.dom.test.tsx @@ -28,7 +28,7 @@ vi.mock("./Feed", () => ({ return null; }, })); -vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); +vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null })); vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, diff --git a/frontend/src/modules/feed/SavedViewsProvider.dom.test.tsx b/frontend/src/modules/feed/SavedViewsProvider.dom.test.tsx index 702a080d..8be872b9 100644 --- a/frontend/src/modules/feed/SavedViewsProvider.dom.test.tsx +++ b/frontend/src/modules/feed/SavedViewsProvider.dom.test.tsx @@ -45,7 +45,7 @@ vi.mock("../plex/plexContext", () => ({ vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); import { SavedViewsProvider } from "./SavedViewsProvider"; -import { useSavedViews } from "./savedViewsContext"; +import { useSavedViews } from "../../features/filters/savedViewsContext"; import { api, type SavedView } from "../../lib/api"; import { claimLocalData, LS } from "../../lib/storage"; import { qk } from "../../lib/queryKeys"; diff --git a/frontend/src/modules/feed/SavedViewsProvider.tsx b/frontend/src/modules/feed/SavedViewsProvider.tsx index 95d1222f..edb27d4d 100644 --- a/frontend/src/modules/feed/SavedViewsProvider.tsx +++ b/frontend/src/modules/feed/SavedViewsProvider.tsx @@ -17,13 +17,13 @@ import { LS, ownKey } from "../../lib/storage"; import { useConfirm } from "../../components/ui/confirmContext"; import { useNavigation } from "../../features/navigation/navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; -import { usePlex, usePlexActions } from "../plex/plexContext"; +import { usePlex, usePlexActions } from "../plex"; import { asFilters, SavedViewsCtx, type SavedViewsContext, type ViewSource, -} from "./savedViewsContext"; +} from "../../features/filters/savedViewsContext"; // Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider // switches which module it operates on by the active page (both filter providers are global, so it can diff --git a/frontend/src/modules/feed/Sidebar.tsx b/frontend/src/modules/feed/Sidebar.tsx index 2628826a..0ac943ff 100644 --- a/frontend/src/modules/feed/Sidebar.tsx +++ b/frontend/src/modules/feed/Sidebar.tsx @@ -7,9 +7,9 @@ import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } fr import { defaultLayout, type PanelLayout } from "../../lib/panelLayout"; import { useDebounced } from "../../lib/useDebounced"; import { useActiveFilters } from "../../lib/useActiveFilters"; -import TagManager from "../channels/TagManager"; +import TagManager from "../../features/channel/TagManager"; import SavedViewsPopover from "../../features/filters/SavedViewsPopover"; -import { useSavedViews } from "./savedViewsContext"; +import { useSavedViews } from "../../features/filters/savedViewsContext"; import FilterRail from "../../components/ui/FilterRail"; import { type PanelItem } from "../../components/ui/PanelGroups"; import { type MediaSourceKind } from "../../lib/mediaSource"; @@ -20,7 +20,7 @@ import YearFilter from "./YearFilter"; import Combobox from "../../components/ui/Combobox"; import MatchToggle from "../../components/ui/MatchToggle"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; -import { useChannelsActions } from "../channels/channelsContext"; +import { useChannelsActions } from "../channels"; import { useTags } from "../../lib/useTags"; import { compareText } from "../../lib/collate"; diff --git a/frontend/src/modules/feed/index.ts b/frontend/src/modules/feed/index.ts new file mode 100644 index 00000000..68ac8954 --- /dev/null +++ b/frontend/src/modules/feed/index.ts @@ -0,0 +1,6 @@ +// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide. +export { FeedFiltersProvider } from "./FeedFiltersProvider"; +export { FeedViewProvider } from "./FeedViewProvider"; +export { SavedViewsProvider } from "./SavedViewsProvider"; +export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; +export { useFeedView, useFeedViewActions } from "./feedViewContext"; diff --git a/frontend/src/modules/feed/modulePages.tsx b/frontend/src/modules/feed/modulePages.tsx index c9616c1a..a3a6d664 100644 --- a/frontend/src/modules/feed/modulePages.tsx +++ b/frontend/src/modules/feed/modulePages.tsx @@ -4,7 +4,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedView, useFeedViewActions } from "./feedViewContext"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import PersonalViewsModal from "../../features/filters/PersonalViewsModal"; -import { useSavedViews } from "./savedViewsContext"; +import { useSavedViews } from "../../features/filters/savedViewsContext"; // The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so // that file exports only data and this one only components (Fast Refresh). Their lazy chunks are diff --git a/frontend/src/features/downloads/DownloadIntake.dom.test.tsx b/frontend/src/modules/files/LinkBox.intake.dom.test.tsx similarity index 97% rename from frontend/src/features/downloads/DownloadIntake.dom.test.tsx rename to frontend/src/modules/files/LinkBox.intake.dom.test.tsx index 0147eabe..eee518a1 100644 --- a/frontend/src/features/downloads/DownloadIntake.dom.test.tsx +++ b/frontend/src/modules/files/LinkBox.intake.dom.test.tsx @@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), })); -vi.mock("./DownloadDialog", () => ({ +vi.mock("../../features/downloads/DownloadDialog", () => ({ default: ({ source, onClose }: { source: string; onClose: () => void }) => (
{source} @@ -20,8 +20,8 @@ vi.mock("./DownloadDialog", () => ({ ), })); -import DownloadIntake from "./DownloadIntake"; -import { LinkBox } from "../../modules/files/LinkBox"; +import DownloadIntake from "../../features/downloads/DownloadIntake"; +import { LinkBox } from "./LinkBox"; import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake"; import { ensureNavId } from "../../lib/history"; import { pushLayer, removeLayer } from "../../lib/layerStack"; diff --git a/frontend/src/modules/files/index.ts b/frontend/src/modules/files/index.ts new file mode 100644 index 00000000..e6dc269d --- /dev/null +++ b/frontend/src/modules/files/index.ts @@ -0,0 +1,4 @@ +// The Files page's public face: its store, mounted at the root, and how a folder is named and pictured. +export { FilesProvider } from "./FilesProvider"; +export { useFiles, useFilesActions } from "./filesContext"; +export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders"; diff --git a/frontend/src/modules/home/Home.tsx b/frontend/src/modules/home/Home.tsx index 29bcd1f2..89309d64 100644 --- a/frontend/src/modules/home/Home.tsx +++ b/frontend/src/modules/home/Home.tsx @@ -55,9 +55,9 @@ import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard"; import { useVideoActions } from "../../features/video/useVideoActions"; import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"; import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards"; -import { usePlaylistsActions } from "../playlists/playlistsContext"; +import { usePlaylistsActions } from "../playlists"; import { useNavigationActions } from "../../features/navigation/navigationContext"; -import { usePlexActions } from "../plex/plexContext"; +import { usePlexActions } from "../plex"; import { navItemAttrs } from "../../lib/listNav"; import { Pressable } from "../../components/ui/Pressable"; import { SURFACE } from "../../components/ui/surfaces"; diff --git a/frontend/src/modules/notifications/NotificationsPanel.tsx b/frontend/src/modules/notifications/NotificationsPanel.tsx index 17c02bc6..4b220755 100644 --- a/frontend/src/modules/notifications/NotificationsPanel.tsx +++ b/frontend/src/modules/notifications/NotificationsPanel.tsx @@ -47,9 +47,9 @@ import { PageToolbar } from "../../components/ui/PageShell"; import { PageMenu } from "../../components/ui/PageMenu"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { useNavigationActions } from "../../features/navigation/navigationContext"; -import { useFilesActions } from "../files/filesContext"; -import { useChannelsActions } from "../channels/channelsContext"; -import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext"; +import { useFilesActions } from "../files"; +import { useChannelsActions } from "../channels"; +import { useFeedFilters, useFeedFiltersActions } from "../feed"; import { SURFACE } from "../../components/ui/surfaces"; import { SectionLabel } from "../../components/ui/Headings"; import NavGrid from "../../components/ui/NavGrid"; diff --git a/frontend/src/modules/playlists/index.ts b/frontend/src/modules/playlists/index.ts new file mode 100644 index 00000000..ddf7271d --- /dev/null +++ b/frontend/src/modules/playlists/index.ts @@ -0,0 +1,3 @@ +// The playlists page's public face: its store, mounted at the root and read app-wide. +export { PlaylistsProvider } from "./PlaylistsProvider"; +export { usePlaylists, usePlaylistsActions } from "./playlistsContext"; diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index 6347948c..564bcbdd 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -18,7 +18,7 @@ import FilterChipGroup from "../../components/ui/FilterChipGroup"; import Combobox, { type ComboboxOption } from "../../components/ui/Combobox"; import SavedViewsPopover from "../../features/filters/SavedViewsPopover"; import PersonalViewsModal from "../../features/filters/PersonalViewsModal"; -import { useSavedViews } from "../feed/savedViewsContext"; +import { useSavedViews } from "../../features/filters/savedViewsContext"; import { usePlex, usePlexActions } from "./plexContext"; import { DismissibleChip } from "../../components/ui/Chip"; import { Input } from "../../components/ui/Input"; diff --git a/frontend/src/modules/plex/index.ts b/frontend/src/modules/plex/index.ts new file mode 100644 index 00000000..93c6296b --- /dev/null +++ b/frontend/src/modules/plex/index.ts @@ -0,0 +1,5 @@ +// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips. +export { PlexProvider } from "./PlexProvider"; +export { PlexViewBridge } from "./PlexViewBridge"; +export { usePlex, usePlexActions } from "./plexContext"; +export { buildPlexActiveFilters } from "./usePlexActiveFilters"; diff --git a/frontend/src/modules/scheduler/Scheduler.tsx b/frontend/src/modules/scheduler/Scheduler.tsx index 99488ecf..8da7744a 100644 --- a/frontend/src/modules/scheduler/Scheduler.tsx +++ b/frontend/src/modules/scheduler/Scheduler.tsx @@ -44,7 +44,7 @@ import { ContextMenu } from "../../components/ui/ContextMenu"; import { MoreButton } from "../../components/ui/MoreButton"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { jobFailureText, jobResultText } from "../../lib/jobResult"; -import { StatusDot, type DotKey } from "../activity/RunStatusDot"; +import { StatusDot, type DotKey } from "../activity"; import { useNavigationActions } from "../../features/navigation/navigationContext"; import { urlWithParams } from "../../lib/urlState"; diff --git a/frontend/src/modules/settings/ApiTokens.tsx b/frontend/src/modules/settings/ApiTokens.tsx index 80fb2b9d..12bbe51b 100644 --- a/frontend/src/modules/settings/ApiTokens.tsx +++ b/frontend/src/modules/settings/ApiTokens.tsx @@ -18,7 +18,7 @@ import { compareText } from "../../lib/collate"; import { formatDate } from "../../lib/format"; import { navItemAttrs } from "../../lib/listNav"; import { useConfirm } from "../../components/ui/confirmContext"; -import { folderIcon, folderLabel, takesUploads } from "../files/fileFolders"; +import { folderIcon, folderLabel, takesUploads } from "../files"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { Badge } from "../../components/ui/Badge"; import { Button } from "../../components/ui/Button"; diff --git a/frontend/src/modules/settings/SettingsPanel.tsx b/frontend/src/modules/settings/SettingsPanel.tsx index d3939292..d2e8bda6 100644 --- a/frontend/src/modules/settings/SettingsPanel.tsx +++ b/frontend/src/modules/settings/SettingsPanel.tsx @@ -20,7 +20,7 @@ import { DownloadFormats } from "./ProfileEditor"; import DiagnosticsSettings from "./DiagnosticsSettings"; import ApiTokens from "./ApiTokens"; import { type SettingsTabId } from "../../lib/settingsTab"; -import { SCHEMES, type Scheme, type ThemePrefs } from "../../lib/theme"; +import { SCHEMES, type Scheme } from "../../lib/theme"; import { api, HttpError, type Me } from "../../lib/api"; import { displayName, roleKey } from "../../lib/api/account"; import { notifyActionError } from "../../lib/actionErrors"; @@ -40,7 +40,7 @@ import { START_ON_HOME_PREF, } from "../../features/navigation/navigationInit"; import { usePref } from "../../lib/usePref"; -import { usePrefs } from "../../features/prefs/prefsContext"; +import { usePrefs, type PrefsController } from "../../features/prefs/prefsContext"; import Tabs from "../../components/ui/Tabs"; import { useTabPanel } from "../../components/ui/tabPanel"; import { useMe } from "../../lib/useMe"; @@ -49,25 +49,6 @@ import { Slider } from "../../components/ui/NativeControls"; import { Input, Textarea } from "../../components/ui/Input"; import { SURFACE } from "../../components/ui/surfaces"; -// The Settings page edits server-persisted preferences. Each change applies locally for instant -// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the -// outcome. PrefsProvider owns the state + the save; this controller -// is how the panel reads/writes it. -export type PrefsSaveState = SaveState; -export interface PrefsController { - theme: ThemePrefs; - setTheme: (t: ThemePrefs) => void; - perf: boolean; - setPerf: (v: boolean) => void; - hints: boolean; - setHints: (v: boolean) => void; - railLabels: boolean; - setRailLabels: (v: boolean) => void; - notif: NotifSettings; - setNotif: (n: NotifSettings) => void; - saveState: PrefsSaveState; -} - type TabId = SettingsTabId; const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "appearance", icon: Monitor }, @@ -138,7 +119,7 @@ export default function SettingsPanel() { // A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the // bottom of the settings card and fades after a success (the state resets to "idle" in App). -function SaveIndicator({ state }: { state: PrefsSaveState }) { +function SaveIndicator({ state }: { state: SaveState }) { const { t } = useTranslation(); if (state === "idle") return null; return ( diff --git a/frontend/src/shell/App.tsx b/frontend/src/shell/App.tsx index dd2991e2..3a7bf67e 100644 --- a/frontend/src/shell/App.tsx +++ b/frontend/src/shell/App.tsx @@ -21,8 +21,12 @@ import { LS } from "../lib/storage"; import { usePref } from "../lib/usePref"; import { useVerifyEntry } from "../lib/useVerifyEntry"; import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext"; -import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext"; -import { useFeedView, useFeedViewActions } from "../modules/feed/feedViewContext"; +import { + useFeedFilters, + useFeedFiltersActions, + useFeedView, + useFeedViewActions, +} from "../modules/feed"; import { moduleOrder } from "../lib/modules"; import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry"; import { lazyWithPreload } from "../lib/lazyWithPreload"; diff --git a/frontend/src/shell/AppCommandPalette.dom.test.tsx b/frontend/src/shell/AppCommandPalette.dom.test.tsx index b8e2c998..7b1a5fa1 100644 --- a/frontend/src/shell/AppCommandPalette.dom.test.tsx +++ b/frontend/src/shell/AppCommandPalette.dom.test.tsx @@ -57,7 +57,7 @@ vi.mock("../modules/playlists/playlistsContext", () => ({ vi.mock("../features/prefs/prefsContext", () => ({ usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }), })); -vi.mock("../modules/feed/savedViewsContext", () => ({ +vi.mock("../features/filters/savedViewsContext", () => ({ useSavedViews: () => ({ views: [], apply: vi.fn() }), })); const requestSurprise = vi.fn(); diff --git a/frontend/src/shell/AppCommandPalette.tsx b/frontend/src/shell/AppCommandPalette.tsx index d9d87b02..48e515b7 100644 --- a/frontend/src/shell/AppCommandPalette.tsx +++ b/frontend/src/shell/AppCommandPalette.tsx @@ -24,9 +24,9 @@ import { requestLinkBox } from "../lib/downloadIntake"; import { openRecorderStart } from "../lib/debugRecorder"; import { useDebugRecording } from "../features/diagnostics/useDebugRecording"; import { useNavigationActions } from "../features/navigation/navigationContext"; -import { usePlaylistsActions } from "../modules/playlists/playlistsContext"; +import { usePlaylistsActions } from "../modules/playlists"; import { usePrefs } from "../features/prefs/prefsContext"; -import { useSavedViews } from "../modules/feed/savedViewsContext"; +import { useSavedViews } from "../features/filters/savedViewsContext"; // What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is // the domain half — the layer boundary says a primitive may not import `lib/api` or a module. diff --git a/frontend/src/features/header/PaletteLauncher.dom.test.tsx b/frontend/src/shell/Header.palette.dom.test.tsx similarity index 83% rename from frontend/src/features/header/PaletteLauncher.dom.test.tsx rename to frontend/src/shell/Header.palette.dom.test.tsx index 24649211..fa6ee72d 100644 --- a/frontend/src/features/header/PaletteLauncher.dom.test.tsx +++ b/frontend/src/shell/Header.palette.dom.test.tsx @@ -11,24 +11,24 @@ vi.mock("react-i18next", async (importOriginal) => ({ })); let page = "home"; -vi.mock("../navigation/navigationContext", () => ({ useNavigation: () => ({ page }) })); -vi.mock("../../modules/feed/feedFiltersContext", () => ({ +vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page }) })); +vi.mock("../modules/feed/feedFiltersContext", () => ({ useFeedFilters: () => ({ q: "", sort: "newest" }), useFeedFiltersActions: () => ({ setFilters: vi.fn() }), })); -vi.mock("../../modules/playlists/playlistsContext", () => ({ +vi.mock("../modules/playlists/playlistsContext", () => ({ usePlaylists: () => ({ search: "" }), usePlaylistsActions: () => ({ setSearch: vi.fn() }), })); -vi.mock("../../modules/channels/channelsContext", () => ({ +vi.mock("../modules/channels/channelsContext", () => ({ useChannels: () => ({ search: "" }), useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }), })); -vi.mock("../../modules/channels/SyncStatus", () => ({ default: () => null })); +vi.mock("../modules/channels/SyncStatus", () => ({ default: () => null })); -import Header from "../../shell/Header"; -import { closePalette, usePaletteOpen } from "../../lib/keymap"; -import type { Me } from "../../lib/api"; +import Header from "./Header"; +import { closePalette, usePaletteOpen } from "../lib/keymap"; +import type { Me } from "../lib/api"; afterEach(() => { cleanup(); diff --git a/frontend/src/shell/Header.tsx b/frontend/src/shell/Header.tsx index f80da75e..769387b2 100644 --- a/frontend/src/shell/Header.tsx +++ b/frontend/src/shell/Header.tsx @@ -3,16 +3,21 @@ import { useTranslation } from "react-i18next"; import { YouTubeIcon } from "../lib/brandIcons"; import type { Me } from "../lib/api"; import SearchBar from "../features/header/SearchBar"; -import { useFiles, useFilesActions } from "../modules/files/filesContext"; +import { useFiles, useFilesActions } from "../modules/files"; +// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots. import PlexSearchBox from "../modules/plex/PlexSearchBox"; +// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots. import PlexScopeToggle from "../modules/plex/PlexScopeToggle"; +// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots. import PlexViewToggle from "../modules/plex/PlexViewToggle"; +// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots. import FeedScopeToggle from "../modules/feed/FeedScopeToggle"; +// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots. import SyncStatus from "../modules/channels/SyncStatus"; import { useNavigation } from "../features/navigation/navigationContext"; -import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext"; -import { usePlaylists, usePlaylistsActions } from "../modules/playlists/playlistsContext"; -import { useChannels, useChannelsActions } from "../modules/channels/channelsContext"; +import { useFeedFilters, useFeedFiltersActions } from "../modules/feed"; +import { usePlaylists, usePlaylistsActions } from "../modules/playlists"; +import { useChannels, useChannelsActions } from "../modules/channels"; import { PaletteLauncher } from "../features/header/PaletteLauncher"; // The floating top header: a row of glass pills over the content — the module's SearchBar where it diff --git a/frontend/src/shell/moduleRegistry.tsx b/frontend/src/shell/moduleRegistry.tsx index a9451124..af7bb464 100644 --- a/frontend/src/shell/moduleRegistry.tsx +++ b/frontend/src/shell/moduleRegistry.tsx @@ -2,8 +2,11 @@ import type { ComponentType } from "react"; import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload"; import type { Page } from "../lib/urlState"; import type { PanelId, PanelLayout } from "../lib/panelLayout"; +// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages. import Sidebar from "../modules/feed/Sidebar"; +// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages. import PlaylistsRail from "../modules/playlists/PlaylistsRail"; +// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages. import { Feed, FeedPage } from "../modules/feed/modulePages"; // The RENDER half of the module registry: which component (and side rail) each page renders. The diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index adceb04f..1a75e62c 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -43,6 +43,8 @@ "vitest.config.ts", "reactPlugin.ts", "devOnlyGuard.ts", + "chunkGuard.ts", + "src/lib/chunkLeaks.ts", "playwright.config.ts", "postcss.config.js" ] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 04fcfd6a..db44f90f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,7 @@ import { ServerResponse } from "node:http"; import { defineConfig, type Plugin, type ProxyOptions } from "vite"; import { reactWithCompiler } from "./reactPlugin"; import { devOnlyGuard } from "./devOnlyGuard"; +import { chunkGuard } from "./chunkGuard"; // During `vite dev` (host with Node), proxy API calls to the backend container. // Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the @@ -54,7 +55,7 @@ const watchEntry = (): Plugin => ({ }); export default defineConfig({ - plugins: [reactWithCompiler(), devOnlyGuard(), watchEntry()], + plugins: [reactWithCompiler(), devOnlyGuard(), chunkGuard(), watchEntry()], server: { port: 5173, proxy }, build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } }, });