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
+7 -6
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { defaultLayout, type PanelId, type PanelLayout } from "../../lib/panelLayout";
import { type PanelLayout } from "../../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
@@ -9,10 +9,10 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
// the layout default, edit hint); each rail supplies only what actually differs — its identity
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
// `children` defaults to nothing for the rails that need none.
// groups (`children`, e.g. the feed's tag manager; nothing for the rails that need none). What Reset
// goes back to is the rail's (`onResetLayout`, from lib/useRail), so this shell names no page.
export default function FilterRail({
kind,
onResetLayout,
title,
icon,
count,
@@ -28,7 +28,8 @@ export default function FilterRail({
titleActionLabel,
children,
}: {
kind: PanelId;
/** Puts the groups back in the rail's default order (lib/useRail's `onResetLayout`). */
onResetLayout: () => void;
title: string;
icon: ReactNode;
count?: number;
@@ -56,7 +57,7 @@ export default function FilterRail({
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout(kind))}
onReset={onResetLayout}
editHint={t("sidebar.editHint")}
actions={actions}
iconExpanded={iconExpanded}
@@ -121,6 +121,46 @@ describe("the header slot", () => {
expect(pad()).toBe("84px"); // the page's two pills, the default gone
});
it("re-measures when one page's pills replace another's in the same commit", () => {
// Feed → Playlists: the leaving page's claim and the arriving page's land in one commit, so the
// number of claims never changes — the pad must follow the new pills all the same.
const height = (el: HTMLElement) => 40 * el.childElementCount;
const Feed = () => (
<PageHeader>
<span>search</span>
<span>scope</span>
</PageHeader>
);
const Playlists = () => (
<PageHeader>
<span>search</span>
</PageHeader>
);
const page = (which: "feed" | "playlists") => (
<PageShell header={hdr}>
{which === "feed" ? <Feed /> : <Playlists />}
{fallback}
</PageShell>
);
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
get() {
return height(this as HTMLElement);
},
configurable: true,
});
const pad = () =>
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
"--hdr-h",
);
rerender(page("feed"));
expect(pad()).toBe("84px");
rerender(page("playlists"));
expect(pad()).toBe("44px");
rerender(page("feed"));
expect(pad()).toBe("84px");
});
it("renders a page's pills nowhere in a shell without a header", () => {
render(
<PageShell>
+13 -4
View File
@@ -153,17 +153,26 @@ export default function PageShell({
// How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
// that leaves releases only its own claim.
const [headerClaims, setHeaderClaims] = useState(0);
// Bumped by every claim and every release. The count alone cannot say the pills changed: when one
// page's PageHeader replaces another's in one commit (feed → playlists), the release and the claim
// net to the same count, and the measurement below would not re-run.
const [headerGeneration, setHeaderGeneration] = useState(0);
const claimHeader = useCallback(() => {
setHeaderClaims((n) => n + 1);
return () => setHeaderClaims((n) => n - 1);
setHeaderGeneration((g) => g + 1);
return () => {
setHeaderClaims((n) => n - 1);
setHeaderGeneration((g) => g + 1);
};
}, []);
// Whether the shell's default is in the header (PageHeaderDefault reports it).
const [defaultShown, setDefaultShown] = useState(false);
// The pills arrive by portal AFTER this shell mounted (with the page's chunk), so the first measurement
// saw an empty header; the ResizeObserver catches up only a frame or more later, and until then the
// content sat under the pills (the feed toolbar jumped 55px on a tablet). So the measurement also re-runs
// here, before paint, whenever what fills the header changes; the observer still covers a change within
// a page's own pills (Plex's view switch appearing).
// here, before paint, whenever what fills the header changes (a claim made or released, the default
// shown or gone); the observer still covers a change within a page's own pills (Plex's view switch
// appearing).
useLayoutEffect(() => {
if (!headerEl || !contentEl) {
// No header (channel page) → drop any stale pad so a leftover value can't linger.
@@ -177,7 +186,7 @@ export default function PageShell({
const ro = new ResizeObserver(apply);
ro.observe(headerEl);
return () => ro.disconnect();
}, [headerEl, contentEl, headerClaims, defaultShown]);
}, [headerEl, contentEl, headerGeneration, defaultShown]);
const headerSlot = {
target: headerEl,
claimed: headerClaims > 0,
@@ -1,7 +1,8 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const root = new URL("../", import.meta.url);
// The whole app source (shell, modules, features, ui): the checkable menus live in all of them.
const root = new URL("../../", import.meta.url);
const sources = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
@@ -53,7 +53,7 @@ const PLAIN: Record<string, [number, string]> = {
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
"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"],
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],