diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 872db160..7232721f 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -72,7 +72,10 @@ style guide.
--warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its
place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
- [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4).
+ [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
+ family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
+ labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
+ and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14).
- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
`--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on
the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
@@ -88,7 +91,9 @@ style guide.
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
- `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent.
+ `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
+ exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
+ (`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`,
`--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`,
`edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab
@@ -129,8 +134,8 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
- choice"), r8 and full width in a form (it lines up with the inputs). Open is the accent edge and the
- chevron turned over.
+ choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and
+ lines up with the inputs. Open is the accent edge and the chevron turned over.
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`,
`CheckRow`, `Slider`, `ColorInput`); the whole labelled row is the 44px target. Keyboard, screen reader and
forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native
@@ -152,8 +157,9 @@ Also in force:
`components/ui/` is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
- `filter` is a selectable option, solid accent when `pressed` (also the "+N" and "More…" triggers, lit
- while their popover is open); `token` is an applied narrowing, the accent tint, the whole chip removing
+ `filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
+ chips too, and while their popover is open they show it as an open trigger does, the accent edge and
+ text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
@@ -353,5 +359,7 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
+| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
+| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `
onSuspend(u)}
- disabled={u.is_demo || self || pendingIds.has(u.id)}
+ aria-disabled={u.is_demo || self}
+ disabled={pendingIds.has(u.id)}
>
{u.is_suspended ? (
@@ -304,6 +310,7 @@ function UsersRoles({ me }: { me: Me }) {
onDelete(u)}
- disabled={u.is_demo || self || pendingIds.has(u.id)}
+ aria-disabled={u.is_demo || self}
+ disabled={pendingIds.has(u.id)}
>
diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx
index f69b8ac7..8412b194 100644
--- a/frontend/src/components/ChannelDiscovery.tsx
+++ b/frontend/src/components/ChannelDiscovery.tsx
@@ -104,6 +104,7 @@ export default function ChannelDiscovery({
cardLabel: false,
render: (c) => (
}
onClick={() => onSubscribe(c)}
- disabled={!canWrite}
+ aria-disabled={!canWrite}
loading={subscribe.isPending}
>
{t("channels.discovery.subscribe")}
diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx
index cb069a99..1162027d 100644
--- a/frontend/src/components/ChannelLink.tsx
+++ b/frontend/src/components/ChannelLink.tsx
@@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import Avatar from "./ui/Avatar";
-import Tooltip from "./ui/Tooltip";
import { channelYouTubeUrl } from "../lib/format";
import { Pressable } from "./ui/Pressable";
+import { IconLink } from "./ui/LinkButton";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
@@ -47,7 +47,7 @@ export default function ChannelLink({
}}
// title = the full name, so a truncated fluid-column name is still readable on hover.
title={name}
- className="text-sm font-medium truncate hover:text-accent min-w-0"
+ className="min-h-6 min-w-0 truncate text-sm font-medium hover:text-accent pointer-coarse:min-h-(--touch-target)"
>
{name}
@@ -56,17 +56,15 @@ export default function ChannelLink({
{name}
)}
-
-
-
-
-
+
+
+
);
}
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index 3adb3713..f885a02a 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -38,6 +38,7 @@ import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
+import { IconLink } from "./ui/LinkButton";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -426,16 +427,16 @@ export default function ChannelPage({
]}
/>
-
-
-
+
+
{ch?.subscribed && (
{
api: {
...actual.api,
adminConfig: () =>
- Promise.resolve({ groups: { g: [text("site_name")] }, secrets_manageable: true }),
+ Promise.resolve({
+ groups: {
+ g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
+ },
+ secrets_manageable: true,
+ }),
},
};
});
@@ -73,4 +78,21 @@ describe("ConfigPanel field row (E22 round 2)", () => {
expect(control.className).toContain("sm:shrink-0");
expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
});
+
+ it("toggles a boolean key from its title and its hint, not only from the switch (F12)", async () => {
+ render(
+
+
+ ,
+ );
+ const sw = await screen.findByRole("switch", { name: "config.fields.signups_open.label" });
+ expect(sw.getAttribute("aria-checked")).toBe("false");
+ fireEvent.click(screen.getByText("config.fields.signups_open.label"));
+ expect(sw.getAttribute("aria-checked")).toBe("true");
+ fireEvent.click(screen.getByText("config.fields.signups_open.hint"));
+ expect(sw.getAttribute("aria-checked")).toBe("false");
+ // The switch itself still toggles exactly once, not once more through its label.
+ fireEvent.click(sw);
+ expect(sw.getAttribute("aria-checked")).toBe("true");
+ });
});
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index d9a900d5..aa4ee953 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useId, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -225,12 +225,13 @@ export default function ConfigPanel() {
variant="glass"
icon={}
loading={testEmail.isPending}
- disabled={dirty}
+ aria-disabled={dirty}
onClick={() => testEmail.mutate()}
>
{t("config.testEmail")}
+ {dirty &&
@@ -369,22 +370,30 @@ function Field({
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
// its own control — storing its value is equivalent to the default, so no reset link.
const showReset = item.is_set && !isBool;
+ const switchId = useId();
+ const Text = isBool ? "label" : "div";
return (
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
// label and hint a sliver of a phone's width.
-
-
{label}
-
+ {/* A switch's whole text column is its label, so the title or the hint toggles it (F12); a
+ label may only hold phrasing content, hence the block spans. */}
+
+ {label}
+
{t(`config.fields.${item.key}.hint`, "")}
-