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
+52 -2
View File
@@ -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,
);
+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>
)}
+3 -1
View File
@@ -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}}"
}
+3 -1
View File
@@ -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"
}
}
}
}
+3 -1
View File
@@ -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"
}
+3 -1
View File
@@ -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);
});
});