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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 <html>). 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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <html>). 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <html>-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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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 = () => <button aria-label="remove" />;\n`)).toBe(1);
|
||||
expect(await hits(`export const B = () => <button title={"Collapse"} />;\n`)).toBe(1);
|
||||
expect(await hits("export const C = () => <input placeholder={`Search`} />;\n")).toBe(1);
|
||||
expect(await hits(`export const D = () => <img alt="A poster" src="" />;\n`)).toBe(1);
|
||||
});
|
||||
|
||||
it("lets through a translation, a decorative image, a symbol and a URL shape", async () => {
|
||||
expect(await hits(`export const E = ({ t }) => <button aria-label={t("x.y")} />;\n`)).toBe(0);
|
||||
expect(await hits(`export const F = () => <img alt="" src="" />;\n`)).toBe(0);
|
||||
expect(await hits(`export const G = () => <span title="×" />;\n`)).toBe(0);
|
||||
expect(await hits(`export const H = () => <input placeholder="https://…" />;\n`)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user