chore(comments): drop epic, sprint and review-round ids from the code

The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
This commit is contained in:
2026-10-05 00:19:39 +02:00
parent c73d8be094
commit 4aaaaa7860
669 changed files with 1831 additions and 1904 deletions
+4 -4
View File
@@ -30,7 +30,7 @@ test.describe("channel view", () => {
await expect(page.getByTestId("video-card").first()).toBeVisible();
});
// Bug 1 (Platform S4): opening a channel and coming Back restores the feed's scroll position
// Opening a channel and coming Back restores the feed's scroll position
// rather than dumping you at the top. The shell owns the scroller and remembers each view's offset.
test("Back restores the feed scroll position", async ({ page }) => {
await openApp(page);
@@ -60,7 +60,7 @@ test.describe("channel view", () => {
.toBeGreaterThan(saved - 500);
});
// Bug 2 (Platform S5): opening a channel no longer FORCES Live/Upcoming on — the channel view
// Opening a channel no longer FORCES Live/Upcoming on — the channel view
// seeds its content-type prefs (normal / shorts / live) from the global feed. Locked both ways so
// a future refactor can't silently re-hardcode it.
test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({
@@ -86,7 +86,7 @@ test.describe("channel view", () => {
await expectContentType(page, "includeLive", true);
});
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
// The channel view is its OWN filter scope — a content toggle made there
// must NOT leak back into the global feed. (FeedFiltersProvider owns the feed's filters; the
// channel page keeps a separate local cell.)
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
@@ -106,7 +106,7 @@ test.describe("channel view", () => {
await expectContentType(page, "includeLive", false);
});
// Platform S4b + E31 S4c: the channel chrome sits outside the scroller. Scrolling into the videos
// The channel chrome sits outside the scroller. Scrolling into the videos
// folds the banner and identity once (the name moves into the tab row); from then on the folded
// chrome stays put while only the video grid scrolls.
test("the channel chrome folds once, then stays fixed while the grid scrolls", async ({
+2 -2
View File
@@ -51,7 +51,7 @@ test.describe("feed", () => {
.toBe(true);
});
// Bug 4 (Platform S4): the toolbar (show/content chips, count, sort, view) is fixed chrome — it
// The toolbar (show/content chips, count, sort, view) is fixed chrome — it
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
await openApp(page);
@@ -66,7 +66,7 @@ test.describe("feed", () => {
expect(Math.abs(topAfter - topBefore)).toBeLessThan(4);
});
// Bug 3 (Platform S4): changing a filter is a fresh result set, so the scroll jumps back to the
// Changing a filter is a fresh result set, so the scroll jumps back to the
// top rather than stranding you deep in the old list's offset.
test("a new filter resets the scroll to the top", async ({ page }) => {
await openApp(page);
+1 -1
View File
@@ -76,7 +76,7 @@ setup("seed + authenticate", async ({ page }) => {
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
// that would intercept every click — the test account has no YouTube connection, so it
// auto-opens), and opt the account out of starting on Home. Since E24 a bare URL lands on
// auto-opens), and opt the account out of starting on Home. A bare URL lands on
// Home, and the feed, channel and player flows this net locks all open "/" expecting the
// feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are
// per-account: `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
+1 -1
View File
@@ -18,7 +18,7 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
* Playwright would scroll it into view to click it, moving the scroll position — fatal to any test
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
* covered click makes Playwright scroll and retry — the same move (E32 S2: webkit failed 3 in 24).
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
* So the link must be the element hit at its own centre. Returns false if none found.
*/
export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
+2 -2
View File
@@ -6,9 +6,9 @@ import { openApp } from "./helpers";
// this exercises is precisely what the module-registry refactor replaces.
test.describe("page switching", () => {
// Core + admin modules (the E2E account is an admin, matching the real primary user), so this
// also exercises the admin pages. Since E31 those sit behind ONE rail row ("Administration",
// also exercises the admin pages. They sit behind ONE rail row ("Administration",
// `nav-admin`) that opens a panel of them — the spec still listed them as rail rows of their own,
// and it failed on every run from E31 to E32 without anyone seeing it, because e2e was in no
// and it failed on every run for a while without anyone seeing it, because e2e was in no
// required gate (it is now part of `release`).
const RAIL = ["channels", "playlists", "stats", "settings", "feed"];
const ADMIN = ["scheduler", "config", "users", "audit"];
+1 -1
View File
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
import { openApp, openFirstVideo } from "./helpers";
// Locks the in-app player's open/close contract. The player is a portaled overlay whose
// inconsistent mounting caused an E3 regression — exactly the kind of thing the refactor's
// inconsistent mounting caused a regression — exactly the kind of thing the refactor's
// overlay-root convention must not break. We assert the modal shell, not YouTube playback.
test.describe("player modal", () => {
test.beforeEach(async ({ page }) => {
+1 -1
View File
@@ -11,7 +11,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// E32: an index read is `T | undefined` here, as it really is (0 errors when switched on).
// An index read is `T | undefined` here, as it really is (0 errors when switched on).
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"resolveJsonModule": true
+42 -42
View File
@@ -10,21 +10,21 @@ import playwright from "eslint-plugin-playwright";
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
// config first met are all gone (0 left, E32 review); what is still suppressed is suppressed inline,
// config first met are all gone (0 left); what is still suppressed is suppressed inline,
// one site at a time, each naming its invariant. tsc already owns unused vars, undefined names
// and types (noUnusedLocals/Parameters + strict), so typescript-eslint's non-type-checked
// `recommended` is layered on but its overlap with tsc is turned off below to avoid double-reporting
// and a churny diff. `eslint-config-prettier` is LAST so formatting rules never fight Prettier.
//
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
// an error — that is what keeps inert disable comments from re-accumulating.
//
// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its
// Gate integrity: every switched-off or narrowed rule here is listed, with its reason and its
// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule
// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with
// a `-- <the invariant>` description (eslint-comments/require-description makes that mandatory).
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
// Every way out of the browser goes through the one module that LOGS it — a request line
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts). A bare `fetch` elsewhere is a
// request the client log never sees, which is exactly how the baseline survey found four. Each owner
// is exempt from its OWN global only; a global with no owner (null) has no exempt file at all.
@@ -40,16 +40,16 @@ const networkGlobals = (exempt) =>
.map(([name, owner]) => ({
name,
message: owner
? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).`
: `No \`${name}\` without a logged owner module (E22 S4).`,
? `Use the logged request layer: \`${name}\` belongs to ${owner} only — a request outside it never reaches the client log.`
: `No \`${name}\` without a logged owner module.`,
}));
const networkProperties = [
{ object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
{ object: "window", property: "fetch", message: "Use lib/http.ts `send`." },
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send`." },
{
object: "navigator",
property: "sendBeacon",
message: "Use lib/api/core.ts `beacon` (E22 S4).",
message: "Use lib/api/core.ts `beacon`.",
},
];
@@ -70,7 +70,7 @@ const jsxCommentText = {
};
},
};
// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
// docs/DESIGN.md "Copy": a date, a number and an alphabetical order are all in the
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
@@ -134,7 +134,7 @@ const localeFormat = {
};
},
};
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
// One primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
@@ -144,7 +144,7 @@ const localeFormat = {
// native disabled drops a focused keyboard user to <body> the moment the button turns disabled (a
// mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in this
// app are disabled by their own click. ui/FocusSafeButton (and ui/Button on top of it) keeps the
// focus. (It was its own rule, `focus-safe-disabled`, until E35.)
// focus. (It used to be its own rule, `focus-safe-disabled`.)
const BUTTON_PRIMITIVES = new Set([
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
"Button.tsx",
@@ -196,8 +196,8 @@ const buttonPrimitive = {
else if (exempt) return;
else if (name === "button" || name === "FocusSafeButton")
context.report({ node, messageId: "raw", data: { name } });
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
// The same button spelled as a div with a role slipped past the rule above (one Plex
// picture did it). A component (capitalised) may take a role for its own reasons.
else if (
/^[a-z]/.test(name) &&
node.attributes.some(
@@ -213,8 +213,8 @@ const buttonPrimitive = {
};
},
};
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6):
// ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
// file picker has no look (it is hidden behind a Button), so it passes.
const INPUT_PRIMITIVES = new Set([
"Input.tsx",
@@ -247,7 +247,7 @@ const inputPrimitive = {
};
},
};
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
// B5 (docs/DESIGN.md): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
// role table and the control primitives that paint a glass BUTTON face.
@@ -389,7 +389,7 @@ const sortableA11y = {
},
};
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
// a name, a 24+ box and the icon button's layer. An audit found five bare 14px ones. Text or an
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
const glyphLink = {
@@ -434,7 +434,7 @@ const localRules = {
// A px-frozen BOX (the no-restricted-syntax block below): an esquery regex, as a string. A box utility
// (`w-[150px]`) or a grid's auto-fill column MINIMUM (`minmax(150px,1fr)`): the text inside grows with
// the slider while the box or the column floor stays, so it re-wraps, clips or cramps at a large text
// size (E30 review for the grid). `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the
// size (seen in the grid). `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the
// integer-only pattern. A fixed track (`grid-cols-[1fr_240px]`) is left alone, as is VirtualGrid's
// measured `minCol` (a number).
const BOX_PX =
@@ -459,14 +459,14 @@ const PALETTE_MESSAGE =
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3).
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`).
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
const SPLIT_CLASS_MESSAGE =
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
// Applies to EVERY linted file: a suppression says which rule and why. A bare
// `eslint-disable-next-line rule` named nothing in four places the E32 audit found, against the
// `eslint-disable-next-line rule` named nothing in four places an audit found, against the
// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all.
const suppressionRules = {
plugins: { "@eslint-community/eslint-comments": eslintComments },
@@ -477,7 +477,7 @@ const suppressionRules = {
},
};
// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none
// The two type-aware rules (a curated pair, not strictTypeChecked — ~2000 hits there, none
// real in the sample). A floating promise is a rejection nobody handles: the audit found two real
// ones (the chat dock's device key store, since removed, and VideoEditor's play()). `void` marks a deliberate fire-and-forget
// whose callee reports its own failure. React Query's invalidate/refetch family never rejects
@@ -524,12 +524,12 @@ export default tseslint.config(
rules: {
...reactHooks.configs.recommended.rules,
...typedRules,
// The React Compiler's own diagnostics that `recommended` leaves out (E32). Each is a function
// The React Compiler's own diagnostics that `recommended` leaves out. Each is a function
// the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint
// error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower
// this shape yet" (E32 S4 rewrote all 31 try statements it found); the rest had 0 hits when
// this shape yet" (all 31 try statements it found were rewritten); the rest had 0 hits when
// switched on, and are tripwires. Three more exist but cannot report anything under
// eslint-plugin-react-hooks 7.1.1 (E32 review, read in its source and probed), so they are not
// eslint-plugin-react-hooks 7.1.1 (read in its source and probed), so they are not
// listed: `exhaustive-effect-dependencies` (its validation is compiled off — the classic
// `exhaustive-deps` reports a missing effect dependency), `memoized-effect-dependencies` (nothing
// emits its category) and `rule-suppression` (lint mode hands it no suppression rules). A
@@ -547,14 +547,14 @@ export default tseslint.config(
"react-hooks/hooks": "error",
"react-hooks/no-deriving-state-in-effects": "error",
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings. 66 were fixed or
// React Compiler rule set. On this codebase that landed 283 findings. 66 were fixed or
// suppressed inline, each naming its invariant; there is DELIBERATELY no suppressions file — a
// bulk `--suppress-all` would hide the next violation instead of answering it.
//
// `refs` (the other 217) was switched OFF in E27 as mostly false positives, and that triage was
// `refs` (the other 217) was once switched OFF as mostly false positives, and that triage was
// wrong: the rule and the React Compiler share one analysis, so every hit was a function the
// compiler SKIPS — 21 popover components among them, and Channels' 0.9 s row menu was the bill
// (E32). E32 S3–S4 fixed every site (245 measured) instead of narrowing the rule: the popover
// compiler SKIPS — 21 popover components among them, and Channels' 0.9 s row menu was the bill.
// Every site was fixed (245 measured) instead of narrowing the rule: the popover
// engine is read through destructuring, render-phase ref writes became `useEffectEvent` /
// `useLatest`. It is on, at error, with no exception.
"react-hooks/refs": "error",
@@ -571,12 +571,12 @@ export default tseslint.config(
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
"@typescript-eslint/no-unused-vars": "off",
"no-undef": "off",
// R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
// Every explicit `any` was eliminated (api.ts prefs/data bags → Record<string, unknown>;
// catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
"@typescript-eslint/no-explicit-any": "error",
// E22 S4: the client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
// The client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
// bounded. A `console.*` call is none of those, and it is invisible to the user's own log.
"no-console": "error",
"no-restricted-globals": ["error", ...networkGlobals(null)],
@@ -592,7 +592,7 @@ export default tseslint.config(
"no-restricted-syntax": [
"error",
// A next-page fetch called bare CANCELS the one in flight and sends the same cursor again;
// our requests ignore the abort, so the server ran both (E31 S4e: every feed page 2–3×).
// our requests ignore the abort, so the server ran both (every feed page 2–3×).
// lib/nextPage.ts NEXT_PAGE makes the second call join the first.
{
selector:
@@ -614,7 +614,7 @@ export default tseslint.config(
},
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
// hover colour of the button and chip primitives was dead that way (E28 audit).
// hover colour of the button and chip primitives was dead that way (a design audit).
{
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
message: SPLIT_CLASS_MESSAGE,
@@ -627,19 +627,19 @@ export default tseslint.config(
message:
"No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
},
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT
// 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").
{
selector: "Literal[value=/text-\\[[\\d.]+px\\]/]",
message:
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx].",
},
{
selector: "TemplateElement[value.raw=/text-\\[[\\d.]+px\\]/]",
message:
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx].",
},
// …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
@@ -654,7 +654,7 @@ export default tseslint.config(
},
},
{
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
// `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
@@ -666,7 +666,7 @@ export default tseslint.config(
{
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
// this rule shipped that way and matched far less than it read like (E10 review round 1).
// this rule shipped that way and matched far less than it read like.
patterns: [
{
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
@@ -696,7 +696,7 @@ export default tseslint.config(
},
},
{
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
// The same one-way rule one layer down. `lib/` is what components are built ON (hooks,
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
// type. That is how the `Column` contract came to be owned by a ui component that three lib
// helpers had to import from. A piece both layers need moves down into `lib/`.
@@ -725,7 +725,7 @@ export default tseslint.config(
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
})),
{
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
// The design lab (src/design-lab) is a dev-only page that never reaches the production
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
// owner's working text, not the product's, so the translate-every-label rule does not apply.
files: ["src/design-lab/**/*.{ts,tsx}"],
@@ -738,7 +738,7 @@ export default tseslint.config(
plugins: { vitest },
rules: {
// A focused test silently skips the rest of the suite; a skipped one is a test nobody runs
// (E32 — vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
// (vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
"vitest/no-focused-tests": "error",
"vitest/no-disabled-tests": "error",
"no-restricted-globals": "off",
@@ -750,7 +750,7 @@ export default tseslint.config(
},
},
{
// Outside src (E32): the e2e suite and the TypeScript build-tool configs went unlinted — the
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
// typed rules as the app; Node globals, since none of it runs in the browser.
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
+2 -2
View File
@@ -1,7 +1,7 @@
import react from "@vitejs/plugin-react";
import reactCompiler from "babel-plugin-react-compiler";
// ONE React transform, shared by vite.config.ts and vitest.config.ts (E27 S1).
// ONE React transform, shared by vite.config.ts and vitest.config.ts.
//
// It is a shared factory rather than two `react()` calls because the two configs must not drift:
// if the compiler ran only in the production build, every vitest run would be exercising code the
@@ -14,7 +14,7 @@ import reactCompiler from "babel-plugin-react-compiler";
// bails out on its own for anything it cannot prove safe — SILENTLY: `panicThreshold` is "none" and
// there is no logger here, so a skipped component still works and simply re-renders everything under
// it. This comment used to say "its rules are also linted"; it was false while `react-hooks/refs` was
// off, and 95 functions sat uncompiled behind it (E32). What actually holds the line now is
// off, and 95 functions sat uncompiled behind it. What actually holds the line now is
// src/components/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source
// and fails on any skip not on its list; the react-hooks lint rules are the early warning. None of
// them is off (three run at the preset's warn, which `--max-warnings 0` fails — docs/gates.md), but
+4 -4
View File
@@ -146,9 +146,9 @@ export default function App() {
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A debug session that is
// recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
// applyDiagnostics computes an effective-level key and returns early when it has not moved
@@ -395,7 +395,7 @@ export default function App() {
)}
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
can't see the sidebar-driven narrowing (E5 S7). NB: `container-type: inline-size` implies
can't see the sidebar-driven narrowing. NB: `container-type: inline-size` implies
`contain: layout`, making this column a containing block for `position: fixed` descendants
too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
@@ -456,7 +456,7 @@ export default function App() {
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
{me && <DebugRecorder />}
<Suspense fallback={null}>
{aboutOpen && (
+1 -1
View File
@@ -17,7 +17,7 @@ import { SURFACE } from "./ui/surfaces";
* tables.
*
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
* properly keyboard-reachable belongs to R9 (focus/a11y), not to the mechanical migration.
* properly keyboard-reachable belongs to the focus/a11y work, not to the mechanical migration.
*/
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
const preview = (text ?? "").trim();
+1 -1
View File
@@ -18,7 +18,7 @@ import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
// one account lives.
//
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
+2 -2
View File
@@ -16,7 +16,7 @@ import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
export default function AddToPlaylist({
@@ -55,7 +55,7 @@ export default function AddToPlaylist({
);
}
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
/** The same panel, opened from an object's menu ("Add to playlist…") rather than its own
* button: anchored to the element the menu came from, and gone when it closes. */
export function AddToPlaylistAt({
videoId,
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E22.6: the admin log console. A request id narrows the list to that request (and says so, in the
// The admin log console. A request id narrows the list to that request (and says so, in the
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
// error, not an empty log; Copy takes every loaded line, oldest first.
+1 -1
View File
@@ -42,7 +42,7 @@ import { DismissibleChip } from "./ui/Chip";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// Admin → Szervernapló: the server's stored log (`log_events`). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// A failed user action is said once, and a user who is gone is not left listed (deep-review round 4).
// A failed user action is said once, and a user who is gone is not left listed.
// setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404
// (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed.
// And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409.
@@ -76,7 +76,7 @@ function mount() {
return users;
}
describe("the last sign-in column (E22 S1)", () => {
describe("the last sign-in column", () => {
it("says how long ago each account signed in, and a dash where nothing is recorded", async () => {
const at = new Date(Date.now() - 3 * 86_400_000).toISOString();
vi.spyOn(api, "adminUsers").mockResolvedValue([
@@ -96,7 +96,7 @@ describe("the last sign-in column (E22 S1)", () => {
});
});
// E28 (owner): the hover ended wherever the row's first badge or button began, because the layer was
// The hover ended wherever the row's first badge or button began, because the layer was
// the name button's own. The card is the row: it paints the layer under its content, and the name
// button's hit layer spans the card, under the actions that sit above it.
describe("a user row", () => {
@@ -117,7 +117,7 @@ describe("a user row", () => {
/** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */
const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true });
/** The row's actions live in its "…" menu (E31): open it, then pick the row. */
/** The row's actions live in its "…" menu: open it, then pick the row. */
async function chooseInRowMenu(label: string) {
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
const item = await waitFor(() => {
@@ -128,7 +128,7 @@ async function chooseInRowMenu(label: string) {
fireEvent.click(item);
}
// E31 (DESIGN § Action placement): the role, the suspension and the delete moved off the row into
// DESIGN § Action placement: the role, the suspension and the delete moved off the row into
// ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with
// its reason.
describe("a user row's menu", () => {
+3 -3
View File
@@ -113,7 +113,7 @@ function UsersRoles({ me }: { me: Me }) {
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
// with per-row disabling the admin can now start actions on several rows concurrently.
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
// The account panel: which user it is showing, or null. Held here rather than per row so
// only one is ever mounted.
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
@@ -208,7 +208,7 @@ function UsersRoles({ me }: { me: Me }) {
if (ok) del.mutate({ id: u.id, email: u.email });
};
// The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and
// The row's actions are ONE menu (DESIGN § Action placement): the role, the suspension and
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
// badges, the last sign-in. A locked action (on yourself) stays listed with its reason.
@@ -273,7 +273,7 @@ function UsersRoles({ me }: { me: Me }) {
{rows.map((u) => {
const self = u.id === me.id;
return (
// The whole card is the row, so the whole card answers (owner, E28): the name's button
// The whole card is the row, so the whole card answers (owner's call): the name's button
// stretches an invisible layer over the card (`after:inset-0`), so a click on a badge or
// a gap opens the account too, and the B3 hover layer is the card's own `::before`, under
// the badges and buttons (`isolate` + `z-under`). The actions are `relative`, above the
@@ -129,7 +129,7 @@ export default function AppCommandPalette() {
id: "actions",
label: t("palette.groups.actions"),
items: [
// "Meglepetés" — Home's reroll, reachable from anywhere (E24). There is deliberately no
// "Meglepetés" — Home's reroll, reachable from anywhere. There is deliberately no
// second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
// because the palette searches a module's descriptive `titleKey` too and Home's is literally
// "Mit nézzek most?". A row that navigates where an existing row already navigates would be
@@ -151,7 +151,7 @@ export default function AppCommandPalette() {
icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp,
},
// The debug recorder (E16). While it runs, Stop and a step marker take Start's place: the palette is reachable inside a
// The debug recorder. While it runs, Stop and a step marker take Start's place: the palette is reachable inside a
// player or a dialog where the pill's buttons are a long Tab away.
...(recordingSession
? [
+1 -1
View File
@@ -6,7 +6,7 @@ import type { AuditRow } from "../lib/api";
import { Button } from "./ui/Button";
import { RelativeTime } from "./ui/RelativeTime";
/** A folded run of one job's successful runs (lib/auditGroups, E31): the row shows the newest, this
/** A folded run of one job's successful runs (lib/auditGroups): the row shows the newest, this
* opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */
export default function AuditEarlierRuns({
runs,
+1 -1
View File
@@ -26,7 +26,7 @@ export default function AuditLog() {
const confirm = useConfirm();
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
const data = q.data;
// Back-to-back successful runs of one job fold into one row (lib/auditGroups, E31).
// Back-to-back successful runs of one job fold into one row (lib/auditGroups).
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
const actionOptions = useMemo(() => {
+1 -1
View File
@@ -11,7 +11,7 @@ function parseKeywords(s: string): string[] {
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
}
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
// slug is the vocabulary word (E30): named in the reader's language, else shown with spaces.
// slug is the vocabulary word: named in the reader's language, else shown with spaces.
function topicSlugs(urls: string[]): string[] {
const seen = new Set<string>();
for (const url of urls) {
+1 -1
View File
@@ -94,7 +94,7 @@ export default function ChannelDiscovery({
const columns: Column<DiscoveredChannel>[] = [
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
// Subscriptions table has no such button — its row ends in the channel's "…", E28 C1).
// Subscriptions table has no such button — its row ends in the channel's "…").
{
key: "actions",
header: t("channels.cols.actions"),
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// The channel manager's card layout as a keyboard list (E11 S4c): each card is an item, and Enter opens
// The channel manager's card layout as a keyboard list: each card is an item, and Enter opens
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
// when the real one would: when it is asked to (`keyboardNav`).
@@ -6,7 +6,7 @@ import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
import { navItemAttrs } from "../lib/listNav";
import { SURFACE } from "./ui/surfaces";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
// The channel manager's `cards` layout, built by reusing the SAME `Column<T>` render closures
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
// the card view for free, and a cell's behaviour (priority bump, tag toggle, sync, actions) is defined
// once. The `table` layout keeps DataTable; this renders only when the manager's layout is `cards`.
@@ -28,7 +28,7 @@ interface ChannelLayoutGridProps<T> {
aboutKey?: string;
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
actionsKey?: string;
/** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which
/** Column key docked at the top-right corner after the lead: the card's "…", which
* belongs beside the name it acts on, not under the card's last line. */
moreKey?: string;
rowClassName?: (row: T) => string;
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
//
// The channel page's Block and Hide toggles (deep-review E11 round 2) and its Subscribe / Unsubscribe
// button (round 3: the same row, missed) while their request runs. A
// The channel page's Block and Hide toggles and its Subscribe / Unsubscribe button (the same row,
// missed by the first fix) while their request runs. A
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// ui/Button), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// still pressed only once.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
@@ -99,7 +99,7 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
});
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
// next waits, a visible <img> shows the browser's broken-image glyph (seen live, E28). It stays
// next waits, a visible <img> shows the browser's broken-image glyph (seen live). It stays
// invisible until a source has actually loaded.
describe("ChannelPage — the banner", () => {
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick): the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
// The owner's pick: the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold
// away and the name rides in the tab row; back at the top they return. The Back button is one element in
// both states, so a focused Back keeps its focus through the fold.
+6 -6
View File
@@ -90,7 +90,7 @@ export default function ChannelPage({
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner and the identity block fold away once the grid scrolls (E31, the owner's pick): the chrome
// The banner and the identity block fold away once the grid scrolls (owner's pick): the chrome
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
const foldable = useRef<HTMLDivElement>(null);
const folded = useScrollFold(foldable);
@@ -102,7 +102,7 @@ export default function ChannelPage({
const MAX_BANNER_RETRIES = 3;
const [bannerAttempt, setBannerAttempt] = useState(0);
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28).
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live).
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
@@ -144,7 +144,7 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
// channel page that looked like a channel with no videos (E11 S2b).
// channel page that looked like a channel with no videos.
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
.finally(() => setExploring(false))
);
@@ -267,7 +267,7 @@ export default function ChannelPage({
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
// the explored, per-user-private videos show — the backend gates them to this explorer).
// The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows
// The watch state is the one filter a channel page remembers (owner's call): a fresh page shows
// what is still unwatched, and a change sticks — across channels, reloads and devices.
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
localKey: LS.channelShow,
@@ -313,7 +313,7 @@ export default function ChannelPage({
ch?.total_view_count != null
? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) })
: null,
// Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful
// Per-type breakdown (moved here off the manager table's old N/S/L column). Only meaningful
// once we actually hold some of the channel's videos.
ch && ch.stored_videos > 0
? t("channel.videoTypes", {
@@ -439,7 +439,7 @@ export default function ChannelPage({
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
{/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and
the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */}
the whole page scrolled sideways. The tag lane takes what is left and folds into "+N". */}
<div
className={`flex flex-wrap items-center gap-2 border-b border-border pb-2 ${folded ? "pt-3" : "mt-3"}`}
>
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E31 (the owner): a freshly opened channel page shows what is still unwatched, and a watch state the
// The owner's call: a freshly opened channel page shows what is still unwatched, and a watch state the
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// Contract of the catalog-stats hover chip: the compact trigger, the rows it reveals on open (value
// formatting + the conditional "left" row), and — the point of the R13-S3 review fix — that each row
// formatting + the conditional "left" row), and — the point of a review fix — that each row
// still carries its per-stat explanation as a `title`, so the guidance the old inline stats showed is
// not lost. i18n is mocked to a key-passthrough so assertions read the stable keys, not translations.
// The AnchoredPopover platform is floating-ui based (hover mode), so we stub the observers autoUpdate
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// Contract of the suggested-tag ghost chips (E8·S4): on an UNTAGGED channel, ChannelTagsControl renders
// Contract of the suggested-tag ghost chips: on an UNTAGGED channel, ChannelTagsControl renders
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
+3 -3
View File
@@ -15,7 +15,7 @@ import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift.
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
@@ -42,7 +42,7 @@ function TagChip({ tag, onClick, title }: TagChipProps) {
);
}
// A suggested user tag (E8·S4): a dashed "ghost" chip the user hasn't applied yet, learned from
// A suggested user tag: a dashed "ghost" chip the user hasn't applied yet, learned from
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
@@ -296,7 +296,7 @@ export function ChannelTagsControl({
onCreate: (name: string) => void;
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
onChipClick?: (tagId: number, name: string) => void;
/** Manager only (E8·S4): suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
/** Manager only: suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
* chips after the (empty) attached row. Omit on the detail page. */
suggestions?: SuggestedTag[];
onAcceptSuggestion?: (tagId: number) => void;
+9 -9
View File
@@ -211,7 +211,7 @@ export default function Channels() {
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
});
// A priority step picked from the row's menu (E28 S3e3b, owner): the one thing a pick is FOR is
// A priority step picked from the row's menu (the owner's call): the one thing a pick is FOR is
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
@@ -248,7 +248,7 @@ export default function Channels() {
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
// the learned pattern (E8·S4).
// the learned pattern.
onSuccess: (id) => {
qc.invalidateQueries({ queryKey: qk.channel(id) });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
@@ -267,7 +267,7 @@ export default function Channels() {
// The options are a memo so the hook's toggleTag/createAndAttachTag stay the same functions: the
// column memo below names them (see `columns`).
const { toggleTag, createAndAttachTag } = useChannelTagActions(tagActionOpts);
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
// --- Suggested tags: learned from the user's own manual-tag pattern, shown as accept/dismiss
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
@@ -311,7 +311,7 @@ export default function Channels() {
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
// B4 (E28 review L7): on a short window the band's filters — the tag chip lane and the status /
// B4: on a short window the band's filters — the tag chip lane and the status /
// pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag,
// plus every other active column filter (the status chips are the Sync column's).
const fold = useFilterFold();
@@ -369,7 +369,7 @@ export default function Channels() {
(c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")),
[onViewChannel, t],
);
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
// The channel's ONE action list — ONE menu for the list, shared by the table and the card
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
@@ -405,7 +405,7 @@ export default function Channels() {
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
// column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
// column — the retired Sync column became a ✓ charm on Stored — so it's
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
// still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
// name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
@@ -698,7 +698,7 @@ export default function Channels() {
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into
"+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the
first line. Only the suggestions block wraps: below 40rem it takes a line of its own under
the row (E28 S3f3, the owner's pick A) — at 375 it had squeezed the chip lane to 0 px and
the row (the owner's pick) — at 375 it had squeezed the chip lane to 0 px and
pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
<div className="flex flex-wrap items-center gap-2 min-w-0">
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
@@ -725,7 +725,7 @@ export default function Channels() {
{t("datatable.clear")}
</Button>
)}
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
{/* Suggested-tags affordance: a sparkle count of the visible suggestions, with a
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
rest. Hidden when there's nothing to suggest. */}
{visibleSuggestions.length > 0 && (
@@ -865,7 +865,7 @@ export default function Channels() {
rowClassName={() => "group"}
/>
{/* Folded, the status / pager row leaves the band, as the table's does — and the
list's own end keeps it here too (round 2, F2: the cards had lost their pager). */}
list's own end keeps it here too (the cards had once lost their pager). */}
{fold.folded && (
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
{statusChips}
@@ -2,7 +2,7 @@
//
// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
// re-routed through useAccountPersistedState in R13 S2b, so it's exercised here rather than only by hand E2E.
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
//
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
@@ -29,7 +29,7 @@ beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
// and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
// this provider's mount-time URL seed and change the starting tab.
window.history.replaceState({ sfPage: "channels" }, "", "/");
});
@@ -78,7 +78,7 @@ describe("ChannelsProvider tab history", () => {
});
});
describe("ChannelsProvider — S8 shareable URL", () => {
describe("ChannelsProvider — shareable URL", () => {
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
localStorage.setItem(VIEW_KEY, "blocked");
+3 -3
View File
@@ -76,7 +76,7 @@ function readStoredFilters(): FilterMap {
return migrateSyncChip() ?? {};
}
// S8: a shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
// A shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
@@ -114,7 +114,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const [focusNameRaw, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const seededRef = useRef(false);
@@ -239,7 +239,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
],
);
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
// deliberately transient rail-search, like the feed's and plex's.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E22 round-2 review: locks the stacking that makes an admin config row usable on a phone. The
// Locks the stacking that makes an admin config row usable on a phone. The
// control column cannot shrink, so beside a wide control it left the label and its hint a sliver of
// the ~250 px a phone leaves next to the navigation rail. The sibling fix — a long-labelled choice
// becoming a dropdown — is covered in ConfigPanel.choice.dom.test.tsx, but that one does nothing for
@@ -61,7 +61,7 @@ afterEach(() => {
vi.clearAllMocks();
});
describe("ConfigPanel field row (E22 round 2)", () => {
describe("ConfigPanel field row", () => {
it("stacks the label above the control below `sm`, and lets the control fit the screen", async () => {
render(
<QueryClientProvider client={new QueryClient()}>
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick): Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they
// The owner's pick: Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they
// are a side list under four headings, as Settings has. The registry owns the order: the groups follow
// its sections, not the order the groups object happens to arrive in.
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -64,7 +64,7 @@ const renderPanel = () =>
</QueryClientProvider>,
);
describe("Configuration sections (E31)", () => {
describe("Configuration sections", () => {
it("lists the groups in the registry's sections, each heading once above its run", async () => {
renderPanel();
const list = await screen.findByRole("tablist");
+3 -3
View File
@@ -159,7 +159,7 @@ export default function ConfigPanel() {
notifyActionError(e, t("config.plexTestFailed"));
},
});
// Bug reports' "Test connection" (E16): the repository answers, takes issues, is private.
// Bug reports' "Test connection": the repository answers, takes issues, is private.
const testIssues = useMutation({
mutationFn: () => api.testIssues(),
onSuccess: (r) =>
@@ -192,7 +192,7 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />;
}
// The groups in the registry's sections, in order (E31: sixteen tabs wrapped to three rows at 133%
// The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133%
// zoom, so they are a side list under four headings, as Settings has).
const groupKeys = data.sections.flatMap((s) => s.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
@@ -214,7 +214,7 @@ export default function ConfigPanel() {
<div className="px-4 pb-24 pt-3 max-w-4xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{/* The groups as a side list under their headings, the fields beside it: Settings' own
layout, one pattern for both settings pages (E31). */}
layout, one pattern for both settings pages. */}
<div className={`${SURFACE.panel} mb-4 flex flex-col overflow-hidden sm:flex-row`}>
<Tabs
idBase={panel.idBase}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 (owner): a native date field reports a complete, valid date on every keystroke of its year, so
// A native date field reports a complete, valid date on every keystroke of its year, so
// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E16 S2: the debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
// The debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the
// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
+3 -3
View File
@@ -31,7 +31,7 @@ import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The debug recorder (E16): Start → reproduce → Stop.
// The debug recorder: Start → reproduce → Stop.
//
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
@@ -296,7 +296,7 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
// It does NOT take the Tab, though: it is a field inside whatever dialog the pill is docked in,
// not a dialog of its own, and the trap belongs to that one — it spans the prompt as it spans the
// rest of the toast host. Claiming it here switched the dialog's off and left every control
// OUTSIDE this little box untrapped (deep-review round 3).
// OUTSIDE this little box untrapped.
useLayer(true, () => onDone(null), { trapsTab: false });
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
@@ -332,7 +332,7 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
/** The five words a past session can read as. A union, not `string`, because `chipTone` below
* matches on these same keys: untyped, a rename in one of them left the other matching nothing
* and every chip untoned, with the gate green (deep-review round 4). */
* and every chip untoned, with the gate green. */
type HistoryState =
| "recorder.history.sending"
| "recorder.history.draft"
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E22 S5: Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
// Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
@@ -32,7 +32,7 @@ function useRemaining(until: string | null): { h: number; m: number } | null {
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
// Settings → Diagnosztika: the consented diagnostics level and the tab's own log.
//
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// The Download Center's confirm dialogs name their ACTION on the confirm button (E11.5): the shared
// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -107,7 +107,7 @@ describe("DownloadCenter confirm dialogs", () => {
</QueryClientProvider>,
);
// E28 C1: the destructive and rare row buttons live in the row's "…".
// The destructive and rare row buttons live in the row's "…".
await pick("Clip 1", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
// The tab's label shares its button with a count badge.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume /
// The Download Center: a download row keeps its primary action (play; pause / resume /
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
+3 -3
View File
@@ -88,7 +88,7 @@ export default function DownloadCenter() {
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done");
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// --- The rows' ONE menu -------------------------------------------------------------
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
// open menu, and its "…" must still close it. The tab is the one on screen.
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
@@ -203,7 +203,7 @@ export default function DownloadCenter() {
>
{t("downloads.page.add")}
</Button>
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
{/* Local upload is a peer to "add a URL": a general file → the same library. */}
<Button
variant="outline"
size="sm"
@@ -307,7 +307,7 @@ export default function DownloadCenter() {
{tab === "shared" && (
<NavGrid className="space-y-2">
{(sharedQ.data ?? []).map((job, i) => {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
+1 -1
View File
@@ -58,7 +58,7 @@ export default function DownloadDialog({
} catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said (E11 S2b).
// open with nothing said.
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
setBusy(false);
}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E30: a render crash's notice speaks the reader's language. It showed `error.message` — the JS
// A render crash's notice speaks the reader's language. It showed `error.message` — the JS
// engine's English ("Cannot read properties of undefined (reading 'x')") — in the toast and the inbox,
// where it outlives the reload. The technical text stays in the `ui.crash` log.
import { afterEach, expect, it, vi } from "vitest";
+1 -1
View File
@@ -33,7 +33,7 @@ export default class ErrorBoundary extends Component<Props, State> {
level: "fatal",
title: i18n.t("errors.boundary.notifTitle"),
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
// is no message for the reader (E30) — the `ui.crash` log above carries it for diagnosis.
// is no message for the reader — the `ui.crash` log above carries it for diagnosis.
message: i18n.t("errors.boundary.notifMessage"),
requiresInteraction: true,
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// The Hidden / Blocked channel cards as a keyboard list (E11 S4c): `j`/`k` walk the cards, Enter opens
// The Hidden / Blocked channel cards as a keyboard list: `j`/`k` walk the cards, Enter opens
// the channel — the ChannelLink name is the card's open element — and the card's own action button
// keeps its Enter.
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// The count pill while a new search's count loads. keepPreviousData holds the last filter's count, and a
// broad word's count takes seconds ("a": ~3 s on the prod-sized library, E31): the pill showed the old
// broad word's count takes seconds ("a": ~3 s on the prod-sized library): the pill showed the old
// number as if it were the new search's. It now dims and is aria-busy until the new count lands.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
@@ -2,7 +2,7 @@
//
// A next page that fails. Retry is off and the read-ahead re-runs whenever a fetch settles, so a failed
// page was requested again once a round-trip, forever, while the end of the list was in view, and
// nothing said it had failed (E31 review 2). The read-ahead now stops at a failed page, and a row says
// nothing said it had failed. The read-ahead now stops at a failed page, and a row says
// so and retries on a click. VirtualFeed is replaced by what Feed hands it: the read-ahead's gate and a
// button standing in for the read-ahead itself.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
@@ -2,7 +2,7 @@
//
// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
// the next refetch (found in the E28 S3e1 UAT). The grid is replaced by a list that shows what Feed
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
// hands it: each video's position, and a button that fires the reset.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
+1 -1
View File
@@ -879,7 +879,7 @@ export default function Feed({
onResetState={onResetState}
onOpen={openVideo}
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
// re-request it once a round-trip for as long as the end is in view (E31 review 2). The row
// re-request it once a round-trip for as long as the end is in view. The row
// below says so and retries on a click.
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
isFetching={query.isFetching}
@@ -2,7 +2,7 @@
//
// The live-YouTube results' Sort control, wired end to end: the pref it writes, the key and
// direction Feed hands to sortYtResults, and the order that reaches the grid. sortYtResults itself
// is pinned in feedSort.yt.test.ts; a deep-review of the 2026-09 bug hunt found that the CALL SITE
// is pinned in feedSort.yt.test.ts; a review during the 2026-09 bug hunt found that the CALL SITE
// could ignore the chosen direction (or skip the sort entirely) with every lane green.
//
// The virtualised grid lays out nothing in jsdom, so it is replaced by a plain list that shows the
+2 -2
View File
@@ -18,7 +18,7 @@ import { PaletteLauncher } from "./PaletteLauncher";
// has one, and the per-user sync chip at the right end.
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
// owns both the header slot and the padded content wrapper (E5 S7).
// owns both the header slot and the padded content wrapper.
const Header = forwardRef<
HTMLDivElement,
{
@@ -107,7 +107,7 @@ const Header = forwardRef<
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && <FeedScopeToggle />}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. The
the filter sidebar so the primary "what am I browsing" control is always in reach. The
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
+11 -11
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
// The Home surface. Covers the four behaviours the browser round actually caught, each of
// which is invisible to tsc and to a render-only check:
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
// • the hero is not printed twice (once as the hero, once as the first resume tile);
@@ -308,7 +308,7 @@ describe("Home", () => {
// through the disabled seed-0 query the chip click leaves behind. So the first reroll under a new
// budget offered the old budget's card — while it loaded, and permanently once it failed, since
// `retry: false` makes a failed pick terminal. Under a chip that promises ten minutes, that makes
// the page's one control meaningless (round 2).
// the page's one control meaningless.
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
@@ -381,7 +381,7 @@ describe("Home", () => {
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
// so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
// `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
// reroll. A press is outstanding only while THIS key has no answer of its own.
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
let resolvePick!: (v: unknown) => void;
@@ -419,7 +419,7 @@ describe("Home", () => {
});
it("a press that FAILED does not leave the button hostage to the next mutation", async () => {
// Round 3's correction to the case above. A key whose request failed never gets data of its own
// A later correction to the case above. A key whose request failed never gets data of its own
// (`retry: false` makes it terminal), so a predicate reading `data === undefined` stayed true
// forever and every later background refetch re-disabled the button — the same defect, one
// branch over. `isFetched` remembers that the key settled, an error counting as settled.
@@ -437,7 +437,7 @@ describe("Home", () => {
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
// `waitFor`, not a fixed tick: react-query hands the rejection to the observer through
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
// "await one macrotask then assert" races it. That race was measured as an intermittent red.
failPick(new Error("boom"));
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
@@ -454,7 +454,7 @@ describe("Home", () => {
// The tagged slot is authoritative for its own budget, including when what it holds is "no hero
// at all". Under a `??` chain that read as "nothing remembered" and fell through to the loaded
// payload, so a failed press resurrected the card the page had already excluded as seen and just
// said it had nothing beyond (round 3).
// said it had nothing beyond.
discover.mockResolvedValue({
budget: "short",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
@@ -543,7 +543,7 @@ describe("Home", () => {
expect(screen.getByText("Hero v1")).toBeTruthy();
});
// E24 S4. The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
// The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
// mounted yet — the request has to WAIT as a token rather than fire into an empty room. And it
// must be spent: a token that survived would make a later visit reroll on its own, which reads as
// the page overruling the answer you came back for.
@@ -703,7 +703,7 @@ describe("Home", () => {
comfort: [],
});
mount();
// Since E28 C1 the tile's ⓘ is its menu's "Media info" row, opened from the tile's "…".
// The tile's ⓘ is now its menu's "Media info" row, opened from the tile's "…".
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
const info = screen
.getAllByRole("menuitem", { hidden: true })
@@ -782,7 +782,7 @@ describe("Home", () => {
});
});
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
// The owner's decision: the rails are one keyboard page — `j` walks a rail's cards and goes on
// into the next rail, whichever order the page stacks them in.
describe("Home · keyboard", () => {
it("walks the rails with j, from one rail on into the next", async () => {
@@ -828,7 +828,7 @@ describe("Home · keyboard", () => {
});
});
// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text
// The owner's call (2026-09-26/27): the hero stands in three quarters of a wide page with its text
// centred beside the picture; one fact line, no repeated year, and prose only where there is some,
// at a readable measure.
describe("Home's hero text", () => {
@@ -883,7 +883,7 @@ describe("Home's hero text", () => {
});
});
describe("Home · the reader's language (E30)", () => {
describe("Home · the reader's language", () => {
it("asks for the page in Hungarian, so a Plex card comes back with its Hungarian title", async () => {
setLanguage("hu");
try {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E24 S3: the comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
// The comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
// sibling test's mocks would leak into.
//
+8 -8
View File
@@ -73,7 +73,7 @@ import {
mediaTitleClass,
} from "./ui/mediaCardStyles";
// The Home surface (E24) — "Mit nézzek most?".
// The Home surface — "Mit nézzek most?".
//
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
@@ -144,7 +144,7 @@ function useHomeCardMenu({
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
// A video tile's own action list, the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
@@ -723,7 +723,7 @@ export default function Home() {
}, [budget, seed, press, seen]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
// A Plex card's title is in the reader's language (E30), so the answer is cached per language.
// A Plex card's title is in the reader's language, so the answer is cached per language.
const lang = plexLang(i18n.language);
const q = useQuery({
queryKey: qk.discover(budget, lang),
@@ -745,7 +745,7 @@ export default function Home() {
//
// But only WITHIN a budget. Plain `keepPreviousData` chains through the disabled seed-0 query a
// chip click leaves behind, so the first reroll under a new budget held up the card the PREVIOUS
// question was answered with — under a chip promising something else (round 2). Returning
// question was answered with — under a chip promising something else. Returning
// undefined instead falls the hero back to this budget's own loaded answer, which is already in
// hand; the spinner still covers the wait.
placeholderData: (prev) => (prev?.budget === budget ? prev : undefined),
@@ -754,11 +754,11 @@ export default function Home() {
// `["discover"]` prefix so a mutation's invalidation reaches the rerolled hero — which also means
// every mark-watched, bookmark, pin/unpin and shelf move on this page refetches it in the
// background, and `ui/Button` turns `loading` into `disabled`. So the reroll spun and swallowed
// clicks after actions the user never associated with it (round 2). A press is outstanding exactly
// clicks after actions the user never associated with it. A press is outstanding exactly
// while THIS key has never settled — which is what `isFetched` means (react-query counts both a
// success and an error as settled), and every press mints a fresh seed, so a fresh key.
//
// Round 3 corrected this: `isPlaceholderData || data === undefined` looked equivalent and was not.
// Since corrected: `isPlaceholderData || data === undefined` looked equivalent and was not.
// A pick that FAILED leaves its key data-less forever (`retry: false`), so the second disjunct
// stayed true and the next unrelated mutation re-disabled the button — the same defect, surviving
// in the error branch. Guarding on `!isError` does NOT help either: query-core resets a data-less
@@ -775,7 +775,7 @@ export default function Home() {
// reset an answer that already resolved, so an untagged slot outlived the question it answered —
// switch to "10 perc", press reroll, let that request fail (`retry: false`, so it is terminal),
// and the two-hour film picked under "Bármennyi" was still the hero, under a chip promising ten
// minutes (round 2). Taken from the SERVER's `budget` echo rather than this render's state, so the
// minutes. Taken from the SERVER's `budget` echo rather than this render's state, so the
// tag cannot be minted from a budget the pick never answered.
const [lastPick, setLastPick] = useState<{
budget: DiscoverBudget;
@@ -802,7 +802,7 @@ export default function Home() {
// pick for this budget resolved to no hero" is a real answer, not a missing one. Under
// `??` the two collapsed, so a failed press after an empty pick resurrected the card the
// page loaded with — which it had already excluded as seen and just said it had nothing
// beyond (round 3). A mismatched or absent tag still falls through to the loaded hero,
// beyond. A mismatched or absent tag still falls through to the loaded hero,
// which is what the FIRST press of a budget needs.
lastPick?.budget === budget
? lastPick.hero
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E16 S3: the issue composer. It drafts the steps from the session, saves as it goes (the rendered
// The issue composer. It drafts the steps from the session, saves as it goes (the rendered
// text included — the export is built from what it SAVED), reopens as it was left, takes a log line
// out of the evidence in the review, adds a pasted image, and a sent session is read-only. i18n is a
// key-passthrough with its params; the API is stubbed.
@@ -495,7 +495,7 @@ describe("the issue composer", () => {
it("opens on the title, not on the Edit / Preview switch above it", async () => {
// The form arrives after the dialog opens; its first control is the switch, and the focus
// landed there — typing went nowhere (E32 review round 3).
// landed there — typing went nowhere.
layOut();
mount();
await waitFor(() => expect(document.activeElement).toBe(title()));
+4 -4
View File
@@ -30,12 +30,12 @@ import { Textarea, Input } from "./ui/Input";
import { Checkbox } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
// The issue composer: one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
// each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick
// to add more — an image pasted anywhere in it is taken). It saves as it goes, and what is still
// waiting to be saved when it closes is saved then, so closing it loses nothing: Settings →
// Diagnosztika reopens any past session. The export and (S4) the issue are built from what it SAVED.
// Diagnosztika reopens any past session. The export and the issue are built from what it SAVED.
const SAVE_DELAY_MS = 800;
type Source = "web" | "server";
@@ -200,7 +200,7 @@ function ComposerBody({
setForm(next);
};
// The issue (S4), like the export, is built from the SAVED draft: save what is on screen first.
// The issue, like the export, is built from the SAVED draft: save what is on screen first.
// A send that stopped half-way is finished by the same call, which does only what is missing.
// While it runs the draft is held as it was saved: nothing typed then would reach the issue.
const target = useMe()?.issues ?? null;
@@ -274,7 +274,7 @@ function ComposerBody({
// draft is already frozen (`locked`), where the save is both pointless and refused. While a send
// holds the claim the server 409s `issue_sending`, and that refusal would take the zip with it:
// `GET /export` has no send guard and would have served exactly what the send is uploading, so
// the one tracker-free way out of a stuck send used to fail at the last step (round 4).
// the one tracker-free way out of a stuck send used to fail at the last step.
const exportZip = useMutation({
mutationFn: async () => {
if (!locked) await save.mutateAsync();
+1 -1
View File
@@ -11,7 +11,7 @@ import { SURFACE } from "./ui/surfaces";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
//
// Two variants, now one implementation on the R8 popover platform: "header" opens below the pill
// Two variants, now one implementation on the popover platform: "header" opens below the pill
// (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left
// nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their
// own anchoring, and the header one also opened on hover — dropped on purpose: the platform focuses
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
// The in-app library player streams the completed item's LOCAL file into a native media
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here.
+1 -1
View File
@@ -6,7 +6,7 @@ import { jobTitle } from "./downloadTitle";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E22 S5: "Saját napló" shows this tab's own log live, newest first, filters it by level group and
// "Saját napló" shows this tab's own log live, newest first, filters it by level group and
// text, and copies exactly what is filtered (oldest first, one JSON event per line).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+1 -1
View File
@@ -186,7 +186,7 @@ export default function NavSidebar({
{systemItems.length > 0 && (
<>
<NavRailDivider />
{/* The admin pages, one row opening a panel of them (E31): five rows of their own sat
{/* The admin pages, one row opening a panel of them: five rows of their own sat
below the fold at 133% zoom, behind a fade alone. */}
<NavRailGroup
testId="nav-admin"
@@ -92,7 +92,7 @@ describe("NavigationProvider · the tab title", () => {
expect(document.title.endsWith(" · Siftlode")).toBe(true);
});
// E31 S1: the Feed's tab read a bare "Siftlode" — the one page that named no place.
// The Feed's tab read a bare "Siftlode" — the one page that named no place.
it("names the Feed like every other page", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
render();
@@ -100,7 +100,7 @@ describe("NavigationProvider · the tab title", () => {
expect(document.title).toBe("header.account.feed · Siftlode");
});
// E31 S1: a channel opened from a link was "Channels · Siftlode", the same channel opened in the
// A channel opened from a link was "Channels · Siftlode", the same channel opened in the
// app was named; a Plex title, person or collection only "Plex". The object is the view's heading.
it("puts the open view's own heading before the place, and drops it when the view closes", async () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
@@ -161,8 +161,8 @@ describe("NavigationProvider · setPage's same-page guard", () => {
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
const { result } = render();
// The page under test must be the one that is ALREADY current, or the guard's first conjunct is
// false and this never reaches the clause it is named for — the mistake round 4 caught here: a
// bare URL with cleared storage lands on "home" (navigationInit's E24 default), not "feed", so
// false and this never reaches the clause it is named for — the mistake a review caught here: a
// bare URL with cleared storage lands on "home" (navigationInit's default), not "feed", so
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
const current = result.current.page;
expect(current).toBe("home");
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 C1 on the notification center: a row keeps its link and its unread mark in sight, its dismiss
// The notification center: a row keeps its link and its unread mark in sight, its dismiss
// shows on hover (on touch it is in the "…"), and everything it can do is its ONE menu — opened by
// the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there.
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first.
@@ -46,7 +46,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
vi.mock("./feedFiltersContext", () => ({
// Every narrowing a feed can carry (E28 review C2: the year, the custom range, the length, the
// Every narrowing a feed can carry (the year, the custom range, the length, the
// content types and the scope survived "Find in feed" and could still hide the video).
useFeedFilters: () => ({
tags: [54],
@@ -331,7 +331,7 @@ describe("NotificationsPanel · the page's background menu", () => {
});
});
describe("a download-lifecycle notice (E30)", () => {
describe("a download-lifecycle notice", () => {
it("is named from its data in the reader's language, never the server's English title", () => {
h.server = [
server(1, {
@@ -128,7 +128,7 @@ export default function NotificationsPanel() {
else refresh();
}
// It empties both lists for good (the server's are deleted), so it asks first — it moved from the
// band into the header's "…" (E28 C1), where a destructive row keeps its confirm.
// band into the header's "…", where a destructive row keeps its confirm.
async function clearEverything() {
const ok = await confirm({
title: t("inbox.clearConfirmTitle"),
@@ -145,8 +145,8 @@ export default function NotificationsPanel() {
// notices — jump to the feed filtered to that state and the video's channel.
// Built from nothing, not spread over the reader's filters: EVERY narrowing that could leave the
// video out goes — a tag, "no tag", a search, the age cap, the year, the custom range, the length,
// a content type (a hidden Short under "no Shorts"), the scope and the library source (E28 review
// C2: the spread kept whatever this list forgot). Only how the reader orders and matches stays.
// a content type (a hidden Short under "no Shorts"), the scope and the library source (the
// spread kept whatever this list forgot). Only how the reader orders and matches stays.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({
tags: [],
@@ -176,7 +176,7 @@ export default function NotificationsPanel() {
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
}
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// --- The rows' ONE menu -------------------------------------------------------------
// A row keeps its link and its unread mark in sight; its dismiss shows on hover. Everything it can
// do — the link again, find / undo on an activity row, mark read, dismiss — is its menu.
// One menu per list, keyed by the notification's id: the server list polls, and the "…" must still
@@ -329,7 +329,7 @@ export default function NotificationsPanel() {
return (
<>
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…", fixed
while the list scrolls. No title card: the nav rail and the tab title name the page, and the
shell's hidden h1 names it to a screen reader. */}
{/* The page's background menu (DESIGN "Background menu"): the band's own non-destructive
@@ -518,7 +518,7 @@ function NotificationRow({
const isScheduler = serverKind(n) === "scheduler";
const isDownloadDone = serverKind(n) === "download";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
// A download's life after it is done (E30): shared with you, about to expire, expired, evicted.
// A download's life after it is done: shared with you, about to expire, expired, evicted.
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
let title = n.title;
let body = n.body;
@@ -529,7 +529,7 @@ function NotificationRow({
body = t("inbox.maintenance.body", { count: d.count });
} else if (isScheduler) {
// "<Job label> finished/failed", with the result as the body — named in the reader's language
// when it rode along as data (E30), else the English summary of an older notice.
// when it rode along as data, else the English summary of an older notice.
const run = jobRunText(t, n.data as { job_id: string }, lang);
title = run.title;
body = run.body || n.body;
@@ -636,7 +636,7 @@ function ClientActivityRow({
}) {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
// A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): the row reads.
// A hidden / watched notice's Find in feed and Undo are in its menu: the row reads.
const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched";
const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null;
@@ -3,7 +3,7 @@
// The wizard is a modal overlay in every way that matters to a user, but it renders its own shell
// instead of going through `ui/Modal` — and for a keyboard or screen-reader user it had nothing of
// the dialog contract: no role, no label, no initial focus, no trap, no restore. Tab past its last
// control walked into the app behind the scrim, where the focus ring is invisible (round 5).
// control walked into the app behind the scrim, where the focus ring is invisible.
//
// `focusablesIn` filters on `el.offsetParent !== null` and jsdom reports null for everything, so the
// trap is untestable as written without the same stub `ui/Modal.dom.test.tsx` uses.
@@ -90,7 +90,7 @@ describe("the onboarding wizard as a dialog", () => {
// `Node.contains(self)` is true, so focus sitting ON the tabIndex=-1 panel — where a click on
// its dead space puts it — matched no branch of `trapTab` and the browser's native Shift+Tab
// ran, into the app behind the scrim. That is what the third argument exists for, and this
// caller was written without it on the very commit that extracted the helper (round 6).
// caller was written without it on the very commit that extracted the helper.
mount();
const panel = screen.getByRole("dialog");
panel.focus();
@@ -104,7 +104,7 @@ describe("the onboarding wizard as a dialog", () => {
// Ownership has to be measured over the same scope the Tab trap uses. The wizard's toast host
// sits beside the panel and INSIDE the dialog, so tabbing to a toast's ✕ is legal — but read
// against the panel alone it said "the user left", and since removing the toast fires no
// focusin to re-arm, closing afterwards stranded the focus on <body> (round 6). Unlike
// focusin to re-arm, closing afterwards stranded the focus on <body>. Unlike
// `ui/Modal`, whose root carries the aria-modal attribute, nothing else can rescue this one.
const outside = document.createElement("button");
document.body.appendChild(outside);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// Escape closes the topmost layer only — the wizard included (deep-review round 4). Its own Escape
// Escape closes the topmost layer only — the wizard included. Its own Escape
// was a raw `document` listener with no layer check, and document listeners run before the window
// listener ui/Modal uses: a sync that failed inside the wizard opens the global error dialog, and one
// Esc dismissed the error AND ended onboarding, skipping the write-scope step.
+4 -4
View File
@@ -117,13 +117,13 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
// A layer in the shared stack, so Escape closes the wizard only when it is on top: a raw document
// listener ran BEFORE the error dialog's (a sync that failed inside the wizard opens one), and one
// Esc closed the error AND ended onboarding, skipping the write-scope step (deep-review round 4).
// Esc closed the error AND ended onboarding, skipping the write-scope step.
const layer = useLayer(true, close);
// The rest of the dialog contract. This is a modal overlay in every way that matters to a user, but
// it renders its own shell (it is not a titled dialog with a header row), so Escape ordering was all
// it had: focus started on <body>, nothing announced a dialog, and Tab past the last control walked
// into the app behind the scrim — where the focus ring is invisible (deep-review round 5). The trap
// into the app behind the scrim — where the focus ring is invisible. The trap
// and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy.
const panelRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
@@ -136,7 +136,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
if (e.key !== "Tab" || layer.blockingAbove()) return;
const panel = panelRef.current;
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
// neither of them (round 6, on the commit that extracted the helper so the two shells could
// neither of them (on the commit that extracted the helper so the two shells could
// not disagree). Scope is the whole dialog, so the toast host beside the panel stays in the
// Tab cycle (a sticky connection notice's ✕ was unreachable from the keyboard); `container`
// is the tabIndex=-1 panel, because `panel.contains(panel)` is true, so focus sitting ON the
@@ -151,7 +151,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
// Are we currently importing (or about to)? Show a progress screen instead of the
// "you're connected" step until the first import settles. Once triggered, the mutation itself says
// which (pending / success / error) — the trigger ref is the effect's once-only latch, never read
// here (a ref read in render made the React Compiler skip the wizard, E32).
// here (a ref read in render made the React Compiler skip the wizard).
const importing =
me.can_read &&
!importSubs.isError &&
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
// The Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
// real modal against a mocked savedViewsContext so we can assert exactly which mutation fires; the
@@ -108,7 +108,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("PersonalViewsModal — S7 editable detail pane", () => {
describe("PersonalViewsModal — editable detail pane", () => {
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
mount();
const removeBtn = await screen.findByLabelText("feed.chips.remove");
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
// had tests; what the modal DOES with them had none — a deep-review of the 2026-09 bug hunt found
// had tests; what the modal DOES with them had none — a review during the 2026-09 bug hunt found
// that deleting the lines that apply them left every lane green.
//
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
@@ -293,9 +293,9 @@ describe("PlayerModal — toasts raised while it is open", () => {
});
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
// This was once the one `aria-modal` shell with no accessible name, no Tab trap and no focus
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
// Escape left a keyboard user on <body>, their place in a long feed lost.
describe("PlayerModal — the dialog contract", () => {
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
@@ -444,7 +444,7 @@ describe("PlayerModal — the side arrows", () => {
});
});
// E31 S1: at 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
// row. The card's width is capped from the window height less what sits under the video, measured.
describe("PlayerModal — fits the window's height", () => {
it("caps the card's width by the chrome measured under the video", () => {
@@ -469,7 +469,7 @@ describe("PlayerModal — fits the window's height", () => {
});
});
// E31 (the owner's pick "A"): on a wide window the video was capped at ~1008 px of a 1920 screen, with
// The owner's pick: on a wide window the video was capped at ~1008 px of a 1920 screen, with
// empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side
// panel beside the video, and the video takes the window's height.
describe("PlayerModal — the side panel on a wide window", () => {
@@ -542,7 +542,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
});
// E31 S5: a failed load read "No description." in the always-open panel — an error drawn as empty.
// A failed load read "No description." in the always-open panel — an error drawn as empty.
it("says the description failed to load, with a retry, instead of 'No description'", async () => {
wideWindow(true);
const detail = vi
@@ -563,7 +563,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(detail).toHaveBeenCalledTimes(2);
});
// E31 review 2: the popover below 1280 px said it the same way only by a copy nobody tested.
// The popover below 1280 px said it the same way only by a copy nobody tested.
it("says it in the description popover below 1280 px too", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
+6 -6
View File
@@ -100,7 +100,7 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
return false;
}
// From this window width the player's details sit in a side panel beside the video (E31). The panel is
// From this window width the player's details sit in a side panel beside the video. The panel is
// a rem width, so it grows with the text size; the video takes what is left.
const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem";
@@ -206,10 +206,10 @@ export default function PlayerModal({
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The card fits the window, video AND what sits under it (the queue strip, title, channel row): the
// video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a
// full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's
// full-width card ran past the bottom and cut its channel row. `below` is that chrome's
// height, measured (it wraps with the title and the text size), and caps the card's width below.
const [below, setBelow] = useState(0);
// A wide window (E31, the owner's pick "A"): the title, channel, actions, description and the queue
// A wide window (the owner's pick): the title, channel, actions, description and the queue
// move into a side panel beside the video, so the video takes the window's whole height — at
// 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it.
const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false);
@@ -229,8 +229,8 @@ export default function PlayerModal({
}, []);
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
// hand-rolled shell — no accessible name, no Tab trap, no restore (deep-review round 7).
// CARD, not its first button, so Space/F/arrows reach the shortcuts. This was once the one
// hand-rolled shell — no accessible name, no Tab trap, no restore.
useDialogChrome(cardRef, dialogRef, { focusScope: true });
const titleId = useId();
const togglePlay = () => {
@@ -477,7 +477,7 @@ export default function PlayerModal({
// over the player by doing so.
const overlayOrder = useOverlayOrder();
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
// download dialog, or an anchored menu) takes Escape first and this player defers.
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// A playlist or tag edit that fails is said, and what the screen shows goes back to what is true
// (deep-review round 4). The Plex playlist page, the add-to-playlist popover and the tag manager had
// A playlist or tag edit that fails is said, and what the screen shows goes back to what is true.
// The Plex playlist page, the add-to-playlist popover and the tag manager had
// no failure handling at all: a 404 for a list or tag deleted elsewhere — now a structured
// `not_found`, which the request layer leaves to the caller — failed in silence, and an optimistic
// removal stayed removed.
@@ -92,7 +92,7 @@ describe("the Plex playlist page", () => {
</QueryClientProvider>,
);
await screen.findByText("Movie 1");
// E28 C1: the row's X went into its menu (right-click here, the "…" beside it on screen).
// The row's X went into its menu (right-click here, the "…" beside it on screen).
await pickFromMenu(
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
"plex.playlist.removeFromPlaylist",
@@ -160,8 +160,8 @@ describe("the add-to-playlist popover", () => {
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
// promise became an unhandled rejection. Round 5 fixed the collection editor and the YouTube
// twin above; this dialog was left behind (round 6).
// promise became an unhandled rejection. A sweep fixed the collection editor and the YouTube
// twin above; this dialog was left behind.
const lists = vi
.spyOn(api, "plexPlaylists")
.mockResolvedValue({ playlists: [{ id: 9, title: "Gone", has_item: false }] } as never);
@@ -9,7 +9,7 @@ import { SURFACE } from "./ui/surfaces";
// The playlists module shows fewer lists than youtube.com does, and without a word about it that
// reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front).
// Three facts, behind one quiet line at the foot of the rail — the owner's pick from the S7 mockups
// Three facts, behind one quiet line at the foot of the rail — the owner's pick from the mockups
// (2026-09-19): always there to find again, never in the way.
export default function PlaylistLimitsHint() {
const { t } = useTranslation();
@@ -392,7 +392,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
// refetch that follows a successful reorder did. So a second reorder rejected while that refetch
// was still in flight rolled the rows back past the FIRST one, which the server had accepted.
// The membership guard cannot correct an order (its key is the sorted id set), so the wrong
// order stayed on screen for good and the next drag persisted it (deep-review round 5).
// order stayed on screen for good and the next drag persisted it.
const reorder = vi
.spyOn(api, "reorderPlaylist")
.mockResolvedValueOnce(undefined as never)
@@ -429,7 +429,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
});
});
// E28 C1: a row's X column went. Remove is in the row's menu and on the Delete key, with no confirm —
// A row's X column went. Remove is in the row's menu and on the Delete key, with no confirm —
// the toast's Undo puts the row back where it stood, in ONE request.
describe("Playlists — removing a row, and undoing it", () => {
// The server's side of a removal, so the refetch after it agrees with it.
@@ -489,7 +489,7 @@ describe("Playlists — removing a row, and undoing it", () => {
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
});
// E28 review C4: the stored index drifts when the list changes before the Undo (a row added at
// The stored index drifts when the list changes before the Undo (a row added at
// the top in another tab) — the row goes back after its old neighbour instead.
it("puts it back after its neighbour when the list changed before the Undo", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
@@ -566,7 +566,7 @@ describe("Playlists — removing a row, and undoing it", () => {
});
});
// E28 C1: the header keeps Play all; rename, YouTube and delete are the list's menu, behind a "…".
// The header keeps Play all; rename, YouTube and delete are the list's menu, behind a "…".
describe("Playlists · the header's '…'", () => {
it("replaces the pencil, the YouTube buttons and the trash, and renames from its menu", async () => {
mount({ 1: [vid("A", 0)] });
@@ -615,7 +615,7 @@ describe("Playlists · a narrow table", () => {
el.classList.contains("hidden") && el.classList.contains("@2xl:table-cell");
const heads = [...document.querySelectorAll("thead th")];
const cells = [...document.querySelectorAll("tbody tr:first-child td")];
// Columns: grip, #, title, channel, length, "…". Length went too (E28 S3f3, the owner's pick A).
// Columns: grip, #, title, channel, length, "…". Length went too (the owner's pick).
expect(heads.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
expect(cells.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
});
@@ -661,7 +661,7 @@ describe("Playlists — the rows are a keyboard list", () => {
});
// The header's meta line and its view-controls gate were typechecked and linted but never rendered,
// so a regression in either shipped green (E9 review). The count and the runtime sum the WHOLE
// so a regression in either shipped green. The count and the runtime sum the WHOLE
// list; an unknown duration counts as nothing rather than poisoning the sum; and a list of one has
// no view to change.
describe("Playlists · the header", () => {
+14 -14
View File
@@ -68,11 +68,11 @@ import { HiddenFilterCue } from "./ui/HiddenFilterCue";
import { PageHeading } from "./ui/Headings";
import { useCopyItems } from "./copyItems";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
//
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
// while the playlist's own order is stored data. Before S4 the sort control wrote straight through
// while the playlist's own order is stored data. At first the sort control wrote straight through
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
// which still goes through the undoable path — the capability is intact, it just stopped being a
@@ -186,7 +186,7 @@ export default function Playlists() {
// `restoreFromCache` rolls back to, and until this refetch landed it still held the order
// from before this reorder — so a SECOND reorder failing in that window rolled the rows
// back to an order the server does not have, permanently (the membership guard cannot
// correct an order). One accepted write, one cache entry that matches it (round 5).
// correct an order). One accepted write, one cache entry that matches it.
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: next } : old,
);
@@ -220,8 +220,8 @@ export default function Playlists() {
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen
// (deep-review round 4). Only for the list on screen: the selection may have moved meanwhile.
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen.
// Only for the list on screen: the selection may have moved meanwhile.
function restoreFromCache(listId: number) {
if (listId !== selectedRef.current) return;
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
@@ -257,7 +257,7 @@ export default function Playlists() {
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
const rowMenu = useContextMenu<Video>();
// The comfort-shelf pin composes into the row's existing menu (E24 S3) rather than mounting a
// The comfort-shelf pin composes into the row's existing menu rather than mounting a
// second one on the same <tr>.
const comfortPin = useComfortPin(rowMenu.open);
const copy = useCopyItems();
@@ -370,7 +370,7 @@ export default function Playlists() {
[items],
);
// The row's "…" opens the row menu under itself (E28 C1). Which row's menu is open is part of the
// The row's "…" opens the row menu under itself. Which row's menu is open is part of the
// cell (aria-expanded), so the columns rebuild when it changes — a cheap array of closures.
const { buttonProps: rowMenuButton, open: rowMenuOpen, target: rowMenuTarget } = rowMenu;
const columns = useMemo(
@@ -451,8 +451,8 @@ export default function Playlists() {
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
}
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (E28 C1:
* the X column became the Delete key + this toast; no confirm, because nothing is lost).
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
* X column became the Delete key + this toast; no confirm, because nothing is lost).
* @returns whether the item was actually removed — the player needs to know, because it must
* step to the next item (or close) only when the removal really happened. */
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
@@ -558,9 +558,9 @@ export default function Playlists() {
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
// impossible — the submenu never offers the current list). So the row used to snap back with no
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
// explanation and the shipped moveErrors strings were unreachable.
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
// checks (E10 review round 2 found it reported twice).
// checks (a review found it reported twice).
notifyActionError(e, t("playlists.moveFailed"));
restoreFromCache(selectedId); // the row is still in this list
}
@@ -600,7 +600,7 @@ export default function Playlists() {
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
})
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
// No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
}
@@ -896,7 +896,7 @@ export default function Playlists() {
{t("playlists.playAll")}
</Button>
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
menu (E28 C1), the same the rail row opens — the header keeps Play all. */}
menu, the same the rail row opens — the header keeps Play all. */}
<MoreButton
ref={renameTarget}
variant="outline"
@@ -1016,7 +1016,7 @@ export default function Playlists() {
dnd-kit has handled). */}
<NavGrid
as="tbody"
// Delete on a walked row = the menu's Remove (E28 C1), with the same undo toast.
// Delete on a walked row = the menu's Remove, with the same undo toast.
onDelete={
canEdit
? (i) => {
@@ -2,7 +2,7 @@
//
// The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render
// the row as a NEW button; without a refocus the keyboard user was dropped on <body> and lost their
// place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is
// place (measured in Chrome). Everything around the rail that is
// not the rename path is stubbed.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
@@ -107,7 +107,7 @@ describe("PlaylistsRail · rename from the keyboard", () => {
it("Escape cancels and puts the focus back on the row", async () => {
const field = await openRename();
fireEvent.change(field, { target: { value: "alpha2" } });
// Cancelled: the closing key must not go on to press the row the focus lands on (E28 audit).
// Cancelled: the closing key must not go on to press the row the focus lands on.
expect(fireEvent.keyDown(field, { key: "Escape" })).toBe(false);
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
expect(rename).not.toHaveBeenCalled();
@@ -122,7 +122,7 @@ describe("PlaylistsRail · rename from the keyboard", () => {
});
});
// E28 C1: the row's "…" opens the same menu as its right-click — the list's one action list.
// The row's "…" opens the same menu as its right-click — the list's one action list.
describe("PlaylistsRail · the row's '…'", () => {
it("opens the playlist's menu under the row, and says so", async () => {
vi.spyOn(api, "playlists").mockResolvedValue([alpha]);
+5 -5
View File
@@ -70,7 +70,7 @@ export default function PlaylistsRail({
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
// "the one I just touched, first". Reads `updated_at`, which the API bumps for item
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
// default: a rail that reshuffles itself while you look at it costs more than it saves.
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
@@ -178,9 +178,9 @@ export default function PlaylistsRail({
});
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
// this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
// S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
// this a keyboard user was dropped on <body> and lost their place in the rail.
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome, a
// focus issued after the commit sticks and nothing takes it back — the first attempt that
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
// blur-commit means the user put the focus somewhere else on purpose.
const refocusRowRef = useRef<number | null>(null);
@@ -189,7 +189,7 @@ export default function PlaylistsRail({
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
// return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
// return, so the field simply mounts and is focused here; measured in Chrome, the field is
// focused and survives.
useEffect(() => {
if (renamingId !== null) renameInputRef.current?.focus();
+1 -1
View File
@@ -120,7 +120,7 @@ export default function PlexBrowse() {
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
const fieldState = healPlexField(field, scope);
// Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search
// Discover is the landing; the flat grid is the "Browse all" secondary view. A header search
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
@@ -84,7 +84,7 @@ export default function PlexCollectionEditor({
} catch (e) {
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
// simply never appeared and the user clicked again — a collection deleted on the Plex server
// or flipped read-only failed in silence (deep-review round 5).
// or flipped read-only failed in silence.
notifyActionError(e, t("plex.collection.editFailed"));
}
setBusy(null);
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// S4 Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
// behaviour the deep-review flagged as untested. A leaf member queues as itself; a show member is
// Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
// behaviour a review flagged as untested. A leaf member queues as itself; a show member is
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
@@ -142,7 +142,7 @@ describe("PlexCollectionView — Play-all", () => {
});
});
// Bug-hunt deep-review: the Sort control put the sort into the detail query's key, and two things
// Bug hunt: the Sort control put the sort into the detail query's key, and two things
// on this page did not follow it.
describe("PlexCollectionView — after the sort joined the query key", () => {
it("flips a poster's watched state on the page it was clicked on", async () => {
@@ -212,7 +212,7 @@ describe("PlexCollectionView — after the sort joined the query key", () => {
});
});
describe("PlexCollectionView — the reader's language (E30)", () => {
describe("PlexCollectionView — the reader's language", () => {
it("asks for the members, and a show it expands, in Hungarian", async () => {
setLanguage("hu");
try {
@@ -228,7 +228,7 @@ describe("PlexCollectionView — the reader's language (E30)", () => {
});
});
describe("PlexCollectionView — the summary (E30 review)", () => {
describe("PlexCollectionView — the summary", () => {
it("offers no More under a summary that fits", async () => {
vi.spyOn(api, "plexCollection").mockResolvedValue({
...detail([card("m1", "movie")]),
@@ -282,7 +282,7 @@ export default function PlexCollectionView({
posterDisabled={!canPlay || queueBusy}
showPlayOverlay={canPlay}
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
// Sized to the text beside it (E31, the owner's pick): a title, one meta line and a short
// Sized to the text beside it (the owner's pick): a title, one meta line and a short
// description sat beside a show-sized poster, two thirds of the hero empty under them.
posterSizeClass="w-20 sm:w-24"
customizeMenu={null}
@@ -423,7 +423,7 @@ export default function PlexCollectionView({
</div>
</div>
{/* Two columns on a phone, like every other poster grid (≥140px a card): three left
an ~85px card whose title shared its line with the touch "…" (E28 C1). */}
an ~85px card whose title shared its line with the touch "…". */}
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c, i) => (
<PlexPosterCard
@@ -451,7 +451,7 @@ export default function PlexCollectionView({
canEdit
? [
{
// A write to the shared Plex library: a menu row since E28 C1 (the card's hover
// A write to the shared Plex library: a menu row (the card's hover
// row owns its corner now), still behind the confirmation in `doRemove`.
id: "collection-remove",
label: t("plex.collection.removeItem"),
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E5 S4: the Plex Discover landing. Covers the rail surface served from the mocked /discover +
// The Plex Discover landing. Covers the rail surface served from the mocked /discover +
// /surprise calls — that each non-empty rail renders, the genre tile fires onGenre, the recently-added
// "see all" fires onSeeAllRecent, the ↻ reshuffle refetches the surprise rail alone, and an all-empty
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
@@ -272,7 +272,7 @@ describe("PlexDiscover", () => {
});
});
// E11 S4b: every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
// Every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
// from one to the next in page order (owner decision D4).
describe("PlexDiscover · keyboard", () => {
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
+1 -1
View File
@@ -17,7 +17,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
import { IconButton } from "./ui/IconButton";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
// The Plex "Discover" landing: a stack of horizontal Carousel rails served from ONE
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its
// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click /
+10 -10
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E5 S5: the enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
// The enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
// multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios /
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
@@ -104,7 +104,7 @@ describe("PlexInfo enriched detail (page)", () => {
expect(screen.getByText("$160M")).toBeTruthy();
expect(screen.getByText("$830M")).toBeTruthy();
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
// E30: a coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
// A coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy();
});
@@ -115,7 +115,7 @@ describe("PlexInfo enriched detail (page)", () => {
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
});
// E28: every value that filters says so on hover — the genre, certificate and studio had no hint
// Every value that filters says so on hover — the genre, certificate and studio had no hint
// while the keyword and the year did — and a value filters in the facts panel as in the header.
it("every filtering value carries a filter hint", () => {
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={() => {}} />);
@@ -139,7 +139,7 @@ describe("PlexInfo enriched detail (page)", () => {
const onOpenPerson = vi.fn();
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
expect(screen.getByText("plex.info.crew")).toBeTruthy();
// E30: the job is TMDB's English word, named through the vocab (key-echo i18n here).
// The job is TMDB's English word, named through the vocab (key-echo i18n here).
expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy();
fireEvent.click(screen.getByText("Jane Director"));
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
@@ -173,9 +173,9 @@ describe("PlexInfo enriched detail (page)", () => {
});
});
// E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
// E31 (the owner's pick "A"): the hero's text stopped at ~55% of the panel. The facts and the key crew are
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
// its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under
// the hero only the collection strips and the cast remain, stacked.
describe("PlexInfo — the hero's second column (page)", () => {
@@ -252,7 +252,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
expect(follows(heading("Test Movie"), kw)).toBe(true);
});
// E31: the hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
// The hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
// a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio.
it("keeps the poster at its own height, not stretched to the row", () => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
@@ -261,7 +261,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
expect(poster.className.split(/\s+/)).toContain("self-start");
});
// E31 (the owner's call): twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
// The owner's call: twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
// the rest behind "Show N more"; a list barely longer shows whole.
it("shows the first crew names and opens the rest on demand", () => {
const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" }));
@@ -301,7 +301,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
});
// E32 S2: the cast row is the shared CastRow now; the page and the player's overlay hand it their
// The cast row is the shared CastRow now; the page and the player's overlay hand it their
// own palette. Only the overlay sits on the dark art, so only it may carry the white text.
it.each([
["page", false],
@@ -313,7 +313,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
});
});
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
// A collection sibling is a Plex card like any other — its "…" menu, its watched
// toggle and its playlist add, as on the related strip — where it used to be a Play and nothing else.
describe("a collection strip's card", () => {
beforeAll(() => {
+8 -9
View File
@@ -38,7 +38,7 @@ import { vocab } from "../lib/vocab";
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew) render
// on the page variant only; the overlay stays lean.
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
@@ -74,7 +74,7 @@ function Score({
);
}
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
// Multi-source ratings cluster: IMDb (links out) · TMDb (with vote count) · Plex audience —
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
function RatingsRow({
@@ -181,8 +181,7 @@ type Props = {
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
onOpenPerson?: (name: string) => void;
// Play a sibling title from a collection strip: the page opens its player, the overlay plays it
// in the running one. Required, so no variant draws a collection card whose Play does nothing
// (E28 review D1).
// in the running one. Required, so no variant draws a collection card whose Play does nothing.
onPlayItem: (id: string, fromStart?: boolean) => void;
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
library?: string;
@@ -229,7 +228,7 @@ export default function PlexInfo({
const [playlistOpen, setPlaylistOpen] = useState(false);
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
// its TMDB backdrops (E5 S2), and its collection siblings' art (E5 S8). Cleared in the overlay
// its TMDB backdrops, and its collection siblings' art. Cleared in the overlay
// variant / when disabled / on unmount.
const backdropPool = [
detail.art,
@@ -251,7 +250,7 @@ export default function PlexInfo({
onStateChange?.();
};
// A collection sibling is a Plex card like any other (E28 review D2): the related strip's menu, its
// A collection sibling is a Plex card like any other: the related strip's menu, its
// watched toggle and its playlist add. The flip lands on this item's cached detail, whose strip it
// is; the resync corrects it to the truth either way.
const colMenu = useContextMenu<PlexCard>();
@@ -416,7 +415,7 @@ export default function PlexInfo({
<PlaybackChip detail={detail} overlay={overlay} />
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
{/* Multi-source ratings row: IMDb / TMDb / audience, whichever are present. */}
<div className="mt-2">
<RatingsRow
imdbRating={detail.imdb_rating}
@@ -539,7 +538,7 @@ export default function PlexInfo({
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over
sat beside them moved up into the hero's second column. The overlay (over
the running player) is the same stack, lean. */}
{(() => {
const collections = (detail.collections ?? []).filter(
@@ -568,7 +567,7 @@ export default function PlexInfo({
</div>
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav>
{col.items.map((m, i) => (
// The one media card, as on every other strip (E28 S3h). A grid cell, so the
// The one media card, as on every other strip. A grid cell, so the
// card fills the slot the carousel stretches to its tallest card.
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 C1: a Plex title's ONE action list. The poster's right-click and its "…" open the same menu,
// A Plex title's ONE action list. The poster's right-click and its "…" open the same menu,
// which holds what the card no longer shows (details, copy, the pin) beside the everyday toggles;
// the card's hover "add" and the menu's "Add to playlist" open the same dialog.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E5 S4: the actor detail page. Covers the two pieces the /code-review flagged as untested — the
// The actor detail page. Covers the two pieces the /code-review flagged as untested — the
// cast-row rewiring (a cast click now OPENS the person page via onOpenPerson, falling back to the old
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
@@ -117,11 +117,11 @@ describe("PlexPersonView", () => {
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
expect(screen.getByText("plex.person.shows")).toBeTruthy();
// E5 S6 bio hero: department, biography, birth facts, aka.
// E30: TMDB's English department, named through the vocab (key-echo i18n here).
// The bio hero: department, biography, birth facts, aka.
// TMDB's English department, named through the vocab (key-echo i18n here).
expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
// E30 S3: the birth date as a date in the UI language, not the raw ISO string
// The birth date as a date in the UI language, not the raw ISO string
expect(screen.getByText("Jan 1, 1970 · Springfield")).toBeTruthy();
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
expect(screen.getByText(/Janie D/)).toBeTruthy();
@@ -157,7 +157,7 @@ describe("PlexPersonView", () => {
});
});
describe("PlexPersonView — the reader's language (E30)", () => {
describe("PlexPersonView — the reader's language", () => {
it("asks for the filmography in Hungarian", async () => {
setLanguage("hu");
try {
@@ -189,7 +189,7 @@ describe("PlexPersonView — the reader's language (E30)", () => {
const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
describe("PlexPersonView — a bio on its way", () => {
const view = (
<PlexPersonView
name="Jane Doe"
@@ -320,7 +320,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
});
});
describe("PlexPersonView — the hero's columns (E30 S6)", () => {
describe("PlexPersonView — the hero's columns", () => {
const heroGrid = async (knownFor: number) => {
plexPerson.mockReset();
plexPerson.mockResolvedValue({
@@ -5,7 +5,7 @@
// player's OWN menus push layers, so ⌫ stopped closing them), then naming just the sheet and the
// palette (the app-wide ErrorDialog is neither, so ⌫ tore the player down under it). The rule that
// holds: the player's own panels answer first; anything else layered above it takes the key. It had
// no test (E10 review coverage debt).
// no test.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// The Plex player's controls on touch and the C5 control set (DESIGN "Players", E28 S3f2): a tap on
// The Plex player's controls on touch and the C5 control set (DESIGN "Players"): a tap on
// the picture only shows or hides the controls, a double-tap on a side third skips 10 s, Back goes by
// position, Next exists only with a next item, the rare tools sit behind a fixed ⋯, and the card's
// Restart opens the player at 0. jsdom has no media pipeline: the <video> is stubbed as in
@@ -226,7 +226,7 @@ describe("a tap on the picture (C3)", () => {
});
});
// E28 review E6: the volume slider counted as a text field, so its arrows never woke the bands — the
// The volume slider counted as a text field, so its arrows never woke the bands — the
// bar faded from under a keyboard user adjusting it — and nothing held the bands while a key-focused
// control sat in them.
describe("keyboard focus in the bands", () => {
@@ -436,7 +436,7 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
expect(session.mock.calls.length).toBe(calls);
});
// E28 review E5: the tap that brings the bands back must not hand the SECOND tap to them — a double-tap
// The tap that brings the bands back must not hand the SECOND tap to them — a double-tap
// at the bottom would land on the seek bar (a seek to wherever the finger was) instead of the skip.
it("a tap that reveals the bands keeps them out of the finger's way for the double-tap window", async () => {
mount({ details: { A: item("A") } });
@@ -464,7 +464,7 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
});
});
// E28 review D1: the info overlay drew its collection siblings with a Play that did nothing — the
// The info overlay drew its collection siblings with a Play that did nothing — the
// overlay had no way to play one. It plays it in this player, at its resume point or from the start.
describe("a collection sibling in the info overlay", () => {
const withSibling = (id: string): PlexItemDetail => ({
@@ -671,7 +671,7 @@ describe("the C5 row", () => {
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
});
// E28 review D3: the ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
// The ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
// closed it with the focus left on <body>. It is a menu on the popover engine now.
it("↑/↓ on the closed ⋯ and tracks buttons are the volume, not an open menu", async () => {
const two = item("A");
@@ -742,7 +742,7 @@ describe("the C5 row", () => {
await waitFor(() => expect(document.activeElement).toBe(more));
});
// E28 review E4: an episode's Next comes from its detail, which is unknown while the next episode
// An episode's Next comes from its detail, which is unknown while the next episode
// loads — the button unmounted from under the focus that had just pressed it (focus on <body>).
it("Next keeps the focus while the next item's detail loads", async () => {
let arrive: (d: PlexItemDetail) => void = () => {};
@@ -158,7 +158,7 @@ describe("an HLS session the server no longer has", () => {
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
// spinner that never became an error (round 5).
// spinner that never became an error.
const session = mount();
const v = () => document.querySelector("video") as Media;
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
@@ -178,7 +178,7 @@ describe("an HLS session the server no longer has", () => {
// The budget used to be cleared by the first FRAG_LOADED of any session — and a reclaimed
// session essentially always delivers one, because `start_session` does not return until its
// first segment exists. So it bounded NEITHER case its own comment named: measured at 40 loss
// cycles, the player opened 41 sessions and never showed an error (deep-review round 6). A
// cycles, the player opened 41 sessions and never showed an error. A
// fragment is not proof the session can be served; surviving the window is.
const session = mount();
const v = () => document.querySelector("video") as Media;
@@ -227,7 +227,7 @@ describe("an HLS session the server no longer has", () => {
// loss and the bound never bit: a transcode falling ~70s behind realtime — the case the ERROR
// handler itself names — reclaimed forever (measured: 200 losses 70s apart → 200 reclaims,
// never an error). The two cases above space their losses 0s and 10min apart, i.e. both sit
// outside the 60–120s band where the defect lived (deep-review round 7).
// outside the 60–120s band where the defect lived.
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
try {
const session = mount();
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// The Plex player's session lifecycle, rendered for real (deep-review round 3 of bug hunt 2026-09).
// The Plex player's session lifecycle, rendered for real.
//
// jsdom has no media pipeline, so the <video> is a small stand-in that behaves like Chrome where it
// matters here: `pause()` fires its `timeupdate` in a LATER task (measured in Chrome — which is how the
@@ -279,7 +279,7 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
});
// --- deep-review round 4 ------------------------------------------------------------------------
// --- leaving a played-through item, restarts, failed requests -----------------------------------
/** A session answer that FAILS when the test says so. */
function deferredFailure() {
@@ -300,8 +300,7 @@ describe("leaving an item that was played through", () => {
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up
// (deep-review round 5).
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up.
const endMedia = () => {
const el = video();
Object.defineProperty(el, "ended", { configurable: true, get: () => true });
@@ -361,7 +360,7 @@ describe("leaving an item that was played through", () => {
// The mark was fired and forgotten (`.catch(() => {})`) while the cache was patched to
// watched / position 0 regardless, and the once-per-id start guard meant the corrected detail
// arriving later was never used: the episode reopened at 0:00 with a Watched badge the server
// disagreed with (round 5).
// disagreed with.
prefs({ upNext: false });
const { qc, progress } = mount({
details: { A: item("A"), B: item("B") },
@@ -379,19 +378,19 @@ describe("leaving an item that was played through", () => {
// And the rollback does NOT write the position back. 2995 of 3000 is inside the backend's
// finish margin, so that checkpoint would have set watched / position 0 and scrobbled to Plex
// — performing, through a second route, the very write that was just refused, while the error
// toast said it had failed (round 6). There is no resume point at the end of the media to
// toast said it had failed. There is no resume point at the end of the media to
// restore; the cache drop is the whole of the rollback here.
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
});
it("performs the refused mark through no OTHER route while it stays on the finished item", async () => {
// Round 6 guarded the explicit rollback write, but the same catch lowered the play-through
// A fix guarded the explicit rollback write, but the same catch lowered the play-through
// latch — and that latch is the only gate on the 10s interval, the page-hide beacon and
// `settle`. All three post `absRef`, which on this path IS the end of the media, and the
// backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish: within ten
// seconds of the toast saying the mark had failed, the item was watched, its position cleared
// and a scrobble sent to Plex (deep-review round 7). `upNext` is ON here — the default — so the
// player STAYS on the finished item and those routes are live; the round-6 test above turns it
// and a scrobble sent to Plex. `upNext` is ON here — the default — so the
// player STAYS on the finished item and those routes are live; the test above turns it
// off, which tears the effect down before any of them can fire.
prefs({ upNext: true });
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
@@ -467,7 +466,7 @@ describe("leaving an item that was played through", () => {
it("records a second viewing when the viewer plays on from the finished frame", async () => {
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
// discarded a whole rewatch with the gate green (round 5).
// discarded a whole rewatch with the gate green.
prefs({ upNext: true });
const { progress } = mount({
details: { A: item("A"), B: item("B") },
@@ -1,11 +1,11 @@
// @vitest-environment jsdom
//
// The Plex player paints an opaque, full-viewport layer over the whole app — and until deep-review
// round 4 it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
// The Plex player paints an opaque, full-viewport layer over the whole app — and for a long
// time it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
// from its menus, or from the debug recorder's pill (which docks in THIS player's toast host) walked
// straight out into the page behind it, where the focus ring is invisible and the next Enter fires
// something the user cannot see. `ui/Modal`, `PlayerModal`, `OnboardingWizard` and `Welcome` all trap;
// the round-3 comment that handed the step prompt's trap to "the dialog below" assumed this one did
// an earlier comment that handed the step prompt's trap to "the dialog below" assumed this one did
// too. Its own file because `layerStack` is module state and these tests push into it.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor, act, screen } from "@testing-library/react";
+27 -28
View File
@@ -114,7 +114,7 @@ const PLAYER_MENU =
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
// the highlight's white text cannot wash out the way it did the accent-coloured label (round 2, P6).
// the highlight's white text cannot wash out the way it did the accent-coloured label.
const TRACK_ROW =
"flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
const TAP_SKIP_S = 10;
@@ -139,12 +139,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
// position (losing live progress). See the detail effect's deps.
const tRef = useLatest(t);
// E30: a track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
// A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
const qc = useQueryClient();
const [id, setId] = useState(itemId);
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
// Serve the player's title/summary/rich detail in the active language, matching the grid.
const lang = plexLang(i18n.language);
const detailQ = useQuery({
queryKey: qk.plexItem(id, lang),
@@ -160,7 +160,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
// A control that goes while it holds the focus — Next onto the last item or a series finale —
// hands it to its neighbour instead of <body> (round 2, P5). The player is portaled outside the
// hands it to its neighbour instead of <body>. The player is portaled outside the
// page scroller, whose keeper does not watch it.
useFocusKeeper(wrapRef);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
@@ -185,7 +185,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Whether the session being asked for plays once it can — null while none is out. Set from
// loadSession's `resumePlay` and then the viewer's to change: until the answer attaches, the media
// is the OUTGOING one, and a play() there sounded the old stream while the new one still attached
// paused (E28 review round 4). `said`: the viewer changed it, so it is what they now see.
// paused. `said`: the viewer changed it, so it is what they now see.
const pendingPlayRef = useRef<{ play: boolean; said?: boolean } | null>(null);
// The key of the last press that landed on faded bands, while it is held (see onKey).
const blindKeyRef = useRef<string | null>(null);
@@ -279,7 +279,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
videoRef,
});
// The pointer resting on either band holds the controls up (F22), and so does a keyboard focus in
// one (E6) — not the focus a click leaves on a button, which would pin the bar until the next click;
// one — not the focus a click leaves on a button, which would pin the bar until the next click;
// `:focus-visible` is the browser's own "this focus came from the keyboard". Both bands wear these.
const holdWhileHovered = {
"data-player-band": "", // what a key on a faded-out control is told apart by (onKey, below)
@@ -294,7 +294,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
// what closes it (below).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
// The ⋯ and the tracks list are real menus on the popover engine (E28 review D3): roving arrows,
// The ⋯ and the tracks list are real menus on the popover engine: roving arrows,
// Home/End, Escape as a layer, and the focus back on the trigger however they close. They were
// hand-built <div>s — no roles, no arrows, and Escape left the focus on <body>. The engine portals
// them into the fullscreen element when there is one, so they show in fullscreen too.
@@ -335,8 +335,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const helpOpen = useKeyboardHelpOpen();
const helpOpenRef = useLatest(helpOpen);
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
// E5, but silently — you changed the track and the only way to see what you landed on was to open
// On-screen confirmation for a KEYBOARD track switch. A/S have long cycled audio and
// subtitles, but silently — you changed the track and the only way to see what you landed on was to open
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
// itself needs no OSD: it shows the selection where you clicked.
const [osd, setOsd] = useState<string | null>(null);
@@ -358,7 +358,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
const [statsOpen, setStatsOpen] = useState(false);
const statsOpenRef = useLatest(statsOpen);
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump.
const [upNextOpen, setUpNextOpen] = useState(false);
const upNextOpenRef = useLatest(upNextOpen);
const upNextPrefRef = useLatest(prefs.upNext);
@@ -540,7 +540,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// server also answers 404 for "that segment is not there YET" — indistinguishable from "the
// session is gone" on the wire — so a transcode that falls behind realtime, or two viewers of
// one item taking turns reclaiming its session, restarted ffmpeg forever behind a spinner
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
// that never became an error. A couple of reclaims is what a genuine
// reap needs; past that the honest answer is that playback failed.
hls.on(Hls.Events.ERROR, (_e, data) => {
// Fatal ones are what the user saw fail; the recoverable ones hls.js retries by itself
@@ -568,8 +568,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// longer than half the window satisfied the reset on every second loss, so the budget
// never reached its bound and the reclaim loop it exists to end ran forever (measured:
// 200 losses 70s apart → 200 reclaims, never an error; 5s apart → 2 and an error, which
// is why the committed test, spacing its cases 0s and 10min apart, could not see it —
// deep-review round 7).
// is why the committed test, spacing its cases 0s and 10min apart, could not see it).
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS) restartsRef.current.n = 0;
restartsRef.current.at = now;
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
@@ -652,7 +651,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// reconnect (Wi-Fi blips, the query is stale after 30 s and every checkpoint moved the position),
// the in-player info panel invalidates it after a watched toggle, a language switch fetches it
// again. Keyed on its identity, each of those restarted the film at the last saved checkpoint — or
// at 0 after marking it watched (deep-review round 4). The trade-off: a detail that was stale in
// at 0 after marking it watched. The trade-off: a detail that was stale in
// the cache when the player opened is not corrected by the refetch that follows; the player's own
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
const startedForRef = useRef<string | null>(null);
@@ -878,7 +877,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
if (otherId && otherId === id) {
// The item already playing (the info overlay's collection strip lists the film itself):
// nothing to switch to. The switch below would pause it and stop trusting its clock, and
// `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1). The play
// `setId` to the same id loads nothing — a frozen 0:00. The play
// intent goes INTO the seek: a paused film whose session starts past 0 restarts the session,
// and a play() beside that resumed the outgoing stream while the new one stayed paused.
if (fromZero) seekTo(0, true);
@@ -927,7 +926,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
: detail?.next_id;
// Whether the row has a Next. An episode's next is in its detail, unknown while the next episode
// loads; the slot keeps its last answer meanwhile (a disabled Next), because the button unmounting
// took the focus that had just pressed it to <body> (E28 review E4). Adjusted during render.
// took the focus that had just pressed it to <body>. Adjusted during render.
const nextKnown = queue || !detailQ.isPending ? !!nextId : undefined;
const [nextSlot, setNextSlot] = useState(false);
if (nextKnown !== undefined && nextKnown !== nextSlot) setNextSlot(nextKnown);
@@ -952,7 +951,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
null,
);
// A tap that brings the bands back keeps them out of the pointer's way until a double-tap can no
// longer follow (E28 review E5): the second tap of a skip at the bottom would otherwise land on the
// longer follow: the second tap of a skip at the bottom would otherwise land on the
// band the first one just revealed — the seek bar, a seek to wherever the finger was.
const [tapShield, setTapShield] = useState(false);
const tapShieldTimer = useRef<number | undefined>(undefined);
@@ -1082,7 +1081,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// the server refuses the mark, in which case `finish` takes itself back (see usePlexProgress).
finishProgress(watchedWrite);
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
// and let IT advance — binge stays in the viewer's control. The card only appears here,
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
else if (nextId) go(nextId);
@@ -1145,7 +1144,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Its toast host's rank among open overlays (see ui/Toaster).
const overlayOrder = useOverlayOrder();
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
// settings/tracks menus included) takes Escape first and this player defers.
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
@@ -1185,7 +1184,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Keyboard use also reveals the controls — but typing in a player field is not "using the
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
// opts in), which is why this is a guard around `wake` rather than an early return.
// A slider is not a text field: its arrows are the remote too (E28 review E6).
// A slider is not a text field: its arrows are the remote too.
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
wake();
if (swallow) {
@@ -1196,7 +1195,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
// PlayerModal, OnboardingWizard, Welcome); this one never did, and deep-review round 4 caught
// PlayerModal, OnboardingWizard, Welcome); this one never did, and a review caught
// it when the recorder's step prompt — which docks in this player's toast host — handed its
// own trap to "the dialog below" and found none. Only while nothing that owns the keyboard is
// layered above: a Modal opened over the player traps for itself. `wake()` above has already
@@ -1257,10 +1256,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Back button (shared cascade).
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
// and its focused close button is not a typing target, so the key reaches us regardless.
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
// Two wrong answers so far: deferring to nothing at all (the first bug — the cheat sheet
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
// (round 1's fix, round 2's bug — the player's OWN gear/tracks popovers push layers too, so
// ⌫ stopped closing them), then naming just the sheet and palette (round 2's fix, round 3's
// (the first fix, the second bug — the player's OWN gear/tracks popovers push layers too, so
// ⌫ stopped closing them), then naming just the sheet and palette (the second fix, the third
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
// The rule that holds: the player's own panels answer first, because `handleBack`'s cascade
// is what closes them; anything else layered above us takes the key.
@@ -1465,7 +1464,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
above the track-switch OSD so the two never overlap. Both notices are `z-base`, under the
settings and tracks popovers (`z-menu`): an open menu is never drawn over (E28 audit). */}
settings and tracks popovers (`z-menu`): an open menu is never drawn over. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-36 z-base flex justify-center px-4"
aria-live="polite"
@@ -1490,8 +1489,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
The REGION is always mounted and only the bubble is conditional: a polite region has to
pre-exist in the accessibility tree for a later insertion to be announced, so one created
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
conditioning the wrapper re-created it for every switch, not just the first (E10 review
round 1). Toaster.tsx carries the same rule and the same shape. */}
conditioning the wrapper re-created it for every switch, not just the first.
Toaster.tsx carries the same rule and the same shape. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-24 z-base flex justify-center px-4"
aria-live="polite"
@@ -1953,7 +1952,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
detail={detail}
variant="overlay"
onClose={() => setInfoOpen(false)}
// A collection sibling plays right here (E28 review D1), as Next would.
// A collection sibling plays right here, as Next would.
onPlayItem={(other, fromZero) => {
setInfoOpen(false);
go(other, undefined, fromZero);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 review D5 + L5 + D6: the Plex player's settings panel. Its section switch was a row of
// The Plex player's settings panel. Its section switch was a row of
// `aria-pressed` buttons (now an ARIA tab list on `ui/Tabs`), its rows sat under the 44 touch target,
// its on/off rows were a hand-laid label (now `CheckRow`), and its sliders and colour wells had no
// accessible name — the visible label beside them was not tied to them.
@@ -21,7 +21,7 @@ export function PlexPlayerSettings(props: {
// about that and 18's types let it.
menuRef: RefObject<HTMLDivElement | null>;
/** The ⋯ that opened the panel: the focus goes back to it when the panel closes with the focus
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body> (E28 review D3). */
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body>. */
returnFocusTo: RefObject<HTMLElement | null>;
settingsTabs: { key: SettingsTab; label: string }[];
activeTab: SettingsTab;
+2 -2
View File
@@ -62,8 +62,8 @@ export default function PlexPlaylistAdd({
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
// another device (a 404 `not_found/playlist` — still offered here, from a cache up to 30 s old)
// was an unhandled rejection: `req` leaves 403/404/429 to the caller, so nothing was shown, the
// tick never flipped and the gone row stayed. Round 5 swept the collection editor and its own
// YouTube twin (`AddToPlaylist`) and missed this one (round 6).
// tick never flipped and the gone row stayed. A sweep fixed the collection editor and its own
// YouTube twin (`AddToPlaylist`) and missed this one.
async function toggle(p: PlexPlaylist) {
if (busy) return;
setBusy(true);
@@ -2,10 +2,10 @@
//
// One accepted write, one cache entry that matches it — the Plex half.
//
// `Playlists.tsx` (the YouTube list) got this rule in round 5: the detail cache is what a rollback
// `Playlists.tsx` (the YouTube list) got this rule first: the detail cache is what a rollback
// reads and what a re-entry inside the 30 s staleTime renders without refetching, so an accepted
// reorder that is not written there leaves the cache disagreeing with the server. The Plex list got
// the rollback half of that round's fix and not this half (round 6).
// the rollback half of that fix and not this half.
//
// Its own file: `@dnd-kit/core` is mocked module-wide so the test can fire `onDragEnd` directly —
// dnd-kit's pointer sensor does not activate under jsdom (no layout, no pointer capture).
@@ -64,10 +64,10 @@ const movie = (id: string) =>
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
it("writes the order the server accepted into the detail cache, without waiting for a refetch", async () => {
// The refetch that same handler schedules (round 7) must not be what makes the cache right: the
// The refetch that same handler schedules must not be what makes the cache right: the
// point of the write is that a re-entry inside the 30 s staleTime renders the accepted order
// immediately, with no request at all. So the refetch is HELD OPEN here — if this assertion
// needed it to land, deleting the write would still pass and round 6's fix would be unguarded.
// needed it to land, deleting the write would still pass and that fix would be unguarded.
let calls = 0;
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
++calls === 1
@@ -101,7 +101,7 @@ it("refetches the detail too, so a membership change that landed meanwhile is no
// surface, an item added elsewhere), and with only the LIST key invalidated nothing corrected it:
// staleTime is 30 s and refetchOnWindowFocus is off, so the phantom row stayed on screen for as
// long as the view was mounted and the next drag persisted an order built from it. The YouTube
// sibling invalidates the detail key right after writing it; this half did not (round 7).
// sibling invalidates the detail key right after writing it; this half did not.
let calls = 0;
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
Promise.resolve(

Some files were not shown because too many files have changed in this diff Show More