fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic

Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
This commit is contained in:
2026-10-23 13:45:40 +02:00
parent ad05932217
commit b443ac0c84
51 changed files with 561 additions and 290 deletions
+6 -1
View File
@@ -133,7 +133,12 @@ type Snapshot = {
/** The register scope a code file belongs to, by its path (relative to frontend/). */
function scopeOf(file: string): string {
if (!file.includes("/")) return /[.][mc]?js$/.test(file) ? "js configs" : "ts configs";
if (!file.includes("/"))
return /[.][mc]?js$/.test(file)
? "js configs"
: /[.]test[.]ts$/.test(file)
? "tool tests"
: "ts configs";
if (file.startsWith("e2e/")) return "e2e";
if (file.startsWith("src/design-lab/")) return "design-lab";
// the network owners (NETWORK_OWNERS in eslint.config.js)
+61
View File
@@ -1044,6 +1044,67 @@
},
"settings": null
},
"tool tests": {
"languageOptions": {
"ecmaVersion": 2022,
"globals": "82 names, sha1 29467d490e30",
"parser": "typescript-eslint/parser",
"parserOptions": { "projectService": true, "tsconfigRootDir": "." },
"sourceType": "module"
},
"linterOptions": { "reportUnusedDisableDirectives": 2 },
"notApplied": [
"local/button-primitive",
"local/glyph-link",
"local/input-primitive",
"local/jsx-comment-text",
"local/layers",
"local/literal-a11y-text",
"local/locale-format",
"local/sortable-a11y",
"local/surface-role",
"no-console",
"no-restricted-globals",
"no-restricted-properties",
"no-restricted-syntax",
"react-hooks/capitalized-calls",
"react-hooks/component-hook-factories",
"react-hooks/config",
"react-hooks/error-boundaries",
"react-hooks/exhaustive-deps",
"react-hooks/gating",
"react-hooks/globals",
"react-hooks/hooks",
"react-hooks/immutability",
"react-hooks/incompatible-library",
"react-hooks/invariant",
"react-hooks/memo-dependencies",
"react-hooks/no-deriving-state-in-effects",
"react-hooks/preserve-manual-memoization",
"react-hooks/purity",
"react-hooks/refs",
"react-hooks/rules-of-hooks",
"react-hooks/set-state-in-effect",
"react-hooks/set-state-in-render",
"react-hooks/static-components",
"react-hooks/syntax",
"react-hooks/todo",
"react-hooks/unsupported-syntax",
"react-hooks/use-memo",
"react-hooks/void-use-memo",
"react-refresh/only-export-components"
],
"plugins": ["@", "@eslint-community/eslint-comments", "@typescript-eslint", "vitest"],
"rules": {
"@typescript-eslint/no-unused-expressions": [
2,
{ "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false }
],
"vitest/no-disabled-tests": [2],
"vitest/no-focused-tests": [2, { "fixable": true }]
},
"settings": null
},
"ts configs": {
"languageOptions": {
"ecmaVersion": 2022,
+29
View File
@@ -76,6 +76,35 @@ describe("the platform and lib layers", () => {
});
});
describe("the entries", () => {
// main.tsx and the /watch page (watch/, its own entry) sit above every layer: no layer imports
// them, or the app and the shared page would stop being separable.
it("are imported by no layer", async () => {
const lib =
imp("../watch/WatchPage") + 'import type { T } from "../main";\nexport type U = T;\n';
expect(await refused(lib, "src/lib/__layers_probe.ts")).toHaveLength(2);
expect(
await refused(imp("../../watch/WatchPage"), "src/components/ui/__layers_probe.tsx"),
).toHaveLength(1);
expect(await refused(imp("../../watch/watchStrings"), IN_FEATURE)).toHaveLength(1);
expect(await refused(imp("../../watch/WatchPage"), IN_FEED)).toHaveLength(1);
expect(await refused(imp("../watch/WatchPage"), "src/shell/__layers_probe.tsx")).toHaveLength(
1,
);
});
it("while the /watch entry itself imports its page", async () => {
expect(await refused(imp("./watch/WatchPage"), "src/__layers_probe.tsx")).toEqual([]);
});
});
describe("an import()", () => {
it("is the same path in backticks", async () => {
const src = "export const L = () => import(`../modules/feed/Feed`);\n";
expect(await refused(src, "src/lib/__layers_probe.ts")).toHaveLength(1);
});
});
describe("the shell and the entry", () => {
it("reach a module through its face, or lazily", async () => {
const src =
+154
View File
@@ -0,0 +1,154 @@
import { vocabI18nKey } from "./vocab";
import { type ActiveFilter } from "./useActiveFilters";
import { type PlexFilters } from "./api";
type TFn = (key: string, options?: Record<string, unknown>) => string;
// The pure builder behind Plex's chip row (modules/plex usePlexActiveFilters): turns a Plex filter
// set + watch-state into the source-agnostic ActiveFilter[]. Apart from the hook so the saved-views
// manager (features/filters) can DESCRIBE an arbitrary view's captured filters (with no-op removes)
// without reaching into the Plex module — the labels stay in one place, no duplication with the live
// chip row. Store-free, like its feed twin useActiveFilters.
export function buildPlexActiveFilters(
filters: PlexFilters,
show: string,
patch: (p: Partial<PlexFilters>) => void,
setShow: (s: string) => void,
t: TFn,
/** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by
* the saved-views manager, which describes a stored view and has only the captured titles. */
collectionTitles?: Record<string, string>,
/** The missing titles are still loading: their chips show a placeholder, not the generic word. */
collectionsPending = false,
): ActiveFilter[] {
const out: ActiveFilter[] = [];
const has = (x: number | null | undefined) => x != null;
for (const g of filters.genres) {
out.push({
key: `genre:${g}`,
label: g,
remove: () => patch({ genres: filters.genres.filter((x) => x !== g) }),
});
}
for (const r of filters.contentRatings) {
out.push({
key: `contentRating:${r}`,
label: vocabI18nKey("certificate", r)
? t(vocabI18nKey("certificate", r), { defaultValue: r })
: r,
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
});
}
if (has(filters.yearMin) || has(filters.yearMax)) {
const range =
has(filters.yearMin) && has(filters.yearMax)
? `${filters.yearMin}–${filters.yearMax}`
: has(filters.yearMin)
? `≥${filters.yearMin}`
: `≤${filters.yearMax}`;
out.push({
key: "year",
label: `${t("plex.filter.year")}: ${range}`,
remove: () => patch({ yearMin: null, yearMax: null }),
});
}
if (has(filters.ratingMin)) {
out.push({
key: "ratingMin",
label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`,
remove: () => patch({ ratingMin: null }),
});
}
if (has(filters.durationMin) || has(filters.durationMax)) {
const lo = has(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null;
const hi = has(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null;
const u = t("plex.filter.durationUnit");
const range =
lo != null && hi != null ? `${lo}–${hi} ${u}` : lo != null ? `≥${lo} ${u}` : `≤${hi} ${u}`;
out.push({
key: "duration",
label: `${t("plex.filter.duration")}: ${range}`,
remove: () => patch({ durationMin: null, durationMax: null }),
});
}
if (filters.addedWithin) {
out.push({
key: "added",
label: `${t("plex.filter.added")}: ${t("plex.filter.addedOpt." + filters.addedWithin)}`,
remove: () => patch({ addedWithin: "" }),
});
}
for (const d of filters.directors) {
out.push({
key: `director:${d}`,
label: d,
remove: () => patch({ directors: filters.directors.filter((x) => x !== d) }),
});
}
for (const a of filters.actors) {
out.push({
key: `actor:${a}`,
label: a,
remove: () => patch({ actors: filters.actors.filter((x) => x !== a) }),
});
}
for (const s of filters.studios) {
out.push({
key: `studio:${s}`,
label: s,
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
});
}
for (const p of filters.playable) {
out.push({
key: `playable:${p}`,
label: t(`plex.filter.playableOpt.${p}`),
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
});
}
for (const k of filters.keywords) {
out.push({
key: `keyword:${k}`,
label: k,
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
});
}
out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending));
if (show !== "all") {
out.push({
key: "show",
label: t("plex.filter.showOpt." + show),
remove: () => setShow("all"),
});
}
return out;
}
// One chip per selected collection, named by the resolved titles (a placeholder while a link's titles
// load), else by the ones the state carries, else the generic word.
function collectionChips(
filters: PlexFilters,
patch: (p: Partial<PlexFilters>) => void,
t: TFn,
collectionTitles: Record<string, string> | undefined,
pending: boolean,
): ActiveFilter[] {
return filters.collections.map((c) => ({
key: `collection:${c}`,
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
pending: pending && collectionTitles?.[c] === undefined,
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
// one (an empty `{}` still hashes differently from an absent key).
remove: () => {
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
patch({
collections: filters.collections.filter((x) => x !== c),
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
});
},
}));
}
+10 -2
View File
@@ -8,6 +8,7 @@ import { LS } from "./storage";
import { usePref } from "./usePref";
import { updateMeCache } from "./useMe";
import {
defaultLayout,
PREF_KEY,
saveLayoutLocal,
seedLayout,
@@ -68,10 +69,17 @@ const COLLAPSE = {
},
} as const;
/** What a rail hands its FilterRail: the layout and the collapse flag, each with its setter. */
/** What a rail hands its FilterRail: the layout and the collapse flag, each with its setter, and the
* reset to the rail's default layout. */
export function useRail(panel: PanelId) {
const [layout, setLayout] = usePanelLayout(panel);
const c = COLLAPSE[panel];
const [collapsed, setCollapsed] = usePref<boolean>(c.key, c.initial, c.opts);
return { layout, setLayout, collapsed, onToggleCollapse: () => setCollapsed(!collapsed) };
return {
layout,
setLayout,
onResetLayout: () => setLayout(defaultLayout(panel)),
collapsed,
onToggleCollapse: () => setCollapsed(!collapsed),
};
}