From 7ed2ac4e9540eb3b6e9b341ca71a053c1a9fa834 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 16:44:28 +0200 Subject: [PATCH] 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. --- frontend/e2e/tsconfig.json | 2 ++ frontend/knip.json | 7 ------- frontend/knip.jsonc | 20 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/components/channelColumns.tsx | 1 + frontend/src/components/plexDetailAside.tsx | 1 + frontend/src/components/ui/CommandPalette.tsx | 2 +- frontend/src/components/ui/FilterSelect.tsx | 1 + frontend/src/components/ui/Skeleton.tsx | 2 +- frontend/src/components/ui/progressiveRows.ts | 3 ++- frontend/src/components/ui/readAhead.ts | 4 ++++ frontend/src/components/ui/scrollFold.ts | 4 +++- frontend/src/components/ui/surfaces.ts | 2 -- frontend/src/design-lab/labTokens.ts | 3 +-- frontend/src/lib/activeAccount.ts | 1 + frontend/src/lib/api.ts | 10 ++-------- frontend/src/lib/api/discover.ts | 4 ++-- frontend/src/lib/api/plex.ts | 2 +- frontend/src/lib/api/tagModes.ts | 7 +++++-- frontend/src/lib/api/tags.ts | 2 +- frontend/src/lib/colorMath.ts | 1 + frontend/src/lib/format.ts | 1 + frontend/src/lib/issueDraft.ts | 1 + frontend/src/lib/jobResult.ts | 1 + frontend/src/lib/keymap.ts | 1 + frontend/src/lib/layerStack.ts | 2 ++ frontend/src/lib/listPaging.ts | 5 ++++- frontend/src/lib/log.ts | 7 ++++++- frontend/src/lib/notifications.ts | 1 + frontend/src/lib/playerModalHooks.ts | 1 + frontend/src/lib/playerVolume.ts | 5 ++++- frontend/src/lib/plexStats.ts | 8 ++++++++ frontend/src/lib/useFocusKeeper.ts | 1 + frontend/tsconfig.node.json | 2 ++ 34 files changed, 84 insertions(+), 33 deletions(-) delete mode 100644 frontend/knip.json create mode 100644 frontend/knip.jsonc 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