fix(design): claim audit part 1b — whole-row switches, the field-family edge, state-layer hovers, icon links, reason-disabled controls, 24/44 targets (E28)

- 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 <a>.
- 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.
This commit is contained in:
2026-09-25 03:58:17 +02:00
parent 0521149cf1
commit 295c1db5f1
62 changed files with 552 additions and 265 deletions
+11 -3
View File
@@ -254,6 +254,7 @@ function UsersRoles({ me }: { me: Me }) {
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.roles.demoLocked")
@@ -269,13 +270,17 @@ function UsersRoles({ me }: { me: Me }) {
size="xs"
className="shrink-0"
onClick={() => onToggle(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
// focusable so a keyboard user reaches the reason. A pending write is transient.
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
icon={<Shield className="w-3.5 h-3.5" />}
>
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</Button>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.suspend.demoLocked")
@@ -294,7 +299,8 @@ function UsersRoles({ me }: { me: Me }) {
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
onClick={() => 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 ? (
<RotateCcw className="w-4 h-4" />
@@ -304,6 +310,7 @@ function UsersRoles({ me }: { me: Me }) {
</IconButton>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<IconButton
@@ -312,7 +319,8 @@ function UsersRoles({ me }: { me: Me }) {
size="sm"
variant="outline-danger"
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
<Trash2 className="w-4 h-4" />
</IconButton>
+2 -1
View File
@@ -104,6 +104,7 @@ export default function ChannelDiscovery({
cardLabel: false,
render: (c) => (
<Tooltip
always={!canWrite}
hint={
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
}
@@ -113,7 +114,7 @@ export default function ChannelDiscovery({
size="xs"
icon={<UserPlus />}
onClick={() => onSubscribe(c)}
disabled={!canWrite}
aria-disabled={!canWrite}
loading={subscribe.isPending}
>
{t("channels.discovery.subscribe")}
+11 -13
View File
@@ -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}
</Pressable>
@@ -56,17 +56,15 @@ export default function ChannelLink({
{name}
</span>
)}
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
<IconLink
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
>
<ExternalLink />
</IconLink>
</div>
);
}
+7 -6
View File
@@ -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({
]}
/>
<div className="ml-2 flex items-center gap-2">
<a
<IconLink
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
label={t("channels.row.openOnYouTube")}
size="md"
variant="outline"
>
<ExternalLink className="w-4 h-4" />
</a>
<ExternalLink />
</IconLink>
{ch?.subscribed && (
<IconButton
label={t("channel.showInFeed")}
@@ -9,7 +9,7 @@
// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
@@ -42,7 +42,12 @@ vi.mock("../lib/api", async (importOriginal) => {
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(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
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");
});
});
+21 -12
View File
@@ -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={<Send />}
loading={testEmail.isPending}
disabled={dirty}
aria-disabled={dirty}
onClick={() => testEmail.mutate()}
>
{t("config.testEmail")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
</div>
)}
@@ -241,13 +242,13 @@ export default function ConfigPanel() {
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
disabled={dirty}
aria-disabled={dirty}
onClick={() => testIssues.mutate()}
>
{t("config.issuesTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
</div>
)}
@@ -258,13 +259,13 @@ export default function ConfigPanel() {
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
disabled={dirty}
aria-disabled={dirty}
onClick={() => testPlex.mutate()}
>
{t("config.plexTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
@@ -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.
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{/* 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. */}
<Text
{...(isBool && { htmlFor: switchId })}
className={`min-w-0 flex-1 ${isBool ? "cursor-pointer select-none" : ""}`}
>
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")}
</p>
<p className="text-2xs text-muted mt-1">{status}</p>
</div>
</span>
<span className="block text-2xs text-muted mt-1">{status}</span>
</Text>
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
{isBool ? (
<Switch
id={switchId}
label={label}
checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")}
+1 -1
View File
@@ -763,7 +763,7 @@ function ReviewList({
return (
<li key={row.id} className="border-b border-border/50 last:border-b-0">
<label
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 hover:bg-card sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 state-layer sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
off ? "opacity-50 line-through" : ""
}`}
>
+2 -2
View File
@@ -52,8 +52,8 @@ export default function LanguageSwitcher({
layer
className={
rail
? "relative p-2 rounded-lg text-muted hover:text-fg transition"
: "flex h-7 items-center gap-1.5 px-2.5 rounded-lg text-sm text-muted hover:text-fg transition"
? "relative p-2 rounded-lg text-muted hover:text-fg transition touch-target"
: "flex h-7 items-center gap-1.5 px-2.5 rounded-lg text-sm text-muted hover:text-fg transition touch-target"
}
>
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
+1 -1
View File
@@ -46,7 +46,7 @@ export default function LogLine({
aria-expanded={open}
onClick={onToggle}
title={title}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 ${
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 touch-target pointer-coarse:content-center ${
open ? "bg-accent/10" : ""
}`}
>
+1 -1
View File
@@ -158,7 +158,7 @@ function ConversationList({
<div
key={c.partner.id}
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
className="group flex items-center gap-1 rounded-xl state-layer transition"
>
<Pressable
data-nav-open=""
+3 -3
View File
@@ -285,7 +285,7 @@ export default function NavSidebar({
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target"
>
<AvatarImg
src={a.avatar_url}
@@ -311,14 +311,14 @@ export default function NavSidebar({
pinOnNextPage(null);
window.location.href = "/auth/login";
}}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</Pressable>
<Pressable
onClick={logout}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
+1 -1
View File
@@ -37,7 +37,7 @@ export function PaletteKeycap() {
onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`}
title={`${t("palette.open")} (${cap})`}
className="shrink-0 rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
>
{cap}
</Pressable>
@@ -51,7 +51,7 @@ function ViewRow({
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
selected ? "bg-accent/15" : "hover:bg-card"
selected ? "bg-accent/15" : "state-layer"
}`}
>
<DragHandle label={t("views.reorder")} {...attributes} {...listeners} />
+10 -10
View File
@@ -65,6 +65,7 @@ import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -745,15 +746,15 @@ export default function PlayerModal({
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
<LinkButton
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium state-filled touch-target"
variant="primary"
icon={<ExternalLink />}
>
<ExternalLink className="size-4" />
{t("player.openOnYoutube")}
</a>
</LinkButton>
</div>
</div>
)}
@@ -875,16 +876,15 @@ export default function PlayerModal({
}}
className="font-medium hover:text-accent truncate text-left"
/>
<a
<IconLink
href={active.channel_url}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
<ExternalLink />
</IconLink>
</div>
)}
{!navigated ? (
+7 -7
View File
@@ -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() {
</IconButton>
)}
{detail.yt_playlist_id && (
<a
<IconLink
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
label={t("playlists.openOnYoutube")}
className="shrink-0"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
<ExternalLink />
</IconLink>
)}
</>
)}
+15 -12
View File
@@ -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 ? (
<Pressable
onClick={() => 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) })}
>
<Star className="h-3.5 w-3.5 fill-current" />
@@ -113,18 +114,20 @@ function RatingsRow({
onFilterRating={onFilterRating}
/>
<span className="text-2xs font-normal opacity-70">IMDb</span>
{imdbUrl && (
<a
href={imdbUrl}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="h-3 w-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
)}
{/* Beside the score, not inside it: a link needs its own ≥ 24 box, and the score is a label. */}
{has(imdbRating) && imdbUrl && (
<IconLink
href={imdbUrl}
target="_blank"
rel="noopener noreferrer"
label={t("plex.info.openImdb")}
className="-ml-1.5"
>
<ExternalLink />
</IconLink>
)}
{has(tmdbRating) && (
<span className={chipCls}>
<Score score={tmdbRating} filterable={false} />
@@ -289,7 +292,7 @@ function CrewPanel({
<Pressable
onClick={() => 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}
</Pressable>
+4 -2
View File
@@ -119,7 +119,7 @@ export default function PlexSearchBox() {
leading={
<ActionMenu
ariaLabel={t("plex.searchField.aria")}
className="min-w-0 shrink inline-flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
className="min-w-0 shrink inline-flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium text-muted transition state-layer hover:text-fg touch-target"
trigger={
<>
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
@@ -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" : ""
}`}
>
<img
@@ -240,3 +240,21 @@ describe("Scheduler · the maintenance batch editor", () => {
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();
});
});
+21 -8
View File
@@ -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({
<Pressable
ref={triggerRef}
onClick={() => 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 })}
<Pencil className="w-3 h-3 reveal transition" />
@@ -260,10 +264,14 @@ function JobRow({
"—"
)}
</div>
<Tooltip hint={t("scheduler.runNow")}>
<Tooltip
hint={paused ? t("scheduler.runAllPausedHint") : t("scheduler.runNow")}
always={paused}
>
<IconButton
onClick={onRun}
disabled={job.running || runDisabled}
aria-disabled={paused}
disabled={job.running || runBusy}
label={t("scheduler.runNow")}
noTitle
>
@@ -515,13 +523,17 @@ export default function Scheduler() {
</div>
</div>
<div className="flex-1" />
<Tooltip hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}>
<Tooltip
hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}
always={data.paused}
>
<Button
variant="glass"
size="sm"
icon={<Play />}
onClick={() => runAllMut.mutate()}
disabled={runAllMut.isPending || data.paused}
aria-disabled={data.paused}
disabled={runAllMut.isPending}
>
{t("scheduler.runAll")}
</Button>
@@ -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}
/>
))}
</div>
+42 -69
View File
@@ -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 }) {
</Section>
<Section title={t("settings.appearance.display")}>
<SettingRow label={t("settings.appearance.darkMode")}>
<Switch
label={t("settings.appearance.darkMode")}
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</SettingRow>
<SettingRow
<SwitchRow
label={t("settings.appearance.darkMode")}
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
<SwitchRow
label={t("settings.appearance.backgroundImage")}
hint={t("settings.appearance.backgroundImageHint")}
>
<Switch
label={t("settings.appearance.backgroundImage")}
checked={theme.bgImage}
onChange={(v) => setTheme({ ...theme, bgImage: v })}
/>
</SettingRow>
checked={theme.bgImage}
onChange={(v) => setTheme({ ...theme, bgImage: v })}
/>
{theme.bgImage && (
<div className="pl-1 pb-1">
<div className="flex items-center justify-between text-xs text-muted mb-1">
@@ -201,45 +195,33 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
/>
</div>
)}
<SettingRow
<SwitchRow
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
>
<Switch
label={t("settings.appearance.performanceMode")}
checked={perf}
onChange={setPerf}
/>
</SettingRow>
<SettingRow
checked={perf}
onChange={setPerf}
/>
<SwitchRow
label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")}
>
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
</SettingRow>
<SettingRow
checked={hints}
onChange={setHints}
/>
<SwitchRow
label={t("settings.appearance.railLabels")}
hint={t("settings.appearance.railLabelsHint")}
>
<Switch
label={t("settings.appearance.railLabels")}
checked={railLabels}
onChange={setRailLabels}
/>
</SettingRow>
checked={railLabels}
onChange={setRailLabels}
/>
</Section>
<Section title={t("settings.appearance.startup")}>
<SettingRow
<SwitchRow
label={t("settings.appearance.startOnHome")}
hint={t("settings.appearance.startOnHomeHint")}
>
<Switch
label={t("settings.appearance.startOnHome")}
checked={startOnHome !== START_ON_HOME.off}
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
/>
</SettingRow>
checked={startOnHome !== START_ON_HOME.off}
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
/>
</Section>
<Section title={t("settings.appearance.textSize")}>
@@ -267,26 +249,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
return (
<Section title={t("settings.notifications.title")}>
<SettingRow
<SwitchRow
label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")}
>
<Switch
label={t("settings.notifications.sound")}
checked={cfg.sound}
onChange={(v) => update({ sound: v })}
/>
</SettingRow>
<SettingRow
checked={cfg.sound}
onChange={(v) => update({ sound: v })}
/>
<SwitchRow
label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")}
>
<Switch
label={t("settings.notifications.autoDismiss")}
checked={autoOn}
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
/>
</SettingRow>
checked={autoOn}
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
/>
{autoOn && (
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
@@ -597,14 +571,13 @@ function PlexWatchSync() {
return (
<Section title={t("settings.plexSync.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
<Switch
label={t("settings.plexSync.toggle")}
checked={shown}
disabled={busy}
onChange={(v) => !busy && toggle.mutate(v)}
/>
</SettingRow>
<SwitchRow
label={t("settings.plexSync.toggle")}
hint={t("settings.plexSync.toggleHint")}
checked={shown}
disabled={busy}
onChange={(v) => !busy && toggle.mutate(v)}
/>
{busy ? (
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
+4 -2
View File
@@ -290,11 +290,13 @@ function SystemStats() {
{data.daily.map((d) => (
<div
key={d.day}
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
// The day under the pointer is lit by the B3 layer over its whole column, not by
// dimming the bar: an opacity dim reads as "disabled".
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
title={t("stats.dailyTooltip", { day: d.day, units: formatNumber(d.total) })}
>
<div
className="w-full bg-accent hover:opacity-80 rounded-t transition"
className="w-full bg-accent rounded-t"
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
/>
</div>
+1 -1
View File
@@ -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 ? (
<Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin text-accent" />
+4 -2
View File
@@ -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 = (
+1 -1
View File
@@ -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"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
+5 -3
View File
@@ -66,10 +66,12 @@ export function ActionsCell({
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
{/* 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. */}
<Tooltip hint={backfillHint} always={!enabled}>
<IconButton
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
onClick={isAdmin ? onReset : onDeep}
aria-disabled={!enabled}
variant={requested ? "ghost-accent" : "ghost"}
label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
noTitle
+2 -2
View File
@@ -179,7 +179,7 @@ function SourceRef({ url }: { url: string }) {
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${url}`}
className="truncate hover:text-accent hover:underline"
className="truncate leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
>
{displayUrl(url)}
</a>
@@ -265,7 +265,7 @@ export function DownloadRow({
{job.status === "error" ? (
<Pressable
onClick={() => 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"))}
+2 -2
View File
@@ -61,7 +61,7 @@ export function playlistColumns({
<Pressable
onClick={() => 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 && (
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
@@ -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"
>
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
</Pressable>
+2 -1
View File
@@ -61,7 +61,8 @@ export function Filterable({
<Pressable
onClick={onClick}
title={hint}
className={`${className ?? ""} cursor-pointer hover:text-accent transition`}
// min-h-6 + touch-target: a value in the meta line is still a 24 / 44 target (B1, A3).
className={`${className ?? ""} min-h-6 min-w-6 cursor-pointer hover:text-accent transition touch-target`}
>
{children}
</Pressable>
+1 -1
View File
@@ -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 */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
@@ -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
+20 -5
View File
@@ -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(<Button>Tap</Button>);
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
unmount();
for (const variant of ["link", "link-muted"] as const) {
const { unmount: u } = render(<Button variant={variant}>Read more</Button>);
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(
<Button onClick={onClick} aria-disabled>
Run all
</Button>,
);
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(
<Button onClick={() => {}} disabled>
+6 -6
View File
@@ -52,14 +52,12 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
aria-pressed={pressed}
aria-label={removeLabel}
title={removeLabel}
className={clsx(chipClass(variant, size), removeLabel && "group pr-1.5", className)}
className={clsx(chipClass(variant, size), removeLabel && "pr-1.5", className)}
{...rest}
>
{icon}
{children}
{removeLabel && (
<X className="size-3 opacity-60 group-hover:opacity-100" aria-hidden="true" />
)}
{removeLabel && <X className="size-3" aria-hidden="true" />}
</FocusSafeButton>
);
});
@@ -104,7 +102,9 @@ export function DismissibleChip({
onClick={onClick}
aria-label={bodyLabel}
title={bodyTitle}
className="inline-flex min-w-0 items-center gap-1 self-stretch [&>svg]:size-3 [&>svg]:shrink-0"
// -my-px + stretch: the body reaches over the chip's 1px edges to the chip's full 24 (44 on
// touch), as the × does; stretched inside the edges it was 22, under the B1 floor.
className="-my-px inline-flex min-w-0 items-center gap-1 self-stretch [&>svg]:size-3 [&>svg]:shrink-0"
>
{body}
</FocusSafeButton>
@@ -116,7 +116,7 @@ export function DismissibleChip({
onClick={onDismiss}
aria-label={dismissLabel}
title={dismissLabel}
className="-my-px grid size-6 shrink-0 place-items-center rounded-full opacity-60 transition hover:opacity-100 hover:bg-accent/20 touch-target"
className="-my-px grid size-6 shrink-0 place-items-center rounded-full transition state-layer touch-target"
>
<X className="size-3" aria-hidden="true" />
</FocusSafeButton>
@@ -47,7 +47,7 @@ describe("Combobox", () => {
const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
expect(screen.getByText("Drama")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
fireEvent.click(screen.getByRole("button", { name: "common.removeNamed" }));
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
});
+17 -18
View File
@@ -1,11 +1,12 @@
import clsx from "clsx";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { Check, ChevronDown } from "lucide-react";
import { PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { InnerInput } from "./Input";
import { DismissibleChip } from "./Chip";
import { fieldClass } from "./fieldStyles";
import { SURFACE } from "./surfaces";
@@ -173,7 +174,13 @@ export default function Combobox({
ref={pop.refs.setPositionReference}
onMouseDown={(e) => {
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the list —
// focus drives "open", so a caret click never toggles it shut.
// focus drives "open", so a caret click never toggles it shut. A press on a chip's × is its
// own control: it neither opens the list nor takes the focus (the × unmounts on the click,
// and a focused one would drop the focus to <body>).
if (e.target instanceof Element && e.target.closest("button")) {
e.preventDefault();
return;
}
if (e.target !== inputRef.current) {
e.preventDefault();
inputRef.current?.focus();
@@ -186,22 +193,14 @@ export default function Combobox({
)}
>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
<span className="max-w-36 truncate">{labelFor(v)}</span>
<button
type="button"
onMouseDown={(e) => {
// Keep the box's focus-the-field handler from firing when removing a chip.
e.stopPropagation();
e.preventDefault();
}}
onClick={() => onChange(selected.filter((x) => x !== v))}
aria-label={t("common.remove", { defaultValue: "Remove" })}
className="hover:opacity-70"
>
<X className="h-3 w-3" />
</button>
</span>
<DismissibleChip
key={v}
className="max-w-40"
onDismiss={() => onChange(selected.filter((x) => x !== v))}
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
>
{labelFor(v)}
</DismissibleChip>
))}
<InnerInput
className="min-w-8 px-1 py-0.5"
+1 -1
View File
@@ -297,7 +297,7 @@ export default function DataTable<T>({
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) ?? ""
}`}
>
@@ -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 (
<button
@@ -50,7 +51,7 @@ export const FocusSafeButton = forwardRef<
aria-disabled={soft || callerSoft || undefined}
aria-busy={busy || undefined}
onClick={(e) => {
if (soft) {
if (soft || callerSoft) {
e.preventDefault();
return;
}
@@ -88,4 +88,16 @@ describe("SelectTrigger", () => {
expect(form.className).toContain("w-full");
expect(form.className).toContain("border-accent");
});
it("wears the field's ≥ 3:1 edge in a form, among the inputs it lines up with (F14)", () => {
render(
<SelectTrigger context="form" open={false}>
MP4
</SelectTrigger>,
);
const form = screen.getByRole("button", { name: "MP4" });
expect(form.className).toContain("edge-strong");
expect(form.className).not.toContain("border-border");
expect(form.className).not.toContain("glass-card");
});
});
+3 -2
View File
@@ -60,7 +60,8 @@ export function Textarea({
);
}
/** The bare text box inside a `fieldClass` frame: no edge, no box, it fills the frame's free width. */
/** The bare text box inside a `fieldClass` frame: no edge, no box, it fills the frame's free width AND
* height (`self-stretch`), so the whole frame is the target, not a 20px line of text in it (B1, A3). */
export function InnerInput({
className,
ref,
@@ -70,7 +71,7 @@ export function InnerInput({
<input
ref={ref}
className={clsx(
"min-w-0 flex-1 bg-transparent text-fg outline-hidden placeholder:text-muted",
"min-w-0 flex-1 self-stretch bg-transparent text-fg outline-hidden placeholder:text-muted",
className,
)}
{...rest}
@@ -48,7 +48,7 @@ export function CheckRow({
return (
<label
className={clsx(
"inline-flex cursor-pointer items-center gap-2 select-none pointer-coarse:min-h-(--touch-target)",
"inline-flex min-h-6 cursor-pointer items-center gap-2 select-none pointer-coarse:min-h-(--touch-target)",
disabled && "cursor-not-allowed opacity-50",
className,
)}
@@ -70,7 +70,7 @@ export function Slider({
type="range"
onChange={(e) => onChange(Number(e.target.value))}
className={clsx(
"w-full cursor-pointer accent-accent pointer-coarse:min-h-(--touch-target)",
"min-h-6 w-full cursor-pointer accent-accent pointer-coarse:min-h-(--touch-target)",
className,
)}
{...rest}
+1 -1
View File
@@ -64,7 +64,7 @@ export function NavRailDivider() {
/** The shape of one rail row, for a row that is not a page (the account button). */
export const RAIL_ROW =
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition";
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition touch-target";
export function NavRailItem({
icon: Icon,
+2 -1
View File
@@ -49,8 +49,9 @@ export default function PanelGroup({
// A non-collapsible group's title acts as a trigger when one is provided.
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
// min-h-6 + touch-target: when the title is a control (a collapse, a trigger) it is a 24 / 44 target.
const titleCls =
"flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide";
"flex-1 min-w-0 min-h-6 flex items-center gap-1 select-none text-sm uppercase tracking-wide touch-target";
return (
<div
@@ -6,9 +6,15 @@ import { useReturnFocus } from "./useReturnFocus";
type Size = "sm" | "md";
// Every part is a target: 24 / 28 on a mouse (B1), 44 on a coarse pointer (A3).
const COARSE_VALUE = "pointer-coarse:h-(--touch-target) pointer-coarse:w-(--touch-target)";
const SIZED = {
sm: { btn: "w-6 h-6", icon: "w-3.5 h-3.5", value: "w-8 h-6 text-sm" },
md: { btn: "w-7 h-7", icon: "w-4 h-4", value: "w-10 h-7 text-sm" },
sm: {
btn: "w-6 h-6 touch-target",
icon: "w-3.5 h-3.5",
value: `w-8 h-6 text-sm ${COARSE_VALUE}`,
},
md: { btn: "w-7 h-7 touch-target", icon: "w-4 h-4", value: `w-10 h-7 text-sm ${COARSE_VALUE}` },
} as const;
function PriorityStepButton({
@@ -160,7 +166,7 @@ export function PriorityStepper({
onClick={beginEdit}
aria-label={t("common.priority.edit")}
title={t("common.priority.edit")}
className={`w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
className={`h-full w-full text-center font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
>
{priority}
</button>
+4 -2
View File
@@ -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 `<a>`, 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 `<button>` outside this directory is a lint error.
@@ -44,7 +44,7 @@ export default function SegmentedToggle<T extends string>({
const hintId = useId();
const track = (
<div
className={`inline-flex items-center rounded-full border border-border bg-card p-0.5 ${
className={`inline-flex items-center rounded-full border border-border bg-card p-px ${
size === "md" ? "text-xs" : "text-3xs"
}${fullWidth ? " w-full" : ""}`}
role="group"
@@ -63,8 +63,10 @@ export default function SegmentedToggle<T extends string>({
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={value === o.value}
className={`pill inline-flex items-center justify-center gap-1 rounded-full ${
size === "md" ? "px-2.5 py-1" : "px-2 py-0.5"
// A segment is the target: 24 (sm) / 28 (md), so the track lands on the ladder's 28 / 32
// with its 1px padding and edge (B1); 44 on a coarse pointer (A3).
className={`pill touch-target inline-flex items-center justify-center gap-1 rounded-full ${
size === "md" ? "h-7 px-2.5" : "h-6 px-2"
}${fullWidth ? " flex-1" : ""}`}
>
{o.icon}
+9 -4
View File
@@ -7,7 +7,10 @@ import { FocusSafeButton } from "./FocusSafeButton";
// The dropdown trigger (docs/DESIGN.md A5, E28 S3c): the one button that opens a list of choices —
// `SortControl`, `FilterSelect`, the view switcher, the date filter. By context: a pill at the toolbar's
// 28 (in a toolbar the pill means "a filter or a choice"), or r8 at the form's 32, full width, lining up
// with the inputs around it. Open is the accent edge and the chevron turned over. The popover wiring
// with the inputs around it. The form trigger is one of the field family, so it wears the field's frame
// (see-through, the ≥ 3:1 `edge-strong`) and does not stand out among the inputs; the toolbar pill keeps
// the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is
// the accent edge and the chevron turned over. The popover wiring
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
export const SelectTrigger = forwardRef<
HTMLButtonElement,
@@ -30,9 +33,11 @@ export const SelectTrigger = forwardRef<
ref={ref}
type="button"
className={clsx(
"glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 border text-left text-sm transition touch-target",
context === "toolbar" ? "h-7 rounded-full px-3" : "h-8 w-full rounded-lg px-2.5",
open ? "border-accent" : "border-border",
"glass-hover inline-flex shrink-0 items-center gap-1.5 border text-left text-sm transition touch-target",
context === "toolbar"
? "glass-card h-7 rounded-full px-3"
: "h-8 w-full rounded-lg bg-transparent px-2.5",
open ? "border-accent" : context === "toolbar" ? "border-border" : "edge-strong",
className,
)}
{...rest}
+1 -1
View File
@@ -58,7 +58,7 @@ export default function SortControl({
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
const segCls = (on: boolean) =>
`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition ${
`flex min-h-6 flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md border px-2 py-1 text-2xs transition touch-target ${
on
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
+2 -2
View File
@@ -48,11 +48,11 @@ export default function Tabs({
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
aria-pressed={on}
className={`pill items-center rounded-lg px-3 text-sm ${
className={`pill touch-target items-center rounded-lg px-3 text-sm ${
vertical
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
+7 -5
View File
@@ -68,7 +68,8 @@ const SIZES: Record<ButtonSize, string> = {
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
none: "gap-1.5",
// No box of its own, but still a 24 target on a mouse (B1).
none: "gap-1.5 min-h-6",
};
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
@@ -151,10 +152,11 @@ export function buttonClass({
pressed !== undefined && PRESSED,
SIZES[size],
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link variant is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
!isLinkVariant(variant) && "touch-target",
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites — on every variant.
// The link variants were once excluded as "a run of text inside a sentence", but none is: every
// one stands on its own ("See all", "Forgot your passphrase?", a notification's action), and the
// E28 audit measured them at 16–20px on a tablet. Text inside a sentence is a plain <a>.
"touch-target",
fullWidth && "w-full",
);
}
+23 -1
View File
@@ -5,7 +5,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Switch } from "./form";
import { Switch, SwitchRow } from "./form";
afterEach(cleanup);
@@ -27,3 +27,25 @@ describe("Switch", () => {
expect(screen.getByRole("switch")).toBeTruthy();
});
});
describe("SwitchRow", () => {
// Owner decision F12: a settings row toggles from its whole surface, as on iOS and in Material.
it("toggles from its text as well as from the switch, once per click", () => {
const onChange = vi.fn();
render(
<SwitchRow label="Dark mode" hint="Easier on the eyes" checked={false} onChange={onChange} />,
);
fireEvent.click(screen.getByText("Dark mode"));
expect(onChange).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("switch", { name: "Dark mode" }));
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith(true);
});
it("does nothing from its text while disabled", () => {
const onChange = vi.fn();
render(<SwitchRow label="Sync" checked onChange={onChange} disabled />);
fireEvent.click(screen.getByText("Sync"));
expect(onChange).not.toHaveBeenCalled();
});
});
+36 -4
View File
@@ -6,26 +6,30 @@ import { SURFACE } from "./surfaces";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** Pill on/off switch — the bare control; compose it with your own label/row. `label` is the
* accessible name (the visible row text), required: the control has no inner text to be named by.
* The button is its own box around the 36×20 track: 24 tall on a mouse (the B1 floor) and the 44
* touch target on a coarse pointer (A3), a real box rather than an invisible hit area. */
/** Pill on/off switch — the bare control. In a settings list use `SwitchRow`, whose whole row toggles;
* a row laid out by hand points a `<label htmlFor={id}>` at it. `label` is the accessible name (the
* visible row text), required: the control has no inner text to be named by. The button is its own box
* around the 36×20 track: 24 tall on a mouse (the B1 floor) and the 44 touch target on a coarse pointer
* (A3), a real box rather than an invisible hit area. */
export function Switch({
checked,
onChange,
label,
disabled,
id,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
disabled?: boolean;
id?: string;
}) {
// FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a
// natively disabled switch that holds the focus throws it to <body>.
return (
<FocusSafeButton
type="button"
id={id}
role="switch"
aria-checked={checked}
aria-label={label}
@@ -85,6 +89,34 @@ function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
);
}
/** A settings row whose control is a switch: the whole row is a `<label>`, so its text toggles the switch
* as the switch does (the iOS / Material settings row, owner decision F12). A `<button>` is labelable, so
* a click on the text is forwarded to it; a click on the switch itself is not forwarded twice. */
export function SwitchRow({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<label
className={`flex items-center justify-between gap-3 py-1.5 text-sm select-none ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
}`}
>
<HintLabel hint={hint}>{label}</HintLabel>
<Switch label={label} checked={checked} onChange={onChange} disabled={disabled} />
</label>
);
}
/** A settings row: a (hint-able) label on the left, a control on the right. */
export function SettingRow({
label,
+3 -3
View File
@@ -115,7 +115,7 @@ function FilterFunnel<T>({
onSelect={() => onApply(col.key, "")}
label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
!filterOn ? "bg-accent text-accent-fg" : "text-muted state-layer"
}`}
>
{t("datatable.all")}
@@ -129,7 +129,7 @@ function FilterFunnel<T>({
onSelect={() => onApply(col.key, o.value)}
label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
value === o.value ? "bg-accent text-accent-fg" : "text-muted state-layer"
}`}
>
{o.label}
@@ -237,7 +237,7 @@ export function ColumnHead<T>({
{col.sortable ? (
<Pressable
onClick={() => onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
className="inline-flex min-h-6 items-center gap-1 hover:text-fg cursor-pointer touch-target"
>
{col.header}
{isSorted &&