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
+30 -4
View File
@@ -443,8 +443,11 @@ function placeOf(abs) {
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 };
// main.tsx, watch/, i18n/, content/, design-lab/, the app-wide tests at the root. The two entries —
// the app's main.tsx and the shared /watch page (watch/ + watchMain.tsx) — sit above every layer.
const root = parts.length === 1;
const entry = top === "watch" || (root && (top === "main" || top === "watchMain"));
return { layer: "other", top: root ? "root" : top, entry };
}
const layers = {
meta: {
@@ -452,6 +455,8 @@ const layers = {
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().",
entry:
"`{{spec}}` is an entry (the app's main.tsx or the shared /watch page): no layer imports one, or the app and the /watch page stop being separable.",
},
},
create(context) {
@@ -465,7 +470,12 @@ const layers = {
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;
if (!to) return;
if (to.layer === "other") {
if (to.entry && from.layer !== "other")
context.report({ node, messageId: "entry", data: { spec } });
return;
}
const fromRank = from.layer === "other" ? 5 : RANK[from.layer];
if (
to.layer === "shell" &&
@@ -492,7 +502,13 @@ const layers = {
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),
ImportExpression: (n) => {
// A template literal with nothing substituted is a plain path to TypeScript and Rollup too.
const s = n.source;
if (s.type === "Literal") check(s, s.value, true);
else if (s.type === "TemplateLiteral" && s.expressions.length === 0)
check(s, s.quasis[0].value.cooked, true);
},
TSImportType: (n) => {
const lit = n.argument?.literal ?? n.argument;
if (lit?.type === "Literal") check(lit, lit.value, false);
@@ -867,6 +883,16 @@ export default tseslint.config(
"no-undef": "off",
},
},
{
// The unit tests of the build tools beside them (chunkGuard's): vitest runs them, so a stray
// `.only` / `.skip` there is the same silent skip as in src.
files: ["*.test.ts"],
plugins: { vitest },
rules: {
"vitest/no-focused-tests": "error",
"vitest/no-disabled-tests": "error",
},
},
{
files: ["e2e/**/*.ts"],
plugins: { playwright },