fix(e10): review round 1 — the boundary that did not bind, and 14 more

The epic-end review found 32 verified issues. This is the first fix round:
the three highs, the user-facing mediums, and the tests that were passing
for the wrong reason. Docs and the remaining low-severity tail are next.

The boundary rule was the worst of them. Both `no-restricted-imports`
regexes were written in plain "..." literals, where `\.` is an unknown
escape that collapses to the any-char `.` — so the rule read like a
boundary and matched far less than one. `../../lib/api` (the barrel every
feature component imports) and `../../components/Feed` (the same file
`../Feed` names) both sailed through a green gate. String.raw, a third
pattern for the second spelling, and a vitest over ESLint.lintText so a
rule that silently matches nothing can never look like a clean codebase
again.

closeThenNavigate waited for exactly one reclaim popstate. With a second
Back-closable layer still mounted — a palette opened over the player —
the navigation unmounted that layer too, and its reconcile backed over
the entry the navigation had just pushed: "go to Channels" closed the
player and landed on the feed. It now waits for the LAST programmatic
back, then adopts the entries the navigation buried, and falls back after
250ms rather than hanging if no popstate is ever coming.

The rail's inline rename died on arrival. With no domReference, floating-
ui's returnFocus falls through to a module-global stack and restores some
unrelated element from an earlier popover — blurring the field into its
own commit-and-close, from the second popover of a session onward. The
context menu now owns the restore: back to the row, but only if the row
is still there and nothing else has claimed focus.

Also:
- Backspace under the cheat sheet stopped Plex playback and dropped to
  the grid; `back` now defers to any layer above, like `escape` beside it.
- A move invalidated only the source list, so the target kept serving
  pre-move contents for 30s while the rail already counted N+1.
- Every move error a user can reach is a 404, which `req` leaves to the
  caller — so the row snapped back in silence and the shipped moveErrors
  strings were unreachable.
- swallowClick armed on long-press but was disarmed only by the click it
  was armed for; any press that produced no click ate the next tap on the
  whole list.
- The context menu stayed parked over whatever row scrolled under it.
- overflow-clip on the chip lanes ate the focus ring's top and bottom.
- "Why is this off" rode on disabled rows, which show no tooltip and take
  no focus: a new `inert` item keeps the reason readable.
- Shift+F10 was advertised app-wide but wired only on Playlists; it has
  its own scope now, and useContextMenu matches through the shared parser
  instead of its own literals.
- Mod+K could not close the palette, `h` machine-gunned on auto-repeat,
  and both were bound above the branch that mounts their overlays.
- Palette options were in the tab order under aria-activedescendant, and
  a scroll-synthesised mousemove stole the keyboard selection.
- The OSD live region was created together with its own text.
- Ending a rename stranded focus on <body>.
- The Button variants' hover halves are `enabled:` — the one migrated
  site that had guarded its own lost the guard on the way in.
- HU builtinLocked named the list "Watch later" in a Hungarian sentence.

