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>`.
+1 -1
View File
@@ -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
```
+40
View File
@@ -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.",
);
},
};
}
+85 -1
View File
@@ -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,
@@ -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(() => {
+4 -4
View File
@@ -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
@@ -51,7 +51,7 @@ const PLAIN: Record<string, [number, string]> = {
"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)"],
@@ -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";
@@ -63,7 +63,7 @@ const CTX = {
toggleDefault: vi.fn(async () => {}),
};
vi.mock("../../modules/feed/savedViewsContext", () => ({
vi.mock("./savedViewsContext", () => ({
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
@@ -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";
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../../modules/feed/savedViewsContext", () => ({
vi.mock("./savedViewsContext", () => ({
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
}));
@@ -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";
@@ -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
@@ -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;
+21 -3
View File
@@ -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<PrefsController>({
theme: DEFAULT_THEME,
setTheme: () => {},
+43
View File
@@ -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);
});
});
+40
View File
@@ -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<string>, 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<ChunkLike>();
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();
}
+2
View File
@@ -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",
],
+4
View File
@@ -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",
+91
View File
@@ -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<string[]> {
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);
});
});
+3
View File
@@ -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",
+5 -8
View File
@@ -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";
+2
View File
@@ -0,0 +1,2 @@
// The activity page's public face: what another unit may use of it.
export { StatusDot, type DotKey } from "./RunStatusDot";
+1 -1
View File
@@ -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";
+4 -4
View File
@@ -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";
@@ -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
+3
View File
@@ -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";
@@ -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";
@@ -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<typeof import("../../components/ui/PageShell")>()),
@@ -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<typeof import("../../components/ui/PageShell")>()),
@@ -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<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
+2 -2
View File
@@ -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";
@@ -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<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
@@ -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";
@@ -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
+3 -3
View File
@@ -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";
+6
View File
@@ -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";
+1 -1
View File
@@ -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
@@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("./DownloadDialog", () => ({
vi.mock("../../features/downloads/DownloadDialog", () => ({
default: ({ source, onClose }: { source: string; onClose: () => void }) => (
<div role="dialog" aria-label="download">
{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";
+4
View File
@@ -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";
+2 -2
View File
@@ -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";
@@ -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";
+3
View File
@@ -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";
+1 -1
View File
@@ -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";
+5
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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 (
+6 -2
View File
@@ -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";
@@ -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();
+2 -2
View File
@@ -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.
@@ -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();
+9 -4
View File
@@ -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
+3
View File
@@ -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
+2
View File
@@ -43,6 +43,8 @@
"vitest.config.ts",
"reactPlugin.ts",
"devOnlyGuard.ts",
"chunkGuard.ts",
"src/lib/chunkLeaks.ts",
"playwright.config.ts",
"postcss.config.js"
]
+2 -1
View File
@@ -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"] } },
});