refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { createStore } from "./store";
// Downloading a link is an action you start anywhere: the Files page's link box, a link pasted onto
// any page, the command palette. They all hand the link to ONE app-wide download dialog
// (components/DownloadIntake), so none of them mounts a dialog of its own.
// (features/downloads/DownloadIntake), so none of them mounts a dialog of its own.
const pending = createStore<string | null>(null);
+1 -1
View File
@@ -26,7 +26,7 @@ describe("a raw button that can be disabled", () => {
it("is refused inside a control primitive and in a test too", async () => {
expect(await hits(DISABLED, "src/components/ui/Button.tsx")).toBe(1);
// an existing test file: the project service admits only lintProbe.testkit's probe names
expect(await hits(DISABLED, "src/components/Feed.countStale.dom.test.tsx")).toBe(1);
expect(await hits(DISABLED, "src/modules/feed/Feed.countStale.dom.test.tsx")).toBe(1);
});
it("leaves a raw button with no disabled, and the two native-disabled owners, alone", async () => {
+2 -2
View File
@@ -19,8 +19,8 @@ function sources(): Array<[string, string]> {
// Pages that render OUTSIDE the app shell own their h1 (no shell, no hidden page name).
const STANDALONE = new Set([
"components/Welcome.tsx",
"components/WatchPage.tsx",
"shell/Welcome.tsx",
"watch/WatchPage.tsx",
"components/legal/LegalLayout.tsx",
"design-lab/DesignLab.tsx",
// The shell's hidden h1 and the primitive itself.
@@ -58,6 +58,14 @@ describe("the language in a component", () => {
expect(await hits(next)).toBe(1);
});
it("is refused in every folder that holds components, not just components/", async () => {
// The rule once matched `/src/components/` only; when the feature code moved out into the shell,
// the modules and the features it silently stopped applying to all of them.
const src = 'import i18n from "../i18n";\nexport const L = () => <b>{i18n.language}</b>;\n';
for (const dir of ["shell", "modules", "features", "watch"])
expect(await hits(src, `src/${dir}/__probe.tsx`), dir).toBe(1);
});
it("is taken from useTranslation, and the singleton stays usable outside components", async () => {
const hook =
'import { useTranslation } from "react-i18next";\n' +
@@ -28,7 +28,7 @@ function offenders(): { hits: string[]; pushes: number } {
const hits: string[] = [];
let pushes = 0;
for (const [path, code] of Object.entries(files)) {
// Glob keys are relative to this file: `./x.ts` is lib, `../components/x.tsx` is a sibling folder.
// Glob keys are relative to this file: `./x.ts` is lib, `../modules/feed/x.tsx` a sibling folder.
const rel = path.startsWith("./") ? `lib/${path.slice(2)}` : path.replace(/^\.\.\//, "");
const lines = code.split("\n");
lines.forEach((line, i) => {
+2 -2
View File
@@ -39,8 +39,8 @@ it("index.css draws an inset item's ring inside, after the outside rule", () =>
const src = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8");
// The history rows (runs, admin actions) and the log lines share one frame, HistoryRow.
it.each([
["../components/HistoryRow.tsx", /navItemAttrs\(navIndex, [^)]*\{ inset: true \}\)/],
["../components/LogLine.tsx", /<HistoryRow\s/],
["../features/diagnostics/HistoryRow.tsx", /navItemAttrs\(navIndex, [^)]*\{ inset: true \}\)/],
["../features/diagnostics/LogLine.tsx", /<HistoryRow\s/],
])("%s asks for the inside ring", (file, re) => {
expect(src(file)).toMatch(re);
});
@@ -5,7 +5,7 @@ import { readSources } from "./rawSources.testkit";
// settles. A read-ahead that only checks `isFetchingNextPage` calls during a background
// refetch, joins it through NEXT_PAGE and loads no page: the grid stalls at its loaded end until the
// reader scrolls the range. The jsdom virtualizer measures nothing, so the wiring is pinned in source.
const files = readSources(__dirname, ["../components/**/*.tsx", "!../components/**/*.test.tsx"]);
const files = readSources(__dirname, ["../**/*.tsx", "!../**/*.test.tsx", "!../design-lab/**"]);
const source = (name: string) => {
const hit = Object.entries(files).find(([path]) => path.endsWith(`/${name}`));
if (!hit) throw new Error(`no ${name}`);
@@ -43,11 +43,11 @@ describe("an infinite list's read-ahead", () => {
// NEXT_PAGE it would join the refetch and load nothing. NEXT_PAGE is only for the read-aheads, so a
// click site that goes back to it fails here (the YouTube "Load more" was once missed).
describe("a click that loads the next page", () => {
const READ_AHEADS = new Set(["ui/VirtualGrid.tsx", "PlexBrowse.tsx"]);
const READ_AHEADS = new Set(["components/ui/VirtualGrid.tsx", "modules/plex/PlexBrowse.tsx"]);
it("uses nextPageOnClick; NEXT_PAGE only in the read-aheads", () => {
const users = Object.entries(files)
.filter(([, src]) => /fetchNextPage\(NEXT_PAGE\)/.test(src as string))
.map(([path]) => path.replace("../components/", ""));
.map(([path]) => path.replace(/^\.\.\//, ""));
expect(users.sort()).toEqual([...READ_AHEADS].sort());
for (const [file, sites] of [
["AdminLogs.tsx", 2],
+1 -1
View File
@@ -3,7 +3,7 @@
// wraps the list at its ends ("all"), or neither ("off"). Both apply to any queued player (feed or
// playlist).
//
// Here rather than in `components/playerModalControls` because `lib/playerModalHooks` reads them
// Here rather than in `features/player/playerModalControls` because `lib/playerModalHooks` reads them
// too, and `lib/` may not import from `components/` (lint-enforced, see eslint.config.js).
export type AutoMode = "off" | "next" | "prev" | "random";
export type LoopMode = "off" | "one" | "all";
+5 -3
View File
@@ -53,9 +53,11 @@ it("names each backdrop cut by the width the proxy cuts it at", () => {
// among the attributes and whatever its variable is called: the first cut of this test read only a
// leading `src={…thumb…}` and missed two of the three sites it was written for.
const plexFiles = readSources(__dirname, [
"../components/{Plex,plex}*.tsx",
"../components/{Home,PosterTile,UpNextCard}.tsx",
"!../components/**/*.test.tsx",
"../modules/plex/{Plex,plex}*.tsx",
"../features/plexCards/{Plex,plex}*.tsx",
"../modules/home/Home.tsx",
"../modules/plex/{PosterTile,UpNextCard}.tsx",
"!../**/*.test.tsx",
]);
const ALLOWED = new Set([
"plexCards.tsx pic.src", // cardImage's own src (srcSet/sizes beside it): the card cut
+4 -3
View File
@@ -24,9 +24,10 @@ describe("a subscription change", () => {
});
it("is refreshed through that one function wherever it is made", () => {
const root = new URL("../components/", import.meta.url);
const sites = readdirSync(root)
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
const root = new URL("../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.split("\\").join("/"))
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab/"))
.map((f) => [f, readFileSync(new URL(f, root), "utf8")] as const)
.filter(([, src]) => /api\.(un)?subscribeChannel\(/.test(src));
expect(sites.length).toBeGreaterThanOrEqual(3);
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
//
// A saved view or the stored feed filter can outlive its tag. The feed then matches nothing,
// so the chip must say the tag is gone, not read as a bare id; while the tag list loads it is a
// placeholder, and if the list fails it falls back to the id rather than claiming "deleted".
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
import { api, type FeedFilters, type Tag } from "./api";
import { clearedFilters, useActiveFilters } from "./useActiveFilters";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const filters: FeedFilters = { ...clearedFilters({ q: "" } as FeedFilters), tags: [1, 99] };
let qc: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: qc }, children);
const chips = () => renderHook(() => useActiveFilters(filters, () => {}), { wrapper }).result;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("a tag filter's chip names", () => {
it("name a live tag, and say a deleted one is gone", async () => {
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Music", category: "topic" }] as Tag[]);
const result = chips();
const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!;
expect(chip(99).pending).toBe(true);
await waitFor(() => expect(chip(1)).toMatchObject({ label: "Music", pending: false }));
expect(chip(99)).toMatchObject({ label: "common.deletedTag", pending: false });
});
it("fall back to the id when the tag list fails, never claiming the tag was deleted", async () => {
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
const result = chips();
const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!;
await waitFor(() => expect(chip(99).pending).toBe(false));
expect(chip(99).label).toBe("99");
});
});
+3 -3
View File
@@ -108,7 +108,7 @@ describe("the media colours", () => {
);
expect(native).not.toMatch(/\baccent-accent\b/);
expect(native.match(/\baccent-chosen\b/g)).toHaveLength(2);
const plex = readFileSync(new URL("../components/PlexPlayer.tsx", import.meta.url), "utf8");
const plex = readFileSync(new URL("../modules/plex/PlexPlayer.tsx", import.meta.url), "utf8");
// No accent fill at all on the Plex player: its progress bar's played part is white and its head
// the media amber — a marked place, like the intro / credits markers and the clip's playhead.
expect(plex).not.toMatch(/\bbg-accent\b/);
@@ -117,7 +117,7 @@ describe("the media colours", () => {
// The player's own components are chrome throughout: nothing chosen there is the accent's text
// (a light theme's accent is dark on it — Slate 2.84:1).
for (const f of ["PlexPlayer.tsx", "PlexPlayerSettings.tsx", "UpNextCard.tsx"]) {
const src = readFileSync(new URL(`../components/${f}`, import.meta.url), "utf8");
const src = readFileSync(new URL(`../modules/plex/${f}`, import.meta.url), "utf8");
expect(src, f).not.toMatch(/\btext-accent\b/);
}
// A class string that paints the chrome carries the media marks (media-scope re-points them
@@ -183,7 +183,7 @@ it("a tonal fill under text is an opaque tint", () => {
const sites = [
"components/ui/Badge.tsx",
"components/ui/ItemCount.tsx",
"components/FilesRail.tsx",
"modules/files/FilesRail.tsx",
];
const offenders = sites.flatMap((f) => {
const src = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");