diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 18534306..1901c399 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -82,8 +82,52 @@ const focusSafeDisabled = { }; }, }; +// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN +// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the +// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only — +// nothing looks at an attribute. A URL-shaped placeholder is not a word, so it passes. +const A11Y_TEXT = new Set([ + "aria-label", + "aria-description", + "aria-placeholder", + "aria-roledescription", + "aria-valuetext", + "title", + "placeholder", + "alt", +]); +const literalText = (v) => { + if (!v) return null; + if (v.type === "Literal") return typeof v.value === "string" ? v.value : null; + if (v.type !== "JSXExpressionContainer") return null; + const e = v.expression; + if (e.type === "Literal") return typeof e.value === "string" ? e.value : null; + if (e.type === "TemplateLiteral" && e.expressions.length === 0) return e.quasis[0].value.cooked; + return null; +}; +const literalA11yText = { + meta: { + type: "problem", + messages: { literal: "`{{name}}` is read or shown to the user: translate it with t()." }, + }, + create(context) { + return { + JSXAttribute(node) { + const name = node.name.type === "JSXIdentifier" ? node.name.name : null; + if (!name || !A11Y_TEXT.has(name)) return; + const text = literalText(node.value); + if (text && /[A-Za-z]{2,}/.test(text) && !/^https?:/.test(text)) + context.report({ node, messageId: "literal", data: { name } }); + }, + }; + }, +}; const localRules = { - rules: { "jsx-comment-text": jsxCommentText, "focus-safe-disabled": focusSafeDisabled }, + rules: { + "jsx-comment-text": jsxCommentText, + "focus-safe-disabled": focusSafeDisabled, + "literal-a11y-text": literalA11yText, + }, }; // A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string. @@ -113,6 +157,7 @@ export default tseslint.config( ...reactHooks.configs.recommended.rules, "local/jsx-comment-text": "error", "local/focus-safe-disabled": "error", + "local/literal-a11y-text": "error", "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], // tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a // finding is reported once, by one tool, and this config stays about hooks + refresh safety. @@ -237,8 +282,13 @@ export default tseslint.config( }, { // Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. + // …and render throwaway markup whose labels no user ever hears. files: ["src/**/*.test.{ts,tsx}"], - rules: { "no-restricted-globals": "off", "no-restricted-properties": "off" }, + rules: { + "no-restricted-globals": "off", + "no-restricted-properties": "off", + "local/literal-a11y-text": "off", + }, }, prettier, ); diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 3b80dccd..243f2a7a 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import Overlay from "./ui/Overlay"; import { copyText } from "../lib/clipboard"; import { usePrefs } from "./prefsContext"; @@ -15,7 +16,8 @@ const LS_KEY = "siftlode.devGlassTuner"; type Slider = { k: string; - label: string; + /** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */ + id: string; min: number; max: number; step: number; @@ -26,11 +28,11 @@ type Slider = { // def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it. // def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css. const SLIDERS: Slider[] = [ - { k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 10 }, - { k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 }, + { k: "--glass-blur", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 }, + { k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 }, { k: "--glass-dark-bright", - label: "Dark brightness", + id: "darkBright", min: 1, max: 2, step: 0.05, @@ -39,7 +41,7 @@ const SLIDERS: Slider[] = [ }, { k: "--glass-surface-alpha", - label: "Panel opacity", + id: "surfaceAlpha", min: 40, max: 100, step: 1, @@ -48,7 +50,7 @@ const SLIDERS: Slider[] = [ }, { k: "--glass-card-alpha", - label: "Card opacity", + id: "cardAlpha", min: 40, max: 100, step: 1, @@ -57,7 +59,7 @@ const SLIDERS: Slider[] = [ }, { k: "--glass-menu-alpha", - label: "Menu opacity", + id: "menuAlpha", min: 40, max: 100, step: 1, @@ -66,36 +68,35 @@ const SLIDERS: Slider[] = [ }, { k: "--glass-border-alpha", - label: "Border opacity", + id: "borderAlpha", min: 20, max: 100, step: 1, unit: "%", def: 80, }, - { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 25 }, - { k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 15 }, - { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 }, - { k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 }, + { k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 }, + { k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 }, + { k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 }, + { k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 }, ]; -const PALETTE: { k: string; label: string }[] = [ - { k: "--bg", label: "Background" }, - { k: "--surface", label: "Surface" }, - { k: "--card", label: "Card" }, - { k: "--border", label: "Border" }, - { k: "--fg", label: "Text" }, - { k: "--muted", label: "Muted" }, - { k: "--accent", label: "Accent" }, +const PALETTE: { k: string; id: string }[] = [ + { k: "--bg", id: "bg" }, + { k: "--surface", id: "surface" }, + { k: "--card", id: "card" }, + { k: "--border", id: "border" }, + { k: "--fg", id: "fg" }, + { k: "--muted", id: "muted" }, + { k: "--accent", id: "accent" }, ]; // Each preset resets every slider to its default, then applies these overrides. -const PRESETS: { id: string; name: string; hint: string; over: Record }[] = [ - { id: "current", name: "Glass/image", hint: "The default (over image)", over: {} }, +// Named under `settings.tuner.preset.`. +const PRESETS: { id: string; over: Record }[] = [ + { id: "current", over: {} }, { id: "solid", - name: "Solid (flat)", - hint: "As shown w/ image off", over: { "--glass-blur": 20, "--glass-dark-bright": 1.25, @@ -109,8 +110,6 @@ const PRESETS: { id: string; name: string; hint: string; over: Record>(() => { const base: Record = {}; @@ -276,9 +276,9 @@ export default function GlassTuner() { onClick={() => persistOpen(true)} style={{ pointerEvents: "auto" }} className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg" - title="Open the glass tuner" + title={t("settings.tuner.open")} > - ◐ GLASS + {t("settings.tuner.tab")} )} {open && ( @@ -287,12 +287,15 @@ export default function GlassTuner() { className="glass-menu rounded-l-2xl rounded-r-none w-75 max-h-[82vh] flex flex-col overflow-hidden" >
- ◐ Glass Tuner - lab + {t("settings.tuner.title")} + + {t("settings.tuner.lab")} + @@ -302,18 +305,22 @@ export default function GlassTuner() { {/* Treatment presets */}
- Dark treatment + {t("settings.tuner.treatment")}
{PRESETS.map((p) => ( ))}
@@ -321,17 +328,18 @@ export default function GlassTuner() { {/* Tab switch */}
- {(["glass", "palette"] as const).map((t) => ( + {(["glass", "palette"] as const).map((id) => ( ))}
@@ -342,6 +350,7 @@ export default function GlassTuner() { setVar(s.k, v)} /> @@ -352,8 +361,7 @@ export default function GlassTuner() { {tab === "palette" && (

- Overrides the live scheme (inline on <html>). Reset clears these back to - the scheme. + {t("settings.tuner.paletteHint")}

{PALETTE.map((p) => ( ))} @@ -373,27 +381,27 @@ export default function GlassTuner() { {/* Export */}
- Export → bake in + {t("settings.tuner.export")}
@@ -408,11 +416,28 @@ export default function GlassTuner() {
   );
 }
 
-function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) {
+function Ctl({
+  s,
+  label,
+  value,
+  onChange,
+}: {
+  s: Slider;
+  label: string;
+  value: number;
+  onChange: (v: number) => void;
+}) {
+  // The label names the number box, and the range beside it says the same thing: both controls
+  // are one setting, and the label was not tied to either before (a screen reader read "spin
+  // button" and "slider" with no name at all).
+  const id = useId();
   return (
     
- + onChange(Number(e.target.value))} + aria-label={label} className="col-span-2 w-full accent-accent" />
diff --git a/frontend/src/components/PlaylistEditFailures.dom.test.tsx b/frontend/src/components/PlaylistEditFailures.dom.test.tsx index 09a0187a..e43a00e0 100644 --- a/frontend/src/components/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/components/PlaylistEditFailures.dom.test.tsx @@ -13,7 +13,11 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + // The row's title rides along, so a query can name WHICH row's control it means. + useTranslation: () => ({ + t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key), + i18n: { language: "en" }, + }), }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); @@ -71,7 +75,7 @@ describe("the Plex playlist page", () => { , ); await screen.findByText("Movie 1"); - fireEvent.click(screen.getAllByLabelText("remove")[0]!); + fireEvent.click(screen.getByLabelText("plex.playlist.removeItem:Movie 1")); await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); expect(screen.getByText("Movie 1")).toBeTruthy(); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 9c9f95ce..5937760e 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -128,12 +128,15 @@ function Poster({ src, className }: { src: string; className: string }) { ); } -function DragHandle(props: Record) { +/** `title` names the row it moves: a screen reader heard the English word "reorder" on every + * row, with nothing to say which. */ +function DragHandle({ title, ...props }: { title: string } & Record) { + const { t } = useTranslation(); return ( @@ -141,6 +144,7 @@ function DragHandle(props: Record) { } function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) { + const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: ep.id, disabled: ctx.busy, @@ -157,7 +161,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean style={style} className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`} > - + {ep.episode_number} @@ -185,7 +189,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean onClick={() => ctx.onRemove([ep.id])} disabled={ctx.busy} className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" - aria-label="remove" + aria-label={t("plex.playlist.removeItem", { title: ep.title })} > @@ -194,6 +198,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean } function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { + const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, disabled: ctx.busy, @@ -210,7 +215,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { style={style} className="glass-card flex items-center gap-3 rounded-xl p-2" > - + {!dense && ( - diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index c1ff744b..b081ac26 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -1,4 +1,5 @@ import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { RotateCcw } from "lucide-react"; import { FocusSafeButton } from "./ui/FocusSafeButton"; @@ -70,6 +71,7 @@ export function Slider({ onChange: (v: number) => void; onReset?: () => void; }) { + const { t } = useTranslation(); return (
@@ -80,7 +82,11 @@ export function Slider({ {suffix ?? ""} {onReset && ( - )} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 05bee624..7be0f61c 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -34,5 +34,7 @@ "edit": "Click to type a priority (negatives allowed)" }, "moreRefine": "{{count}} more — refine with search", - "matchMode": "Match" + "matchMode": "Match", + "removeNamed": "Remove {{name}}", + "resetNamed": "Reset {{name}}" } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 97650499..6beaaa53 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -327,7 +327,9 @@ "removeFailed": "Couldn't remove it from the playlist.", "renameFailed": "Couldn't rename the playlist.", "deleteFailed": "Couldn't delete the playlist.", - "reorderFailed": "Couldn't save the new order." + "reorderFailed": "Couldn't save the new order.", + "removeItem": "Remove “{{title}}” from the playlist", + "reorderItem": "Move “{{title}}”" }, "people": { "count_one": "{{count}} title", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 6e0ed193..197099d5 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -249,5 +249,60 @@ "clear": "Clear", "empty": "No event matches the filter.", "emptyAll": "No events yet." + }, + "tuner": { + "open": "Open the glass tuner", + "tab": "◐ GLASS", + "title": "◐ Glass Tuner", + "lab": "lab", + "collapse": "Collapse", + "treatment": "Dark treatment", + "tabs": { + "glass": "Glass", + "palette": "Palette" + }, + "paletteHint": "Overrides the live scheme (inline on ). Reset clears these back to the scheme.", + "export": "Export → bake in", + "syncHint": "Load the values of the theme you're currently viewing (light or dark) into the sliders", + "sync": "⟳ Sync to current theme", + "copy": "Copy CSS", + "copied": "Copied ✓", + "reset": "Reset", + "slider": { + "blur": "Blur radius", + "saturate": "Saturation", + "darkBright": "Dark brightness", + "surfaceAlpha": "Panel opacity", + "cardAlpha": "Card opacity", + "menuAlpha": "Menu opacity", + "borderAlpha": "Border opacity", + "edge": "Edge-light rim", + "inset": "Top highlight", + "scrim": "Under-text scrim", + "ambient": "Ambient intensity" + }, + "color": { + "bg": "Background", + "surface": "Surface", + "card": "Card", + "border": "Border", + "fg": "Text", + "muted": "Muted", + "accent": "Accent" + }, + "preset": { + "current": { + "name": "Glass/image", + "hint": "The default (over image)" + }, + "solid": { + "name": "Solid (flat)", + "hint": "As shown w/ image off" + }, + "readable": { + "name": "Max readable", + "hint": "Solid + strong scrim" + } + } } } diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index c4bcb664..0f59bd60 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -34,5 +34,7 @@ "edit": "Kattints a prioritás beírásához (negatív is lehet)" }, "moreRefine": "még {{count}} — finomíts kereséssel", - "matchMode": "Egyezés" + "matchMode": "Egyezés", + "removeNamed": "{{name}}: eltávolítás", + "resetNamed": "{{name}}: alaphelyzet" } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 50569bb3..3be0a0d0 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -327,7 +327,9 @@ "removeFailed": "Nem sikerült eltávolítani a lejátszási listáról.", "renameFailed": "Nem sikerült átnevezni a lejátszási listát.", "deleteFailed": "Nem sikerült törölni a lejátszási listát.", - "reorderFailed": "Nem sikerült menteni az új sorrendet." + "reorderFailed": "Nem sikerült menteni az új sorrendet.", + "removeItem": "„{{title}}” eltávolítása a listáról", + "reorderItem": "„{{title}}” áthelyezése" }, "people": { "count_one": "{{count}} cím", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 853a762f..3456df8d 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -249,5 +249,60 @@ "clear": "Ürítés", "empty": "Nincs a szűrésnek megfelelő esemény.", "emptyAll": "Még nincs esemény." + }, + "tuner": { + "open": "Glass tuner megnyitása", + "tab": "◐ ÜVEG", + "title": "◐ Glass tuner", + "lab": "labor", + "collapse": "Összecsukás", + "treatment": "Sötét téma", + "tabs": { + "glass": "Üveg", + "palette": "Paletta" + }, + "paletteHint": "Felülírja az élő színsémát (inline a -en). A Visszaállítás a séma színeit adja vissza.", + "export": "Exportálás → beégetés", + "syncHint": "A most látott téma (világos vagy sötét) értékeinek betöltése a csúszkákba", + "sync": "⟳ Igazítás a jelenlegi témához", + "copy": "CSS másolása", + "copied": "Másolva ✓", + "reset": "Visszaállítás", + "slider": { + "blur": "Elmosás sugara", + "saturate": "Telítettség", + "darkBright": "Sötét fényerő", + "surfaceAlpha": "Panel fedése", + "cardAlpha": "Kártya fedése", + "menuAlpha": "Menü fedése", + "borderAlpha": "Keret fedése", + "edge": "Élfény-perem", + "inset": "Felső csúcsfény", + "scrim": "Szöveg alatti sötétítés", + "ambient": "Környezeti intenzitás" + }, + "color": { + "bg": "Háttér", + "surface": "Felület", + "card": "Kártya", + "border": "Keret", + "fg": "Szöveg", + "muted": "Halvány", + "accent": "Kiemelés" + }, + "preset": { + "current": { + "name": "Üveg/kép", + "hint": "Az alapérték (kép fölött)" + }, + "solid": { + "name": "Tömör (lapos)", + "hint": "Ahogy kép nélkül látszik" + }, + "readable": { + "name": "Legolvashatóbb", + "hint": "Tömör + erős sötétítés" + } + } } } diff --git a/frontend/src/lib/literalA11yText.contract.test.ts b/frontend/src/lib/literalA11yText.contract.test.ts new file mode 100644 index 00000000..ddb95a9c --- /dev/null +++ b/frontend/src/lib/literalA11yText.contract.test.ts @@ -0,0 +1,33 @@ +import { fileURLToPath } from "node:url"; + +import { ESLint } from "eslint"; +import { describe, expect, it } from "vitest"; + +// `local/literal-a11y-text` (eslint.config.js): what a screen reader or a tooltip says is user-facing +// text, so it goes through t() like the rest. Five English `aria-label`s and a whole English panel +// survived every lane before it. A rule that matches nothing looks exactly like a clean codebase, so +// this asserts it fires. +const RULE = "local/literal-a11y-text"; +const FRONTEND = fileURLToPath(new URL("../../", import.meta.url)); + +async function hits(source: string): Promise { + const eslint = new ESLint({ cwd: FRONTEND }); + const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" }); + return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; +} + +describe("a literal word in an attribute the user hears or sees", () => { + it("is refused, in every spelling of a string", async () => { + expect(await hits(`export const A = () =>