diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json index 246d917f..81e33f9c 100644 --- a/frontend/e2e/tsconfig.json +++ b/frontend/e2e/tsconfig.json @@ -11,6 +11,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, + // E32: an index read is `T | undefined` here, as it really is (0 errors when switched on). + "noUncheckedIndexedAccess": true, "esModuleInterop": true, "resolveJsonModule": true }, diff --git a/frontend/knip.json b/frontend/knip.json deleted file mode 100644 index 03dea5bc..00000000 --- a/frontend/knip.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@6/schema.json", - "playwright": { - "config": ["playwright.config.ts"], - "entry": ["e2e/**/*.@(spec|setup).ts"] - } -} diff --git a/frontend/knip.jsonc b/frontend/knip.jsonc new file mode 100644 index 00000000..7fa7cc10 --- /dev/null +++ b/frontend/knip.jsonc @@ -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"], + }, +} diff --git a/frontend/package.json b/frontend/package.json index 64620f6a..282b942f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "typecheck:e2e": "tsc --noEmit -p e2e/tsconfig.json", "build": "npm run typecheck:app && npm run typecheck:node && vite build", "preview": "vite preview", - "knip": "knip", + "knip": "knip && knip --production", "lint": "eslint . --max-warnings 0", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index 35a5f466..32e4bc1e 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -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 * 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. */ +/** @internal exported for channelColumns.dom.test.tsx */ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0; return values.some((v) => c.tag_ids.includes(Number(v))); diff --git a/frontend/src/components/plexDetailAside.tsx b/frontend/src/components/plexDetailAside.tsx index 3c312a76..0696bae9 100644 --- a/frontend/src/components/plexDetailAside.tsx +++ b/frontend/src/components/plexDetailAside.tsx @@ -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 // 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. +/** @internal exported for PlexInfo.dom.test.tsx */ export const CREW_SHOWN = 6; const CREW_FOLD_OVER = CREW_SHOWN + 2; diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index 2cdee2fc..05adc686 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -19,7 +19,7 @@ import { fieldClass } from "./fieldStyles"; // 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. -export interface CommandItem { +interface CommandItem { /** Unique across the whole palette; also the React key. */ id: string; label: string; diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx index d310cc06..f3ad40a9 100644 --- a/frontend/src/components/ui/FilterSelect.tsx +++ b/frontend/src/components/ui/FilterSelect.tsx @@ -13,6 +13,7 @@ import { SURFACE } from "./surfaces"; // 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. +/** @internal exported for FilterSelect.dom.test.tsx */ export interface FilterSelectOption { value: string; label: string; diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx index a481a499..5c1576ff 100644 --- a/frontend/src/components/ui/Skeleton.tsx +++ b/frontend/src/components/ui/Skeleton.tsx @@ -7,7 +7,7 @@ import { SURFACE } from "./surfaces"; // 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. -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). const Tag = inline ? "span" : "div"; return ( diff --git a/frontend/src/components/ui/progressiveRows.ts b/frontend/src/components/ui/progressiveRows.ts index d420c3bf..d3e39d50 100644 --- a/frontend/src/components/ui/progressiveRows.ts +++ b/frontend/src/components/ui/progressiveRows.ts @@ -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, // 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 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 // rows, so the urgent render after a click or a key stays small. New data for the same view (a refetch) diff --git a/frontend/src/components/ui/readAhead.ts b/frontend/src/components/ui/readAhead.ts index 701adcdc..817eda1d 100644 --- a/frontend/src/components/ui/readAhead.ts +++ b/frontend/src/components/ui/readAhead.ts @@ -8,12 +8,16 @@ // - 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 // 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; +/** @internal exported for readAhead.test.ts */ export const PREFETCH_VIEWPORTS = 2; +/** @internal exported for readAhead.test.ts */ export const MAX_LOOKAHEAD_ROWS = 40; /** 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]. */ +/** @internal exported for readAhead.test.ts */ export function readAheadLookahead(renderedRows: number): number { return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS)); } diff --git a/frontend/src/components/ui/scrollFold.ts b/frontend/src/components/ui/scrollFold.ts index 7a0aee54..11e08800 100644 --- a/frontend/src/components/ui/scrollFold.ts +++ b/frontend/src/components/ui/scrollFold.ts @@ -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 // 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. +/** @internal exported for scrollFold.test.ts */ export const FOLD_AT = 32; -export interface ScrollPosition { +interface ScrollPosition { // The scroller's scrollTop. top: number; // 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). +/** @internal exported for scrollFold.test.ts */ export function nextFolded( folded: boolean, { top, room }: ScrollPosition, diff --git a/frontend/src/components/ui/surfaces.ts b/frontend/src/components/ui/surfaces.ts index 403442f6..acc5f2b7 100644 --- a/frontend/src/components/ui/surfaces.ts +++ b/frontend/src/components/ui/surfaces.ts @@ -28,5 +28,3 @@ export const SURFACE = { /** A full-width sticky band (the design lab's header): square. */ band: "glass", } as const; - -export type SurfaceRole = keyof typeof SURFACE; diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts index a58c2f9e..ea28b967 100644 --- a/frontend/src/design-lab/labTokens.ts +++ b/frontend/src/design-lab/labTokens.ts @@ -6,9 +6,8 @@ /** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --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 FILLED = "state-filled"; /** B9: the header + fade edge (index.css utilities; the host is positioned). */ export const FADE_BELOW = "fade-below"; diff --git a/frontend/src/lib/activeAccount.ts b/frontend/src/lib/activeAccount.ts index c57493ff..3f196d3f 100644 --- a/frontend/src/lib/activeAccount.ts +++ b/frontend/src/lib/activeAccount.ts @@ -10,6 +10,7 @@ // again through `onAccountPinned`. // // 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"; // Re-readers for module-level per-account state, run each time the tab is pinned. diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5dd9fc72..3b4fdd87 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,16 +6,10 @@ // 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.) -import { - alreadyReported, - getActiveAccount, - HttpError, - setActiveAccount, - setUnauthorizedHandler, -} from "./api/core"; +import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core"; // 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"; export type * from "./api/account"; diff --git a/frontend/src/lib/api/discover.ts b/frontend/src/lib/api/discover.ts index c69e1118..e7d3794f 100644 --- a/frontend/src/lib/api/discover.ts +++ b/frontend/src/lib/api/discover.ts @@ -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. */ 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 // 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 interface DiscoverCard { diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index ea60bdbc..516124d6 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -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. // 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. -export interface PlexTmdbCrew { +interface PlexTmdbCrew { name: string; job: string; tmdb_id?: number | null; diff --git a/frontend/src/lib/api/tagModes.ts b/frontend/src/lib/api/tagModes.ts index 2282b53d..a8e67d28 100644 --- a/frontend/src/lib/api/tagModes.ts +++ b/frontend/src/lib/api/tagModes.ts @@ -2,8 +2,11 @@ // 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. -/** 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. */ export type TagCategory = "language" | "topic" | "other"; export type TagModes = Record; diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts index e1f9ec2b..144eb30d 100644 --- a/frontend/src/lib/api/tags.ts +++ b/frontend/src/lib/api/tags.ts @@ -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). -export interface TagSuggestion { +interface TagSuggestion { tag_id: number; confidence: number; } diff --git a/frontend/src/lib/colorMath.ts b/frontend/src/lib/colorMath.ts index c5d072f0..a58f6b19 100644 --- a/frontend/src/lib/colorMath.ts +++ b/frontend/src/lib/colorMath.ts @@ -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 * browser does in the srgb space. */ +/** @internal exported for theme.contract.test.ts */ export function mixHex(a: string, b: string, w: number): string { const [x, y] = [rgb(a), rgb(b)]; return `#${x diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 92a2999e..1ba32853 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -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. */ // 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. +/** @internal exported for format.test.ts */ export const RELATIVE_UNITS: readonly [ [number, Intl.RelativeTimeFormatUnit], ...[number, Intl.RelativeTimeFormatUnit][], diff --git a/frontend/src/lib/issueDraft.ts b/frontend/src/lib/issueDraft.ts index 60783988..4602d936 100644 --- a/frontend/src/lib/issueDraft.ts +++ b/frontend/src/lib/issueDraft.ts @@ -58,6 +58,7 @@ export function draftSteps( /** 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". */ +/** @internal exported for issueDraft.test.ts */ export function osName(ua: string): string { const tests: [RegExp, (m: RegExpExecArray) => string][] = [ [/Android [\d.]+/, (m) => m[0]], diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts index ff22e8ef..70b7bc45 100644 --- a/frontend/src/lib/jobResult.ts +++ b/frontend/src/lib/jobResult.ts @@ -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 * 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. */ +/** @internal exported for jobResult.dom.test.ts */ export function parseSummary(summary: string | null | undefined): JobResult | null { if (!summary || /[{}[\]]/.test(summary)) return null; if (/^\d+$/.test(summary)) return { enriched: Number(summary) }; diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index 3372b9c3..1284b35b 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -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 * 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). */ +/** @internal exported for keymap.test.ts */ export function matches(e: KeyboardEvent, chord: string): boolean { const parts = chord.split("+"); const key = parts[parts.length - 1] ?? ""; diff --git a/frontend/src/lib/layerStack.ts b/frontend/src/lib/layerStack.ts index 23abd042..a653c91d 100644 --- a/frontend/src/lib/layerStack.ts +++ b/frontend/src/lib/layerStack.ts @@ -48,6 +48,7 @@ export function anyBlockingLayer(): boolean { return stack.some((id) => !passive.has(id)); } +/** @internal exported for layerStack.dom.test.ts */ export function isTopmost(id: number): boolean { 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. // WeakSet so a spent event is GC'd without a manual sweep. const handledEscapes = new WeakSet(); +/** @internal exported for keymap.test.ts, layerStack.dom.test.ts */ export function markEscapeHandled(e: KeyboardEvent): void { handledEscapes.add(e); } diff --git a/frontend/src/lib/listPaging.ts b/frontend/src/lib/listPaging.ts index 78f9c033..6468535a 100644 --- a/frontend/src/lib/listPaging.ts +++ b/frontend/src/lib/listPaging.ts @@ -3,7 +3,10 @@ // 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. -/** 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; /** The nearest ancestor that scrolls vertically. */ diff --git a/frontend/src/lib/log.ts b/frontend/src/lib/log.ts index e3c347c8..ce36e3bf 100644 --- a/frontend/src/lib/log.ts +++ b/frontend/src/lib/log.ts @@ -21,7 +21,10 @@ import { FRONTEND_VERSION } from "./version"; 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 = { TRACE: 1, 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 * navigation and requests, without growing for as long as a tab stays open. */ +/** @internal exported for log.test.ts */ export const RING_MAX = 2000; // --- 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 * 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 { const lowered = key.toLowerCase(); if (key && SECRET_KEY_PARTS.some((part) => lowered.includes(part))) return REDACTED; diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 96d0914b..60b16a72 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -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 // 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. +/** @internal exported for notifications.actionTtl.dom.test.ts */ export const ACTION_TTL_MIN = 8000; // 6b: per-account notification settings (persisted by the Settings panel into the diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index 99f3b720..3b6881a1 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -36,6 +36,7 @@ const MAX_CONSECUTIVE_ERRORS = 3; 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 * 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; let apiPromise: Promise | null = null; function loadYouTubeApi(): Promise { diff --git a/frontend/src/lib/playerVolume.ts b/frontend/src/lib/playerVolume.ts index f9cc2beb..face470f 100644 --- a/frontend/src/lib/playerVolume.ts +++ b/frontend/src/lib/playerVolume.ts @@ -17,7 +17,10 @@ export interface VolumeScale { 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 }; /** HTML5