From 36653886d5381ada2497b8fcc7f491bc39aaff4f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 21:59:27 +0200 Subject: [PATCH] build(frontend)!: knip drops --production; plain knip stays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production pass (7ed2ac4e, E32) asked "is this export used by what ships?". No real hit: its only findings were 37 exports a test imports, answered with 36 `@internal` JSDoc tags (E34 S2) — test-only exports, not defects. Since then it has only ever asked for another tag. The tags go with it (36, no longer read by anything), and so do the production-only `!…!` exclusions in knip.jsonc and their register rows. Plain knip (unused files, exports and dependencies) keeps running in the gate. --- CLAUDE.md | 2 +- docs/development.md | 2 +- docs/gates.md | 8 -------- frontend/knip.jsonc | 14 +------------- frontend/package.json | 2 +- frontend/src/components/channelColumns.tsx | 1 - frontend/src/components/downloadAdmin.tsx | 1 - frontend/src/components/plexDetailAside.tsx | 1 - frontend/src/components/ui/FilterSelect.tsx | 1 - frontend/src/components/ui/progressiveRows.ts | 1 - frontend/src/components/ui/readAhead.ts | 4 ---- frontend/src/components/ui/scrollFold.ts | 2 -- frontend/src/lib/activeAccount.ts | 1 - frontend/src/lib/api/tagModes.ts | 1 - frontend/src/lib/colorMath.ts | 1 - frontend/src/lib/format.ts | 1 - frontend/src/lib/gates.contract.test.ts | 10 +++++----- frontend/src/lib/gates.snapshot.json | 4 ++-- 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 | 1 - frontend/src/lib/log.ts | 3 --- frontend/src/lib/notifications.ts | 1 - frontend/src/lib/playerModalHooks.ts | 1 - frontend/src/lib/playerVolume.ts | 1 - frontend/src/lib/plexStats.ts | 8 -------- frontend/src/lib/useFocusKeeper.ts | 1 - scripts/check.sh | 2 +- 30 files changed, 12 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6335df25..5c504e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ router-free navigation provider. ## The gate — never merge or ship red The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + the lock check + typecheck (3 tsc projects) + -eslint + prettier --check + knip (both modes) + vitest + vite build + pytest + pip-audit + npm audit**, then a coverage report (no threshold). It runs +eslint + prettier --check + knip + vitest + vite build + pytest + pip-audit + npm audit**, then a coverage report (no threshold). It runs before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip` flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it green **before** any merge, release, or handing work to review. diff --git a/docs/development.md b/docs/development.md index 5356de25..10fb564a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -97,7 +97,7 @@ node ../scripts/check-node-modules.mjs # node_modules == package-lock.json (el npm run typecheck # tsc x3 (app / node / e2e) npm run lint # eslint (typed rules included; warnings fail) npm run format:check # prettier -npm run knip # unused files/exports/deps — and unused by what SHIPS (--production) +npm run knip # unused files/exports/deps npm run test # vitest (incl. the compiler and gate-register contracts) npx vite build # the production bundle (devOnlyGuard fails it on dev-only code) npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above diff --git a/docs/gates.md b/docs/gates.md index f90ae3e7..835997c4 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -103,14 +103,6 @@ project checks needs a row. | --- | --- | --- | --- | | a hand run | `SIFTLODE_TEST_REQUIRE_DB=0` — the DB-backed and ffmpeg tests skip | The explicit "pure-logic only" run for a machine without Postgres. Off by default since E32: without it a missing DB is a hard error; the gate and CI never set it. | — | -## knip (frontend) - -| Scope | Narrowed | Why | Measured | -| --- | --- | --- | --- | -| --production | `!**/*.testkit.ts!`, `!e2e/**!` | Test kits and the e2e suite are test code: the production pass asks what the SHIPPED code uses. | — | -| --production | `!reactPlugin.ts!`, `!devOnlyGuard.ts!` | Build tooling the vite config imports: it shapes the bundle but is not in it. | — | -| --production | `@internal` exports | An export only its test imports says so with a JSDoc `@internal` tag naming the test; knip skips those in production mode. | 35 (E32 S1) | - ## Lanes and pipeline | Scope | Narrowed | Why | Measured | diff --git a/frontend/knip.jsonc b/frontend/knip.jsonc index e3ac4ab9..e5bb1921 100644 --- a/frontend/knip.jsonc +++ b/frontend/knip.jsonc @@ -1,18 +1,6 @@ { "$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!", - ], + "project": ["**/*.{ts,tsx,js}"], "playwright": { "config": ["playwright.config.ts"], "entry": ["e2e/**/*.@(spec|setup|measure).ts"], diff --git a/frontend/package.json b/frontend/package.json index 6cf9f47e..0fc26506 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 --production", + "knip": "knip", "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 1bbd8808..a9ce2e2e 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -45,7 +45,6 @@ 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/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index bf8adaaf..eb7372d3 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -22,7 +22,6 @@ import UserPicker from "./UserPicker"; const GiB = 1024 ** 3; -/** @internal exported for downloadAdmin.dom.test.tsx */ export function QuotaModal({ userId, email, diff --git a/frontend/src/components/plexDetailAside.tsx b/frontend/src/components/plexDetailAside.tsx index 0696bae9..3c312a76 100644 --- a/frontend/src/components/plexDetailAside.tsx +++ b/frontend/src/components/plexDetailAside.tsx @@ -184,7 +184,6 @@ 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/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx index 1765812c..d94b4997 100644 --- a/frontend/src/components/ui/FilterSelect.tsx +++ b/frontend/src/components/ui/FilterSelect.tsx @@ -13,7 +13,6 @@ 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/progressiveRows.ts b/frontend/src/components/ui/progressiveRows.ts index d3e39d50..06a11f2b 100644 --- a/frontend/src/components/ui/progressiveRows.ts +++ b/frontend/src/components/ui/progressiveRows.ts @@ -12,7 +12,6 @@ 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; const STEP_ROWS = 40; diff --git a/frontend/src/components/ui/readAhead.ts b/frontend/src/components/ui/readAhead.ts index 817eda1d..701adcdc 100644 --- a/frontend/src/components/ui/readAhead.ts +++ b/frontend/src/components/ui/readAhead.ts @@ -8,16 +8,12 @@ // - 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 11e08800..5d815975 100644 --- a/frontend/src/components/ui/scrollFold.ts +++ b/frontend/src/components/ui/scrollFold.ts @@ -10,7 +10,6 @@ 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; interface ScrollPosition { @@ -21,7 +20,6 @@ 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/lib/activeAccount.ts b/frontend/src/lib/activeAccount.ts index 3f196d3f..c57493ff 100644 --- a/frontend/src/lib/activeAccount.ts +++ b/frontend/src/lib/activeAccount.ts @@ -10,7 +10,6 @@ // 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/tagModes.ts b/frontend/src/lib/api/tagModes.ts index a8e67d28..47863787 100644 --- a/frontend/src/lib/api/tagModes.ts +++ b/frontend/src/lib/api/tagModes.ts @@ -4,7 +4,6 @@ /** * 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. */ diff --git a/frontend/src/lib/colorMath.ts b/frontend/src/lib/colorMath.ts index a58f6b19..c5d072f0 100644 --- a/frontend/src/lib/colorMath.ts +++ b/frontend/src/lib/colorMath.ts @@ -34,7 +34,6 @@ 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 1ba32853..92a2999e 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -60,7 +60,6 @@ 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/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index ed065311..6996b029 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -542,20 +542,20 @@ describe("the gate register (docs/gates.md)", () => { }); // --- knip ----------------------------------------------------------------------------------------- - it("lists every knip production exclusion and every other key, and runs both modes", () => { + it("lists every knip exclusion and every other key", () => { const cfg = jsonc("frontend/knip.jsonc") as { project?: string[] } & Record; const found: [string, string][] = (cfg.project ?? []) .filter((p) => p.startsWith("!")) - .map((p): [string, string] => ["--production", p]); + .map((p): [string, string] => ["knip", p]); for (const key of Object.keys(cfg).filter( (k) => !["$schema", "project", "playwright"].includes(k), )) found.push(["knip", key]); expect(missingFrom(found)).toEqual([]); const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record }; - expect(pkg.scripts.knip).toBe("knip && knip --production"); - // the positive pattern is the project itself: narrowing it drops files from both modes - expect((cfg.project ?? []).filter((p) => !p.startsWith("!"))).toEqual(["**/*.{ts,tsx,js}!"]); + expect(pkg.scripts.knip).toBe("knip"); + // 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) const KNIP_FILES = /^\.?knip(\.config)?\.(json|jsonc|js|ts|mjs|cjs|mts|cts|toml|ya?ml)$/; diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index ef2e43f2..99fe32f1 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -2217,7 +2217,7 @@ "e2e": "playwright test", "format": "prettier --write .", "format:check": "prettier --check .", - "knip": "knip && knip --production", + "knip": "knip", "lint": "eslint . --max-warnings 0", "preview": "vite preview", "test": "vitest run", @@ -2313,7 +2313,7 @@ "lane \"tsc (src + configs + e2e)\" frontend npm run --silent typecheck", "lane \"eslint (warnings fail too)\" frontend npm run --silent lint", "lane \"prettier --check\" frontend npm run --silent format:check", - "lane \"knip (unused, and unused by what ships)\" frontend npm run --silent knip", + "lane \"knip (unused files, exports, dependencies)\" frontend npm run --silent knip", "pytest_lane() { # the full suite against a throwaway Postgres; --pull keeps the base image current", " docker compose -f \"$TESTCOMPOSE\" build --pull -q test || return 1", " local rc=0", diff --git a/frontend/src/lib/issueDraft.ts b/frontend/src/lib/issueDraft.ts index 4602d936..60783988 100644 --- a/frontend/src/lib/issueDraft.ts +++ b/frontend/src/lib/issueDraft.ts @@ -58,7 +58,6 @@ 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 70b7bc45..ff22e8ef 100644 --- a/frontend/src/lib/jobResult.ts +++ b/frontend/src/lib/jobResult.ts @@ -7,7 +7,6 @@ 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 44b48468..dc98d14b 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -258,7 +258,6 @@ 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 0a1ed310..e433ac70 100644 --- a/frontend/src/lib/layerStack.ts +++ b/frontend/src/lib/layerStack.ts @@ -48,7 +48,6 @@ 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; } @@ -79,7 +78,6 @@ 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 6468535a..130df7f9 100644 --- a/frontend/src/lib/listPaging.ts +++ b/frontend/src/lib/listPaging.ts @@ -5,7 +5,6 @@ /** * 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; diff --git a/frontend/src/lib/log.ts b/frontend/src/lib/log.ts index 39aa8628..81af7094 100644 --- a/frontend/src/lib/log.ts +++ b/frontend/src/lib/log.ts @@ -23,7 +23,6 @@ export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL /** * 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, @@ -62,7 +61,6 @@ 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) ------------------------------------------------------ @@ -101,7 +99,6 @@ 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 60b16a72..96d0914b 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -95,7 +95,6 @@ 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 d2773848..bf0e502f 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -36,7 +36,6 @@ 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 face470f..61f5f726 100644 --- a/frontend/src/lib/playerVolume.ts +++ b/frontend/src/lib/playerVolume.ts @@ -19,7 +19,6 @@ export interface VolumeScale { /** * 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