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
@@ -22,6 +22,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
useFeedFiltersActions: () => ({ setFilters: () => {} }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
@@ -71,9 +72,6 @@ describe("ChannelPage — the grid follows a running backfill", () => {
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
@@ -20,6 +20,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
useFeedFiltersActions: () => ({ setFilters: () => {} }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
@@ -73,9 +74,6 @@ describe("ChannelPage — the banner", () => {
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
@@ -20,6 +20,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
useFeedFiltersActions: () => ({ setFilters: () => {} }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
@@ -80,9 +81,6 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
@@ -127,9 +125,6 @@ describe("ChannelPage — the banner", () => {
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
@@ -19,6 +19,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
useFeedFiltersActions: () => ({ setFilters: () => {} }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
@@ -91,9 +92,6 @@ async function renderPage() {
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
+10 -12
View File
@@ -30,6 +30,8 @@ import ChannelAboutContent from "../../features/channel/ChannelAbout";
import { PageToolbar } from "../../components/ui/PageShell";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
import { useFeedView, useFeedViewActions } from "./feedViewContext";
import { useShowChannelInFeed } from "./useFeedPageHooks";
import { SHOW_IDS } from "./feedFiltersInit";
import { usePref } from "../../lib/usePref";
import { LS } from "../../lib/storage";
@@ -44,7 +46,6 @@ import {
type Me,
type ChannelDetail,
} from "../../lib/api";
import type { FeedView } from "../../lib/feedView";
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
import { ChannelTagsControl } from "../../features/channel/ChannelTags";
import { useChannelTagActions } from "../../lib/useChannelTagActions";
@@ -64,21 +65,10 @@ export default function ChannelPage({
channelId,
initialName,
me,
view,
setView,
onShowInFeed,
}: {
channelId: string;
initialName?: string;
me: Me;
// The feed's view pref, passed straight through to the Feed this page renders — so its
// toolbar carries the same switcher and a change here is the same account-wide choice.
view: FeedView;
setView: (v: FeedView) => void;
// Narrow the real feed to this channel and go there. Offered only for a channel you're
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
// follow would just land you on an empty page.
onShowInFeed: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -90,6 +80,14 @@ export default function ChannelPage({
// shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
const globalFilters = useFeedFilters();
// The feed's view pref, handed straight to the Feed this page renders — so its toolbar carries the
// same switcher and a change here is the same account-wide choice.
const view = useFeedView();
const { setView } = useFeedViewActions();
// Narrow the real feed to this channel and go there. Offered only for a channel you're subscribed
// to: the feed defaults to scope "my", so filtering it to a channel you don't follow would just
// land you on an empty page.
const onShowInFeed = useShowChannelInFeed(channelId);
const [tab, setTab] = useState<"videos" | "about">("videos");
const panel = useTabPanel();
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
@@ -18,6 +18,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
useFeedFiltersActions: () => ({ setFilters: () => {} }),
}));
const feed = vi.hoisted(() => ({
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
@@ -75,9 +76,6 @@ function mount(preferences: Record<string, unknown> = {}) {
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
+4 -4
View File
@@ -41,7 +41,7 @@ const DATE_PRESETS: { days: number; key: string }[] = [
export default function Sidebar() {
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
const { layout, setLayout, onResetLayout, collapsed, onToggleCollapse } = useRail("feed");
const { t, i18n } = useTranslation();
const lang = i18n.language;
// The feed's saved views, handed to the shared views UI in this rail through the scope below.
@@ -83,8 +83,8 @@ export default function Sidebar() {
const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
// the panel layout key. `FilterRail` owns the customize `editing` state now.
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; picks
// the default group order below. `FilterRail` owns the customize `editing` state.
const source: MediaSourceKind = "feed";
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
@@ -227,7 +227,7 @@ export default function Sidebar() {
return (
<SavedViewsScope value={savedViews}>
<FilterRail
kind={source}
onResetLayout={onResetLayout}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
+3 -2
View File
@@ -1,5 +1,6 @@
// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide.
// The feed's public face: its stores (filters, saved views), mounted at the root and read app-wide,
// and the feed page's scroll key, which the shell's scroller restores by.
export { useFeedSavedViews } from "./feedSavedViews";
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
export { useFeedView, useFeedViewActions } from "./feedViewContext";
export { useFeedScrollKey } from "./useFeedPageHooks";
export { ChannelPage, feedModule } from "./module";
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
//
// The feed's two page hooks the shell used to hand-build: the channel page's "Show in feed" (adds the
// channel to the picked set and opens the feed) and the feed page's scroll key.
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
const h = vi.hoisted(() => ({
filters: { channelIds: [] as string[], q: "", show: "unwatched" } as Record<string, unknown>,
ytSearch: null as string | null,
setFilters: vi.fn(),
setPage: vi.fn(),
}));
vi.mock("../../features/navigation/navigationContext", () => ({
useNavigation: () => ({ ytSearch: h.ytSearch }),
useNavigationActions: () => ({ setPage: h.setPage }),
}));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => h.filters,
useFeedFiltersActions: () => ({ setFilters: h.setFilters }),
}));
import { useFeedScrollKey, useShowChannelInFeed } from "./useFeedPageHooks";
beforeEach(() => {
h.filters = { channelIds: ["a"], q: "", show: "unwatched" };
h.ytSearch = null;
h.setFilters.mockClear();
h.setPage.mockClear();
});
describe("Show in feed", () => {
it("adds the channel to the picked ones, keeps the other filters, and opens the feed", () => {
renderHook(() => useShowChannelInFeed("b")).result.current();
expect(h.setFilters).toHaveBeenCalledWith({
channelIds: ["a", "b"],
q: "",
show: "unwatched",
});
expect(h.setPage).toHaveBeenCalledWith("feed");
});
it("changes nothing in the set for a channel already picked", () => {
renderHook(() => useShowChannelInFeed("a")).result.current();
expect(h.setFilters.mock.calls[0]![0]).toMatchObject({ channelIds: ["a"] });
});
});
describe("the feed's scroll key", () => {
it("follows the filters but not the search text", () => {
const first = renderHook(() => useFeedScrollKey()).result.current;
h.filters = { ...h.filters, q: "typing" };
expect(renderHook(() => useFeedScrollKey()).result.current).toBe(first);
h.filters = { ...h.filters, show: "all" };
expect(renderHook(() => useFeedScrollKey()).result.current).not.toBe(first);
});
it("is the live search's own while it is open", () => {
h.ytSearch = "cats";
expect(renderHook(() => useFeedScrollKey()).result.current).toBe("yt:cats");
});
});
@@ -0,0 +1,30 @@
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
// "Show in feed" on a channel page: narrow the real feed to this channel and go there. The rest of
// the feed's filters stay — the point is "this channel, the way I normally read": unwatched, my tags,
// my date window. The channel is ADDED to the picked set, not swapped in: channels OR together, so
// arriving from a second channel page widens the feed instead of silently dropping the first; picking
// one already in the set changes nothing.
export function useShowChannelInFeed(channelId: string): () => void {
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const { setPage } = useNavigationActions();
return () => {
setFilters({ ...filters, channelIds: [...new Set([...filters.channelIds, channelId])] });
// setPage already leaves an open channel page and pushes a clean history entry. Calling
// closeChannel() first ran history.back(), whose popstate landed AFTER this and restored whatever
// page the channel had been opened from.
setPage("feed");
};
}
// The feed page's scroll restore/reset key (see PageScroller): the same feed filters return you to
// where you were; a changed filter is a new key, so the fresh result set starts at the top. `q` is
// normalised out so typing in the search box doesn't reset the scroll on every keystroke; the
// live-YouTube sub-view gets its own key.
export function useFeedScrollKey(): string {
const filters = useFeedFilters();
const { ytSearch } = useNavigation();
return ytSearch != null ? `yt:${ytSearch}` : `feed:${JSON.stringify({ ...filters, q: "" })}`;
}