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
+2 -2
View File
@@ -23,7 +23,7 @@ beforeEach(() => {
describe("a Plex collection name that is already taken", () => {
it("is reported once, in the reader's language", async () => {
// The 409 was a bare English string: the global error dialog showed it in English, and the
// editor's own toast said the same thing again, translated (deep-review round 4).
// editor's own toast said the same thing again, translated.
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "collection", reason: "name_taken" } }), {
status: 409,
@@ -78,7 +78,7 @@ describe("notifyActionError", () => {
it("says a bare-English server reason in the reader's language, not the server's", () => {
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
// English a route hand-wrote went straight into the toast — "No user with that email." in an
// otherwise Hungarian UI, while the caller's translated fallback sat unused (round 5).
// otherwise Hungarian UI, while the caller's translated fallback sat unused.
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
+1 -1
View File
@@ -5,7 +5,7 @@
// unchanged,
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.)
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
+3 -3
View File
@@ -31,11 +31,11 @@ export interface Me {
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
email_configured: boolean;
preferences: Record<string, unknown>;
/** The consented diagnostics level in force and when it lapses (E22 S5). */
/** The consented diagnostics level in force and when it lapses. */
diagnostics: DiagnosticsState;
/** The debug session recording right now, if any (E16) — a reload resumes it. */
/** The debug session recording right now, if any — a reload resumes it. */
debug_session: DebugSession | null;
/** Where a bug report goes — the tracker's repository — or null: download only (E16 S4). */
/** Where a bug report goes — the tracker's repository — or null: download only. */
issues: { repo: string } | null;
}
+5 -5
View File
@@ -55,7 +55,7 @@ export interface ConfigItem {
}
export interface SystemConfigData {
groups: Record<string, ConfigItem[]>;
/** The page's headings, in order, each with its groups in order; every group is in one (E31). */
/** The page's headings, in order, each with its groups in order; every group is in one. */
sections: { id: string; groups: string[] }[];
secrets_manageable: boolean;
}
@@ -74,14 +74,14 @@ export interface AdminUserRow {
* account) from "we took it away" — different problems needing different help. */
password_revoked_at: string | null;
has_google: boolean;
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
/** The Google account's address, ONLY when it differs from the account address. */
google_email: string | null;
created_at: string | null;
/** Last sign-in (Google or password). Null = not since 0.88 (no backfill). */
last_login_at: string | null;
}
/** The account panel's view of why someone can or cannot get in (E12 S5). */
/** The account panel's view of why someone can or cannot get in. */
export interface AdminUserAccess extends AdminUserRow {
/** The address passes the invite gate — so Google sign-in will be let through, not filed as a
* fresh access request. */
@@ -193,7 +193,7 @@ export interface SchedulerJob {
last_started: string | null;
last_finished: string | null;
last_result: string | null; // the English one-liner — the fallback for a job with no `result`
// The last result as data (E30): counts, a `skipped` / `reason` word, a nested dict per phase —
// The last result as data: counts, a `skipped` / `reason` word, a nested dict per phase —
// named in the reader's language by lib/jobResult.
result: JobResult | null;
last_error: string | null;
@@ -241,7 +241,7 @@ export const adminApi = {
req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
/** The bug-report tracker (E16): answers, takes issues, and is private. */
/** The bug-report tracker: answers, takes issues, and is private. */
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
req("/api/admin/config/test-issues", { method: "POST" }),
// --- users & roles ---
+3 -3
View File
@@ -6,7 +6,7 @@ import { req, uiLang } from "./core";
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
* so the card can render an honest state instead of a red line; `invalid` and `rate_limited` answer
* BEFORE the password is proven and therefore carry nothing (they must stay identical for a real
* account and an unknown one). Since E9.5 S1 the gates are order-independent — read both flags, never
* account and an unknown one). The gates are order-independent — read both flags, never
* infer one from the other. */
export type LoginBlocked = {
reason: "email_unverified" | "pending_approval" | "suspended" | "invalid" | "rate_limited";
@@ -101,8 +101,8 @@ export const authApi = {
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
// `password_reset_required` = SEC-10 dropped the password this account registered with, so signing
// in would fail with the uniform credentials error. The card sends the user to the reset flow instead
// of leaving them to guess. Rare since E9.5 S1: only accounts stripped before that change, and the
// Google-adoption path, still reach it.
// of leaving them to guess. Rare now: only accounts stripped before the gates became
// order-independent, and the Google-adoption path, still reach it.
// `email` is the address that was just confirmed — echoed back so the banner can name it, which
// matters when the browser holds a session for a DIFFERENT account.
verifyEmail: (
+2 -2
View File
@@ -97,7 +97,7 @@ describe("uploadReq", () => {
// Both abort producers are asserted through `isAborted`, not through `{status: 0}`: what the
// upload dialog reads is the SENTINEL, and a cancelled upload that stops matching it becomes a
// red "the upload failed" toast. Changing either `reject` to a friendlier message left every
// test green, because the only consumer test hand-builds the error it expects (round 6).
// test green, because the only consumer test hand-builds the error it expects.
it("rejects immediately if the signal is already aborted (never sends)", async () => {
const ac = new AbortController();
ac.abort();
@@ -403,7 +403,7 @@ describe("req — what it has already put in front of the user", () => {
});
});
// E16: while a debug session records, every request path names it — the server tags ITS lines of
// While a debug session records, every request path names it — the server tags ITS lines of
// that request with it. A path that forgot the header would leave holes in the evidence exactly
// where the upload (XHR) or the unload beacon happened.
describe("the debug session a tab records into", () => {
+6 -6
View File
@@ -13,7 +13,7 @@ import { currentDebugSession, newRequestId } from "../log";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
* every caller is one every caller can forget, which is how the registration language shipped as a
* field nobody ever filled (E9.5 S6 → S7). */
* field nobody ever filled. */
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
// Errors `req` has already put in front of the user for good — the blocking error dialog, or the
@@ -24,7 +24,7 @@ export const uiLang = (): string => i18n.language.split("-")[0] || "en";
// NOT the connection notice. It says the server is unreachable, not that this action failed, and it
// removes itself on the next answer — which every caller's own resync refetch fetches at once. Marked
// as reported, a delete that hit a keepalive reset (a 502) flashed the notice for a moment and was
// then reported nowhere at all, the list still there (deep-review round 4).
// then reported nowhere at all, the list still there.
//
// A caller that reports a failure itself marks it too (`notifyActionError` does), which is what lets
// the ONE app-wide mutation reporter (lib/queryClient) stay quiet for a mutation whose own `onError`
@@ -96,7 +96,7 @@ function localizeDetail(d: unknown): string {
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals (E9.5 S4). The card renders the account-shaped ones as a full panel
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
@@ -109,7 +109,7 @@ function localizeDetail(d: unknown): string {
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
@@ -136,7 +136,7 @@ function localizeDetail(d: unknown): string {
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// E10 S2: the one-step "move to another list". Both reasons mean the same thing to a reader —
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
@@ -267,7 +267,7 @@ export const setupHeaders = (token: string) => ({
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
// The debug session this tab is recording (E16): the server tags its own lines of the request with
// The debug session this tab is recording: the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
+2 -2
View File
@@ -1,4 +1,4 @@
// The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and
// The user's diagnostics level and the browser log it lets out. Only touches core's req and
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log";
import type { ServerLogRow } from "./admin";
@@ -37,7 +37,7 @@ export const diagnosticsApi = {
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }),
};
// --- debug sessions (E16) ----------------------------------------------------------------------
// --- debug sessions ----------------------------------------------------------------------
/** The two levels a recording can run at — it always records more than the base. */
export type DebugSessionLevel = "DEBUG" | "TRACE";
+1 -1
View File
@@ -51,7 +51,7 @@ describe("discoverApi query strings", () => {
});
});
describe("the reader's language (E30)", () => {
describe("the reader's language", () => {
it("asks for Hungarian Plex cards on every Home read, and leaves English implied", async () => {
await discoverApi.discover("any", 0, "hu");
expect(lastUrl()).toContain("lang=hu");
+2 -2
View File
@@ -1,4 +1,4 @@
// The Home surface ("Mit nézzek most?", E24): one hero suggestion, a cross-source resume rail, and
// The Home surface ("Mit nézzek most?"): one hero suggestion, a cross-source resume rail, and
// the comfort shelf. Types mirror `backend/app/discover/` — the card carries the NATIVE feed / Plex
// payload under `video` / `plex`, so the page renders through the existing VideoCard /
// PlexPosterCard instead of a third card component.
@@ -71,7 +71,7 @@ export interface DiscoverPayload {
}
export const discoverApi = {
// `lang` "hu" serves a Plex card's Hungarian title (E30); en is the server default.
// `lang` "hu" serves a Plex card's Hungarian title; en is the server default.
discover: (budget: DiscoverBudget, seed: number, lang?: string): Promise<DiscoverPayload> =>
req(`/api/discover?budget=${budget}&seed=${seed}${lang === "hu" ? "&lang=hu" : ""}`),
// The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the
+1 -1
View File
@@ -194,7 +194,7 @@ export const downloadsApi = {
spec?: DownloadSpec;
display_name?: string;
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
// Ingest a local file into the library (E7 S5). Sent via XHR rather than `req` so the dialog can
// Ingest a local file into the library. Sent via XHR rather than `req` so the dialog can
// show upload progress; any file type is accepted (media gets the full function set, a generic
// file is download-only). Returns the already-`done` job the ingest creates.
// The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll } from "vitest";
// The R7.5 guard: lib/api.ts is a spread-only façade over per-domain slice modules. These tests
// The façade guard: lib/api.ts is a spread-only façade over per-domain slice modules. These tests
// mechanise the two invariants that make that structure safe — so a future edit that reintroduces
// the interleave (a method dropped into two slices → a silent last-wins spread override) or bloats
// the barrel (a method defined inline instead of in a domain module) fails CI instead of shipping.
+1 -1
View File
@@ -1,4 +1,4 @@
// E32 S2: lib/api/feed was barely covered, and it is where a filter turns into a query string.
// Lib/api/feed was barely covered, and it is where a filter turns into a query string.
// FastAPI IGNORES a query parameter it does not know, so a misspelt or renamed key does not fail —
// the filter simply stops applying while its chip still shows. Locked two ways: what each filter
// emits, and that every key emitted is one the backend's feed endpoints actually read.
+2 -2
View File
@@ -99,7 +99,7 @@ export interface FeedFilters {
year?: number;
}
/** One year's video count for the year control's chips + "more…" dialog (item 8 / E8·S2). */
/** One year's video count for the year control's chips + "more…" dialog. */
export interface YearCount {
year: number;
count: number;
@@ -169,7 +169,7 @@ export const feedApi = {
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
req(`/api/feed/summary?${filterParams(f).toString()}`),
// Per-year video counts for the year control (item 8 / E8·S2). The backend drops the publish-date
// Per-year video counts for the year control. The backend drops the publish-date
// narrowing, so callers key this on the date-stripped filters (picking a year mustn't refetch it).
feedYears: (f: FeedFilters): Promise<{ years: YearCount[] }> =>
req(`/api/feed/years?${filterParams(f).toString()}`),
+8 -8
View File
@@ -55,8 +55,8 @@ export interface PlexCard {
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
summary?: string | null; // movie/show hover-overview synopsis (trimmed) + episode summary
}
// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail.
// null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row,
// TMDB enrichment block, served from OUR DB (never a live TMDB call) on a movie/show detail.
// null until the row has been enriched. Powers the enriched detail page: multi-source ratings row,
// facts sidebar, keyword chips (→ filter), and the full crew.
interface PlexTmdbCrew {
name: string;
@@ -73,7 +73,7 @@ export interface PlexTmdb {
keywords: string[];
crew: PlexTmdbCrew[];
studios: string[];
// ISO code (null on a row enriched before E30 whose name TMDB's list lacked) + TMDB's English name.
// ISO code (null on an older enriched row whose name TMDB's list lacked) + TMDB's English name.
production_countries: { code: string | null; name: string }[];
recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[];
backdrops: string[];
@@ -133,7 +133,7 @@ export interface PlexMarker {
start_s: number;
end_s: number;
}
// What the player names an audio/subtitle track from (E30, `lib/vocab` trackLabel). Plex's own title
// What the player names an audio/subtitle track from (`lib/vocab` trackLabel). Plex's own title
// (`label`) is in the Plex SERVER's language, so it is only the fallback for a track with no language tag.
export interface PlexTrackNaming {
ord: number;
@@ -239,7 +239,7 @@ export interface PlexPersonCard {
export interface PlexPersonDetail {
name: string;
photo?: string | null; // cached headshot, or null (placeholder)
// TMDB bio (E5 S6), fetched ahead of time by the people_enrich job (E30 S6) and served from the row.
// TMDB bio, fetched ahead of time by the people_enrich job and served from the row.
// Null when the person has no linked tmdb_person_id (never appeared in a TMDB-enriched title), TMDB
// has none, or it is still on its way (`enriching`).
biography?: string | null;
@@ -270,7 +270,7 @@ export interface PlexFilters {
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio
playable: string[]; // OR — playback class (direct | remux | transcode_hw | transcode_sw); movie-only
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
keywords: string[]; // AND — TMDB keyword chips from the detail page
collections: string[]; // Plex collection rating_keys; a title can be in several
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
collectionTitles?: Record<string, string>; // rating_key → title, for the active-filter chips
@@ -400,7 +400,7 @@ export function parsePlexFilters(p: URLSearchParams): PlexFilters {
export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
playable: { value: string; count: number }[];
year_min: number | null;
@@ -418,7 +418,7 @@ export interface PlexPlaySession {
tonemap?: boolean; // hls HW transcode only: 10-bit/HDR source tone-mapped HDR→SDR on the GPU (OpenCL)
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
// Discover surface: the consolidated rail payload. Every rail is a plain PlexCard list the
// dedicated Discover landing renders as a horizontal Carousel; `because_you_watched` is null when the
// user has no TMDB-enriched watch history (rail hidden). Served from DB — no live Plex/TMDB call.
export interface PlexDiscover {
+1 -1
View File
@@ -6,7 +6,7 @@ import { plexFiltersToParams, type PlexFilters } from "./plex";
// FastAPI ignores a query parameter it does not know, so a Plex filter key the frontend misspells or
// the backend renames does not fail anywhere — the filter silently stops narrowing while its chip
// still shows (E32 S2, the sibling of lib/api/feed.test.ts). Every key the filter set can put on the
// still shows (the sibling of lib/api/feed.test.ts). Every key the filter set can put on the
// wire must be a field of the backend's LibraryFilters, which /library and /facets both inject.
// Required: a new filter field fails tsc here until it has a value, so the checks below see its key.
const EVERYTHING: Required<PlexFilters> = {
+1 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E30: every Plex read that returns cards carries the reader's language, so the server can serve the
// Every Plex read that returns cards carries the reader's language, so the server can serve the
// Hungarian titles. The grid and the detail pages always did; these reads did not.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { plexApi } from "./plex";
+1 -1
View File
@@ -10,7 +10,7 @@ export interface Tag {
channel_count: number;
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
interface TagSuggestion {
tag_id: number;
confidence: number;
+1 -1
View File
@@ -1,4 +1,4 @@
// E31: the audit log's first screen was one job's "done" ten times over. A job's successful runs fold
// The audit log's first screen was one job's "done" ten times over. A job's successful runs fold
// into one row whatever ran between them (the owner: a fold broken by every other job read as the same
// row five times); only that job's own failure or skip breaks it.
import { describe, expect, it } from "vitest";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AuditRow } from "./api";
// The audit log's scheduler noise, folded (E31, the owner's pick): the background jobs write a row per
// The audit log's scheduler noise, folded (the owner's pick): the background jobs write a row per
// run that changed something, so an hour of "enrichment done" filled the first screen and pushed the
// admins' own actions down. A job's SUCCESSFUL runs become one row that names how many there were and
// opens to each — whatever ran between them (another job, an admin's action): the owner read a fold
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/button-primitive` (eslint.config.js): one primitive per button family (docs/DESIGN.md, E28
// S3b). 268 hand-rolled buttons were swept onto ui/Button, IconButton, Pressable, Chip and
// `local/button-primitive` (eslint.config.js): one primitive per button family (docs/DESIGN.md).
// 268 hand-rolled buttons were swept onto ui/Button, IconButton, Pressable, Chip and
// DragHandle; this pins that the rule keeping a new one out actually fires, and where it stands down.
const RULE = "local/button-primitive";
+1 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E16: one screenshot of this tab. The browser's picker is stubbed (jsdom has no Screen Capture API):
// One screenshot of this tab. The browser's picker is stubbed (jsdom has no Screen Capture API):
// a cancelled or refused picker is the user saying no (null, no error); anything else is an error;
// a tab that was shared but gave no frame is an error too, not a silent nothing; and the capture is
// stopped whatever happened — nothing keeps sharing the tab after one frame.
+1 -1
View File
@@ -1,4 +1,4 @@
// One screenshot of this tab, for a debug session (E16 D5).
// One screenshot of this tab, for a debug session.
//
// Through the Screen Capture API with the current tab preferred: the browser asks every time (there
// is no silent capture on the web, by design), the user can pick something else or cancel, and one
+1 -1
View File
@@ -1,4 +1,4 @@
// The channel manager's layout vocabulary (E4 S3). Named `ChannelLayout` — NOT `ChannelView` — on
// The channel manager's layout vocabulary. Named `ChannelLayout` — NOT `ChannelView` — on
// purpose: the manager already has a `ChannelsView` (the subscribed/discovery/blocked TAB), and a
// one-letter-apart type would be a footgun. This is the table-vs-card density switch, riding the
// same generic `ViewSwitcher` as the feed. Two layouts: the DataTable, and a virtualized card grid.
+1 -1
View File
@@ -42,7 +42,7 @@ describe("placeByPriority", () => {
expect(ids(placeByPriority(list, "nope", EN))).toEqual(ids(list));
});
it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => {
it("places a title in the reader's alphabet, as the server's collation does", () => {
const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")];
const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }];
expect(ids(placeByPriority(moved, "m", HU))).toEqual(["e", "m", "f"]);
+1 -1
View File
@@ -1,7 +1,7 @@
import { compareText } from "./collate";
import { serverOrderLang } from "./saveLanguage";
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
// The channel manager's list order, and the priority steps its menu offers.
/** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up
* the list is one pick, not a run of +1 presses, and not straight to the top either. The row's
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// The two shared channel write flows report their own failures, with the real error (E11 S2b).
// The two shared channel write flows report their own failures, with the real error.
// Before: the channel detail page's tag toggle and both pages' priority stepper only refetched, so a
// refused write just snapped back; and the manager reported a failed tag toggle by handing the
// CHANNEL ID to the reporter as though it were the error.
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// S8 — the Channels-manager content state (active tab + column sort + the column-filter map) round-trips
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the S4/plexUrlState
// The Channels-manager content state (active tab + column sort + the column-filter map) round-trips
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the plexUrlState
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
import { describe, expect, it } from "vitest";
import {
+3 -3
View File
@@ -1,6 +1,6 @@
// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/
// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's
// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
// from the address bar so a manager view is bookmarkable/shareable — the analogue of the feed's
// urlState and Plex's plexUrlState. Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
@@ -8,7 +8,7 @@
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
import { type FilterMap } from "./tableColumns";
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
// The content state this mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
// raw provider SortState (healed against live columns only at the point of use), so the user's real
// preference round-trips through the URL.
export interface ChannelsUrlState {
+4 -4
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { chipLaneClass } from "./useChipOverflowFit";
// E10 S4. The chip lanes' hidden measurer is laid out UNBOUNDED on purpose (it exists to read natural
// The chip lanes' hidden measurer is laid out UNBOUNDED on purpose (it exists to read natural
// widths), and an absolutely-positioned box still feeds its ancestors' SCROLLABLE overflow even while
// `visibility: hidden`. Un-clipped, that leaked to <html>: at a 1024px viewport the channel manager's
// document scrolled 249px sideways and the fixed header slid off. `chipLaneClass` carries the clip that
@@ -15,7 +15,7 @@ import { chipLaneClass } from "./useChipOverflowFit";
// COUNTED per occurrence, not tested per file. The first cut asked `src.includes(chipLaneClass)`, which
// a file could satisfy with ONE compliant lane while hand-rolling a second — and ChannelTags already
// holds two lanes and two mirrors, so the likeliest place for a fifth lane was the one file the check
// was blind inside (E10 review round 1).
// was blind inside.
const MIRROR = "invisible absolute left-0 top-0";
// A mirror only exists to be measured, so the hook is the other half of the signature. Discovering by
// BOTH means a mirror written with the utilities in another order is still caught by its import.
@@ -28,7 +28,7 @@ function count(haystack: string, needle: string): number {
/** Occurrences of `chipLaneClass` that are actually a LANE, i.e. not the import that brings it in.
* Counting raw occurrences gave every file one free mirror — the import — so a second, unprotected
* lane in a file that already had a compliant one still passed, which is the exact hole this test
* was rewritten to close (E10 review round 2, caught by mutation). */
* was rewritten to close (caught by mutation). */
function laneCount(src: string): number {
// `[^;]` rather than `[\s\S]`: an import is stripped up to ITS OWN `from "…";`. The lazy any-char
// form ran past a side-effect import (`import "./x.css";` has no `from`) into the next statement's
@@ -86,7 +86,7 @@ describe("chip lane containment contract", () => {
it("finds the measurers it claims to guard (the marker has not been renamed away)", () => {
// The MIRROR alone, not the hook: `lib/chipOverflow.ts` names the hook in a prose comment and was
// padding this canary, so it stayed satisfied with one real measurer fewer (round 2).
// padding this canary, so it stayed satisfied with one real measurer fewer.
expect(sources().filter(([, src]) => src.includes(MIRROR)).length).toBeGreaterThanOrEqual(3);
});
+1 -1
View File
@@ -1,4 +1,4 @@
// Alphabetical order in the UI language (E30 S3): Hungarian puts "Ö" after "O" and "Cs" after "Cu". A
// Alphabetical order in the UI language: Hungarian puts "Ö" after "O" and "Cs" after "Cu". A
// bare `localeCompare` compares in the BROWSER's locale; the server sorts by the same ICU rules
// (backend `app/collation.py`), so a list sorted here agrees with one the server sorted. Its own
// module, apart from lib/format.ts, because it needs no i18n: the pure sort helpers import it.
+1 -1
View File
@@ -55,7 +55,7 @@ describe("sortRows", () => {
]);
});
// E30 S3: text sorts in the UI language's alphabet, not the browser's — Hungarian puts "Ö" after
// Text sorts in the UI language's alphabet, not the browser's — Hungarian puts "Ö" after
// "O" and "Cs" after "Cu". Both languages are asserted and differ, so a bare `localeCompare` (one
// runtime locale for both) cannot pass on any machine.
it("sorts text in the UI language's alphabet", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
// Crop geometry for the video editor — the display-px ↔ source-px mapping, isolated as pure
// functions so the math is unit-testable without a DOM.
//
// The bug this fixes (E7 S1→S2): the crop marquee is drawn in fractions of the *video element box*,
// The bug this fixes: the crop marquee is drawn in fractions of the *video element box*,
// but a letterboxed video (object-fit: contain) does NOT fill that box — pillar/letterbox bars sit
// between the box edge and the actual picture. Multiplying box-fractions by the source resolution
// therefore emitted an ffmpeg crop shifted/scaled off from what the selector showed. The fix is to
+1 -1
View File
@@ -67,7 +67,7 @@ const FIRST_UPLOAD_YEAR = 2005;
/** Whether a native date field's value is a bound worth filtering by. The field reports a COMPLETE
* date on every keystroke of its year — typing 2026 passes 0002, 0020 and 0202, each a valid date to
* the browser — so applying every value refetched the feed four times for one year (E28). A bound is
* the browser — so applying every value refetched the feed four times for one year. A bound is
* applied once it is a plausible upload day; a half-typed field reports "" and is not one either. */
export function isCommittableDay(s: string): boolean {
const m = /^(\d{4})-\d{2}-\d{2}$/.exec(s);
+1 -1
View File
@@ -1,6 +1,6 @@
import { createStore } from "./store";
// The debug recorder's app-wide state (E16) — what several surfaces open or read, held outside any
// The debug recorder's app-wide state — what several surfaces open or read, held outside any
// one of them: Settings, the command palette and the recorder itself all open the start dialog, and
// the composer opens for whichever session just ended.
+1 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E22 S5: the detail the consented levels add — filter changes and what a player went through.
// The detail the consented levels add — filter changes and what a player went through.
// Below Részletes they cost nothing and log nothing, except a media error, which is always a warning.
import { afterEach, describe, expect, it } from "vitest";
+1 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E22 S5: the consented level applied to the tab, and the stream it opens. The module keeps its state
// The consented level applied to the tab, and the stream it opens. The module keeps its state
// at module level (what is applied, the queue), so every test imports a fresh copy — and the log
// from that same copy, or it would be reading a different ring.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E16: a debug session applied to the tab. It raises the level for its own run (starting it was the
// A debug session applied to the tab. It raises the level for its own run (starting it was the
// consent), never lowers a raised one, tags every event it keeps, and its last seconds reach the
// server before Stop. Its own file: `lib/diagnostics` and `lib/log` keep module-level state, so every
// test imports fresh copies of both, as diagnostics.dom.test does.
+2 -2
View File
@@ -1,5 +1,5 @@
// The user's consented diagnostics level, applied to this tab (E22 S5) — and the debug session it
// may be recording (E16).
// The user's consented diagnostics level, applied to this tab — and the debug session it
// may be recording.
//
// `applyDiagnostics(me.diagnostics, me.debug_session)` runs whenever `me` answers. It sets the client
// log's level, and while that level is raised (Részletes / Teljes) it streams the log to the server:
+1 -1
View File
@@ -1,4 +1,4 @@
// E7·S5b — the Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
// The Library/Shared row-action gate. A generic uploaded file (media_kind='file') is
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
// is caught (the affordance itself, not just the backend guard behind it).
+1 -1
View File
@@ -1,7 +1,7 @@
import type { DownloadJob } from "./api";
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
// job_kind (E7·S5b). A generic uploaded file (media_kind='file') is download + share only: no player,
// job_kind. A generic uploaded file (media_kind='file') is download + share only: no player,
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
+2 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// A failed download's explanation: the failure-catalog entry first, its category as the fallback
// (E11.5 S1). The second case runs the REAL i18next with the real locales, because the fallback is
// A failed download's explanation: the failure-catalog entry first, its category as the fallback.
// The second case runs the REAL i18next with the real locales, because the fallback is
// i18next's list-key lookup, not code of ours — an older client meeting a detail it has no copy for
// must still say the category, not print a key.
import { describe, expect, it } from "vitest";
+3 -3
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// One-click share (E7 S3 · D2-A): quickShareLink turns the row's Share button into a single action —
// ensure a public link (mint on demand, downloadable per D1), copy it, and toast with an "open in new
// One-click share: quickShareLink turns the row's Share button into a single action —
// ensure a public link (mint on demand, downloadable), copy it, and toast with an "open in new
// tab" action. These pin the branch matrix: reuse-vs-mint, copy-ok-vs-fail, and the create-failure path.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
@@ -50,7 +50,7 @@ describe("quickShareLink", () => {
const url = await quickShareLink(qc(), { id: 42 }, t);
expect(url).toBe(`${origin}/watch/tok7`);
// Minted with an EMPTY body so the backend D1 default (allow_download=true) applies.
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
expect(createDownloadLink).toHaveBeenCalledWith(42, {});
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
const arg = firstToast();
+3 -3
View File
@@ -13,9 +13,9 @@ export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
}
// One-click share (E7 S3 · D2-A): the row's Share button turns "share this" into a single action.
// One-click share: the row's Share button turns "share this" into a single action.
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
// D1 default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
// default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
@@ -32,7 +32,7 @@ export async function quickShareLink(
});
link = existing[0];
if (!link) {
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true)
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
qc.setQueryData(qk.downloadLinks(job.id), [link]);
}
} catch (e) {
+3 -3
View File
@@ -19,7 +19,7 @@ export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/,
/** One-time QUERY flags: safe to drop once the snapshot above has them, because re-reading them on a
* reload would re-show a notice about something that already happened (`access` only appears here
* in its pre-S4 shape — it rides the fragment now). Deliberately NOT `link`,
* in its old shape — it rides the fragment now). Deliberately NOT `link`,
* `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup
* wizard, the lazily-mounted module views) do not run until several commits later. */
const ONE_TIME_QUERY = ["login", "deleted", "access"] as const;
@@ -30,7 +30,7 @@ const ONE_TIME_QUERY = ["login", "deleted", "access"] as const;
*
* SURGICAL, and that is the whole of it. This used to blank the entire query string, the fragment
* and `history.state`, which was safe only while it lived inside the signed-out auth card; moving it
* up to App (E9.5 S2) pointed it at every load. It then ran on the FIRST commit — before the setup
* up to App pointed it at every load. It then ran on the FIRST commit — before the setup
* wizard mounts behind its own `?token=`, before App's own `?link=`/`?oauth=` effects read theirs,
* and before a lazily-loaded module view reads its `?v=`/`?chan=` deep link — and took
* `history.state` (scroll restoration, the channel entry) with it. Everything not listed above is
@@ -45,7 +45,7 @@ export function clearEntryUrl(): void {
// again does the same, because the token is untouched and the session is still there. Leaving it
// in the address bar keeps the recovery `main` had, where this strip lived inside the auth card:
// sign out (which reloads, fragment intact) and the reset form is waiting. Once the card has used
// it the token is spent, so a later reload meets S4's named dead-link screen — which is true.
// it the token is spent, so a later reload meets the named dead-link screen — which is true.
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const keep = hash.has("reset") ? window.location.hash : "";
if (!dropped.length && !window.location.hash) return;
+2 -2
View File
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// E28 S3d4 (B9). The header + fade edge is one recipe, the `fade-below` / `fade-below-panel` /
// docs/DESIGN.md B9. The header + fade edge is one recipe, the `fade-below` / `fade-below-panel` /
// `fade-above-panel` utilities in index.css: a crisp band and a 1rem gradient of the colour under
// it. The table header carried its own arbitrary-value copy of it, which a change to the recipe (its
// height, its colour on a panel) would have left behind.
@@ -30,7 +30,7 @@ describe("the fade edge", () => {
});
// A sticky header's fade shows only once content has slid under it. Drawn at rest it lay over the
// first row's top 16 px, dimming it and hiding the top edge of the row's keyboard ring (E28, seen
// first row's top 16 px, dimming it and hiding the top edge of the row's keyboard ring (seen
// live). jsdom runs no scroll timeline, so what is pinned is the recipe: resting opacity 0, and the
// scroll-driven fade-in over the first 1rem, only where the engine has scroll timelines.
describe("the sticky header's fade", () => {
+1 -1
View File
@@ -23,7 +23,7 @@ export interface FeedViewSpec {
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
colMaxWidth: number | null;
/** Multi-column views only: the most columns to lay out (null = as many as fit). The UPPER end of
* the tablet..ultrawide contract (E10 S4; tablet portrait since E28). An auto-fill grid always settles just above `minCol`,
* the tablet..ultrawide contract (tablet portrait included). An auto-fill grid always settles just above `minCol`,
* so without a cap a wider screen buys more, smaller items rather than better ones — measured at
* 3440px, `cards` laid out 11 columns of 264px. Each cap here is set so the item tops out near
* 1.5x its own `minCol` on a 3440px ultrawide, which keeps each view recognisably itself (a capped
+1 -1
View File
@@ -1,4 +1,4 @@
// A filter/sort/view change, for the client log (E22 S5). Filter state is what makes "the feed shows
// A filter/sort/view change, for the client log. Filter state is what makes "the feed shows
// nothing" diagnosable, so its VALUES are logged — at Részletes (DEBUG), the level the user consented
// to for exactly that. What the user TYPED into a search box is Teljes (TRACE) only; below it the log
// just says whether there is a search at all.
+2 -2
View File
@@ -124,7 +124,7 @@ describe("relativeFromMs", () => {
});
});
// formatRuntime went from "one Plex movie's length" to "a playlist's total playtime" (E9 12D), and a
// formatRuntime went from "one Plex movie's length" to "a playlist's total playtime", and a
// TOTAL is the only input that can legitimately be 1-59 seconds. That tier had no coverage and no
// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail
// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be
@@ -248,7 +248,7 @@ describe("formatDateTime / formatDate", () => {
});
});
// E30 S3: every number a reader sees is in the UI language — the decimal mark, the grouping and the
// Every number a reader sees is in the UI language — the decimal mark, the grouping and the
// compact suffix. `toFixed` and hand-built "1.2K" wrote English in both languages. Both languages are
// asserted and differ, so an implementation that ignores the language cannot pass.
describe("numbers in the UI language", () => {
+5 -5
View File
@@ -4,8 +4,8 @@ import i18n from "../i18n";
// The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) —
// all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere).
//
// Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style
// (E30 S3): the React Compiler memoizes a call on its arguments, so a formatter that read the language
// Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style:
// the React Compiler memoizes a call on its arguments, so a formatter that read the language
// off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min"
// beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a
// dependency the compiler can see; the signature makes forgetting it a type error.
@@ -121,8 +121,8 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
// A duration's unit in the UI language, in the browser's own short form (docs/DESIGN.md "Copy", E28
// S3g): "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and
// A duration's unit in the UI language, in the browser's own short form (docs/DESIGN.md "Copy"):
// "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and
// came in three spellings. One formatter per language × unit, built lazily (the grids format one per card).
export type DurationUnit = "second" | "minute" | "hour" | "day";
export function formatUnit(
@@ -225,7 +225,7 @@ export function formatCountOrDash(n: number | null | undefined, lang: string): s
return n != null ? formatCompact(n, lang) : "—";
}
// The app's date forms (docs/DESIGN.md "Copy", E28 S3g), all in the UI language — never a bare
// The app's date forms (docs/DESIGN.md "Copy"), all in the UI language — never a bare
// `toLocaleString()`, which formats in the BROWSER's locale (an English UI showed a Hungarian-shaped
// date) and, with no options, as numbers with seconds ("9/27/2026, 8:30:14 AM"):
// relativeTime "3d ago" / "3 napja" — recency in a list; the exact form on hover
+1 -2
View File
@@ -74,8 +74,7 @@ function useFullscreenElement(): HTMLElement | null {
* and builds it again when its container changes, and the overlay's contents go with it (a
* half-filled form reset to its defaults the moment the viewer left true fullscreen).
*
* Two rules this owns, which the two hand-written copies it replaced disagreed about (deep-review
* round 5):
* Two rules this owns, which the two hand-written copies it replaced disagreed about:
*
* 1. **It is attached BEFORE the portal's children commit.** `useInsertionEffect` runs in the
* mutation phase, and React handles `autoFocus` in `commitMount` during the LAYOUT phase — one
+13 -13
View File
@@ -10,7 +10,7 @@ import ts from "typescript";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
// The gate register (docs/gates.md) is held to the EFFECTIVE configuration (E32). A narrowing that
// The gate register (docs/gates.md) is held to the EFFECTIVE configuration. A narrowing that
// is not on the register fails here — a rule switched off for a scope, a weak compiler flag, a ruff
// ignore, a knip exclusion, an accepted CVE. So "the gate checks X" is a claim this test verifies,
// not one a comment makes. (The comment that claimed the React Compiler's rules "are also linted"
@@ -101,7 +101,7 @@ function restrictedEntries(entry: Linter.RuleEntry | undefined): Set<string> {
// --- the snapshot ---------------------------------------------------------------------------------
// The configs that can narrow a gate in more shapes than a check can enumerate are held to a
// COMMITTED SNAPSHOT (E32 review): ESLint's effective config per scope, and the lines that run the
// COMMITTED SNAPSHOT: ESLint's effective config per scope, and the lines that run the
// lanes. Any change fails until the snapshot is regenerated, so a narrowing arrives as a diff of
// `gates.snapshot.json` in review instead of slipping past a detector. (Three review rounds kept
// finding shapes the detector this replaced did not model — a `basePath` global ignore, a widened glob
@@ -109,7 +109,7 @@ function restrictedEntries(entry: Linter.RuleEntry | undefined): Set<string> {
// shape to miss.)
//
// A scope is named by PATH (`scopeOf`), and every file of a scope must get the same effective config.
// So a new file in a known place changes nothing (until E35 the snapshot listed every file, and 31% of
// So a new file in a known place changes nothing (the snapshot once listed every file, and 31% of
// commits regenerated it), while a glob that moves a file into another config splits its scope and
// fails below, and a scope whose rules change is a snapshot diff.
//
@@ -404,7 +404,7 @@ describe("the gate register (docs/gates.md)", () => {
});
// `strict: true` is a bundle: a sub-flag set false beside it, an `extends` this test cannot see
// through, or a narrowed `include` / an `exclude` weakens the project while the five flags above
// still read true (E32 review).
// still read true.
const STRICT_FAMILY = [
"noImplicitAny",
"strictNullChecks",
@@ -448,7 +448,7 @@ describe("the gate register (docs/gates.md)", () => {
.map((f): [string, string] => ["config files", `${f} — not type-checked`]);
expect(missingFrom(found)).toEqual([]);
// a `.js` in `include` is dropped without a word unless allowJs, and read unchecked without
// checkJs (E32 review round 2)
// checkJs
const opts = jsonc("frontend/tsconfig.node.json").compilerOptions as Record<string, unknown>;
if ([...checked].some((f) => /[.][mc]?js$/.test(f)))
expect([opts.allowJs, opts.checkJs]).toEqual([true, true]);
@@ -457,10 +457,10 @@ describe("the gate register (docs/gates.md)", () => {
// --- Ruff -----------------------------------------------------------------------------------------
// Ruff, pyright, pytest and knip are read as whitelists: a key this test does not know (an
// `extend-exclude`, a `lint.extend-ignore`, a narrowing `select`, a pyright `exclude`, a pytest
// `-m "not slow"`, a knip `rules`) fails until someone decides whether it narrows (E32 review —
// only `ignore` and the immutable calls used to be read). The TOML is PARSED, not scanned line by
// line: a `[lint.per-file-ignores]` table or a quoted key is a key path like any other (E32 review
// round 2 — a line regex saw neither).
// `-m "not slow"`, a knip `rules`) fails until someone decides whether it narrows (only
// `ignore` and the immutable calls used to be read). The TOML is PARSED, not scanned line by
// line: a `[lint.per-file-ignores]` table or a quoted key is a key path like any other (a line
// regex saw neither).
const keyPaths = (o: Record<string, unknown>, prefix = ""): string[] =>
Object.entries(o).flatMap(([k, v]) =>
v !== null && typeof v === "object" && !Array.isArray(v)
@@ -501,7 +501,7 @@ describe("the gate register (docs/gates.md)", () => {
// Every ruff lane passes `--config backend/ruff.toml`, which makes ruff ignore any other config
// file it would discover (measured: a nested `ruff.toml` selecting the `D` rules changed nothing).
// So that flag is what makes this file the whole configuration. The snapshot holds every line of
// the gate script; these two are spelled out because the reason is (round 3: a substring check let
// the gate script; these two are spelled out because the reason is (a substring check once let
// an extra `--config "lint.extend-ignore=…"` override through).
it("runs both ruff lanes on backend/ruff.toml and nothing else", () => {
const lanes = read("scripts/check.sh")
@@ -541,8 +541,8 @@ describe("the gate register (docs/gates.md)", () => {
// --- pytest ---------------------------------------------------------------------------------------
// Held line by line, not key by key: pytest's ini reader also takes `key: value` and indented
// continuation lines (`addopts = -q` + ` -m "not slow"`), and a key regex saw neither (E32 review
// round 2). Every line that is not blank or a comment must be one of these, and all must be there.
// continuation lines (`addopts = -q` + ` -m "not slow"`), and a key regex saw neither.
// Every line that is not blank or a comment must be one of these, and all must be there.
it("keeps pytest strict: the whole tests/ tree, no selection, no warning filters, --strict-markers", () => {
const lines = read("backend/pytest.ini")
.split("\n")
@@ -572,7 +572,7 @@ describe("the gate register (docs/gates.md)", () => {
// the positive pattern is the project itself: narrowing it drops files from the lane
expect((cfg.project ?? []).filter((p) => !p.startsWith("!"))).toEqual(["**/*.{ts,tsx,js}"]);
// and knip.jsonc is the config knip reads: a knip.json (read first) or a package.json `knip` key
// (merged in) would narrow it unseen (E32 review round 3)
// (merged in) would narrow it unseen
const KNIP_FILES = /^\.?knip(\.config)?\.(json|jsonc|js|ts|mjs|cjs|mts|cts|toml|ya?ml)$/;
expect(readdirSync(FRONTEND).filter((f) => KNIP_FILES.test(f))).toEqual(["knip.jsonc"]);
expect("knip" in pkg).toBe(false);
+36 -104
View File
@@ -210,21 +210,15 @@
"no-restricted-globals": [
0,
{
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only — a request outside it never reaches the client log.",
"name": "fetch"
},
{
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only — a request outside it never reaches the client log.",
"name": "XMLHttpRequest"
},
{
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
{ "message": "No `WebSocket` without a logged owner module.", "name": "WebSocket" },
{ "message": "No `EventSource` without a logged owner module.", "name": "EventSource" }
],
"no-restricted-imports": [
2,
@@ -239,18 +233,10 @@
],
"no-restricted-properties": [
0,
{ "message": "Use lib/http.ts `send`.", "object": "window", "property": "fetch" },
{ "message": "Use lib/http.ts `send`.", "object": "globalThis", "property": "fetch" },
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "window",
"property": "fetch"
},
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "globalThis",
"property": "fetch"
},
{
"message": "Use lib/api/core.ts `beacon` (E22 S4).",
"message": "Use lib/api/core.ts `beacon`.",
"object": "navigator",
"property": "sendBeacon"
}
@@ -282,17 +268,11 @@
"no-restricted-globals": [
2,
{
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only — a request outside it never reaches the client log.",
"name": "fetch"
},
{
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
{ "message": "No `WebSocket` without a logged owner module.", "name": "WebSocket" },
{ "message": "No `EventSource` without a logged owner module.", "name": "EventSource" }
],
"no-restricted-imports": [
2,
@@ -330,17 +310,11 @@
"no-restricted-globals": [
2,
{
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only — a request outside it never reaches the client log.",
"name": "XMLHttpRequest"
},
{
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
{ "message": "No `WebSocket` without a logged owner module.", "name": "WebSocket" },
{ "message": "No `EventSource` without a logged owner module.", "name": "EventSource" }
],
"no-restricted-imports": [
2,
@@ -740,36 +714,22 @@
"no-restricted-globals": [
2,
{
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only — a request outside it never reaches the client log.",
"name": "fetch"
},
{
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only — a request outside it never reaches the client log.",
"name": "XMLHttpRequest"
},
{
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
{ "message": "No `WebSocket` without a logged owner module.", "name": "WebSocket" },
{ "message": "No `EventSource` without a logged owner module.", "name": "EventSource" }
],
"no-restricted-properties": [
2,
{ "message": "Use lib/http.ts `send`.", "object": "window", "property": "fetch" },
{ "message": "Use lib/http.ts `send`.", "object": "globalThis", "property": "fetch" },
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "window",
"property": "fetch"
},
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "globalThis",
"property": "fetch"
},
{
"message": "Use lib/api/core.ts `beacon` (E22 S4).",
"message": "Use lib/api/core.ts `beacon`.",
"object": "navigator",
"property": "sendBeacon"
}
@@ -797,11 +757,11 @@
"selector": "JSXOpeningElement[name.name='select']"
},
{
"message": "px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
"message": "px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx].",
"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).",
"message": "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\\]/]"
},
{
@@ -1019,36 +979,22 @@
"no-restricted-globals": [
0,
{
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only — a request outside it never reaches the client log.",
"name": "fetch"
},
{
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only — a request outside it never reaches the client log.",
"name": "XMLHttpRequest"
},
{
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
{ "message": "No `WebSocket` without a logged owner module.", "name": "WebSocket" },
{ "message": "No `EventSource` without a logged owner module.", "name": "EventSource" }
],
"no-restricted-properties": [
0,
{ "message": "Use lib/http.ts `send`.", "object": "window", "property": "fetch" },
{ "message": "Use lib/http.ts `send`.", "object": "globalThis", "property": "fetch" },
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "window",
"property": "fetch"
},
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "globalThis",
"property": "fetch"
},
{
"message": "Use lib/api/core.ts `beacon` (E22 S4).",
"message": "Use lib/api/core.ts `beacon`.",
"object": "navigator",
"property": "sendBeacon"
}
@@ -1217,21 +1163,15 @@
"no-restricted-globals": [
0,
{
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only — a request outside it never reaches the client log.",
"name": "fetch"
},
{
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).",
"message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only — a request outside it never reaches the client log.",
"name": "XMLHttpRequest"
},
{
"message": "No `WebSocket` without a logged owner module (E22 S4).",
"name": "WebSocket"
},
{
"message": "No `EventSource` without a logged owner module (E22 S4).",
"name": "EventSource"
}
{ "message": "No `WebSocket` without a logged owner module.", "name": "WebSocket" },
{ "message": "No `EventSource` without a logged owner module.", "name": "EventSource" }
],
"no-restricted-imports": [
2,
@@ -1254,18 +1194,10 @@
],
"no-restricted-properties": [
0,
{ "message": "Use lib/http.ts `send`.", "object": "window", "property": "fetch" },
{ "message": "Use lib/http.ts `send`.", "object": "globalThis", "property": "fetch" },
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "window",
"property": "fetch"
},
{
"message": "Use lib/http.ts `send` (E22 S4).",
"object": "globalThis",
"property": "fetch"
},
{
"message": "Use lib/api/core.ts `beacon` (E22 S4).",
"message": "Use lib/api/core.ts `beacon`.",
"object": "navigator",
"property": "sendBeacon"
}
+3 -3
View File
@@ -3,7 +3,7 @@
// The `global` scope's LIVE behaviour, as opposed to `matches`/`dispatch` in isolation (keymap.test.ts
// covers those against a handler map the test supplies itself, which cannot see the real handlers).
//
// Every assertion here pins a fix that E10 review round 2 proved was un-pinned: reverting the guard
// Every assertion here pins a fix that a review proved was un-pinned: reverting the guard
// each one covers left the whole suite green, which is how a fix round quietly becomes the next
// round's source of regressions.
import { useEffect } from "react";
@@ -69,7 +69,7 @@ describe("useGlobalKeymap", () => {
// Mod+K is written as a toggle, and the palette autofocuses its own search field — so without
// `allowInInput` on the row the second press was eaten by the typing guard and Chrome took the
// chord instead (round 1). The FIRST press must still be routed from a field, too.
// chord instead. The FIRST press must still be routed from a field, too.
it("toggles the palette shut from inside a text field", () => {
let state = "";
render(<Host onState={(s) => (state = s)} />);
@@ -119,7 +119,7 @@ describe("useGlobalKeymap", () => {
expect(state).toBe("none");
});
// E24. The chord is the point: the Plex player binds bare letters and `w` sits on top of `s`, so a
// The chord is the point: the Plex player binds bare letters and `w` sits on top of `s`, so a
// bare `w` would yank a viewer out of the film on a blind reach for the subtitle key.
it("jumps Home on Shift+W and ignores a bare w", () => {
const onHome = vi.fn();
+1 -1
View File
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// E28 S3d4 (B4, UX-15). One `h1` per page: the shell's hidden page name, or a view's own visible
// DESIGN.md B4, UX-15. One `h1` per page: the shell's hidden page name, or a view's own visible
// title through `PageHeading`, which makes the shell's step aside. A hand-written `<h1>` inside the
// shell would be a second one, beside the page name the shell already put there.
function sources(): Array<[string, string]> {
+2 -2
View File
@@ -142,7 +142,7 @@ describe("closeOverlaysThenNavigate", () => {
// Not inside the popstate dispatch that settled it — a later listener would still apply the
// OLD entry's page over the push. Asserted FROM such a later listener (registered after the
// helper's own), not after `settle()`: that async act can flush the unmounts on a macrotask,
// and the navigate task sometimes ran first — a 1-in-5 false failure (E11 S2b gate).
// and the navigate task sometimes ran first — a 1-in-5 false failure in the gate.
const navigatedByPopstate: number[] = [];
const laterListener = () => navigatedByPopstate.push(navigate.mock.calls.length);
window.addEventListener("popstate", laterListener);
@@ -165,7 +165,7 @@ describe("closeOverlaysThenNavigate", () => {
// An overlay's Back callback is a Back STEP: the Plex player keeps its panels on one entry and peels
// one per call. Called once, the entry stayed and the navigation was dropped 1.5 s later — `i`, `d`,
// Shift+W in the player closed the info panel and nothing else (deep-review E11 round 1).
// Shift+W in the player closed the info panel and nothing else.
it("asks again, a render apart, when one call peels only a step off the overlay", async () => {
const back = stubBack();
try {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// Back hands focus to the list item a view was left from (E11 S5): opening a Plex title from Home's
// Back hands focus to the list item a view was left from: opening a Plex title from Home's
// rail switches the page, which unmounts the rail, so focus used to come back to <body>. The position is
// stamped onto the entry being left (`rememberNavPosition`) and a popstate landing on it focuses the item
// once the page's lists are back. A landing is simulated the way the browser delivers one: the entry's
@@ -97,8 +97,8 @@ describe("rememberNavPosition · Back focuses the item a view was left from", ()
});
// A sub-view (a Plex show page) switches back in its OWN popstate listener, registered after this
// module's: while ours runs, focus still sits in the view being left. Measured in Chrome (deep-review
// E11 round 1) — Discover card → show → `j` on the show page → Back left focus on <body>.
// module's: while ours runs, focus still sits in the view being left. Measured in Chrome:
// Discover card → show → `j` on the show page → Back left focus on <body>.
it("restores when a later popstate listener switches the view the focus was still in", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
@@ -147,7 +147,7 @@ describe("rememberNavPosition · Back focuses the item a view was left from", ()
});
});
// E28 review H1: the page region is `<main tabIndex={-1}>` (the skip link's target), so a mouse click
// The page region is `<main tabIndex={-1}>` (the skip link's target), so a mouse click
// on the page's blank area focuses it. The restore took that for a control the reader had chosen and
// gave up: Back left the focus on the page instead of on the item the view was opened from.
describe("rememberNavPosition · the focus resting on the page region", () => {
@@ -163,7 +163,7 @@ describe("rememberNavPosition · the focus resting on the page region", () => {
});
});
describe("rememberNavPosition · the list it comes back to (deep-review E11 round 2)", () => {
describe("rememberNavPosition · the list it comes back to", () => {
// Discover: a title opened from "Recently added" and watched — the page's cache expires and
// "Continue watching" fills with that very title, a rail ABOVE. Found by its place, focus landed in
// that rail, on a title never picked.
@@ -2,7 +2,7 @@
//
// The stamp `rememberNavPosition` leaves in history.state survives a reload, but module state does not:
// the listener that reads it was installed by the first push, so after F5 the first Back onto a
// stamped entry restored nothing (deep-review E11 round 1). Its own file, because the point is a
// stamped entry restored nothing. Its own file, because the point is a
// module that has made NO push yet — any other test in history.navPosition would have made one.
import { useRef } from "react";
import { afterEach, expect, it } from "vitest";
+4 -4
View File
@@ -2,7 +2,7 @@
//
// useHistorySubview: a module's active sub-view rides in history.state._sub, with optional address-bar
// mirroring so a deep sub-view (Plex show/season/info/person/playlist) is bookmarkable. This backfills
// the E4 deep-review's deferred coverage for the S2 fix — the seed AND the popstate handler must share
// the coverage a review found missing — the seed AND the popstate handler must share
// the SAME URL fallback, so a popstate that lands on a bookmark entry (URL-only, no _sub — e.g. after
// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing
// it to root. The overlay/back-ladder half of history.ts is covered in history.dom.test.ts.
@@ -77,7 +77,7 @@ describe("useHistorySubview open", () => {
expect(window.location.search).toBe(""); // ?v dropped
});
// E24: a cross-module hand-off (Home plays a Plex title) arrives on an entry the page navigation
// A cross-module hand-off (Home plays a Plex title) arrives on an entry the page navigation
// just pushed. Opening the sub-view on TOP of it stranded the user on a module root they never
// visited — browser-Back from the player landed on the Plex Discover page instead of Home
// (measured 2026-09-06). `replace` makes the sub-view BE that entry.
@@ -121,7 +121,7 @@ describe("useHistorySubview popstate", () => {
expect(result.current.view).toBe("a");
});
it("restores from the ?<key> URL when the landed entry has no _sub — the S2 bookmark fix", () => {
it("restores from the ?<key> URL when the landed entry has no _sub — the bookmark fix", () => {
// Start in-app at root, then a popstate lands on a URL-only bookmark entry (no _sub). Reading
// history.state alone would wrongly collapse to root; the shared URL fallback must decode ?v=b.
const { result } = renderHook(() => useHistorySubview("root", url));
@@ -139,7 +139,7 @@ describe("useHistorySubview popstate", () => {
});
});
// E28 review E1: a one-shot flag the view is OPENED with (the Plex player's `fromStart`, a card's
// A one-shot flag the view is OPENED with (the Plex player's `fromStart`, a card's
// Restart) rode in history.state._sub, so a reload or a Forward onto that entry restarted from 0
// again — and the player's checkpoint then overwrote the resume point. `entry` says what the
// history entry keeps of a view; the view itself still opens with the flag.
+5 -5
View File
@@ -70,7 +70,7 @@ export function subscribeNav(cb: () => void): () => void {
// push opens a new view over it; read when Back/Forward lands on that entry, and dropped from it there,
// so a later landing on the same entry — an overlay closing over it — cannot pull focus to an old item.
// Installed at load, not by the first push: a stamp survives a reload in history.state, and the first
// Back after one must still find a listener (deep-review E11 round 1).
// Back after one must still find a listener.
let cancelNavRestore = () => {};
if (typeof window !== "undefined") {
window.addEventListener("popstate", (e) => {
@@ -196,12 +196,12 @@ export function useHistorySubview<T>(
// Back walked off the app entirely instead of closing the modal. Coalescing removes both.
//
// A navigation never lands UNDER an open overlay: `setPage` and `openChannel` close every overlay
// first and push only once their entries are back (`closeOverlaysThenNavigate`). The design before
// E11 let the push land above the overlays and "buried" their entries with a counter, and that
// first and push only once their entries are back (`closeOverlaysThenNavigate`). An earlier design
// let the push land above the overlays and "buried" their entries with a counter, and that
// counter was a whole family of bugs — a burial spent by a different overlay closing first, a
// navigation that could not report whether it pushed and was assumed to have, one browser Back that
// both undid the navigation AND closed the dialog above it, and the dead Back press after that (the
// last two measured in Chrome, E11 S1). With nothing ever buried, the stack and the history agree by
// last two measured in Chrome). With nothing ever buried, the stack and the history agree by
// construction.
type Overlay = { token: number; cb: () => void };
const overlayStack: Overlay[] = [];
@@ -280,7 +280,7 @@ const RECLOSE_MS = 50;
* silently does nothing but close. PlayerModal hit it first; the command palette the day it was
* written.
*
* It waits for a STATE, not an event. The helper before E11 waited for "a popstate" plus a 250 ms
* It waits for a STATE, not an event. An earlier helper waited for "a popstate" plus a 250 ms
* fallback — but one reconcile can issue several back()s, an overlay closed in the tick it opened
* issues none, and the fallback then ran with back()s still in flight. Here the reconcile and every
* popstate re-check one predicate.
+1 -1
View File
@@ -1,7 +1,7 @@
import { ensureNavId, peekNavId } from "./history";
import { createStore } from "./store";
// A one-shot "…and surprise me straight away" request for the Home surface (E24), handed over by
// A one-shot "…and surprise me straight away" request for the Home surface, handed over by
// callers that cannot reach its state — today the command palette's "Meglepetés".
//
// A TOKEN that waits, not an event that fires: Home is a lazy module and is normally NOT mounted
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// Three E28-audit rules in eslint.config.js, pinned so a selector that stops matching cannot pass for
// Three audit rules in eslint.config.js, pinned so a selector that stops matching cannot pass for
// a clean codebase: a hover that swaps a colour instead of the B3 state layer, an <a> that holds only a
// glyph (DESIGN.md "A link that looks like a button" is IconLink), and a palette colour (F16).
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// `local/input-primitive` (eslint.config.js): every text field is ui/Input, Textarea or InnerInput and
// every checkbox / slider / colour well a NativeControls primitive (docs/DESIGN.md A4, A6; E28 S3c).
// every checkbox / slider / colour well a NativeControls primitive (docs/DESIGN.md A4, A6).
// ~100 hand-styled boxes were swept; this pins that the rule keeping a new one out fires, and where it
// stands down.
const RULE = "local/input-primitive";
+1 -1
View File
@@ -1,4 +1,4 @@
// E16 S3: the draft a session suggests, and the markdown an issue carries. Pure, so the composer's
// The draft a session suggests, and the markdown an issue carries. Pure, so the composer's
// preview and what it saves are provably one computation.
import { describe, expect, it } from "vitest";
+1 -1
View File
@@ -1,4 +1,4 @@
// The issue drafted from a debug session (E16 S3) — pure functions, so what the composer shows and
// The issue drafted from a debug session — pure functions, so what the composer shows and
// what it saves are one computation, testable without a browser.
//
// The composer renders the markdown HERE, in the user's language, and the server carries it as it
+3 -3
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E30: a scheduler job's result in the reader's language, through the REAL catalog. The server's own
// A scheduler job's result in the reader's language, through the REAL catalog. The server's own
// one-liner was "recent={'channels_processed': 0, …}, deep={…}" — English keys and a Python repr.
import { afterEach, describe, expect, it } from "vitest";
import i18n, { setLanguage } from "../i18n";
@@ -66,7 +66,7 @@ describe("an older notice's one-liner", () => {
});
});
// E31: the audit log showed a failed RSS poll as the server's English sentence ("RSS poll failed for
// The audit log showed a failed RSS poll as the server's English sentence ("RSS poll failed for
// all 399 channels — first: …") under a Hungarian UI. A failure the job names rides along as data.
describe("a failed run", () => {
const run = {
@@ -88,7 +88,7 @@ describe("a failed run", () => {
);
});
// E31 S5: the server raises it for an instance with one subscription too ("none of the 1 channels").
// The server raises it for an instance with one subscription too ("none of the 1 channels").
it("reads one channel as one, and keeps its stop", () => {
const t = i18n.getFixedT("en");
expect(jobFailureText(t, { ...run.result, total: 1 }, "en")).toBe(
+4 -4
View File
@@ -20,7 +20,7 @@ export function parseSummary(summary: string | null | undefined): JobResult | nu
return out;
}
/** A scheduler job's result in the reader's language (E30): "új videó: 3 · sikertelen: 0". The server
/** A scheduler job's result in the reader's language: "új videó: 3 · sikertelen: 0". The server
* stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is
* named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or
* word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */
@@ -39,7 +39,7 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st
}
/** A failure the job named (the server's `progress.JobFailed`: `{failure: <reason>, …params}`) in the
* reader's language (E31); null for any other failure, which keeps the upstream's own text. */
* reader's language; null for any other failure, which keeps the upstream's own text. */
export function jobFailureText(t: TFunction, result: unknown, lang: string): string | null {
if (!result || typeof result !== "object") return null;
const { failure, ...params } = result as Record<string, unknown>;
@@ -61,10 +61,10 @@ function rssCause(t: TFunction, cause: string): string {
return t(`scheduler.rssCause.${cause}`, { defaultValue: cause });
}
/** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job
/** What a job run said, as the inbox notice and the audit row both show it: a title naming the job
* and how the run ended, and its result in the reader's language — from the data when it rode along,
* else read back from the English one-liner. A skipped run names only why; a failed run says the
* failure the job named (`jobFailureText`, E31), else keeps the upstream's own text (diagnostic). */
* failure the job named (`jobFailureText`), else keeps the upstream's own text (diagnostic). */
export function jobRunText(
t: TFunction,
run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown },
+3 -3
View File
@@ -144,7 +144,7 @@ describe("dispatch", () => {
input.remove();
});
// E28: an Escape a menu above already took is spent — a player or page acting on it too is the
// An Escape a menu above already took is spent — a player or page acting on it too is the
// double close the layer stack exists to prevent.
it("ignores an Escape a layer already took", () => {
const calls: string[] = [];
@@ -154,7 +154,7 @@ describe("dispatch", () => {
expect(calls).toEqual([]);
});
// E28 UAT: an arrow the player's settings tabs had already taken (they switch tab and prevent its
// UAT: an arrow the player's settings tabs had already taken (they switch tab and prevent its
// default) ALSO sought the video 10 s. A key a focused widget consumed is spent, for every scope.
it("ignores a key a focused widget already handled", () => {
const calls: string[] = [];
@@ -209,7 +209,7 @@ describe("the table itself", () => {
}
});
// The done-condition of E10 S3: every shortcut in the app is listed in one place AND named in
// The keymap's contract: every shortcut in the app is listed in one place AND named in
// both languages. A row whose label is missing would render as a raw i18n key in the help sheet.
it("names every shortcut in both languages", () => {
for (const s of SHORTCUTS) {
+15 -15
View File
@@ -4,7 +4,7 @@ import { createStore } from "./store";
import type { Page } from "./urlState";
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
// Before E10 S3 six surfaces each owned a private `keydown` listener with its own key literals and
// Earlier, six surfaces each owned a private `keydown` listener with its own key literals and
// its own idea of "am I typing?" — and they had drifted: `PlexPlayer` matched `case "a"` without
// checking modifiers (so Ctrl+A, select-all, cycled the audio track) and its typing guard missed
// `<select>` and `contentEditable`, which the other two surfaces already covered. The classic
@@ -22,7 +22,7 @@ import type { Page } from "./urlState";
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
* is mounted. The help sheet shows `global` plus the registered scopes.
*
* `page`, `search` and `list` are the PAGE-LEVEL scopes (E11 S4): they act on the page underneath
* `page`, `search` and `list` are the PAGE-LEVEL scopes: they act on the page underneath
* every overlay, so `dispatch` keeps them quiet while a layer that owns the keyboard is open. That is
* also what keeps them clear of the players' bare letters — the Plex player's `s` and `k` only ever
* run with the player's own layer up. */
@@ -78,7 +78,7 @@ export interface Shortcut {
// focused button reports `key === " "` inconsistently across engines). A SPACE separates the two
// steps of a sequence: `g h` is `g`, then `h` within SEQUENCE_MS.
//
// Owner rule (E11 D1): no new binding may need a modifier on the Hungarian QWERTZ layout — so no `/`
// Owner rule: no new binding may need a modifier on the Hungarian QWERTZ layout — so no `/`
// or `?` (both Shift chords there). Letters, digits, `-`, `,`, `.`, Esc/Enter/arrows only. `keyMatches`
// compares `e.key`, so a letter follows the printed legend (the HU Z/Y swap included).
export const SHORTCUTS = [
@@ -86,11 +86,11 @@ export const SHORTCUTS = [
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
// have in their fingers here is the Plex player's `h`. Making it app-wide costs no relearning.
// `allowInInput` because the palette AUTOFOCUSES its search field: without it the typing guard ate
// the second Mod+K, so the toggle only ever fired one way and Chrome took the chord instead
// (E10 review round 1). Its handler preventDefaults, so nothing leaks to the browser either way.
// the second Mod+K, so the toggle only ever fired one way and Chrome took the chord instead.
// Its handler preventDefaults, so nothing leaks to the browser either way.
{ id: "palette", scope: "global", keys: ["Mod+k"], labelKey: "keys.palette", allowInInput: true },
{ id: "help", scope: "global", keys: ["h"], labelKey: "keys.help" },
// Shift+W and not a bare `w` (E24): the Plex player binds bare letters (a s d f h i k m) and `w`
// Shift+W and not a bare `w`: the Plex player binds bare letters (a s d f h i k m) and `w`
// sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
// and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
{ id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
@@ -143,7 +143,7 @@ export const SHORTCUTS = [
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
// — where nothing answers the key (E10 review round 1). Same honest-states rule `plexBrowse` obeys.
// — where nothing answers the key. Same honest-states rule `plexBrowse` obeys.
{
id: "contextMenu",
scope: "contextMenu",
@@ -229,7 +229,7 @@ export type ShortcutId<S extends KeyScope> = Extract<
{ scope: S; handledLocally?: undefined }
>["id"];
/** True while focus sits somewhere that swallows plain keys. The ONE definition: before S3 the
/** True while focus sits somewhere that swallows plain keys. The ONE definition: before it the
* players disagreed about `<select>` and `contentEditable`, so the same keystroke was a shortcut in
* one and text in the other. Reads `e.target` (the focused node for a keydown) and falls back to
* `document.activeElement` for synthetic events that carry no target. */
@@ -272,11 +272,11 @@ export function matches(e: KeyboardEvent, chord: string): boolean {
* matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window),
* but it must still go through `matches` or the table's `keys` become decoration. They had: the
* context menu hand-rolled `e.key === "F10" && e.shiftKey`, so Ctrl+Shift+F10 opened it too and
* editing the row changed the help sheet and nothing else (E10 review round 1).
* editing the row changed the help sheet and nothing else.
*
* Keyed on scope AND id, both as literal types: ids are only unique WITHIN a scope (`back` is on
* both players), so a bare `ROWS.find(s => s.id === id)` would answer for whichever came first, and
* a typo'd string would silently never match (round 2). */
* a typo'd string would silently never match. */
export function matchesShortcut<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
@@ -306,7 +306,7 @@ function liveSequence(e: KeyboardEvent): typeof pending {
return null;
}
if (MODIFIER_KEYS.has(e.key)) return null;
// A chord with a modifier cannot be a second step (no binding may need one, D1): it abandons the
// A chord with a modifier cannot be a second step (no binding may need one): it abandons the
// sequence and is matched as itself, or `g` then Mod+K would open the browser's search, not ours.
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
pending = null;
@@ -332,7 +332,7 @@ export function dispatch<S extends KeyScope>(
if (sequenceEvents.has(e)) return false;
// A key a focused widget already handled is not a shortcut: every listener here is on `window`,
// so a tablist or a keyboard drag (dnd-kit, on the document) has acted on it and prevented its
// default first. The player's settings tabs switched tab AND sought the video 10 s (E28 UAT).
// default first. The player's settings tabs switched tab AND sought the video 10 s (seen in UAT).
if (e.defaultPrevented) return false;
// An Escape a layer already took (a menu that just closed) is spent: a page or a player reacting to
// it too is the double close the layer stack exists to prevent.
@@ -417,8 +417,8 @@ export function useActiveScopes(): KeyScope[] {
const stack = scopeStack.useValue();
// DEDUPED: a scope can be registered by more than one live component (the Playlists page and its
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key
// (E10 review round 2). Innermost registration wins the ordering.
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key.
// Innermost registration wins the ordering.
const live = [...new Set([...stack].reverse())];
// The page-level scopes go in a FIXED order after the surfaces, not in registration order: App
// registers `page` in its own effect, which React runs after every child's — so by mount order
@@ -474,7 +474,7 @@ export const usePaletteOpen = (): boolean => paletteOpen.useValue();
* `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
* Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
* browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
* popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
* popped the overlay open by itself the moment the signed-in shell mounted. */
export function useGlobalKeymap(
enabled: boolean,
onNavigate: (page: Page) => void,
+1 -1
View File
@@ -80,7 +80,7 @@ describe("layerStack Escape coordination (UX-8)", () => {
});
});
// A layer answers TWO questions, and round 3 found they are not the same one: "do the page's own
// A layer answers TWO questions, and a review found they are not the same one: "do the page's own
// keys stay quiet?" and "do I take the Tab trap from the dialog below?". The recorder's step prompt
// is the case that separates them — a field inside whatever dialog the pill is docked in, so it
// owns the keyboard but its two controls are no trap for that dialog's many.
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useEffectEvent, useRef } from "react";
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
// One shared registry of open "dismissable layers" (modals, popovers, players), ordered by
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
// its own `stopPropagation`: Modal's private `modalStack`, the popovers' document listeners, and the
// two players' `modalCount()` checks. Now EVERY layer keeps its own key handler but coordinates
@@ -24,7 +24,7 @@ const passive = new Set<number>();
// inside one (the recorder's step field). It must silence the page's keys — an autofocused field
// does — yet it is not a dialog of its own, so claiming the trap left the dialog around it with
// none, and Tab from anything outside the prompt walked into the page behind the scrim. The two
// questions are separate; a layer answers each for itself (deep-review round 3).
// questions are separate; a layer answers each for itself.
const defers = new Set<number>();
export function pushLayer(opts?: { passive?: boolean; trapsTab?: boolean }): number {
@@ -88,7 +88,7 @@ export function escapeHandled(e: KeyboardEvent): boolean {
/** THE close-on-Escape test for layer `id`: an Escape no layer above has taken yet, while `id` is on
* top — and it claims the event for `id`. Both halves are needed: a list inside a dialog closes with a
* synchronous flush that pops its layer before the dialog's `window` listener runs, so topmost alone
* read "yes" and one Escape closed the list AND the dialog (E28, the share dialog's user picker). */
* read "yes" and one Escape closed the list AND the dialog (the share dialog's user picker). */
export function takeEscape(e: KeyboardEvent, id: number): boolean {
if (e.key !== "Escape" || escapeHandled(e) || !isTopmost(id)) return false;
markEscapeHandled(e);
+2 -2
View File
@@ -5,12 +5,12 @@ import { beforeAll } from "vitest";
// The one ESLint the lint-rule contract tests probe with (`*.contract.test.ts` that pin a selector in
// eslint.config.js). Its first lint loads the whole config and every plugin: ~1.5 s warm, measured at
// 8 s cold, past vitest's 5 s per test, so the first probe of a file timed out once (E28 audit). The
// 8 s cold, past vitest's 5 s per test, so the first probe of a file timed out once. The
// hook below pays that cost once per test file, under its own budget; importing this registers it.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
// The probes lint TEXT under file names that do not exist (`__probe.tsx`, `x.ts`, a feature-folder
// `Button.tsx`). Since E32 the src config runs type-aware rules, whose project service refuses a
// `Button.tsx`). The src config runs type-aware rules, whose project service refuses a
// file no tsconfig includes, so those names are let into TypeScript's default project here — the
// probe still runs the real config, typed rules included, rather than a copy with them switched off.
const PROBE_FILES = [
+8 -8
View File
@@ -87,7 +87,7 @@ describe("useListNav", () => {
expect(screen.getByTestId("a1").getAttribute("tabindex")).toBe("-1");
});
// The player (E11 S2) restores focus to whatever held it when it opened. If the item gave its
// The player restores focus to whatever held it when it opened. If the item gave its
// tabindex back on blur, that restore would silently fail and focus would fall to <body>.
it("keeps the item focusable after focus leaves it, so a dialog can restore onto it", () => {
render(
@@ -112,7 +112,7 @@ describe("useListNav", () => {
expect(focused()).toBe("a1");
});
// Owner decision D4: j/k walk every collection on the page in reading order.
// Owner decision: j/k walk every collection on the page in reading order.
it("continues into the next list, and back, in document order", () => {
render(
<>
@@ -272,7 +272,7 @@ describe("useListNav · measured lists (rails and plain grids)", () => {
);
});
// Owner decision D4: ↑↓ from a rail go to the rail above or below, not nowhere.
// Owner decision: ↑↓ from a rail go to the rail above or below, not nowhere.
it("goes on from a list's bottom row to the next list's nearest item, and back up", () => {
withLayout(
// Two rails of four; the lower one sits 30px further right.
@@ -350,7 +350,7 @@ describe("useListNav · keys and lists it must leave alone", () => {
});
});
describe("useListNav · the roving tabindex and a removed item (deep-review E11 round 1)", () => {
describe("useListNav · the roving tabindex and a removed item", () => {
// A tile that is a native <button> is a Tab stop already. A tabindex="-1" on it took it out of the
// Tab order until the next `j` moved elsewhere: Shift+Tab back to a genre tile skipped it.
it("leaves an item that is a Tab stop in its own right out of the tabindex it roves", () => {
@@ -430,7 +430,7 @@ describe("useListNav · the roving tabindex and a removed item (deep-review E11
});
});
describe("useListNav · leaving the list, and naming the item (deep-review E11 round 2)", () => {
describe("useListNav · leaving the list, and naming the item", () => {
function Swappable({ ids }: { ids: number[] }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
@@ -514,7 +514,7 @@ describe("useListNav · leaving the list, and naming the item (deep-review E11 r
// A Plex poster's open element is the whole card — badges, title, year, rating and summary — and a
// season card's holds only the hover caption "Open show": named from that text, every season was
// "Open show" (deep-review E11 round 3). The item's own name wins.
// "Open show". The item's own name wins.
it("names a card by the name its item gives, over the text of what it opens", () => {
function Posters() {
const ref = useRef<HTMLDivElement>(null);
@@ -535,7 +535,7 @@ describe("useListNav · leaving the list, and naming the item (deep-review E11 r
});
// Hide a card, then Undo in the toast that put up: focus on the toast's button is not the reader
// leaving the list, and `j` goes on from the card put back (deep-review E11 round 3).
// leaving the list, and `j` goes on from the card put back.
it("keeps the slot when focus went to the Undo of a toast", () => {
withLayout(
(_, n) => ({ left: 0, top: n * 100 }),
@@ -563,7 +563,7 @@ describe("useListNav · leaving the list, and naming the item (deep-review E11 r
});
});
describe("useListNav · Delete (E28 S3e2: a playlist row's X became the Delete key)", () => {
describe("useListNav · Delete (a playlist row's X became the Delete key)", () => {
function Deletable({ onDelete }: { onDelete?: boolean }) {
const [ids, setIds] = useState([0, 1, 2]);
const ref = useRef<HTMLDivElement>(null);
+3 -3
View File
@@ -2,8 +2,8 @@
//
// The `list` key scope — what puts j/k, the arrows and Enter on the cheat sheet — is live only while a
// registered list has an item to land on. An empty download queue, a list still loading or one that
// failed mounts its container all the same, and the sheet listed the keys over nothing (deep-review
// E11 round 2). Its own file: listNav keeps module state.
// failed mounts its container all the same, and the sheet listed the keys over nothing.
// Its own file: listNav keeps module state.
import { useRef } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
@@ -52,7 +52,7 @@ describe("useListNav · the list key scope", () => {
await waitFor(() => expect(scopes()).not.toContain("list"));
});
// E28 S3e2: Delete is on the sheet only where a list can take an item out — a playlist's rows, not
// Delete is on the sheet only where a list can take an item out — a playlist's rows, not
// the feed or a Plex grid, where nothing answers it.
it("puts Delete on the sheet only for a list that removes items", async () => {
const { rerender } = render(page(2));
+12 -12
View File
@@ -3,7 +3,7 @@ import { dispatch, useKeyScope } from "./keymap";
import { scrollParent } from "./listPaging";
import { focusIsUnclaimed } from "./useFocusKeeper";
// ── Keyboard navigation over lists and grids (E11 S4, UX-6) ──────────────────────────────────────
// ── Keyboard navigation over lists and grids (UX-6) ──────────────────────────────────────────────
// Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so
// the tenth card sat ~135 Tabs from the top of the page. `j`/`k` step from ITEM to item instead, the
// arrows move around a grid, and Enter opens — the same thing a click on the item does.
@@ -17,10 +17,10 @@ import { focusIsUnclaimed } from "./useFocusKeeper";
// The Tab order is untouched: an item root is NOT a Tab stop. It gets `tabindex="-1"` at the moment
// the keyboard lands on it — one item at a time, the previous one gives it back — so Tab still walks
// the controls inside the cards, a mouse click on a card's blank area still focuses nothing, and the
// player's focus restore (E11 S2) can land back on the card it was opened from.
// player's focus restore can land back on the card it was opened from.
//
// Every page-level list shares ONE window listener, and the lists are walked in DOCUMENT order: `j`
// at the end of one list continues at the start of the next (owner decision D4, "reading order").
// at the end of one list continues at the start of the next (owner decision, "reading order").
// ←→ stay inside the list they are in; ↑ on a list's top row and ↓ on its bottom row go on to the
// neighbouring list — the rail above or below on Home and Discover — landing on the item nearest the
// one you left, so a column of rails reads like one grid.
@@ -50,7 +50,7 @@ const REVEAL_FRAMES = 10;
/** Spread onto an item's ROOT element. `name` is what a screen reader calls the item while the keyboard
* is on it (its title) — pass it where the element Enter opens wraps the whole card (a Plex poster:
* badges, year, rating and summary run together), shows a caption that is not the item's name, or
* holds no text at all (deep-review E11 round 3).
* holds no text at all.
*
* `inset` draws the keyboard ring INSIDE the item. The ring sits 2 px outside by default, which a
* card with a gap round it has room for; an item flush against its neighbours (a log line) or against
@@ -152,8 +152,8 @@ function onFocusIn(e: FocusEvent): void {
// for other content — like a click on <body>, which fires no focusin at all.
else if (focusIsUnclaimed(target)) return;
// Focus went on to page content that is not an item — the search box, a filter chip: the reader
// left the list, and whatever it shows next (new results) is not "the slot they were in" (deep-review
// E11 round 2). A layer opened FROM the item — its menu, a confirm, the Undo toast its removal put
// left the list, and whatever it shows next (new results) is not "the slot they were in".
// A layer opened FROM the item — its menu, a confirm, the Undo toast its removal put
// up — does not count: focus comes back from it, and what it did (a hidden card, a deleted row, the
// card put back) is what the slot is kept for. Removing the focused node itself fires no focusin.
else if (!target?.closest?.('[data-floating-ui-portal], [role="dialog"], [data-toast-region]'))
@@ -178,7 +178,7 @@ function slotOfRemoved(delta: 1 | -1): { list: NavList; index: number } | null {
}
/** What an item is called: its own name when it gives one (`navItemAttrs`), else the open element's
* label or text — a title link's text is the title (deep-review E11 round 3). */
* label or text — a title link's text is the title. */
function itemName(el: HTMLElement): string {
const open = el.querySelector("[data-nav-open]");
return (
@@ -203,7 +203,7 @@ function focusItem(el: HTMLElement, reveal = true): void {
roved = el;
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
// opens (deep-review E11 round 2). A row (<tr>) keeps its own role.
// opens. A row (<tr>) keeps its own role.
const name = itemName(el);
if (
el.tagName === "DIV" &&
@@ -386,7 +386,7 @@ function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
// ── The item a view was left from ────────────────────────────────────────────────────────────────
// Opening something that switches the page or the view — a Plex title from Home's rail, a channel from
// the Channels table — unmounts the list the keyboard was on, so Back used to hand focus to <body> and
// the next `j` started over at the top (E11 S5 UAT). The history entry being left remembers the
// the next `j` started over at the top (seen in UAT). The history entry being left remembers the
// position (lib/history `rememberNavPosition`) and Back gives focus back to that item once its list is
// on screen again. An overlay (the YouTube player) never unmounts the list; it restores focus itself.
@@ -440,7 +440,7 @@ function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
/** Focus the item at `pos` once its list is on screen again. A NAMED list (a rail) is found by its
* name: a rail that appeared above it in the meantime — "Continue watching", filled by the very title
* watched in between, after the page's cache expired — shifts every place after it, and matching by
* place landed the focus in the wrong rail (deep-review E11 round 2). An unnamed list only by its
* place landed the focus in the wrong rail. An unnamed list only by its
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
@@ -469,8 +469,8 @@ export function restoreNavPosition(pos: NavPosition): () => void {
};
// Not inside the popstate dispatch that landed: a sub-view's own listener (`useHistorySubview`)
// may run after ours and has not switched the view yet — focus still sits in the view being left,
// and "focus is already somewhere" would give up for good (measured in Chrome, deep-review E11
// round 1: Plex show → Back left focus on <body>). One task later the view has rendered.
// and "focus is already somewhere" would give up for good (measured in Chrome:
// Plex show → Back left focus on <body>). One task later the view has rendered.
timer = setTimeout(attempt, 0);
return () => clearTimeout(timer);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// One definition of a PageUp/PageDown page for the palette and every popover list (E11, owner
// One definition of a PageUp/PageDown page for the palette and every popover list (owner
// request). The DOM-bound halves are covered where they are wired (CommandPalette, Combobox,
// ActionMenu); this pins the arithmetic both lean on.
import { describe, expect, it } from "vitest";
+1 -1
View File
@@ -1,4 +1,4 @@
// ── PageUp/PageDown over a list the keyboard otherwise walks row by row (E11, owner request) ─────
// ── PageUp/PageDown over a list the keyboard otherwise walks row by row (owner request) ──────────
// The jump-to palette (461 rows, ~11 in view) and every popover list — the sidebar's channel filter
// over hundreds of channels, the Plex pickers, the filter and sort menus — had only the arrows. One
// definition of "a page" here, so the palette and the dropdowns cannot drift apart.
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { redact, SEVERITY } from "./log";
// The browser log and the server log are ONE log (E22): a client batch lands in `log_events` beside
// The browser log and the server log are ONE log: a client batch lands in `log_events` beside
// the server's rows. So the two must agree on what is safe to keep and on what a level is worth —
// asserted against the backend's own files, not a copy of them.
const backend = (path: string) =>
+5 -5
View File
@@ -1,11 +1,11 @@
// The client log (E22 S4): the browser half of the ONE event shape `backend/app/logs.py` defines.
// The client log: the browser half of the ONE event shape `backend/app/logs.py` defines.
//
// Every event is `{ts, level, sev, source: "web", event, msg, attrs, request_id?, tab_id, version}` —
// the same fields and level names as a server line, so a client batch can land in `log_events` beside
// the server's own rows and a request joins across the two by `request_id`.
//
// Events are kept in memory only, in a bounded ring: nothing leaves the browser from here. Sending
// them is `lib/diagnostics.ts`'s job, and only while the user's consented level is raised (E22 S5);
// them is `lib/diagnostics.ts`'s job, and only while the user's consented level is raised;
// it and the Settings log viewer follow the ring through `subscribe`.
//
// REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler,
@@ -55,7 +55,7 @@ export interface LogEvent {
request_id?: string;
tab_id: string;
version: string;
/** The debug session recording when the event was kept (E16). */
/** The debug session recording when the event was kept. */
debug_session_id?: string;
}
@@ -142,7 +142,7 @@ const TAB_ID = tabId();
// --- the logger -----------------------------------------------------------------------------------
let threshold: Level = "INFO";
let ring: LogEvent[] = [];
// The debug session this tab is recording into (E16), or null. Held HERE, the lowest layer, because
// The debug session this tab is recording into, or null. Held HERE, the lowest layer, because
// two things below everything else need it: every kept event carries it, and `api/core` puts it on
// every request as `X-Debug-Session` — which is how the server tags its own lines of that request.
let debugSession: string | null = null;
@@ -158,7 +158,7 @@ export function currentDebugSession(): string | null {
}
/** The capture level. INFO is the always-on "Alap"; DEBUG and TRACE come from the user's consented
* diagnostics level (E22 S5). */
* diagnostics level. */
export function setLevel(level: Level): void {
threshold = level;
}
+2 -2
View File
@@ -1,6 +1,6 @@
// The client log's app-wide hooks (E22 S4), installed once from main.tsx before anything renders:
// The client log's app-wide hooks, installed once from main.tsx before anything renders:
// the start snapshot, uncaught errors and rejections, navigation, connectivity, and — only while the
// user's consented level is raised (E22 S5) — which control was used and what the tab itself went
// user's consented level is raised — which control was used and what the tab itself went
// through (hidden/shown, restored from the back-forward cache, resized).
//
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
+1 -1
View File
@@ -1,4 +1,4 @@
// What a player went through, for the client log (E22 S5) — one definition for every player, so a
// What a player went through, for the client log — one definition for every player, so a
// stall in the Plex player and one in the library player read the same in a diagnosis.
//
// A media ERROR is a warning at every level: it is the thing a user reports. The rest — play, pause,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
// Every `useMutation` NAMES what failed (E11 S2b, owner decision: the toast's title says what, the
// Every `useMutation` NAMES what failed (owner decision: the toast's title says what, the
// line under it why). The app-wide reporter (lib/queryClient) reports a mutation that names nothing
// too — so nothing is silent any more — but under the generic "Couldn't complete that", which is the
// very toast that says nothing about what it is about when it lands a few seconds after the click.
@@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest";
// under the title from `meta` if there is one).
//
// The assertion pins the SET of unnamed mutations, never a count, and checks the glob matched at
// all: a guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
// all: a guard whose glob stops matching is green over nothing.
const NAMES = /\bfailTitle\b|\berrorShownInline\b|\bonError\b/;
/** The source of each `useMutation(...)` call, found by paren balance with comments stripped —
+1 -1
View File
@@ -12,7 +12,7 @@ export function setNavigator(fn: (p: Page) => void): void {
/** The "View" on a "Download added" toast is clicked INSIDE the player (its toast host). That is
* safe because `setPage` closes every open overlay before it pushes (see
* `lib/history.closeOverlaysThenNavigate`) — without it the player's Back-to-close cleanup popped the
* entry the navigation had just pushed (deep-review round 4). */
* entry the navigation had just pushed. */
export function navigateTo(p: Page): void {
_navigate?.(p);
}
@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
// Every push that opens a new view over the current one remembers the list item it was opened from
// (`lib/history.rememberNavPosition`), so Back hands focus back to that item (E11 S5). A push that
// (`lib/history.rememberNavPosition`), so Back hands focus back to that item. A push that
// forgets strands keyboard focus on <body> again with no test noticing — the flow only shows in a real
// browser, after a round trip. So the source is the check: each `history.pushState(` in the app has a
// `rememberNavPosition(` right before it or inside its arguments. The overlay stack's own push is the
// one exception — an overlay entry opens no view, and its list stays mounted underneath.
//
// The assertion pins the SET of offending sites and checks the scan found pushes at all: a guard whose
// glob stops matching is green over nothing (E10 review round 4).
// glob stops matching is green over nothing.
const files = import.meta.glob(["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"], {
query: "?raw",
+1 -1
View File
@@ -7,7 +7,7 @@ import { navItemAttrs } from "./listNav";
// The keyboard ring of a list item (`[data-nav-index]`) sits 2 px OUTSIDE the item: a card with a gap
// round it has room. A table row, a log line or a row flush in a clipping card has none — the ring ran
// under a sticky table head and past the table's sides, and in a Plex playlist show's card it was
// clipped away so the focus was invisible (E28, seen live). A `tr` is inset by its tag (a table
// clipped away so the focus was invisible (seen live). A `tr` is inset by its tag (a table
// hand-built outside DataTable was the one first reported); the others ask for `inset`. jsdom does no
// layout, so what is pinned is the attribute and the rule that draws it inside, with every selector the
// outside ring uses (a `:has()` one outranks a plain attribute pair).
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// E22 S4 put "every request is logged" into eslint.config.js, not prose: `fetch`, `XMLHttpRequest`
// "Every request is logged" lives in eslint.config.js, not prose: `fetch`, `XMLHttpRequest`
// and `WebSocket` each belong to ONE module that logs them, and `console` to none. A restriction
// that matches nothing looks exactly like a clean codebase, so this asserts the rules FIRE — and
// that each owner is exempt from its own global only.
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// eslint.config.js: a bare `fetchNextPage()` cancels the page already loading and sends it again
// (E31 S4e). Pins that the rule keeping every call on NEXT_PAGE actually fires.
// eslint.config.js: a bare `fetchNextPage()` cancels the page already loading and sends it again.
// Pins that the rule keeping every call on NEXT_PAGE actually fires.
const MESSAGE = "fetchNextPage() cancels";
async function hits(source: string): Promise<number> {
+2 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// A next-page call while that page is still loading joins it instead of sending it again (E31 S4e):
// A next-page call while that page is still loading joins it instead of sending it again:
// on prod every feed page went out 2–3 times with the same cursor.
import { InfiniteQueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
@@ -46,7 +46,7 @@ describe("NEXT_PAGE", () => {
unsubscribe();
});
// E31 S5: the join takes ANY fetch in flight. During a background refetch (an invalidation, a
// The join takes ANY fetch in flight. During a background refetch (an invalidation, a
// poll) it reloads the pages already there and appends none — why a read-ahead waits it out.
it("joins a background refetch and appends no page", async () => {
const sent: (string | null)[] = [];
+2 -2
View File
@@ -3,11 +3,11 @@
* fetch that is still in flight and sends it again with the same cursor. Our requests do not take the
* abort signal, so the first one is not stopped: the server ran both. The feed's read-ahead effect
* re-runs on every scroll frame, and a frame that lands before `isFetchingNextPage` turns true calls
* again. On prod (E31 S4e) every feed page went out 2–3 times; on a 0.8 s title sort the copies
* again. On prod every feed page went out 2–3 times; on a 0.8 s title sort the copies
* queued to 2.3 s. With this a second call joins the fetch already running.
*
* The join takes ANY fetch in flight, not only a next page: a call during a background refetch (an
* invalidation, a poll) joins THAT, which reloads the pages already there and appends none (E31 S5).
* invalidation, a poll) joins THAT, which reloads the pages already there and appends none.
* So a read-ahead also waits while the list is fetching at all (`mayReadAhead`) and runs again when
* the fetch settles; a click uses `nextPageOnClick`.
*/
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
// Every infinite list's read-ahead waits while the list fetches ANYTHING and runs again when the fetch
// settles (E31 S5). A read-ahead that only checks `isFetchingNextPage` calls during a background
// settles. A read-ahead that only checks `isFetchingNextPage` calls during a background
// refetch, joins it through NEXT_PAGE and loads no page: the grid stalls at its loaded end until the
// reader scrolls the range. The jsdom virtualizer measures nothing, so the wiring is pinned in source.
const files = import.meta.glob(["../components/**/*.tsx", "!../components/**/*.test.tsx"], {
@@ -31,7 +31,7 @@ describe("an infinite list's read-ahead", () => {
});
// Retry is off, so a failed page settles at once; a read-ahead that re-runs on the settle would
// request it again once a round-trip while the end is in view (E31 review 2).
// request it again once a round-trip while the end is in view.
it("stands down at a failed next page", () => {
expect(source("Feed.tsx")).toMatch(
/hasNextPage=\{!!hasNextPage && !query\.isFetchNextPageError\}/,
@@ -44,7 +44,7 @@ describe("an infinite list's read-ahead", () => {
// A click joins an in-flight next page but cancels a background refetch (nextPageOnClick): through
// NEXT_PAGE it would join the refetch and load nothing. NEXT_PAGE is only for the read-aheads, so a
// click site that goes back to it fails here (E31 review 2: the YouTube "Load more" was missed).
// click site that goes back to it fails here (the YouTube "Load more" was once missed).
describe("a click that loads the next page", () => {
const READ_AHEADS = new Set(["ui/VirtualGrid.tsx", "PlexBrowse.tsx"]);
it("uses nextPageOnClick; NEXT_PAGE only in the read-aheads", () => {
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// A toast with an action (the list page's "Undo") outlives a short toast setting: its button is the
// only way back, so it gets ACTION_TTL_MIN at least (owner, E28 S3e2). The setting still wins when it
// only way back, so it gets ACTION_TTL_MIN at least (owner's call). The setting still wins when it
// is longer, "never" still means never, and a plain toast keeps the reader's time.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import {
+1 -1
View File
@@ -93,7 +93,7 @@ const SETTINGS_KEY = LS.notifSettings;
const MAX_HISTORY = 100;
const ERROR_TTL = 15000;
// A toast that carries an action (an "Undo") lives at least this long, whatever the reader's toast
// time: its button is the only way back, and a 3 s toast leaves no time to reach it (owner, E28).
// time: its button is the only way back, and a 3 s toast leaves no time to reach it (owner's call).
// The setting still decides for longer, and "never" still means never.
export const ACTION_TTL_MIN = 8000;
@@ -3,7 +3,7 @@
// The YouTube IFrame API script failing to load must END in an error state. `loadYouTubeApi` used to
// return a promise that only ever resolved: a blocked or failed script (a content blocker, no
// network) left the modal on an empty stage forever, with no error and no way to retry short of a
// reload — the promise was memoised, so every later open waited on the same dead one (E32 audit).
// reload — the promise was memoised, so every later open waited on the same dead one.
//
// Its own file on purpose: `loadYouTubeApi` memoises the API promise in a module-level singleton,
// and these tests need a module instance that has never seen `window.YT`.
+2 -2
View File
@@ -155,7 +155,7 @@ export function useYtVolume({
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
// Over a panel that can still scroll that way, the wheel scrolls it: the wide player's side panel
// holds the description, which a wheel spent on the volume could never be read past (E31).
// holds the description, which a wheel spent on the volume could never be read past.
const panel =
e.target instanceof Element ? e.target.closest<HTMLElement>("[data-wheel-scrolls]") : null;
if (
@@ -555,7 +555,7 @@ export function useYtProgress({
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What
// genuinely has to stay live mid-session is behind refs (currentIdRef, modeRef, navRef,
// sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and THAT split is the enforcement:
// a value that is not a snapshot does not belong in this closure. (E32: this replaced an
// a value that is not a snapshot does not belong in this closure. (This replaced an
// exhaustive-deps suppression, which made the React Compiler skip the whole hook.)
}, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]);
}
@@ -4,8 +4,8 @@ import { expect, it } from "vitest";
// The YouTube player's "full window" (F) is a `position: fixed; inset: 0` stage INSIDE the modal's card.
// A fixed element is sized by the viewport only while no ancestor has a filter: a `backdrop-filter`
// makes that ancestor its containing block. E28 S3d1 moved the card from `.glass-card` (no blur) to
// `.glass` (blurred), and the full window shrank to a 894×161 strip inside the card (E28 audit, seen
// makes that ancestor its containing block. A rework moved the card from `.glass-card` (no blur) to
// `.glass` (blurred), and the full window shrank to a 894×161 strip inside the card (seen
// live). jsdom does no layout, so what is pinned is the escape rule: while the stage is maximised, the
// card drops its blur, with a selector that outranks `html[data-theme="dark"] .glass` (0,2,1).
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
+1 -1
View File
@@ -3,7 +3,7 @@ import { applyView, sameOrder } from "./playlistView";
import type { Column } from "./tableColumns";
import type { Video } from "./api";
// The playlist's view derivation (E4 S4). What makes it worth a test: the column sort and the
// The playlist's view derivation. What makes it worth a test: the column sort and the
// channel grouping compose — groups are ordered by channel name in the SORT's direction, and the
// sort then applies within each group. Getting that backwards silently produces a plausible-looking
// order, and the whole "save this order" feature writes whatever this function returned.
+1 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a
// The crossfading art backdrop hook. Covers the pieces that don't need real layout — that a
// pool paints #root + isolates it (so the z-index:-1 overlay can paint above #root's own background),
// creates exactly one overlay layer when it may animate, and stays static (no overlay, first image
// only) for a single image, the crossfade off, or when disabled. Then the crossfade setting itself:
+5 -5
View File
@@ -15,7 +15,7 @@ export function useDetailPrefs() {
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Untouched,
// Slowly cross-dissolve the art backdrop through the item's backdrops/related art. Untouched,
// it follows the system: off where the reader asked for less motion (prefers-reduced-motion — which
// Windows also reports whenever its animation effects are off). Once the reader turns it on or off
// here, that pick wins on every device: the owner never saw the crossfade, because the system signal
@@ -52,10 +52,10 @@ export type AsideSection = "facts" | "keywords" | "crew";
// The movie and the show page's poster. It keeps its 2:3 (self-start, never stretched to the row: the
// second column beside the text runs taller than the poster), and a wide hero (the @container
// panel at 64rem+) gives it a bigger rung, so it is not a stamp beside a full text column (E31).
// panel at 64rem+) gives it a bigger rung, so it is not a stamp beside a full text column.
export const DETAIL_POSTER = "w-32 sm:w-44 @5xl:w-56";
// The detail pages' second column (plexDetailAside, E31): which of its three sections — the TMDB facts,
// The detail pages' second column (plexDetailAside): which of its three sections — the TMDB facts,
// keywords and key crew — have something to show, which the reader keeps on (three per-user prefs,
// default ON, the same on the movie and the show page), and the toggles' flips.
export function useDetailAside(f: DetailFacts) {
@@ -101,7 +101,7 @@ export function tmdbBackdropUrl(path: string): string {
return `https://image.tmdb.org/t/p/w1280${path}`;
}
// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8).
// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images.
//
// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport
// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav
@@ -211,7 +211,7 @@ export function epLabel(ep?: PlexCard | null): string | undefined {
}
// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got
// English there (E30). A generic name is rebuilt from the number in the reader's language; a season
// English there. A generic name is rebuilt from the number in the reader's language; a season
// with a name of its own ("Book One: Water") keeps it.
const GENERIC_SEASON = /^(season \d+|specials)$/i;
+4 -4
View File
@@ -5,8 +5,8 @@ import { cardImage } from "./plexImage";
// A card's srcset names each Plex cut by its width, and the browser picks one by that width. The
// widths are the image proxy's (backend `app/plex/imgcache.py` SIZES), with no wire format between
// the two: a backend-only change would have the browser pick a cut by a width it does not have (E31
// S5). Read from source, as plexFinishMargin.contract does.
// the two: a backend-only change would have the browser pick a cut by a width it does not have.
// Read from source, as plexFinishMargin.contract does.
const source = readFileSync(
new URL("../../../backend/app/plex/imgcache.py", import.meta.url),
"utf8",
@@ -45,12 +45,12 @@ it("names each backdrop cut by the width the proxy cuts it at", () => {
});
});
// Every small Plex picture asks for its card-size cut through cardImage (E31 S4e, swept in S5: the
// Every small Plex picture asks for its card-size cut through cardImage (the
// playlist rows, the search box and the collection picker still fetched the 400px poster for a 32px
// picture). A plain `src={…}` is allowed only where the full cut is the point or the URL is not the
// proxy's; the SET is pinned, so a new one fails here. Every <img>'s src counts, wherever it sits
// among the attributes and whatever its variable is called: the first cut of this test read only a
// leading `src={…thumb…}` and missed two of the three sites it was written for (E31 review 2).
// leading `src={…thumb…}` and missed two of the three sites it was written for.
const plexFiles = import.meta.glob(
[
"../components/{Plex,plex}*.tsx",
+1 -1
View File
@@ -1,4 +1,4 @@
// A Plex picture at a card's size (E31 S4e). The image proxy cuts each picture twice (backend
// A Plex picture at a card's size. The image proxy cuts each picture twice (backend
// `app/plex/imgcache.py` SIZES): the full one a detail page shows and a card's `_sm` one. Cards fetched
// the full cut — a 400px poster for a ~144px picture, the 1280px backdrop for a ~212px shelf picture.
// A card now hands the browser both and lets it pick by the laid-out width: `sizes="auto"`, which the

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