From 8018cd4685f04fdf8998a17ffd16d818ef266d20 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 07:46:33 +0200 Subject: [PATCH] fix(ui): the platform layer speaks platform words; boxes scale with the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PriorityStepper labelled itself with channels.row.* and told every caller it orders the Feed; Combobox said plex.loading / plex.filter.moreRefine / sidebar.match on the feed sidebar. The generic strings moved to common.*, and what a priority MEANS arrives as the stepper's `hint` prop. uiI18n.contract.test.ts pins components/ui/ to the platform namespaces — the import boundary could never see an i18n key. - 32 px-frozen box utilities (w-[150px], max-w-[1600px], left-[18px], …) became spacing-scale steps (w-37.5, max-w-400, left-4.5): identical at the default text size, and now they grow with the Settings slider instead of clipping or re-wrapping their own labels. An eslint rule refuses the px form beside the older text-[Npx] one, pinned by pxBox.contract.test.ts; measured in the browser that every new step is generated at its old px. - chipLane's import-strip regex ran past a side-effect import into the next statement's `from`, hiding a lane between them; it now stops at the import's own `;`. --- frontend/eslint.config.js | 13 +++- frontend/src/components/AboutCell.tsx | 2 +- frontend/src/components/ChannelPage.tsx | 1 + frontend/src/components/ChannelTags.tsx | 2 +- frontend/src/components/GlassTuner.tsx | 2 +- frontend/src/components/Home.tsx | 5 +- frontend/src/components/PlaylistsRail.tsx | 4 +- frontend/src/components/PlexBrowse.tsx | 12 ++-- .../src/components/PlexCollectionView.tsx | 2 +- frontend/src/components/PlexDiscover.tsx | 2 +- frontend/src/components/PlexPlaylistView.tsx | 2 +- frontend/src/components/UpNextCard.tsx | 2 +- frontend/src/components/VideoInfoCard.tsx | 2 +- frontend/src/components/Welcome.tsx | 2 +- frontend/src/components/channelColumns.tsx | 1 + frontend/src/components/downloadRow.tsx | 2 +- frontend/src/components/playlistColumns.tsx | 2 +- frontend/src/components/plexViews.tsx | 10 +-- .../src/components/ui/Combobox.dom.test.tsx | 2 +- frontend/src/components/ui/Combobox.tsx | 8 +-- .../ui/PriorityStepper.dom.test.tsx | 70 +++++++++++++++---- .../src/components/ui/PriorityStepper.tsx | 18 +++-- frontend/src/components/ui/README.md | 12 ++-- frontend/src/components/ui/Tooltip.tsx | 2 +- frontend/src/components/ui/form.tsx | 2 +- .../src/components/ui/uiI18n.contract.test.ts | 44 ++++++++++++ frontend/src/i18n/locales/en/channels.json | 3 - frontend/src/i18n/locales/en/common.json | 9 ++- frontend/src/i18n/locales/en/plex.json | 3 +- frontend/src/i18n/locales/en/sidebar.json | 1 - frontend/src/i18n/locales/hu/channels.json | 3 - frontend/src/i18n/locales/hu/common.json | 9 ++- frontend/src/i18n/locales/hu/plex.json | 3 +- frontend/src/i18n/locales/hu/sidebar.json | 1 - frontend/src/lib/chipLane.contract.test.ts | 14 +++- frontend/src/lib/pxBox.contract.test.ts | 26 +++++++ 36 files changed, 223 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/ui/uiI18n.contract.test.ts create mode 100644 frontend/src/lib/pxBox.contract.test.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 9d7169a5..49ccb65b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -63,6 +63,12 @@ const jsxCommentText = { }; const localRules = { rules: { "jsx-comment-text": jsxCommentText } }; +// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string. +const BOX_PX = + "/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[\\d+px\\]/"; +const BOX_PX_MESSAGE = + "px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx]."; + export default tseslint.config( { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { @@ -107,7 +113,7 @@ export default tseslint.config( // R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT scale // with the Settings --font-scale slider while its rem neighbours do; use `text-2xs`/`text-3xs` // or a `text-[Nrem]` arbitrary instead. Matches both plain strings and template-literal chunks; - // rem arbitraries (text-[0.75rem]) don't match (no "px"). Font-size `text-` only — not w-/h-/p-. + // rem arbitraries (text-[0.75rem]) don't match (no "px"). "no-restricted-syntax": [ "error", { @@ -120,6 +126,11 @@ export default tseslint.config( message: "px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).", }, + // …and the same for a BOX: a `w-[150px]` stays 150px while the text inside it grows with the + // slider, so the label re-wraps or clips at large sizes. Use the spacing scale (`w-37.5` is + // 150px at the default size) — 32 of these were swept in the 2026-09 debt burn. + { selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE }, + { selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE }, ], }, }, diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index 4ef722ef..2ceebd28 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -56,7 +56,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch bumpPriority(channelId, d)} onSet={(v) => setPriority(channelId, v)} size="md" + hint={t("channels.row.priorityHint")} /> )} diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 1d5de0fc..87a69fd4 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -218,7 +218,7 @@ function TagEditorMenu({ }; return (
-
+
{userTags.map((tg) => { const on = attached.has(tg.id); return ( diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 0a132b59..3b80dccd 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -284,7 +284,7 @@ export default function GlassTuner() { {open && (
◐ Glass Tuner diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index a3a3717a..4e96f003 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -65,7 +65,8 @@ const PlayerModal = lazy(() => import("./PlayerModal")); // A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at // 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would -// drift from it. +// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value, +// see ui/README): the card also takes it as its MEASURED width, which must be real pixels. const YT_SLOT = 200; // How many already-offered cards the reroll remembers. Matched to the server's own cap @@ -182,7 +183,7 @@ function Rail({ {cards.map((c, i) => (
{c.video ? ( diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index cf8ad09f..e32a0dc6 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -313,7 +313,7 @@ export default function PlaylistsRail({ // ~12px the moment you start renaming, and every row below it moves under the // pointer.
-
+
{pl.cover_thumbnail && ( )} @@ -357,7 +357,7 @@ export default function PlaylistsRail({ : "border-transparent hover:bg-card/60" }`} > -
+
{pl.cover_thumbnail && ( )} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index b0cb4f17..2699b982 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -469,7 +469,7 @@ export default function PlexBrowse() { if (sub.view.kind === "discover") { return ( -
+
+
sub.open({ kind: "playlist", id })} />
); } if (sub.view.kind === "collections") { return ( -
+
sub.open({ kind: "collection", id })} />
); } return ( -
+
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put while only the poster grid scrolls. */} -
+
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail), @@ -557,7 +557,7 @@ export default function PlexBrowse() {
)}
-
+
{p.name}
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 24e38ca1..5ec60c1b 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -246,7 +246,7 @@ export default function PlexCollectionView({ ].filter(Boolean) as string[]; return ( -
+
{q.isError && !q.data ? ( diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index b5ef31af..00a87b4d 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -64,7 +64,7 @@ function CardRail({ keyboardNav > {cards.map((c, i) => ( -
+
+
diff --git a/frontend/src/components/ui/uiI18n.contract.test.ts b/frontend/src/components/ui/uiI18n.contract.test.ts new file mode 100644 index 00000000..1ae6df6a --- /dev/null +++ b/frontend/src/components/ui/uiI18n.contract.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +// The platform layer (`components/ui/`) may not import a feature component or a domain API slice — +// eslint's `no-restricted-imports` enforces that. It cannot see an i18n KEY, though, and two +// primitives spoke domain vocabulary through one: `PriorityStepper` labelled itself with +// `channels.row.*` (and told every caller that it orders the Feed), `Combobox` said `plex.loading` +// and `plex.filter.moreRefine` on the feed sidebar. A primitive's strings live in a platform +// namespace; what a value MEANS where it is used arrives as a prop. +const PLATFORM_NAMESPACES = new Set([ + "common", + "errors", + "datatable", + "keys", + "palette", + "sidebar", + "notifications", + "time", +]); + +const files = import.meta.glob("./**/*.{ts,tsx}", { + query: "?raw", + import: "default", + eager: true, +}); +// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot. +const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g; + +describe("components/ui/ speaks only platform i18n namespaces", () => { + it("names no feature namespace", () => { + const sources = Object.entries(files).filter(([p]) => !/\.test\.tsx?$/.test(p)); + expect(sources.length).toBeGreaterThan(20); // the glob matched the layer + const offenders = sources.flatMap(([p, src]) => + [...String(src).matchAll(KEY)] + .map((m) => m[1]!) + .filter((ns) => !PLATFORM_NAMESPACES.has(ns)) + .map((ns) => `${p}: ${ns}.*`), + ); + expect(offenders).toEqual([]); + }); + + it("sees a feature namespace when one is used", () => { + expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); + }); +}); diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 7961ee69..4f88acf4 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -108,9 +108,6 @@ "editTags": "Edit tags", "filterFeedByTag": "Filter the Feed by “{{name}}”", "priorityHint": "Your ranking for this channel. Sort the Feed by “Channel priority” to bring higher-priority channels to the top.", - "raisePriority": "Raise priority", - "lowerPriority": "Lower priority", - "editPriority": "Click to type a priority (negatives allowed)", "fullHint": "Full history fetched.", "fullySynced": "fully synced", "fullySyncedHint": "Recent uploads and the full back-catalog are both in.", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 2b1a652d..05bee624 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -27,5 +27,12 @@ "scrollRight": "Scroll right", "search": "Search", "remove": "Remove", - "noResults": "No matches" + "noResults": "No matches", + "priority": { + "raise": "Raise priority", + "lower": "Lower priority", + "edit": "Click to type a priority (negatives allowed)" + }, + "moreRefine": "{{count}} more — refine with search", + "matchMode": "Match" } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 3a02f8e3..97650499 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -111,8 +111,7 @@ "remux": "Direct Stream", "transcode_hw": "Transcode · HW", "transcode_sw": "Transcode · SW" - }, - "moreRefine": "{{count}} more — refine with search" + } }, "count": "{{count}} titles", "searchCount": "{{count}} matches", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index b524d0c5..733ded12 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -20,7 +20,6 @@ "any": "Any", "all": "All", "tagModeTooltip": "Match any vs all selected tags", - "match": "Match", "custom": "Custom", "from": "From", "to": "To", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 531d1d40..cae6384c 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -108,9 +108,6 @@ "editTags": "Címkék szerkesztése", "filterFeedByTag": "Feed szűrése: „{{name}}”", "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", - "raisePriority": "Prioritás növelése", - "lowerPriority": "Prioritás csökkentése", - "editPriority": "Kattints a prioritás beírásához (negatív is lehet)", "fullHint": "Teljes előzmény letöltve.", "fullySynced": "teljesen szinkronban", "fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 32f80ee7..c4bcb664 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -27,5 +27,12 @@ "scrollRight": "Görgetés jobbra", "search": "Keresés", "remove": "Eltávolítás", - "noResults": "Nincs találat" + "noResults": "Nincs találat", + "priority": { + "raise": "Prioritás növelése", + "lower": "Prioritás csökkentése", + "edit": "Kattints a prioritás beírásához (negatív is lehet)" + }, + "moreRefine": "még {{count}} — finomíts kereséssel", + "matchMode": "Egyezés" } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index b2c04c14..50569bb3 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -111,8 +111,7 @@ "remux": "Közvetlen streamelés", "transcode_hw": "Átkódolás · HW", "transcode_sw": "Átkódolás · SW" - }, - "moreRefine": "még {{count}} — finomíts kereséssel" + } }, "count": "{{count}} cím", "searchCount": "{{count}} találat", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 622df4ef..ecb78cf5 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -20,7 +20,6 @@ "any": "Bármelyik", "all": "Összes", "tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen", - "match": "Egyezés", "custom": "Egyéni", "from": "Ettől", "to": "Eddig", diff --git a/frontend/src/lib/chipLane.contract.test.ts b/frontend/src/lib/chipLane.contract.test.ts index 4d9c4ee3..544f7e8d 100644 --- a/frontend/src/lib/chipLane.contract.test.ts +++ b/frontend/src/lib/chipLane.contract.test.ts @@ -30,7 +30,10 @@ function count(haystack: string, needle: string): number { * lane in a file that already had a compliant one still passed, which is the exact hole this test * was rewritten to close (E10 review round 2, caught by mutation). */ function laneCount(src: string): number { - return count(src.replace(/^\s*import[\s\S]*?from\s+"[^"]*";$/gm, ""), "chipLaneClass"); + // `[^;]` rather than `[\s\S]`: an import is stripped up to ITS OWN `from "…";`. The lazy any-char + // form ran past a side-effect import (`import "./x.css";` has no `from`) into the next statement's + // `from`, swallowing whatever lay between — a lane there would be uncounted. + return count(src.replace(/^\s*import\s[^;]*?\sfrom\s+"[^"]*";$/gm, ""), "chipLaneClass"); } /** Every source file under src/, not just src/components/ — App.tsx and main.tsx sit above it. */ @@ -55,6 +58,15 @@ function withMirror(): Array<[string, string]> { } describe("chip lane containment contract", () => { + it("strips imports without swallowing what follows a side-effect import", () => { + const src = [ + 'import "./x.css";', + "const lane = chipLaneClass;", + 'import { chipLaneClass } from "./chipLane";', + ].join("\n"); + expect(laneCount(src)).toBe(1); + }); + it("keeps all three halves of the lane class — offsetParent, clip, and room for the focus ring", () => { expect(chipLaneClass.split(/\s+/)).toEqual( expect.arrayContaining(["relative", "overflow-clip", "[overflow-clip-margin:0.25rem]"]), diff --git a/frontend/src/lib/pxBox.contract.test.ts b/frontend/src/lib/pxBox.contract.test.ts new file mode 100644 index 00000000..d8421751 --- /dev/null +++ b/frontend/src/lib/pxBox.contract.test.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from "node:url"; + +import { ESLint } from "eslint"; +import { describe, expect, it } from "vitest"; + +// The px-frozen BOX rule in eslint.config.js (beside the older `text-[Npx]` one). A `no-restricted- +// syntax` selector that matches nothing looks exactly like a clean codebase, so pin that it fires. +const FRONTEND = fileURLToPath(new URL("../../", import.meta.url)); + +async function refused(source: string): Promise { + const eslint = new ESLint({ cwd: FRONTEND }); + const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" }); + return (result?.messages ?? []).filter((m) => m.message.startsWith("px-frozen box size")).length; +} + +describe("a px-frozen box utility", () => { + it("is refused in a string and in a template literal", async () => { + expect(await refused('export const a = "group w-[150px] shrink-0";\n')).toBe(1); + expect(await refused("export const b = `x ${1} max-w-[240px]`;\n")).toBe(1); + }); + + it("leaves the spacing scale, a rem arbitrary and a grid track alone", async () => { + expect(await refused('export const c = "w-37.5 max-w-60 text-[0.75rem]";\n')).toBe(0); + expect(await refused('export const d = "grid-cols-[1fr_240px]";\n')).toBe(0); + }); +});