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:
+14
-6
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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"} />
|
||||
|
||||
@@ -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" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {}} />
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"scrollLeft": "Scroll left",
|
||||
"scrollRight": "Scroll right",
|
||||
"search": "Search",
|
||||
"remove": "Remove",
|
||||
"noResults": "No matches",
|
||||
"priority": {
|
||||
"raise": "Raise priority",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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*\{/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user