fix(i18n): every label a screen reader or tooltip says is translated

- The Plex playlist rows' "reorder"/"remove", the Plex filter chip's "remove" and the player
  slider's "reset" were English literals; each now names its row or setting in HU/EN.
- The playlist show header's chevron had no accessible name; its decorative thumbnail button left
  the tab order.
- The opt-in Glass tuner panel was English only: every word is translated, and each slider's label
  now names its number box and range.
- New `local/literal-a11y-text` lint rule (with its contract test): a literal word in aria-label,
  title, placeholder, alt and the other spoken/shown attributes fails lint.
This commit is contained in:
2026-09-19 17:12:47 +02:00
parent 7b94e9a651
commit 4f72b193aa
13 changed files with 325 additions and 70 deletions
+78 -52
View File
@@ -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<string, number> }[] = [
{ id: "current", name: "Glass/image", hint: "The default (over image)", over: {} },
// Named under `settings.tuner.preset.<id>`.
const PRESETS: { id: string; over: Record<string, number> }[] = [
{ 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<string, nu
},
{
id: "readable",
name: "Max readable",
hint: "Solid + strong scrim",
over: {
"--glass-edge": 42,
"--glass-scrim": 45,
@@ -141,6 +140,7 @@ function loadSaved(): Saved {
export default function GlassTuner() {
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
const enabled = usePrefs().theme.showTuner;
const { t } = useTranslation();
const initial = useMemo(loadSaved, []);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
@@ -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")}
>
<span style={{ writingMode: "vertical-rl" }}>◐ GLASS</span>
<span style={{ writingMode: "vertical-rl" }}>{t("settings.tuner.tab")}</span>
</button>
)}
{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"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
<span className="text-3xs uppercase tracking-wider text-muted">lab</span>
<span className="text-sm font-semibold text-fg">{t("settings.tuner.title")}</span>
<span className="text-3xs uppercase tracking-wider text-muted">
{t("settings.tuner.lab")}
</span>
<button
onClick={() => persistOpen(false)}
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
title="Collapse"
title={t("settings.tuner.collapse")}
aria-label={t("settings.tuner.collapse")}
>
×
</button>
@@ -302,18 +305,22 @@ export default function GlassTuner() {
{/* Treatment presets */}
<div className="flex flex-col gap-1.5">
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
Dark treatment
{t("settings.tuner.treatment")}
</div>
<div className="grid grid-cols-2 gap-1.5">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => applyPreset(p.over)}
title={p.hint}
title={t(`settings.tuner.preset.${p.id}.hint`)}
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
>
<b className="block font-semibold">{p.name}</b>
<span className="text-muted text-3xs">{p.hint}</span>
<b className="block font-semibold">
{t(`settings.tuner.preset.${p.id}.name`)}
</b>
<span className="text-muted text-3xs">
{t(`settings.tuner.preset.${p.id}.hint`)}
</span>
</button>
))}
</div>
@@ -321,17 +328,18 @@ export default function GlassTuner() {
{/* Tab switch */}
<div className="flex gap-1.5">
{(["glass", "palette"] as const).map((t) => (
{(["glass", "palette"] as const).map((id) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 rounded-lg px-2 py-1 text-xs capitalize ${
tab === t
key={id}
onClick={() => setTab(id)}
aria-pressed={tab === id}
className={`flex-1 rounded-lg px-2 py-1 text-xs ${
tab === id
? "bg-accent text-(--accent-fg) font-semibold"
: "glass-card text-muted"
}`}
>
{t}
{t(`settings.tuner.tabs.${id}`)}
</button>
))}
</div>
@@ -342,6 +350,7 @@ export default function GlassTuner() {
<Ctl
key={s.k}
s={s}
label={t(`settings.tuner.slider.${s.id}`)}
value={vars[s.k] ?? s.def}
onChange={(v) => setVar(s.k, v)}
/>
@@ -352,8 +361,7 @@ export default function GlassTuner() {
{tab === "palette" && (
<div className="flex flex-col gap-2">
<p className="text-3xs text-muted leading-relaxed">
Overrides the live scheme (inline on &lt;html&gt;). Reset clears these back to
the scheme.
{t("settings.tuner.paletteHint")}
</p>
{PALETTE.map((p) => (
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
@@ -363,7 +371,7 @@ export default function GlassTuner() {
onChange={(e) => setColor(p.k, e.target.value)}
className="w-8 h-6 rounded-sm border border-border bg-transparent cursor-pointer"
/>
<span className="flex-1">{p.label}</span>
<span className="flex-1">{t(`settings.tuner.color.${p.id}`)}</span>
<code className="text-3xs text-muted">{swatches[p.k]}</code>
</label>
))}
@@ -373,27 +381,27 @@ export default function GlassTuner() {
{/* Export */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
Export → bake in
{t("settings.tuner.export")}
</div>
<button
onClick={syncToTheme}
title="Load the values of the theme you're currently viewing (light or dark) into the sliders"
title={t("settings.tuner.syncHint")}
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-xs text-fg text-left"
>
⟳ Sync to current theme
{t("settings.tuner.sync")}
</button>
<div className="flex gap-1.5">
<button
onClick={copy}
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-(--accent-fg)"
>
{copied ? "Copied ✓" : "Copy CSS"}
{copied ? t("settings.tuner.copied") : t("settings.tuner.copy")}
</button>
<button
onClick={reset}
className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg"
>
Reset
{t("settings.tuner.reset")}
</button>
</div>
<pre className="text-[9.5px] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
@@ -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 (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 gap-y-1">
<label className="text-xs text-fg">{s.label}</label>
<label htmlFor={id} className="text-xs text-fg">
{label}
</label>
<input
id={id}
type="number"
min={s.min}
max={s.max}
@@ -428,6 +453,7 @@ function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: n
step={s.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-label={label}
className="col-span-2 w-full accent-accent"
/>
</div>
@@ -13,7 +13,11 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
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", () => {
</QueryClientProvider>,
);
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();
+21 -8
View File
@@ -128,12 +128,15 @@ function Poster({ src, className }: { src: string; className: string }) {
);
}
function DragHandle(props: Record<string, unknown>) {
/** `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<string, unknown>) {
const { t } = useTranslation();
return (
<button
{...props}
className="shrink-0 cursor-grab text-muted hover:text-fg active:cursor-grabbing"
aria-label="reorder"
aria-label={t("plex.playlist.reorderItem", { title })}
>
<GripVertical className="h-4 w-4" />
</button>
@@ -141,6 +144,7 @@ function DragHandle(props: Record<string, unknown>) {
}
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"}`}
>
<DragHandle {...attributes} {...listeners} />
<DragHandle title={ep.title} {...attributes} {...listeners} />
<span className="w-5 shrink-0 text-center text-2xs text-muted tabular-nums">
{ep.episode_number}
</span>
@@ -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 })}
>
<X className="h-4 w-4" />
</FocusSafeButton>
@@ -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"
>
<DragHandle {...attributes} {...listeners} />
<DragHandle title={item.title} {...attributes} {...listeners} />
<button
onClick={() => ctx.onPlay(item.id)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
@@ -228,7 +233,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
onClick={() => ctx.onRemove([item.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: item.title })}
>
<X className="h-4 w-4" />
</FocusSafeButton>
@@ -258,16 +263,24 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
<div className="flex items-center gap-2.5 p-2">
<DragHandle {...attributes} {...listeners} />
<DragHandle title={block.title} {...attributes} {...listeners} />
<button
onClick={() => ctx.toggleShow(block.showKey)}
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
aria-expanded={open}
aria-label={block.title}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{!dense && (
<button onClick={() => ctx.toggleShow(block.showKey)} className="flex shrink-0">
// A second, pointer-only target for the same toggle (its images are decorative): out of
// the tab order, so the keyboard does not stop on a button with no name.
<button
onClick={() => ctx.toggleShow(block.showKey)}
className="flex shrink-0"
tabIndex={-1}
aria-hidden
>
{block.items.slice(0, 3).map((ep, i) => (
<img
key={ep.id}
+6 -1
View File
@@ -496,10 +496,15 @@ function CollectionPicker({
}
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
const { t } = useTranslation();
return (
<span className="chip px-2 py-1 text-xs">
<span className="truncate max-w-36">{label}</span>
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
<button
onClick={onRemove}
className="hover:opacity-70"
aria-label={t("common.removeNamed", { name: label })}
>
<X className="w-3 h-3" />
</button>
</span>
@@ -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 (
<div className="px-1 py-1">
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
@@ -80,7 +82,11 @@ export function Slider({
{suffix ?? ""}
</span>
{onReset && (
<button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
<button
onClick={onReset}
className="text-white/40 hover:text-white"
aria-label={t("common.resetNamed", { name: label })}
>
<RotateCcw className="h-3 w-3" />
</button>
)}