From 295c1db5f15ab71098eadc557a6307b3c6472540 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 25 Sep 2026 03:58:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(design):=20claim=20audit=20part=201b=20?= =?UTF-8?q?=E2=80=94=20whole-row=20switches,=20the=20field-family=20edge,?= =?UTF-8?q?=20state-layer=20hovers,=20icon=20links,=20reason-disabled=20co?= =?UTF-8?q?ntrols,=2024/44=20targets=20(E28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title and hint (a label pointed at the switch). - F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge); labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows. - F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP) refuses the swap, contract-tested. - F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only . - F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the Config tests say why while dirty (testDirty, HU/EN). - F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle, Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest. - F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips. - F15: the "+N" / "More..." open state worded as it renders. --- docs/DESIGN.md | 20 +++- frontend/eslint.config.js | 46 +++++++- frontend/src/components/AdminUsers.tsx | 14 ++- frontend/src/components/ChannelDiscovery.tsx | 3 +- frontend/src/components/ChannelLink.tsx | 24 ++-- frontend/src/components/ChannelPage.tsx | 13 +- .../components/ConfigPanel.field.dom.test.tsx | 26 +++- frontend/src/components/ConfigPanel.tsx | 33 ++++-- frontend/src/components/IssueComposer.tsx | 2 +- frontend/src/components/LanguageSwitcher.tsx | 4 +- frontend/src/components/LogLine.tsx | 2 +- frontend/src/components/Messages.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 6 +- frontend/src/components/PaletteLauncher.tsx | 2 +- .../src/components/PersonalViewsModal.tsx | 2 +- frontend/src/components/PlayerModal.tsx | 20 ++-- frontend/src/components/Playlists.tsx | 14 +-- frontend/src/components/PlexInfo.tsx | 27 +++-- frontend/src/components/PlexSearchBox.tsx | 6 +- .../src/components/Scheduler.dom.test.tsx | 18 +++ frontend/src/components/Scheduler.tsx | 29 +++-- frontend/src/components/SettingsPanel.tsx | 111 +++++++----------- frontend/src/components/Stats.tsx | 6 +- frontend/src/components/SyncStatus.tsx | 2 +- frontend/src/components/VideoCard.tsx | 6 +- frontend/src/components/WatchPage.tsx | 2 +- frontend/src/components/channelCells.tsx | 8 +- frontend/src/components/downloadRow.tsx | 4 +- frontend/src/components/playlistColumns.tsx | 4 +- frontend/src/components/plexDetailUi.tsx | 3 +- frontend/src/components/plexViews.tsx | 2 +- .../src/components/ui/AnchoredPopover.tsx | 3 +- .../src/components/ui/Button.dom.test.tsx | 25 +++- frontend/src/components/ui/Chip.tsx | 12 +- .../src/components/ui/Combobox.dom.test.tsx | 2 +- frontend/src/components/ui/Combobox.tsx | 35 +++--- frontend/src/components/ui/DataTable.tsx | 2 +- .../src/components/ui/FocusSafeButton.tsx | 7 +- frontend/src/components/ui/Input.dom.test.tsx | 12 ++ frontend/src/components/ui/Input.tsx | 5 +- frontend/src/components/ui/NativeControls.tsx | 4 +- frontend/src/components/ui/NavRail.tsx | 2 +- frontend/src/components/ui/PanelGroup.tsx | 3 +- .../src/components/ui/PriorityStepper.tsx | 12 +- frontend/src/components/ui/README.md | 6 +- .../src/components/ui/SegmentedToggle.tsx | 8 +- frontend/src/components/ui/SelectTrigger.tsx | 13 +- frontend/src/components/ui/SortControl.tsx | 2 +- frontend/src/components/ui/Tabs.tsx | 4 +- frontend/src/components/ui/buttonStyles.ts | 12 +- frontend/src/components/ui/form.dom.test.tsx | 24 +++- frontend/src/components/ui/form.tsx | 40 ++++++- frontend/src/components/ui/tableHeader.tsx | 6 +- frontend/src/design-lab/DesignLab.tsx | 6 +- frontend/src/design-lab/batchA.tsx | 7 +- frontend/src/i18n/locales/en/common.json | 1 - frontend/src/i18n/locales/en/config.json | 2 +- frontend/src/i18n/locales/hu/common.json | 1 - frontend/src/i18n/locales/hu/config.json | 2 +- frontend/src/index.css | 19 +-- .../src/lib/hoverAndLink.contract.test.ts | 57 +++++++++ frontend/src/lib/touchTarget.contract.test.ts | 22 ++++ 62 files changed, 552 insertions(+), 265 deletions(-) create mode 100644 frontend/src/lib/hoverAndLink.contract.test.ts create mode 100644 frontend/src/lib/touchTarget.contract.test.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 872db160..7232721f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -72,7 +72,10 @@ style guide. --warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. - [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). + [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field + family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A + labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it, + and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14). - **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from `--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is @@ -88,7 +91,9 @@ style guide. - **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%** (hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and - `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. + `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one + exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge + (`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14). - **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`, `--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`, `edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab @@ -129,8 +134,8 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type (`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11] - **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a - choice"), r8 and full width in a form (it lines up with the inputs). Open is the accent edge and the - chevron turned over. + choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and + lines up with the inputs. Open is the accent edge and the chevron turned over. - **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`, `CheckRow`, `Slider`, `ColorInput`); the whole labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native @@ -152,8 +157,9 @@ Also in force: `components/ui/` is a lint error. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2] - **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch). - `filter` is a selectable option, solid accent when `pressed` (also the "+N" and "More…" triggers, lit - while their popover is open); `token` is an applied narrowing, the accent tint, the whole chip removing + `filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter + chips too, and while their popover is open they show it as an open trigger does, the accent edge and + text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent); `ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip @@ -353,5 +359,7 @@ Wherever there is text, the user can select it and copy it, and no click action | 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role | | 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing | | 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill | +| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names | +| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down | | 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw ` onSuspend(u)} - disabled={u.is_demo || self || pendingIds.has(u.id)} + aria-disabled={u.is_demo || self} + disabled={pendingIds.has(u.id)} > {u.is_suspended ? ( @@ -304,6 +310,7 @@ function UsersRoles({ me }: { me: Me }) { onDelete(u)} - disabled={u.is_demo || self || pendingIds.has(u.id)} + aria-disabled={u.is_demo || self} + disabled={pendingIds.has(u.id)} > diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index f69b8ac7..8412b194 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -104,6 +104,7 @@ export default function ChannelDiscovery({ cardLabel: false, render: (c) => ( } onClick={() => onSubscribe(c)} - disabled={!canWrite} + aria-disabled={!canWrite} loading={subscribe.isPending} > {t("channels.discovery.subscribe")} diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx index cb069a99..1162027d 100644 --- a/frontend/src/components/ChannelLink.tsx +++ b/frontend/src/components/ChannelLink.tsx @@ -1,9 +1,9 @@ import { useTranslation } from "react-i18next"; import { ExternalLink } from "lucide-react"; import Avatar from "./ui/Avatar"; -import Tooltip from "./ui/Tooltip"; import { channelYouTubeUrl } from "../lib/format"; import { Pressable } from "./ui/Pressable"; +import { IconLink } from "./ui/LinkButton"; // Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the // discovery list (and anywhere else that lists a channel). When `onView` is given the name @@ -47,7 +47,7 @@ export default function ChannelLink({ }} // title = the full name, so a truncated fluid-column name is still readable on hover. title={name} - className="text-sm font-medium truncate hover:text-accent min-w-0" + className="min-h-6 min-w-0 truncate text-sm font-medium hover:text-accent pointer-coarse:min-h-(--touch-target)" > {name} @@ -56,17 +56,15 @@ export default function ChannelLink({ {name} )} - - - - - + + + ); } diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 3adb3713..f885a02a 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -38,6 +38,7 @@ import { ChannelTagsControl } from "./ChannelTags"; import { useChannelTagActions } from "../lib/useChannelTagActions"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; +import { IconLink } from "./ui/LinkButton"; // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it @@ -426,16 +427,16 @@ export default function ChannelPage({ ]} />
- - - + + {ch?.subscribed && ( { api: { ...actual.api, adminConfig: () => - Promise.resolve({ groups: { g: [text("site_name")] }, secrets_manageable: true }), + Promise.resolve({ + groups: { + g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }], + }, + secrets_manageable: true, + }), }, }; }); @@ -73,4 +78,21 @@ describe("ConfigPanel field row (E22 round 2)", () => { expect(control.className).toContain("sm:shrink-0"); expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/); }); + + it("toggles a boolean key from its title and its hint, not only from the switch (F12)", async () => { + render( + + + , + ); + const sw = await screen.findByRole("switch", { name: "config.fields.signups_open.label" }); + expect(sw.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(screen.getByText("config.fields.signups_open.label")); + expect(sw.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(screen.getByText("config.fields.signups_open.hint")); + expect(sw.getAttribute("aria-checked")).toBe("false"); + // The switch itself still toggles exactly once, not once more through its label. + fireEvent.click(sw); + expect(sw.getAttribute("aria-checked")).toBe("true"); + }); }); diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index d9a900d5..aa4ee953 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -225,12 +225,13 @@ export default function ConfigPanel() { variant="glass" icon={} loading={testEmail.isPending} - disabled={dirty} + aria-disabled={dirty} onClick={() => testEmail.mutate()} > {t("config.testEmail")} + {dirty &&

{t("config.testDirty")}

}
)} @@ -241,13 +242,13 @@ export default function ConfigPanel() { variant="glass" icon={} loading={testIssues.isPending} - disabled={dirty} + aria-disabled={dirty} onClick={() => testIssues.mutate()} > {t("config.issuesTest")} - {dirty &&

{t("config.plexTestDirty")}

} + {dirty &&

{t("config.testDirty")}

} )} @@ -258,13 +259,13 @@ export default function ConfigPanel() { variant="glass" icon={} loading={testPlex.isPending} - disabled={dirty} + aria-disabled={dirty} onClick={() => testPlex.mutate()} > {t("config.plexTest")} - {dirty &&

{t("config.plexTestDirty")}

} + {dirty &&

{t("config.testDirty")}

} {plexResult && (

@@ -369,22 +370,30 @@ function Field({ // toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is // its own control — storing its value is equivalent to the default, so no reset link. const showReset = item.is_set && !isBool; + const switchId = useId(); + const Text = isBool ? "label" : "div"; return ( // Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the // label and hint a sliver of a phone's width.

-
-
{label}
-

+ {/* A switch's whole text column is its label, so the title or the hint toggles it (F12); a + label may only hold phrasing content, hence the block spans. */} + + {label} + {t(`config.fields.${item.key}.hint`, "")} -

-

{status}

-
+ + {status} +
{isBool ? ( onChange(v ? "true" : "false")} diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx index c5227e69..abe9f333 100644 --- a/frontend/src/components/IssueComposer.tsx +++ b/frontend/src/components/IssueComposer.tsx @@ -763,7 +763,7 @@ function ReviewList({ return (
  • )} @@ -875,16 +876,15 @@ export default function PlayerModal({ }} className="font-medium hover:text-accent truncate text-left" /> - - - + +
    )} {!navigated ? ( diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 14d919e1..64509f72 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -71,6 +71,7 @@ import { DragHandle } from "./ui/DragHandle"; import { Input } from "./ui/Input"; import { CheckRow } from "./ui/NativeControls"; import { useReturnFocus } from "./ui/useReturnFocus"; +import { IconLink } from "./ui/LinkButton"; // The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a // body that is still a drag-reorderable @dnd-kit list. @@ -133,7 +134,7 @@ function Row({ }} // transition-COLORS, not `transition` — the bare utility animates transform too, which is // the same lag by another route (150ms behind the pointer, on every row). - className="border-b border-border/50 hover:bg-card/40 transition-colors" + className="border-b border-border/50 state-layer transition-colors" {...navItemAttrs(index)} {...menuProps} > @@ -908,16 +909,15 @@ export default function Playlists() { )} {detail.yt_playlist_id && ( - - - + + )} )} diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index cc13c3b6..c3d70198 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -20,6 +20,7 @@ import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; +import { IconLink } from "./ui/LinkButton"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -65,7 +66,7 @@ function Score({ return filterable && onFilterRating ? ( onFilterRating(Math.floor(score))} - className="inline-flex items-center gap-1 hover:underline" + className="inline-flex min-h-6 items-center gap-1 hover:underline touch-target" title={t("plex.info.filterRating", { n: Math.floor(score) })} > @@ -113,18 +114,20 @@ function RatingsRow({ onFilterRating={onFilterRating} /> IMDb - {imdbUrl && ( - - - - )} )} + {/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */} + {has(imdbRating) && imdbUrl && ( + + + + )} {has(tmdbRating) && ( @@ -289,7 +292,7 @@ function CrewPanel({ onOpenPerson(c.name)} title={t("plex.info.viewPerson", { name: c.name })} - className="max-w-full truncate text-sm font-medium hover:text-accent" + className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)" > {c.name} diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index ebab47a9..29e62d2c 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -119,7 +119,7 @@ export default function PlexSearchBox() { leading={ {pillLabel} @@ -155,8 +155,10 @@ export default function PlexSearchBox() { onMouseDown={(e) => e.preventDefault()} // keep input focus through the click onMouseEnter={() => setHi(i)} onClick={() => pick(i)} + // The pointer moves `hi` too, so the highlight IS the hover: the menus' roving + // recipe (AnchoredPopover's HIGHLIGHT_UNSELECTED), one look for the mouse and the keys. className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ - hi === i ? "bg-border/60" : "hover:bg-border/40" + hi === i ? "bg-accent/35 text-fg ring-1 ring-inset ring-accent/60" : "" }`} > { await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000")); }); }); + +describe("Scheduler while paused (E28 audit F13)", () => { + // Unavailable WITH a reason ("resume first"): the runs stay in the tab order, so a keyboard user + // reaches the hint, and they are inert. Natively disabled, the reason was mouse-only. + it("keeps Run all and a job's Run focusable and inert", async () => { + const runAll = vi.spyOn(api, "runAllSchedulerJobs").mockResolvedValue({ started: [] }); + const runOne = vi.spyOn(api, "runSchedulerJob").mockResolvedValue({ started: true } as never); + await mount(status({ paused: true })); + for (const name of ["scheduler.runAll", "scheduler.runNow"]) { + const btn = screen.getByRole("button", { name }); + expect(btn.hasAttribute("disabled")).toBe(false); + expect(btn.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(btn); + } + expect(runAll).not.toHaveBeenCalled(); + expect(runOne).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index bb97e033..534e1692 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -133,13 +133,17 @@ function JobRow({ onSave, saving, onRun, - runDisabled, + paused, + runBusy, }: { job: SchedulerJob; onSave: (minutes: number) => void; saving: boolean; onRun: () => void; - runDisabled: boolean; + /** Unavailable with a reason (the hint says: resume first), so it stays focusable. */ + paused: boolean; + /** A run-all in flight: transient, natively disabled. */ + runBusy: boolean; }) { const { t } = useTranslation(); // The box shows the server's value and holds a draft only while it is being edited, so there is @@ -226,7 +230,7 @@ function JobRow({ setDraft(String(job.interval_minutes))} - className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition" + className="group text-muted font-normal hover:text-accent inline-flex min-h-6 items-center gap-1 transition touch-target" > · {t("scheduler.everyMin", { count: job.interval_minutes })} @@ -260,10 +264,14 @@ function JobRow({ "—" )}
    - + @@ -515,13 +523,17 @@ export default function Scheduler() {
    - + @@ -579,7 +591,8 @@ export default function Scheduler() { saving={intervalMut.isPending} onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })} onRun={() => runMut.mutate(job.id)} - runDisabled={data.paused || runAllMut.isPending} + paused={data.paused} + runBusy={runAllMut.isPending} /> ))}
    diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index e9ed0c04..fdc0d2fb 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -14,7 +14,7 @@ import { LS, useAccountPersistedState } from "../lib/storage"; import Avatar from "./ui/Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; -import { Section, SettingRow, Switch } from "./ui/form"; +import { Section, SwitchRow } from "./ui/form"; import { Button } from "./ui/Button"; import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./confirmContext"; @@ -157,7 +157,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) { onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })} aria-label={s.name} aria-pressed={theme.scheme === s.id} - className={`h-9 w-full rounded-lg border-2 transition ${ + className={`h-9 w-full rounded-lg border-2 transition touch-target ${ theme.scheme === s.id ? "border-fg" : "border-transparent" }`} style={{ background: s.swatch }} @@ -168,23 +168,17 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
    - - setTheme({ ...theme, mode: v ? "dark" : "light" })} - /> - - setTheme({ ...theme, mode: v ? "dark" : "light" })} + /> + - setTheme({ ...theme, bgImage: v })} - /> - + checked={theme.bgImage} + onChange={(v) => setTheme({ ...theme, bgImage: v })} + /> {theme.bgImage && (
    @@ -201,45 +195,33 @@ function Appearance({ prefs }: { prefs: PrefsController }) { />
    )} - - - - + - - - + - - + checked={railLabels} + onChange={setRailLabels} + />
    - - setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)} - /> - + checked={startOnHome !== START_ON_HOME.off} + onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)} + />
    @@ -267,26 +249,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) { return (
    - - update({ sound: v })} - /> - - update({ sound: v })} + /> + - update({ durationMs: v ? 6000 : 0 })} - /> - + checked={autoOn} + onChange={(v) => update({ durationMs: v ? 6000 : 0 })} + /> {autoOn && (
    @@ -597,14 +571,13 @@ function PlexWatchSync() { return (

    {t("settings.plexSync.intro")}

    - - !busy && toggle.mutate(v)} - /> - + !busy && toggle.mutate(v)} + /> {busy ? (
    diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 8d77afdb..0cc9c0f9 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -290,11 +290,13 @@ function SystemStats() { {data.daily.map((d) => (
    diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index bf8af4ea..0ddc4d4d 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -125,7 +125,7 @@ export default function SyncStatus({ // Short label only: the popover already explains the numbers, and the long one overflowed // the viewport edge from here. title={countsText} - className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted`} + className={`${SURFACE.floating} glass-hover relative flex h-10 items-center gap-1.5 px-3 text-xs text-muted touch-target`} > {active ? ( diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 083e8c8c..78a81aa1 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -480,7 +480,8 @@ function VideoCard({ data-nav-open="" title={titleClamped ? (video.title ?? undefined) : undefined} className={clsx( - "font-medium leading-snug hover:text-accent", + // min-h-6: a one-line title is still a 24 target (B1). + "min-h-6 font-medium leading-snug hover:text-accent", noThumb ? "block truncate" : "line-clamp-2", )} > @@ -501,7 +502,8 @@ function VideoCard({ } stopPropagation tooltip={channelClamped ? (video.channel_title ?? undefined) : undefined} - className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg" + // leading-6: the one-line name is a 24 target (B1), not its 20px line of text. + className="text-sm leading-6 text-muted truncate block w-fit max-w-full text-left hover:text-fg" /> ); const actions = ( diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index 2625fc42..20f9171c 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -420,7 +420,7 @@ export default function WatchPage() { target="_blank" rel="noopener noreferrer" title={url} - className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0" + className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target" > {displayUrl(url)} diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx index 7faef334..02b3a1e1 100644 --- a/frontend/src/components/channelCells.tsx +++ b/frontend/src/components/channelCells.tsx @@ -66,10 +66,12 @@ export function ActionsCell({ const enabled = isAdmin || optInActionable; return (
    - + {/* Unavailable with a reason (queued by someone else, already full): aria-disabled keeps it + focusable, so the hint that says why is reachable by keyboard too. */} + {displayUrl(url)} @@ -265,7 +265,7 @@ export function DownloadRow({ {job.status === "error" ? ( onShowError?.(job)} - className="text-danger inline-flex items-center gap-1 truncate" + className="text-danger inline-flex min-h-6 items-center gap-1 truncate touch-target" title={t("downloads.errors.fail.title")} > · {t(failCopyKeys(job, "short"))} diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx index 654f49be..aed14534 100644 --- a/frontend/src/components/playlistColumns.tsx +++ b/frontend/src/components/playlistColumns.tsx @@ -61,7 +61,7 @@ export function playlistColumns({ onPlay(v)} aria-label={`${t("card.play")} — ${v.title}`} - className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group" + className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group touch-target" > {v.thumbnail_url && ( @@ -74,7 +74,7 @@ export function playlistColumns({ onClick={() => onPlay(v)} // The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it. data-nav-open="" - className="min-w-0 flex-1" + className="min-h-6 min-w-0 flex-1 touch-target" > {v.title} diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index b75f3343..bb7596aa 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -61,7 +61,8 @@ export function Filterable({ {children} diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 7942b32f..e6bf2bb0 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -439,7 +439,7 @@ export function PlexShowView({ title={ onFilter ? t("plex.info.filterRating", { n: Math.floor(score) }) : undefined } - className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`} + className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`} > {/* decorative rating gold — intentionally NOT a status token */} · diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx index d95e4f9a..240d07af 100644 --- a/frontend/src/components/ui/AnchoredPopover.tsx +++ b/frontend/src/components/ui/AnchoredPopover.tsx @@ -208,7 +208,8 @@ export function PopoverItem({ tabIndex={virtual ? -1 : focusActive ? 0 : -1} data-active={visualActive ? "true" : undefined} disabled={disabled} - className={`outline-hidden ${ + // touch-target: a menu row is a control primitive, 44 tall on a coarse pointer (A3). + className={`outline-hidden touch-target ${ checked || selected ? keepSelectedPaint ? HIGHLIGHT_SELECTED_RING_ONLY diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx index ae1d4d60..b7b30dba 100644 --- a/frontend/src/components/ui/Button.dom.test.tsx +++ b/frontend/src/components/ui/Button.dom.test.tsx @@ -156,17 +156,18 @@ describe("Button primitive (S5)", () => { } }); - it("carries the coarse-pointer minimum target, except on a link", () => { + it("carries the coarse-pointer minimum target and the 24 floor, a link variant too", () => { // The tablet comfort contract: the minimum lives in the primitive, so no call site can opt - // out of it by accident. A link is text in a sentence and is excluded by design. + // out of it by accident. A link variant was once excluded as "text in a sentence", but every + // one stands alone, and the E28 audit measured them at 16–20px. const { unmount } = render(); expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target"); unmount(); for (const variant of ["link", "link-muted"] as const) { const { unmount: u } = render(); - expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain( - "touch-target", - ); + const cls = screen.getByRole("button", { name: "Read more" }).className; + expect(cls).toContain("touch-target"); + expect(cls).toContain("min-h-6"); u(); } }); @@ -223,6 +224,20 @@ describe("Button · disabled while it holds the focus", () => { active.mockRestore(); }); + it("keeps a caller's aria-disabled focusable and inert to a click (unavailable with a reason)", () => { + const onClick = vi.fn(); + render( + , + ); + const btn = screen.getByRole("button", { name: "Run all" }); + expect(btn.hasAttribute("disabled")).toBe(false); + expect(btn.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(btn); + expect(onClick).not.toHaveBeenCalled(); + }); + it("is natively disabled again once the focus is elsewhere", () => { render( - + onChange(selected.filter((x) => x !== v))} + dismissLabel={t("common.removeNamed", { name: labelFor(v) })} + > + {labelFor(v)} + ))} ({ key={rowKey(row)} {...rowProps?.(row)} {...(keyboardNav && navItemAttrs(i))} - className={`border-b border-border/50 hover:bg-(--hover) transition ${ + className={`border-b border-border/50 state-layer transition ${ rowClassName?.(row) ?? "" }`} > diff --git a/frontend/src/components/ui/FocusSafeButton.tsx b/frontend/src/components/ui/FocusSafeButton.tsx index 2005e364..33ce42c4 100644 --- a/frontend/src/components/ui/FocusSafeButton.tsx +++ b/frontend/src/components/ui/FocusSafeButton.tsx @@ -38,8 +38,9 @@ export const FocusSafeButton = forwardRef< const focused = focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current); const soft = busy || (!!disabled && focused); - // A caller's own aria-disabled is the "unavailable, and here is why" state (the tooltip or label - // carries the reason, so the button stays focusable): it is kept, never overwritten by ours. + // A caller's own aria-disabled is the "unavailable, and here is why" state (DESIGN.md: the tooltip or + // label carries the reason, so the button stays focusable): it is kept, never overwritten by ours, and + // it is inert to a click like ours, so no caller has to guard its own handler. const callerSoft = rest["aria-disabled"] === true || rest["aria-disabled"] === "true"; return ( diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index dfa250c2..2400560d 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -64,11 +64,13 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co against a container's own pixel width rather than sized against text. A px-frozen BOX utility (`w-[Npx]`, `max-w-[Npx]`, …) is an eslint error too, since the 2026-09 sweep: use the spacing scale (`w-37.5` is 150px at the default size and grows with the slider). -- **Minimum target: 2.75rem, on coarse pointers only** (`.touch-target`, index.css). A 32px icon +- **Minimum target: 2.75rem, on coarse pointers only** (the `touch-target` utility, index.css). A 32px icon button is fine with a mouse and not with a thumb, so the box grows where there is no precise pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas on neighbouring icon buttons would overlap, and an overlap fires the _wrong_ button. -- The link variants are excluded from the minimum: they are text inside a sentence. +- The link variants carry it too (and the 24 mouse floor): every one stands on its own. Text inside a + sentence is a plain ``, which WCAG 2.5.8 exempts. On a coarse pointer the target wins over a + caller's own `min-h-6` / `min-w-6` (it is `!important` there), or a control carrying both stays 24. E28 S3b brings every button in the app onto these primitives (the chips and the player's overlay buttons last), after which a raw `