build(frontend): knip also checks what ships; strict indexing in every project

`npm run knip` now runs a second, --production pass, which asks whether the
code that ships uses an export, not just whether anything does. Of the 47
exports only tests reached:
- 34 are exported for their test and say so with @internal, naming the test.
- 11 were module-private names with a stray `export`. Two of them were not
  used at all and are deleted.
- The unused alreadyReported re-export is gone.
Test kits, the e2e suite and the two build-tooling modules are excluded from
the production project, each named in knip.jsonc.

noUncheckedIndexedAccess is now on for the node and e2e tsconfigs too (0
errors), matching the app project. tsconfig.node.json listed a
tailwind.config.js that has not existed since Tailwind 4. That entry is now
playwright.config.ts, which the typed lint finds through this project.
This commit is contained in:
2026-10-03 16:44:28 +02:00
parent cff8fe9a6a
commit 7ed2ac4e95
34 changed files with 84 additions and 33 deletions
+2
View File
@@ -11,6 +11,8 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// E32: an index read is `T | undefined` here, as it really is (0 errors when switched on).
"noUncheckedIndexedAccess": true,
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true "resolveJsonModule": true
}, },
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"playwright": {
"config": ["playwright.config.ts"],
"entry": ["e2e/**/*.@(spec|setup).ts"]
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
// `--production` (E32) asks a second question besides "is this used?": "is it used by the code
// that SHIPS?" — an export only a test reaches is either dead in the bundle or exported for its
// test, which then says so with a JSDoc `@internal` tag (knip skips those in production mode).
// What is not production code at all is excluded from the production project with a `!…!`
// pattern: the test kits, the e2e suite, and the two build-tooling modules the vite config
// imports (they shape the bundle but are not in it).
"project": [
"**/*.{ts,tsx,js}!",
"!**/*.testkit.ts!",
"!e2e/**!",
"!reactPlugin.ts!",
"!devOnlyGuard.ts!",
],
"playwright": {
"config": ["playwright.config.ts"],
"entry": ["e2e/**/*.@(spec|setup).ts"],
},
}
+1 -1
View File
@@ -14,7 +14,7 @@
"typecheck:e2e": "tsc --noEmit -p e2e/tsconfig.json", "typecheck:e2e": "tsc --noEmit -p e2e/tsconfig.json",
"build": "npm run typecheck:app && npm run typecheck:node && vite build", "build": "npm run typecheck:app && npm run typecheck:node && vite build",
"preview": "vite preview", "preview": "vite preview",
"knip": "knip", "knip": "knip && knip --production",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
@@ -43,6 +43,7 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't /** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that), * personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */ * so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
/** @internal exported for channelColumns.dom.test.tsx */
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0; if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
return values.some((v) => c.tag_ids.includes(Number(v))); return values.some((v) => c.tag_ids.includes(Number(v)));
@@ -184,6 +184,7 @@ export function DetailKeywords({
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the // The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
// text is the hero's tallest part, and twelve names ran it ~170px past the text (E31, the owner's call). // text is the hero's tallest part, and twelve names ran it ~170px past the text (E31, the owner's call).
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room. // A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
/** @internal exported for PlexInfo.dom.test.tsx */
export const CREW_SHOWN = 6; export const CREW_SHOWN = 6;
const CREW_FOLD_OVER = CREW_SHOWN + 2; const CREW_FOLD_OVER = CREW_SHOWN + 2;
@@ -19,7 +19,7 @@ import { fieldClass } from "./fieldStyles";
// and playlists. On the shell it reuses `Modal` in its `top` placement — same layer stack, Escape, // and playlists. On the shell it reuses `Modal` in its `top` placement — same layer stack, Escape,
// focus restore and Back-to-close as every other dialog. // focus restore and Back-to-close as every other dialog.
export interface CommandItem { interface CommandItem {
/** Unique across the whole palette; also the React key. */ /** Unique across the whole palette; also the React key. */
id: string; id: string;
label: string; label: string;
@@ -13,6 +13,7 @@ import { SURFACE } from "./surfaces";
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip); // Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test. // reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
/** @internal exported for FilterSelect.dom.test.tsx */
export interface FilterSelectOption { export interface FilterSelectOption {
value: string; value: string;
label: string; label: string;
+1 -1
View File
@@ -7,7 +7,7 @@ import { SURFACE } from "./surfaces";
// paragraph's, for prose that arrives after its page). The // paragraph's, for prose that arrives after its page). The
// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role. // shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role.
export function Skeleton({ className, inline = false }: { className?: string; inline?: boolean }) { function Skeleton({ className, inline = false }: { className?: string; inline?: boolean }) {
// Inline: a span, for a bone inside phrasing content (a chip is a button, which may not hold a div). // Inline: a span, for a bone inside phrasing content (a chip is a button, which may not hold a div).
const Tag = inline ? "span" : "div"; const Tag = inline ? "span" : "div";
return ( return (
@@ -12,8 +12,9 @@ import { startTransition, useEffect, useState } from "react";
// //
// Not virtualization: every row still ends up in the DOM, so browser find, keyboard nav past the screen, // Not virtualization: every row still ends up in the DOM, so browser find, keyboard nav past the screen,
// and the Back focus/scroll restore (which wait for the list to grow) keep working as they did. // and the Back focus/scroll restore (which wait for the list to grow) keep working as they did.
/** @internal exported for DataTable.progressive.dom.test.tsx */
export const FIRST_ROWS = 30; export const FIRST_ROWS = 30;
export const STEP_ROWS = 40; const STEP_ROWS = 40;
// `view` names what the rows show (page, size, sort, filters). A new view starts over from the first // `view` names what the rows show (page, size, sort, filters). A new view starts over from the first
// rows, so the urgent render after a click or a key stays small. New data for the same view (a refetch) // rows, so the urgent render after a click or a key stays small. New data for the same view (a refetch)
+4
View File
@@ -8,12 +8,16 @@
// - ceiling MAX_LOOKAHEAD_ROWS: a tall window renders many rows, and an uncapped lookahead would // - ceiling MAX_LOOKAHEAD_ROWS: a tall window renders many rows, and an uncapped lookahead would
// eagerly prefetch several pages on every mount / filter change — wasted work on the app's // eagerly prefetch several pages on every mount / filter change — wasted work on the app's
// heaviest query (the feed). The cap bounds that while still buffering a couple of screens ahead. // heaviest query (the feed). The cap bounds that while still buffering a couple of screens ahead.
/** @internal exported for readAhead.test.ts */
export const PREFETCH_ROWS = 4; export const PREFETCH_ROWS = 4;
/** @internal exported for readAhead.test.ts */
export const PREFETCH_VIEWPORTS = 2; export const PREFETCH_VIEWPORTS = 2;
/** @internal exported for readAhead.test.ts */
export const MAX_LOOKAHEAD_ROWS = 40; export const MAX_LOOKAHEAD_ROWS = 40;
/** How many rows ahead of the last rendered row to keep buffered, for a viewport rendering /** How many rows ahead of the last rendered row to keep buffered, for a viewport rendering
* `renderedRows` rows. Clamped to [PREFETCH_ROWS, MAX_LOOKAHEAD_ROWS]. */ * `renderedRows` rows. Clamped to [PREFETCH_ROWS, MAX_LOOKAHEAD_ROWS]. */
/** @internal exported for readAhead.test.ts */
export function readAheadLookahead(renderedRows: number): number { export function readAheadLookahead(renderedRows: number): number {
return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS)); return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS));
} }
+3 -1
View File
@@ -10,9 +10,10 @@ import { CONTENT_ID } from "./landmarks";
// back (`room - saving > FOLD_AT`): a page barely taller than the window would otherwise fold, lose its // back (`room - saving > FOLD_AT`): a page barely taller than the window would otherwise fold, lose its
// scroll, land at the top, unfold, and so on, with every wheel tick; // scroll, land at the top, unfold, and so on, with every wheel tick;
// - unfold only at the very top, not at the fold point: between the two the header keeps whatever it was. // - unfold only at the very top, not at the fold point: between the two the header keeps whatever it was.
/** @internal exported for scrollFold.test.ts */
export const FOLD_AT = 32; export const FOLD_AT = 32;
export interface ScrollPosition { interface ScrollPosition {
// The scroller's scrollTop. // The scroller's scrollTop.
top: number; top: number;
// How far it can scroll at all: scrollHeight − clientHeight. // How far it can scroll at all: scrollHeight − clientHeight.
@@ -20,6 +21,7 @@ export interface ScrollPosition {
} }
// `saving` = the height the header gives back when it folds (measured while it is unfolded). // `saving` = the height the header gives back when it folds (measured while it is unfolded).
/** @internal exported for scrollFold.test.ts */
export function nextFolded( export function nextFolded(
folded: boolean, folded: boolean,
{ top, room }: ScrollPosition, { top, room }: ScrollPosition,
-2
View File
@@ -28,5 +28,3 @@ export const SURFACE = {
/** A full-width sticky band (the design lab's header): square. */ /** A full-width sticky band (the design lab's header): square. */
band: "glass", band: "glass",
} as const; } as const;
export type SurfaceRole = keyof typeof SURFACE;
+1 -2
View File
@@ -6,9 +6,8 @@
/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */ /** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */
export const STRONG = "var(--edge-strong)"; export const STRONG = "var(--edge-strong)";
/** B3 state layers (index.css utilities). */ /** B3 state layer (an index.css utility). */
export const LAYER = "state-layer"; export const LAYER = "state-layer";
export const FILLED = "state-filled";
/** B9: the header + fade edge (index.css utilities; the host is positioned). */ /** B9: the header + fade edge (index.css utilities; the host is positioned). */
export const FADE_BELOW = "fade-below"; export const FADE_BELOW = "fade-below";
+1
View File
@@ -10,6 +10,7 @@
// again through `onAccountPinned`. // again through `onAccountPinned`.
// //
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free. // ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
/** @internal exported for 13 tests (BootGate.dom.test.tsx, …) */
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount"; export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
// Re-readers for module-level per-account state, run each time the tab is pinned. // Re-readers for module-level per-account state, run each time the tab is pinned.
+2 -8
View File
@@ -6,16 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call. // 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 // 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 R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.)
import { import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core";
alreadyReported,
getActiveAccount,
HttpError,
setActiveAccount,
setUnauthorizedHandler,
} from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged. // Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export { alreadyReported, HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler }; export { HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
import { accountApi } from "./api/account"; import { accountApi } from "./api/account";
export type * from "./api/account"; export type * from "./api/account";
+2 -2
View File
@@ -19,11 +19,11 @@ export type HeroTier = "resume" | "comfort" | "surprise";
* leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */ * leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */
export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"]; export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"];
export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist"; type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
// What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned // What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned
// as `plex_show` because "put that show on" resolves to whichever episode is next. // as `plex_show` because "put that show on" resolves to whichever episode is next.
export const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const; const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
export type PinKind = (typeof PIN_KINDS)[number]; export type PinKind = (typeof PIN_KINDS)[number];
export interface DiscoverCard { export interface DiscoverCard {
+1 -1
View File
@@ -58,7 +58,7 @@ export interface PlexCard {
// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail. // 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, // null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row,
// facts sidebar, keyword chips (→ filter), and the full crew. // facts sidebar, keyword chips (→ filter), and the full crew.
export interface PlexTmdbCrew { interface PlexTmdbCrew {
name: string; name: string;
job: string; job: string;
tmdb_id?: number | null; tmdb_id?: number | null;
+5 -2
View File
@@ -2,8 +2,11 @@
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no // URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery. // imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */ /**
export type TagMode = "or" | "and"; * A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL.
* @internal exported for its test
*/
type TagMode = "or" | "and";
/** The three tag categories, each with its OWN independent match mode. */ /** The three tag categories, each with its OWN independent match mode. */
export type TagCategory = "language" | "topic" | "other"; export type TagCategory = "language" | "topic" | "other";
export type TagModes = Record<TagCategory, TagMode>; export type TagModes = Record<TagCategory, TagMode>;
+1 -1
View File
@@ -11,7 +11,7 @@ export interface Tag {
} }
// 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 (E8·S4).
export interface TagSuggestion { interface TagSuggestion {
tag_id: number; tag_id: number;
confidence: number; confidence: number;
} }
+1
View File
@@ -34,6 +34,7 @@ export function hueGap(a: string, b: string): number {
/** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the /** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the
* browser does in the srgb space. */ * browser does in the srgb space. */
/** @internal exported for theme.contract.test.ts */
export function mixHex(a: string, b: string, w: number): string { export function mixHex(a: string, b: string, w: number): string {
const [x, y] = [rgb(a), rgb(b)]; const [x, y] = [rgb(a), rgb(b)];
return `#${x return `#${x
+1
View File
@@ -60,6 +60,7 @@ export function formatSpeed(bps: number | null | undefined, lang: string): strin
* Module-level because it is constant, and exported so the tests can hold that invariant. */ * Module-level because it is constant, and exported so the tests can hold that invariant. */
// A non-empty tuple type (first element + rest) so `RELATIVE_UNITS[0]` is known-present under // A non-empty tuple type (first element + rest) so `RELATIVE_UNITS[0]` is known-present under
// noUncheckedIndexedAccess — the "sub-minute" branch relies on that first entry always existing. // noUncheckedIndexedAccess — the "sub-minute" branch relies on that first entry always existing.
/** @internal exported for format.test.ts */
export const RELATIVE_UNITS: readonly [ export const RELATIVE_UNITS: readonly [
[number, Intl.RelativeTimeFormatUnit], [number, Intl.RelativeTimeFormatUnit],
...[number, Intl.RelativeTimeFormatUnit][], ...[number, Intl.RelativeTimeFormatUnit][],
+1
View File
@@ -58,6 +58,7 @@ export function draftSteps(
/** The operating system a user agent names. Checked most specific first: Android's agent also says /** The operating system a user agent names. Checked most specific first: Android's agent also says
* "Linux" — and says it first — and an iPad's also says "Mac OS X". */ * "Linux" — and says it first — and an iPad's also says "Mac OS X". */
/** @internal exported for issueDraft.test.ts */
export function osName(ua: string): string { export function osName(ua: string): string {
const tests: [RegExp, (m: RegExpExecArray) => string][] = [ const tests: [RegExp, (m: RegExpExecArray) => string][] = [
[/Android [\d.]+/, (m) => m[0]], [/Android [\d.]+/, (m) => m[0]],
+1
View File
@@ -7,6 +7,7 @@ import { vocabKey } from "./vocab";
* one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a * one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a
* result, and `enrich`'s bare count ("50") into `{enriched: 50}` as `_result_data` names it; a nested * result, and `enrich`'s bare count ("50") into `{enriched: 50}` as `_result_data` names it; a nested
* phase (a Python dict repr) or anything else does not, and stays as it was. */ * phase (a Python dict repr) or anything else does not, and stays as it was. */
/** @internal exported for jobResult.dom.test.ts */
export function parseSummary(summary: string | null | undefined): JobResult | null { export function parseSummary(summary: string | null | undefined): JobResult | null {
if (!summary || /[{}[\]]/.test(summary)) return null; if (!summary || /[{}[\]]/.test(summary)) return null;
if (/^\d+$/.test(summary)) return { enriched: Number(summary) }; if (/^\d+$/.test(summary)) return { enriched: Number(summary) };
+1
View File
@@ -259,6 +259,7 @@ function keyMatches(e: KeyboardEvent, name: string): boolean {
* Ctrl+A too, so select-all cycled the Plex audio track. It also means Shift+H is not `h` — a * Ctrl+A too, so select-all cycled the Plex audio track. It also means Shift+H is not `h` — a
* narrowing from the old `case "h": case "H":`, and the reason Shift chords can now be their own * narrowing from the old `case "h": case "H":`, and the reason Shift chords can now be their own
* rows (Shift+← steps an episode without ← also seeking). */ * rows (Shift+← steps an episode without ← also seeking). */
/** @internal exported for keymap.test.ts */
export function matches(e: KeyboardEvent, chord: string): boolean { export function matches(e: KeyboardEvent, chord: string): boolean {
const parts = chord.split("+"); const parts = chord.split("+");
const key = parts[parts.length - 1] ?? ""; const key = parts[parts.length - 1] ?? "";
+2
View File
@@ -48,6 +48,7 @@ export function anyBlockingLayer(): boolean {
return stack.some((id) => !passive.has(id)); return stack.some((id) => !passive.has(id));
} }
/** @internal exported for layerStack.dom.test.ts */
export function isTopmost(id: number): boolean { export function isTopmost(id: number): boolean {
return stack.length > 0 && stack[stack.length - 1] === id; return stack.length > 0 && stack[stack.length - 1] === id;
} }
@@ -78,6 +79,7 @@ export function anyBlockingLayerAbove(id: number): boolean {
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own. // mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
// WeakSet so a spent event is GC'd without a manual sweep. // WeakSet so a spent event is GC'd without a manual sweep.
const handledEscapes = new WeakSet<KeyboardEvent>(); const handledEscapes = new WeakSet<KeyboardEvent>();
/** @internal exported for keymap.test.ts, layerStack.dom.test.ts */
export function markEscapeHandled(e: KeyboardEvent): void { export function markEscapeHandled(e: KeyboardEvent): void {
handledEscapes.add(e); handledEscapes.add(e);
} }
+4 -1
View File
@@ -3,7 +3,10 @@
// over hundreds of channels, the Plex pickers, the filter and sort menus — had only the arrows. One // 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. // definition of "a page" here, so the palette and the dropdowns cannot drift apart.
/** A page when there is no layout to measure (jsdom, or a list not painted yet). */ /**
* A page when there is no layout to measure (jsdom, or a list not painted yet).
* @internal exported for listPaging.test.ts
*/
export const PAGE_FALLBACK = 10; export const PAGE_FALLBACK = 10;
/** The nearest ancestor that scrolls vertically. */ /** The nearest ancestor that scrolls vertically. */
+6 -1
View File
@@ -21,7 +21,10 @@ import { FRONTEND_VERSION } from "./version";
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL"; export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
/** OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares). */ /**
* OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares).
* @internal exported for log.contract.test.ts
*/
export const SEVERITY: Record<Level, number> = { export const SEVERITY: Record<Level, number> = {
TRACE: 1, TRACE: 1,
DEBUG: 5, DEBUG: 5,
@@ -59,6 +62,7 @@ export interface LogEvent {
/** Events kept. At ~0.5 KB each the ring stays around a megabyte — a long session's worth of /** Events kept. At ~0.5 KB each the ring stays around a megabyte — a long session's worth of
* navigation and requests, without growing for as long as a tab stays open. */ * navigation and requests, without growing for as long as a tab stays open. */
/** @internal exported for log.test.ts */
export const RING_MAX = 2000; export const RING_MAX = 2000;
// --- redaction (mirrors backend/app/logs.py) ------------------------------------------------------ // --- redaction (mirrors backend/app/logs.py) ------------------------------------------------------
@@ -97,6 +101,7 @@ function redactText(text: string): string {
/** Recursively scrub a structure destined for the log. A value whose KEY names a secret is dropped /** Recursively scrub a structure destined for the log. A value whose KEY names a secret is dropped
* whole — a prefix of a token is still a leak. Everything else is scrubbed as text. */ * whole — a prefix of a token is still a leak. Everything else is scrubbed as text. */
/** @internal exported for log.contract.test.ts */
export function redact(value: unknown, key = ""): unknown { export function redact(value: unknown, key = ""): unknown {
const lowered = key.toLowerCase(); const lowered = key.toLowerCase();
if (key && SECRET_KEY_PARTS.some((part) => lowered.includes(part))) return REDACTED; if (key && SECRET_KEY_PARTS.some((part) => lowered.includes(part))) return REDACTED;
+1
View File
@@ -95,6 +95,7 @@ const ERROR_TTL = 15000;
// A toast that carries an action (an "Undo") lives at least this long, whatever the reader's toast // 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, E28).
// The setting still decides for longer, and "never" still means never. // The setting still decides for longer, and "never" still means never.
/** @internal exported for notifications.actionTtl.dom.test.ts */
export const ACTION_TTL_MIN = 8000; export const ACTION_TTL_MIN = 8000;
// 6b: per-account notification settings (persisted by the Settings panel into the // 6b: per-account notification settings (persisted by the Settings panel into the
+1
View File
@@ -36,6 +36,7 @@ const MAX_CONSECUTIVE_ERRORS = 3;
export const YT_API_LOAD_FAILED = -2; export const YT_API_LOAD_FAILED = -2;
/** How long the script may take before the player gives up on it. The real answer comes in well /** How long the script may take before the player gives up on it. The real answer comes in well
* under a second; anything near this is a blocked or stalled request, not a slow one. */ * under a second; anything near this is a blocked or stalled request, not a slow one. */
/** @internal exported for playerModalHooks.apiload.dom.test.tsx */
export const YT_API_TIMEOUT_MS = 15_000; export const YT_API_TIMEOUT_MS = 15_000;
let apiPromise: Promise<YTNamespace> | null = null; let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> { function loadYouTubeApi(): Promise<YTNamespace> {
+4 -1
View File
@@ -17,7 +17,10 @@ export interface VolumeScale {
round: boolean; round: boolean;
} }
/** YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted). */ /**
* YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted).
* @internal exported for playerVolume.test.ts
*/
export const YT_SCALE: VolumeScale = { min: 0, max: 100, default: 100, round: true }; export const YT_SCALE: VolumeScale = { min: 0, max: 100, default: 100, round: true };
/** HTML5 <video>: a 0..1 float (mute is a separate flag the caller carries). */ /** HTML5 <video>: a 0..1 float (mute is a separate flag the caller carries). */
export const PLEX_SCALE: VolumeScale = { min: 0, max: 1, default: 1, round: false }; export const PLEX_SCALE: VolumeScale = { min: 0, max: 1, default: 1, round: false };
+8
View File
@@ -58,6 +58,7 @@ export interface StatRow {
const DASH = "—"; const DASH = "—";
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads). // bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
/** @internal exported for plexStats.test.ts */
export function fmtBitrate(bps: number | undefined, lang: string): string { export function fmtBitrate(bps: number | undefined, lang: string): string {
if (!bps || bps <= 0) return DASH; if (!bps || bps <= 0) return DASH;
if (bps >= 1_000_000) return `${formatDecimal(bps / 1_000_000, lang)} Mbps`; if (bps >= 1_000_000) return `${formatDecimal(bps / 1_000_000, lang)} Mbps`;
@@ -65,12 +66,14 @@ export function fmtBitrate(bps: number | undefined, lang: string): string {
} }
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp". // Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
/** @internal exported for plexStats.test.ts */
export function fmtBufferSecs(s: number, lang: string): string { export function fmtBufferSecs(s: number, lang: string): string {
if (!Number.isFinite(s) || s < 0) return DASH; if (!Number.isFinite(s) || s < 0) return DASH;
return formatUnit(s, "second", lang, 1); return formatUnit(s, "second", lang, 1);
} }
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded. // Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
/** @internal exported for plexStats.test.ts */
export function fmtFrames(dropped: number, total: number, lang: string): string { export function fmtFrames(dropped: number, total: number, lang: string): string {
if (total <= 0) return DASH; if (total <= 0) return DASH;
const pct = (dropped / total) * 100; const pct = (dropped / total) * 100;
@@ -80,12 +83,14 @@ export function fmtFrames(dropped: number, total: number, lang: string): string
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash, // Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
// which is itself the tell for a not-yet-playable codec pre-transcode. // which is itself the tell for a not-yet-playable codec pre-transcode.
/** @internal exported for plexStats.test.ts */
export function fmtRes(w: number, h: number): string { export function fmtRes(w: number, h: number): string {
if (w <= 0 || h <= 0) return DASH; if (w <= 0 || h <= 0) return DASH;
return `${w}×${h}`; return `${w}×${h}`;
} }
// Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately). // Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately).
/** @internal exported for plexStats.test.ts */
export function fmtClock(s: number): string { export function fmtClock(s: number): string {
if (!Number.isFinite(s) || s < 0) return DASH; if (!Number.isFinite(s) || s < 0) return DASH;
const total = Math.floor(s); const total = Math.floor(s);
@@ -95,6 +100,7 @@ export function fmtClock(s: number): string {
} }
// hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count. // hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count.
/** @internal exported for plexStats.test.ts */
export function fmtLevel(l: HlsLevelInfo | null): string { export function fmtLevel(l: HlsLevelInfo | null): string {
if (!l || l.total <= 0) return DASH; if (!l || l.total <= 0) return DASH;
const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`; const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`;
@@ -103,6 +109,7 @@ export function fmtLevel(l: HlsLevelInfo | null): string {
} }
// Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash). // Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash).
/** @internal exported for plexStats.test.ts */
export function fmtCodec(video: string | undefined, audio: string | undefined): string { export function fmtCodec(video: string | undefined, audio: string | undefined): string {
const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1" const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1"
const a = audio?.split(".")[0] || null; const a = audio?.split(".")[0] || null;
@@ -115,6 +122,7 @@ export function fmtCodec(video: string | undefined, audio: string | undefined):
// "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU · // "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
// HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens // HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens
// (not i18n'd), consistent with the other technical values in this overlay (e.g. transport "hls"). // (not i18n'd), consistent with the other technical values in this overlay (e.g. transport "hls").
/** @internal exported for plexStats.test.ts */
export function fmtMode( export function fmtMode(
playable: string, playable: string,
hwEncoder?: boolean | null, hwEncoder?: boolean | null,
+1
View File
@@ -38,6 +38,7 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null
* else the previous one, widening to the parent's neighbours — so its removal does not drop the * else the previous one, widening to the parent's neighbours — so its removal does not drop the
* focus to <body>. For a removal the caller makes itself (a chip's ×) anywhere, including a modal * focus to <body>. For a removal the caller makes itself (a chip's ×) anywhere, including a modal
* or a side panel, where the keeper below does not watch. False when nothing was found. */ * or a side panel, where the keeper below does not watch. False when nothing was found. */
/** @internal exported for useFocusKeeper.dom.test.tsx */
export function focusNeighbour(el: HTMLElement): boolean { export function focusNeighbour(el: HTMLElement): boolean {
for (let at: HTMLElement | null = el; at && at !== document.body; at = at.parentElement) { for (let at: HTMLElement | null = el; at && at !== document.body; at = at.parentElement) {
const hit = firstFocusable(at.nextSibling) ?? firstFocusable(at.previousSibling, true); const hit = firstFocusable(at.nextSibling) ?? firstFocusable(at.previousSibling, true);
+2
View File
@@ -23,6 +23,8 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// E32: an index read is `T | undefined` here, as it really is (0 errors when switched on).
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true, "resolveJsonModule": true,
// The build-tool configs include a hand-written .js file (postcss; tailwind 4 needs no config // The build-tool configs include a hand-written .js file (postcss; tailwind 4 needs no config
// file, and the stale `tailwind.config.js` entry pointed at nothing until E32). checkJs runs // file, and the stale `tailwind.config.js` entry pointed at nothing until E32). checkJs runs