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
+14 -6
View File
@@ -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 `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+45 -1
View File
@@ -102,7 +102,7 @@ const BUTTON_PRIMITIVES = new Set([
"SortControl.tsx",
"SelectTrigger.tsx", // the dropdown trigger (A5)
"FilterSelect.tsx", // its option rows
"Combobox.tsx", // the field's own chips and chevron (A4)
"Combobox.tsx", // the field's chevron (A4)
]);
const buttonPrimitive = {
meta: {
@@ -300,8 +300,39 @@ const sortableA11y = {
};
},
};
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
const glyphLink = {
meta: {
type: "problem",
messages: {
glyph:
"a glyph-only <a> is a link that looks like a button: use ui/LinkButton's IconLink (a name, a 24+ box, the state layer).",
},
},
create(context) {
const hasText = (children) =>
children.some(
(c) =>
(c.type === "JSXText" && /\S/.test(c.value)) ||
(c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression") ||
((c.type === "JSXElement" || c.type === "JSXFragment") && hasText(c.children)),
);
return {
JSXElement(node) {
const open = node.openingElement;
if (open.name.type !== "JSXIdentifier" || open.name.name !== "a" || open.selfClosing)
return;
if (!hasText(node.children)) context.report({ node: open, messageId: "glyph" });
},
};
},
};
const localRules = {
rules: {
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
@@ -324,6 +355,15 @@ const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
const Z_RAW_MESSAGE =
"z-index off the layer scale: use a named level (z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
// A hover that swaps a colour instead of laying the B3 state layer (DESIGN.md Tokens): `bg-card`,
// `bg-border` and `bg-surface` vanish on a light card, an opacity dim reads as "disabled", and
// `bg-(--hover)` restates the `state-layer` utility by hand. `group-hover:` (a reveal on a parent's
// hover, C1) and an edged pill's accent hover (F14) are not matched.
const HOVER_SWAP =
"/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/";
const HOVER_SWAP_MESSAGE =
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
{
@@ -371,6 +411,7 @@ export default tseslint.config(
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
"local/glyph-link": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
@@ -438,6 +479,8 @@ export default tseslint.config(
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
],
},
},
@@ -537,6 +580,7 @@ export default tseslint.config(
"local/button-primitive": "off",
"local/input-primitive": "off",
"local/surface-role": "off",
"local/glyph-link": "off",
},
},
prettier,
+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 &&
+3 -3
View File
@@ -73,13 +73,13 @@ export default function DesignLab() {
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
A ✓
</a>
<a href="#decided-b" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
<a href="#decided-b" className="rounded-md px-1.5 py-0.5 text-success state-layer">
B ✓
</a>
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success state-layer">
C ✓
</a>
</nav>
+2 -5
View File
@@ -18,7 +18,7 @@ import { Checkbox, Slider } from "../components/ui/NativeControls";
import { Input, InnerInput } from "../components/ui/Input";
import { fieldClass } from "../components/ui/fieldStyles";
import { SelectTrigger } from "../components/ui/SelectTrigger";
import { Switch } from "../components/ui/form";
import { SwitchRow } from "../components/ui/form";
// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended), rendered with the
// batch B decisions that refine them: B1's compact ladder and B3's state layers. Only the chosen
@@ -101,10 +101,7 @@ function NativeSet() {
<Checkbox defaultChecked />
Only unwatched
</label>
<div className={clsx(row, "justify-between")}>
Hide Shorts
<Switch checked onChange={() => {}} label="Hide Shorts" />
</div>
<SwitchRow checked onChange={() => {}} label="Hide Shorts" />
<label className="flex flex-col gap-1 px-2 text-sm">
Text size
<Slider min={90} max={120} defaultValue={102} onChange={() => {}} />
-1
View File
@@ -25,7 +25,6 @@
"scrollLeft": "Scroll left",
"scrollRight": "Scroll right",
"search": "Search",
"remove": "Remove",
"noResults": "No matches",
"priority": {
"raise": "Raise priority",
+1 -1
View File
@@ -295,7 +295,7 @@
"testFailed": "Test email failed — check the settings",
"plexTest": "Test connection",
"plexTestHint": "Verify the Plex server URL + token and load the library list. Save any edits first.",
"plexTestDirty": "Save your changes before testing the connection.",
"testDirty": "Save your changes before testing them.",
"plexTestOk": "Connected to {{name}}",
"plexTestFailed": "Plex connection failed — check the URL and token",
"plexConnected": "Connected to {{name}} {{version}}",
-1
View File
@@ -25,7 +25,6 @@
"scrollLeft": "Görgetés balra",
"scrollRight": "Görgetés jobbra",
"search": "Keresés",
"remove": "Eltávolítás",
"noResults": "Nincs találat",
"priority": {
"raise": "Prioritás növelése",
+1 -1
View File
@@ -295,7 +295,7 @@
"testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat",
"plexTest": "Kapcsolat tesztelése",
"plexTestHint": "Ellenőrzi a Plex-szerver URL-t és tokent, és betölti a könyvtárlistát. Előbb mentsd a módosításokat.",
"plexTestDirty": "Mentsd a változásokat a kapcsolat tesztelése előtt.",
"testDirty": "Mentsd a változásokat a tesztelés előtt.",
"plexTestOk": "Kapcsolódva: {{name}}",
"plexTestFailed": "A Plex-kapcsolat sikertelen — ellenőrizd az URL-t és a tokent",
"plexConnected": "Kapcsolódva: {{name}} {{version}}",
+12 -7
View File
@@ -81,6 +81,16 @@
background-color: var(--press);
}
}
/* A3: the coarse-pointer target (the why is at the E10 S1c note below). A caller's own `min-h-6` /
`min-w-6` (the mouse's 24 floor, B1) is a utility too and sorted after this one, so every control
that carried both stayed 24 on a tablet (E28 audit, measured): on a coarse pointer the target wins,
hence `!important`. (It would lower a caller's LARGER minimum; no control pairs it with one.) */
@utility touch-target {
@media (pointer: coarse) {
min-width: var(--touch-target) !important;
min-height: var(--touch-target) !important;
}
}
/* B3: a filled control mixes its own text colour into its fill (14% hover, 24% pressed). */
@utility state-filled {
background-color: var(--accent);
@@ -636,13 +646,8 @@ body {
button — a worse failure than a small target. Real space cannot overlap.
2.75rem, not 44px: the whole app is rem-derived off `--font-scale`, so a target defined in px
would be the one control that ignores the user's text-size setting. */
@media (pointer: coarse) {
.touch-target {
min-width: var(--touch-target);
min-height: var(--touch-target);
}
}
would be the one control that ignores the user's text-size setting.
(The rule itself is the `touch-target` utility near the top of this file.) */
/* ===== E10 S2 · rows that answer a right-click / long-press (useContextMenu) =====
The long-press path competes with two browser behaviours that fire at the same gesture:
@@ -0,0 +1,57 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// Two E28-audit rules in eslint.config.js, pinned so a selector that stops matching cannot pass for a
// clean codebase: a hover that swaps a colour instead of the B3 state layer, and an <a> that holds only
// a glyph (DESIGN.md "A link that looks like a button" is IconLink).
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function messages(source: string, starts: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith(starts)).length;
}
const swaps = (src: string) => messages(src, "hover by a colour swap");
const glyphs = (src: string) => messages(src, "a glyph-only <a>");
describe("a hover by a colour swap", () => {
// Assembled, so this file does not trip the very rule it probes.
const h = (v: string) => `hover:${v}`;
it("is refused for bg-card, bg-border, a hand-written layer and an opacity dim", async () => {
expect(await swaps(`export const a = "px-2 ${h("bg-card")}";\n`)).toBe(1);
expect(await swaps(`export const b = "${h("bg-border/40")}";\n`)).toBe(1);
expect(await swaps("export const c = `x ${1} " + h("opacity-80") + "`;\n")).toBe(1);
expect(await swaps(`export const d = "enabled:${h("bg-(--hover)")}";\n`)).toBe(1);
});
it("leaves the state layer, a parent's reveal and an edged pill's accent hover alone", async () => {
expect(await swaps(`export const e = "state-layer group-${h("opacity-100")}";\n`)).toBe(0);
expect(await swaps(`export const f = "${h("border-accent")} ${h("bg-accent/20")}";\n`)).toBe(0);
expect(await swaps(`export const g = "${h("bg-cardinal")}";\n`)).toBe(0);
});
});
describe("a glyph-only link", () => {
it("is refused, whatever its attributes hold", async () => {
const src =
'import { X } from "lucide-react";\n' +
"export const A = ({ u }: { u: string }) => (\n" +
' <a href={u} aria-label="Open">\n <X />\n </a>\n);\n';
expect(await glyphs(src)).toBe(1);
});
it("passes a text link, a card with content, and an i18n Trans slot", async () => {
const src =
'import { X } from "lucide-react";\n' +
"export const B = ({ u, n }: { u: string; n: string }) => (\n" +
" <>\n" +
" <a href={u}>\n <X /> Open\n </a>\n" +
" <a href={u}>\n <span>{n}</span>\n </a>\n" +
' <a href={u} className="text-accent" />\n' +
" </>\n);\n";
expect(await glyphs(src)).toBe(0);
});
});
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// DESIGN.md A3: on a coarse pointer every control primitive is a 44 target. The E28 audit measured
// that it was not: `.touch-target` sat in @layer components, so a caller's own `min-h-6` (the mouse's
// 24 floor, a utility) beat it and the control stayed 24 on a tablet. jsdom does no cascade, so what is
// pinned is the rule's shape: a utility whose two minimums win over a sibling utility's.
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
describe("the coarse-pointer target", () => {
it("is a utility whose minimums win on a coarse pointer", () => {
const block = /@utility touch-target\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? "";
expect(block).toContain("@media (pointer: coarse)");
expect(block).toMatch(/min-width:\s*var\(--touch-target\)\s*!important/);
expect(block).toMatch(/min-height:\s*var\(--touch-target\)\s*!important/);
});
it("is not also a component class that a utility would override", () => {
expect(css).not.toMatch(/\.touch-target\s*\{/);
});
});