Tests: the chip-lane contract counted per FILE, so a second hand-rolled
lane in an already-compliant file was invisible; the closeThenNavigate
ordering test only passed on a popstate left behind by its neighbours and
failed alone, so it moved to its own file.
This commit is contained in:
2026-09-05 23:52:29 +02:00
parent 7d302f71b7
commit 20e17c0ccb
23 changed files with 562 additions and 89 deletions
+35 -13
View File
@@ -11,38 +11,60 @@ import { chipLaneClass } from "./useChipOverflowFit";
// contains it — so the invariant worth locking is not "this lane looks right" but "every mirror lives
// inside a lane that clips". A fifth lane hand-rolling `relative` is exactly the drift this catches,
// and no DOM test can: jsdom has no layout, so the overflow it guards against is unobservable there.
//
// COUNTED per occurrence, not tested per file. The first cut asked `src.includes(chipLaneClass)`, which
// a file could satisfy with ONE compliant lane while hand-rolling a second — and ChannelTags already
// holds two lanes and two mirrors, so the likeliest place for a fifth lane was the one file the check
// was blind inside (E10 review round 1).
const MIRROR = "invisible absolute left-0 top-0";
// A mirror only exists to be measured, so the hook is the other half of the signature. Discovering by
// BOTH means a mirror written with the utilities in another order is still caught by its import.
const HOOK = "useChipOverflowFit";
function componentSources(): Array<[string, string]> {
const dir = new URL("../components/", import.meta.url);
function count(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
/** Every source file under src/, not just src/components/ — App.tsx and main.tsx sit above it. */
function sources(): Array<[string, string]> {
const walk = (u: URL, prefix: string): Array<[string, string]> =>
readdirSync(u, { withFileTypes: true }).flatMap((e) => {
if (e.isDirectory()) return walk(new URL(`${e.name}/`, u), `${prefix}${e.name}/`);
if (!e.name.endsWith(".tsx") || e.name.includes(".test.")) return [];
if (!/\.tsx?$/.test(e.name) || e.name.includes(".test.")) return [];
return [
[`${prefix}${e.name}`, readFileSync(new URL(e.name, u), "utf-8")] as [string, string],
];
});
return walk(dir, "");
return walk(new URL("../", import.meta.url), "");
}
/** Files that render a measurer, by either half of its signature. */
function withMirror(): Array<[string, string]> {
return sources().filter(
([name, src]) =>
!name.startsWith("lib/useChipOverflowFit") && (src.includes(MIRROR) || src.includes(HOOK)),
);
}
describe("chip lane containment contract", () => {
it("keeps both halves of the lane class — the offsetParent AND the clip", () => {
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"]),
expect.arrayContaining(["relative", "overflow-clip", "[overflow-clip-margin:0.25rem]"]),
);
});
it("every component rendering a hidden chip measurer uses chipLaneClass for its lane", () => {
const offenders = componentSources()
.filter(([, src]) => src.includes(MIRROR))
.filter(([, src]) => !src.includes("chipLaneClass"))
.map(([name]) => name);
it("gives every hidden chip measurer its own chipLaneClass lane", () => {
const offenders = withMirror()
.map(([name, src]) => ({
name,
mirrors: count(src, MIRROR),
lanes: count(src, "chipLaneClass"),
}))
.filter((f) => f.lanes < f.mirrors);
expect(offenders).toEqual([]);
});
it("finds the measurers it claims to guard (the marker has not been renamed away)", () => {
const withMirror = componentSources().filter(([, src]) => src.includes(MIRROR));
expect(withMirror.length).toBeGreaterThanOrEqual(3);
expect(withMirror().length).toBeGreaterThanOrEqual(3);
});
});
+51
View File
@@ -111,4 +111,55 @@ describe("closeThenNavigate", () => {
window.dispatchEvent(new PopStateEvent("popstate"));
expect(navigate).toHaveBeenCalledTimes(1);
});
// E10 review round 1. Waiting for ONE reclaim is not enough when a SECOND Back-closable layer is
// still mounted and the navigation is what unmounts it: that layer's own reconcile fired another
// history.back(), which popped the entry the navigation had just pushed and bounced the user back
// where they started (palette opened over the player → "go to Channels" landed on the feed).
//
// NOTE this one may NOT use the raw-popstate shortcut the rest of the file uses: a synthetic
// popstate is read as a GENUINE Back, which pops the lower layer off the stack and decrements
// historyDepth by itself — so the test would pass with or without the fix. The bug only appears
// on the programmatic path, so back() is stubbed to land its own popstate instead.
it("adopts the entries of layers the navigation itself unmounts", async () => {
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
});
try {
const player = renderHook(() => useBackToClose(() => {}));
await settle();
const palette = renderHook(() => useBackToClose(() => {}));
await settle();
expect(back).not.toHaveBeenCalled(); // both layers hold an entry; nothing reclaimed yet
const navigate = vi.fn(() => window.history.pushState({ sfPage: "channels" }, ""));
await act(async () => {
closeThenNavigate(() => palette.unmount(), navigate);
await Promise.resolve(); // let the palette's own reclaim reconcile run
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(back).toHaveBeenCalledTimes(1); // the palette's entry, and only that
player.unmount(); // …which is what the navigation does to the layer below
await settle();
expect(back).toHaveBeenCalledTimes(1); // must NOT back over the entry navigate just pushed
} finally {
back.mockRestore();
}
});
// Without this the command is not late, it never happens: an overlay closed in the same tick it
// opened never pushed an entry, so no reclaim popstate is ever coming.
it("navigates anyway when no reclaim popstate ever arrives", () => {
vi.useFakeTimers();
try {
const navigate = vi.fn();
closeThenNavigate(() => {}, navigate);
expect(navigate).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(navigate).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
+26 -3
View File
@@ -200,11 +200,34 @@ function ensureOverlayListener(): void {
* Only call this from INSIDE an overlay that uses `useBackToClose`: the popstate we wait for is
* that overlay's own entry being reclaimed, and without one it never arrives. */
export function closeThenNavigate(close: () => void, navigate: () => void): void {
const once = () => {
window.removeEventListener("popstate", once);
let done = false;
// Armed BEFORE the close, so it is a plain const and there is no declaration order to reason about.
// Degrades honestly if no popstate is coming at all — an overlay closed in the same tick it opened
// never pushed an entry, so there is nothing to reclaim and the command would hang forever.
// Navigating a beat late beats a control that silently does nothing.
const fallback = setTimeout(() => run(), 250);
const run = () => {
if (done) return;
done = true;
window.removeEventListener("popstate", onPop);
clearTimeout(fallback);
navigate();
// Any overlay entry still counted in `historyDepth` is now BURIED under the entry the navigation
// just pushed, so a later unmount must NOT try to reclaim it with history.back() — that pops the
// navigation instead and silently bounces the user back where they started. Found with a palette
// opened over the player (E10 review round 1): closing the palette landed its own popstate, the
// navigation then unmounted the player too, and the player's reconcile ate the new page entry.
// Adopting the entries (rather than backing over them) leaves Back meaning "return to where I
// was", which is what a navigation should mean anyway.
historyDepth = 0;
};
window.addEventListener("popstate", once);
const onPop = () => {
// The same reconcile can issue MORE than one back(); a pushState made between them would be
// popped by the next. Wait for the last one to land.
if (pendingProgrammatic > 0) return;
run();
};
window.addEventListener("popstate", onPop);
close();
}
+39 -8
View File
@@ -19,7 +19,7 @@ import { createStore } from "./store";
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
* is mounted. The help sheet shows `global` plus the topmost registered scope. */
export type KeyScope = "global" | "undo" | "ytPlayer" | "plexPlayer" | "plexBrowse";
export type KeyScope = "global" | "undo" | "contextMenu" | "ytPlayer" | "plexPlayer" | "plexBrowse";
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
@@ -51,13 +51,18 @@ export const SHORTCUTS = [
// --- global ---------------------------------------------------------------------------------
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
// have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning.
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette" },
// `allowInInput` because the palette AUTOFOCUSES its search field: without it the typing guard ate
// the second Mod+K, so the toggle only ever fired one way and Chrome took the chord instead
// (E10 review round 1). Its handler preventDefaults, so nothing leaks to the browser either way.
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
// Matched by `useContextMenu` on the focused row, not by a window-level dispatch: which row the
// menu belongs to is a property of focus, not of the app.
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
// — where nothing answers the key (E10 review round 1). Same honest-states rule `plexBrowse` obeys.
{
id: "contextMenu",
scope: "global",
scope: "contextMenu",
keys: ["Shift+F10", "ContextMenu"],
labelKey: "keys.contextMenu",
handledLocally: true,
@@ -183,6 +188,16 @@ export function matches(e: KeyboardEvent, chord: string): boolean {
* whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped
* type over the scope's ids, so the table and the implementation cannot drift without tsc saying
* so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */
/** Test an event against ONE row's chords, by id. The escape hatch for a `handledLocally` row: it is
* matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window),
* but it must still go through `matches` or the table's `keys` become decoration. They had: the
* context menu hand-rolled `e.key === "F10" && e.shiftKey`, so Ctrl+Shift+F10 opened it too and
* editing the row changed the help sheet and nothing else (E10 review round 1). */
export function matchesShortcut(e: KeyboardEvent, id: string): boolean {
const row = ROWS.find((s) => s.id === id);
return !!row?.keys.some((k) => matches(e, k));
}
export function dispatch<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
@@ -252,10 +267,26 @@ export const usePaletteOpen = (): boolean => paletteOpen.use();
/** Bind the `global` scope, once, for the whole app. It lives here rather than inside either
* overlay because both are opened by it: a listener that sat in one of them would have to route a
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
* calls this; the overlays are pure renderers of their store. */
export function useGlobalKeymap(): void {
* calls this; the overlays are pure renderers of their store.
*
* `enabled` must track where those overlays are actually MOUNTED. Bound unconditionally it swallowed
* `h` and Mod+K on the loading/Welcome/setup screens — the browser shortcut was preventDefault'ed,
* nothing appeared, and the flipped module-level store then popped the overlay open by itself the
* moment the signed-in shell mounted (E10 review round 1). */
export function useGlobalKeymap(enabled = true): void {
useEffect(() => {
if (!enabled) {
// Leaving a stale `true` behind would spring the overlay open on the next mount.
helpOpen.set(false);
paletteOpen.set(false);
return;
}
const onKey = (e: KeyboardEvent) => {
// Both global keys TOGGLE an overlay, and each overlay mount/unmount costs a real history
// entry (useBackToClose). On auto-repeat that is ~30 push/back pairs a second, which Chrome
// starts dropping while our own depth bookkeeping keeps counting them — a desynced stack whose
// next browser Back walks out of the app. Holding a key is not a second press.
if (e.repeat) return;
dispatch(e, "global", {
help: (ev) => {
ev.preventDefault();
@@ -270,7 +301,7 @@ export function useGlobalKeymap(): void {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}, [enabled]);
}
/** Answers for the `when` conditions, published by the surface that knows them. */
@@ -0,0 +1,63 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// E10 S1 declared `components/ui/` a platform layer and put the rule that makes it one — no feature
// components, no domain API slices — into eslint.config.js rather than prose. This asserts the rule
// actually FIRES, because a `no-restricted-imports` pattern that matches nothing is indistinguishable
// from a clean codebase: the first cut wrote its regexes in plain "..." literals, where `\.` collapses
// to the any-char `.`, and it silently let through both `../../lib/api` (the barrel every feature
// component imports) and `../../components/Feed` (the same file `../Feed` names). Eight green lanes
// said nothing (E10 review round 1). A rule stated in a comment must also be enforced in code — and a
// rule meant to enforce something must itself be tested.
const BOUNDARY = "no-restricted-imports";
// Linted as if it were this file, so eslint.config.js's `files: ["src/components/ui/**"]` block applies.
const AS_UI = "src/components/ui/__boundary_probe.tsx";
// fileURLToPath, not URL.pathname: on Windows the latter yields "/D:/…", which eslint cannot resolve
// a config from. Anchored to this file rather than process.cwd() so the test holds wherever it is run.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function restrictedIn(source: string, filePath: string): Promise<string[]> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === BOUNDARY).map((m) => m.message);
}
describe("components/ui/ boundary rule", () => {
it("refuses a feature component by BOTH of its spellings", async () => {
const found = await restrictedIn(
`import A from "../Sidebar";\nimport B from "../../components/Feed";\nexport default [A, B];\n`,
AS_UI,
);
expect(found).toHaveLength(2);
expect(found.every((m) => m.includes("may not import a feature component"))).toBe(true);
});
it("refuses a domain API slice AND the barrel that re-exports every slice", async () => {
const found = await restrictedIn(
`import { api } from "../../lib/api";\nimport * as p from "../../lib/api/playlists";\nexport default [api, p];\n`,
AS_UI,
);
expect(found).toHaveLength(2);
expect(found.every((m) => m.includes("may not call a domain API slice"))).toBe(true);
});
it("allows what the layer is FOR — sibling primitives and generic lib helpers", async () => {
const found = await restrictedIn(
`import B from "./Button";\nimport M from "../../components/ui/Modal";\nimport { qk } from "../../lib/queryKeys";\nexport default [B, M, qk];\n`,
AS_UI,
);
expect(found).toEqual([]);
});
it("binds only inside ui/ — a feature component may still import its neighbours", async () => {
const found = await restrictedIn(
`import { api } from "../lib/api";\nimport S from "./Sidebar";\nexport default [api, S];\n`,
"src/components/__boundary_probe.tsx",
);
expect(found).toEqual([]);
});
});
+9 -1
View File
@@ -31,8 +31,16 @@ import { useLayoutEffect, useRef, useState, type RefObject } from "react";
* fixed-layout column had assigned it. `clip`, not `hidden`, because clip creates no scrollport, so
* nothing can ever scroll the mirror into view. The clip cannot disturb the measurement: offsetLeft and
* offsetWidth are layout values, and clipping does not change layout.
*
* `overflow-clip-margin` is what keeps the clip from eating the KEYBOARD FOCUS RING. A bare
* `overflow: clip` cuts at the padding box, while the app-wide base ring is `outline: 2px` at
* `outline-offset: 2px` — i.e. it paints 4px OUTSIDE the border box. These lanes have no padding and
* their height is set by the chip itself, so the first commit of this fix silently cut the ring's top
* and bottom bands off every applied-filter chip and the tag lane's `+` trigger, leaving two floating
* side bars (E10 review round 1). 0.25rem is exactly the ring's reach and nowhere near the ~600px the
* mirror needs, so containment is unaffected.
*/
export const chipLaneClass = "relative overflow-clip";
export const chipLaneClass = "relative overflow-clip [overflow-clip-margin:0.25rem]";
export interface ChipRowGeometry {
/** Available lane width — the visible row's clientWidth. */