({
// Optional left-hand caption; when set the control renders as a justify-between row (the feed/Plex
// "Match" toggles). Omit for a standalone bar (the Mine/Library scope switch).
label?: ReactNode;
- title?: string; // tooltip on the whole row (caption mode only)
+ // What the choice means, beyond its name: the row's tooltip for the pointer, and the group's
+ // accessible description for the keyboard and a screen reader — a `title` on a non-focusable box
+ // alone reaches neither (it had only ever been the pointer's).
+ title?: string;
// Full-width track with flex-1 buttons (the scope bar); default is intrinsic width.
fullWidth?: boolean;
// sm = text-3xs, compact padding (the captioned match toggles); md = text-xs (the scope bar).
size?: "sm" | "md";
className?: string;
}) {
+ const hintId = useId();
const track = (
({
}${fullWidth ? " w-full" : ""}`}
role="group"
aria-label={ariaLabel}
+ aria-describedby={title ? hintId : undefined}
+ title={label == null ? title : undefined}
>
+ {title && (
+
+ {title}
+
+ )}
{options.map((o) => (
{
it(`${lang}: every audit and quota action`, () => {
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
+ // …and no name for an action the backend no longer has (the unused-key contract leaves
+ // these two namespaces to this check).
+ expect([...idsUnder(keys, "auditActions")].filter((a) => !auditActions.includes(a))).toEqual(
+ [],
+ );
+ expect([...idsUnder(keys, "quotaActions")].filter((a) => !quotaActions.includes(a))).toEqual(
+ [],
+ );
});
it(`${lang}: every download failure-catalog entry`, () => {
diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json
index 9ae8b2d6..95639153 100644
--- a/frontend/src/i18n/locales/en/card.json
+++ b/frontend/src/i18n/locales/en/card.json
@@ -8,7 +8,6 @@
"saveForLater": "Save for later",
"unhide": "Unhide",
"hide": "Hide",
- "onlyThisChannel": "Only this channel",
"resetState": "Reset — clear watch progress/status",
"thisChannel": "This channel",
"continueTitle": "Continue where you left off",
diff --git a/frontend/src/i18n/locales/en/editor.json b/frontend/src/i18n/locales/en/editor.json
index bab11cc8..de64e34e 100644
--- a/frontend/src/i18n/locales/en/editor.json
+++ b/frontend/src/i18n/locales/en/editor.json
@@ -1,15 +1,11 @@
{
"title": "Edit “{{name}}”",
"playPause": "Play / pause",
- "setIn": "Set start",
- "setOut": "Set end",
"in": "Start",
"out": "End",
- "selected": "Selection",
"cropOff": "Add crop",
"cropOn": "Cropping",
"cropReencode": "Cropping always re-encodes (frame-accurate).",
- "split": "Split into",
"accurate": {
"label": "Precise cut",
"hint": "Frame-accurate. Re-encodes the clip."
diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json
index 9c4662c4..8ee45c97 100644
--- a/frontend/src/i18n/locales/hu/card.json
+++ b/frontend/src/i18n/locales/hu/card.json
@@ -8,7 +8,6 @@
"saveForLater": "Mentés későbbre",
"unhide": "Megjelenítés",
"hide": "Elrejtés",
- "onlyThisChannel": "Csak ez a csatorna",
"resetState": "Alaphelyzet — nézési előzmény/státusz törlése",
"thisChannel": "Ez a csatorna",
"continueTitle": "Folytatás ott, ahol abbahagytad",
diff --git a/frontend/src/i18n/locales/hu/editor.json b/frontend/src/i18n/locales/hu/editor.json
index 61656d4a..e9ae6bf8 100644
--- a/frontend/src/i18n/locales/hu/editor.json
+++ b/frontend/src/i18n/locales/hu/editor.json
@@ -1,15 +1,11 @@
{
"title": "Szerkesztés: „{{name}}”",
"playPause": "Lejátszás / szünet",
- "setIn": "Kezdet itt",
- "setOut": "Vég itt",
"in": "Kezdet",
"out": "Vég",
- "selected": "Kijelölés",
"cropOff": "Vágókeret",
"cropOn": "Vágókeret bekapcsolva",
"cropReencode": "A vágókeret mindig újrakódol (frame-pontos).",
- "split": "Feldarabolás",
"accurate": {
"label": "Pontos vágás",
"hint": "Frame-pontos. Újrakódolja a klipet."
diff --git a/frontend/src/i18n/unusedKeys.contract.test.ts b/frontend/src/i18n/unusedKeys.contract.test.ts
index ccb8cb38..2a89b2d4 100644
--- a/frontend/src/i18n/unusedKeys.contract.test.ts
+++ b/frontend/src/i18n/unusedKeys.contract.test.ts
@@ -9,11 +9,21 @@ import { describe, expect, it } from "vitest";
// for nobody, and each one making "is this key live?" a grep instead of a fact.
//
// A key counts as used when the source (tests excluded — a test naming a key does not show it to
-// anyone) holds it as a whole string literal, or holds a string that ends in `.` right before an
-// interpolation or a concatenation — a DYNAMIC prefix like `plex.sort.${k}` — which then covers every
-// key under it. Plural forms count through their base key. A key read in a way this cannot see goes
-// in ALLOWED with the reason, rather than the check being loosened for everyone.
-const ALLOWED: Record = {};
+// anyone) holds it as a whole string literal, or matches the shape of a DYNAMIC key — a template
+// or concatenation that opens with a key head, each interpolation any segment: `plex.sort.${k}`
+// covers `plex.sort.*`, `editor.${m}.hint` only `editor.*.hint`. A shape that is nothing but the
+// namespace and one interpolation (`card.${status}`) would cover the whole file — it hid five dead
+// keys — so it covers nothing; a key it reads that nothing else names is listed below. Plural forms
+// count through their base key. A key read in a way this cannot see goes in ALLOWED with the reason,
+// rather than the check being loosened for everyone.
+const LOG_FAILURE = "AdminLogs reads `adminLogs.${failure}`, a word its query hook returns";
+const ALLOWED: Record = {
+ "adminLogs.olderFailed": LOG_FAILURE,
+ "adminLogs.stale": LOG_FAILURE,
+ "adminLogs.countsFailed": LOG_FAILURE,
+};
+// Namespaces that are one backend enum each, and held key-for-value to it by backendIds.contract.
+const BACKEND_ENUMS = ["auditActions.", "quotaActions."];
const SRC = fileURLToPath(new URL("../", import.meta.url));
@@ -42,16 +52,45 @@ const keys = readdirSync(locales).flatMap((f) =>
);
const code = sources(SRC).join("\n");
const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!));
-const prefixes = [...code.matchAll(/["'`]([\w.]+\.)(?:\$\{|["'`]\s*\+)/g)].map((m) => m[1]!);
+// Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`,
+// `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated.
+const shapes = [
+ ...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!),
+ ...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`),
+]
+ .filter((s) => s.includes("${"))
+ .map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
+// A shape that is one namespace and one `*` covers nothing (see above).
+const namespaceWide = (shape: string) => /^[\w-]+\.\*$/.test(shape);
+const patterns = shapes
+ .filter((s) => !namespaceWide(s))
+ .map(
+ (s) =>
+ new RegExp(
+ `^${s
+ .split("*")
+ .map((part) => part.replace(/[.$]/g, "\\$&"))
+ .join("[\\w.-]+")}$`,
+ ),
+ );
const base = (k: string) => k.replace(/_(zero|one|two|few|many|other)$/, "");
const used = (k: string) =>
- literals.has(k) || literals.has(base(k)) || prefixes.some((p) => k.startsWith(p));
+ literals.has(k) ||
+ literals.has(base(k)) ||
+ patterns.some((p) => p.test(k) || p.test(base(k))) ||
+ BACKEND_ENUMS.some((ns) => k.startsWith(ns));
describe("i18n keys", () => {
it("finds keys and code to check them against", () => {
expect(keys.length).toBeGreaterThan(1000);
expect(literals.has("home.hero.reroll")).toBe(true);
- expect(prefixes).toContain("plex.sort.");
+ expect(used("plex.sort.newest")).toBe(true); // `plex.sort.${k}`
+ expect(shapes).toContain("editor.*.hint"); // `editor.${m}.hint`
+ expect(used("editor.accurate.hint")).toBe(true);
+ expect(used("editor.accurate.nope")).toBe(false);
+ // A bare namespace head covers nothing — the hole that hid five dead keys.
+ expect(shapes).toContain("card.*");
+ expect(used("card.anythingAtAll")).toBe(false);
});
it("are all read somewhere in the app", () => {