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:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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.
|
||||
|
||||
@@ -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()}`),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,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,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,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,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,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.
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,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,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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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,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,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.
|
||||
|
||||
@@ -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,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,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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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]> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,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,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,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,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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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 —
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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)[] = [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user