fix(design): a toggle is pressed, never a bare aria-pressed; static labels are flat Badges with a tone, edged label spans lint-refused (E28)

This commit is contained in:
2026-09-25 17:04:08 +02:00
parent b9850b1710
commit e13d93a3ed
18 changed files with 113 additions and 44 deletions
+2 -2
View File
@@ -308,10 +308,10 @@ export default function AdminLogs() {
/>
<span className="ml-auto flex gap-1.5">
<Button
variant={polling ? "outline-accent" : "outline"}
variant="outline"
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
aria-pressed={polling}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
+5 -22
View File
@@ -28,6 +28,7 @@ import { notify } from "../lib/notifications";
import { formatDateTime, relativeTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Tooltip from "./ui/Tooltip";
@@ -92,24 +93,6 @@ export default function AdminUsers() {
);
}
function Badge({
tone,
children,
}: {
tone: "accent" | "muted" | "warning";
children: React.ReactNode;
}) {
const cls =
tone === "accent"
? "border-accent/40 text-accent"
: tone === "warning"
? "border-warn/40 text-warn"
: "border-border text-muted";
return (
<span className={`shrink-0 text-2xs px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
);
}
function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -245,13 +228,13 @@ function UsersRoles({ me }: { me: Me }) {
<div className="text-2xs text-muted truncate">{u.email}</div>
</Pressable>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
{u.is_demo && <Badge>{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>}
{/* A Google sign-in proves the email, so "unverified" only applies to a
password-only account that hasn't clicked its verification link. */}
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
always={u.is_demo || self}
+4 -3
View File
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
import { notify } from "../lib/notifications";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
@@ -538,10 +539,10 @@ export default function Channels() {
{visibleSuggestions.length > 0 && (
<div className="shrink-0 flex items-center gap-1.5">
<Tooltip hint={t("channels.tags.suggestionsHint")}>
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
<Sparkles className="h-3 w-3" />
<Badge tone="accent">
<Sparkles />
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
</span>
</Badge>
</Tooltip>
{highConfidence.length > 0 && (
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
+2 -2
View File
@@ -61,9 +61,9 @@ export function PaletteLauncher() {
>
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{t("palette.title")}</span>
<span className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
<kbd className="rounded-md border border-border px-1.5 py-0.5 font-mono text-3xs @max-[640px]/appmain:hidden">
{cap}
</span>
</kbd>
</Pressable>
);
}
+3 -6
View File
@@ -15,6 +15,7 @@ import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { Section, SwitchRow } from "./ui/form";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./confirmContext";
@@ -320,9 +321,7 @@ function AccessRow({
</p>
</div>
{granted ? (
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
{t("settings.account.granted")}
</span>
<Badge tone="accent">{t("settings.account.granted")}</Badge>
) : (
<Tooltip hint={t("settings.account.enableHint")}>
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
@@ -422,9 +421,7 @@ function SignInMethods({ me }: { me: Me }) {
)}
</div>
{me.has_google ? (
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
{t("settings.account.googleLink.connected")}
</span>
<Badge tone="accent">{t("settings.account.googleLink.connected")}</Badge>
) : (
<Button
variant="glass"
+12 -2
View File
@@ -4,13 +4,23 @@ import type { ReactNode } from "react";
// A static label (a topic, a keyword, a genre, a status): flat and tonal, with NO edge (owner, E28 S3d).
// Every chip that does something has an edge, so "edged = pressable, flat = just says" holds at a
// glance; a static tag painted like a chip is clicked and does nothing. The tint is the text colour
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's.
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's. A
// `tone` says which kind of label it is (a role, a state to notice): its own colour at 6%, the most
// that keeps its text at 4.5:1 on light (theme.contract.test.ts measures all three in every scheme).
const TONES = {
neutral: "bg-fg/8 text-muted",
accent: "bg-accent/6 text-accent",
warn: "bg-warn/6 text-warn",
} as const;
export function Badge({
tone = "neutral",
onDark = false,
title,
className,
children,
}: {
tone?: keyof typeof TONES;
onDark?: boolean;
title?: string;
/** Layout only (margin); the paint is the primitive's. */
@@ -22,7 +32,7 @@ export function Badge({
title={title}
className={clsx(
"inline-flex h-5 max-w-full shrink-0 items-center gap-1 truncate rounded-full px-2 text-xs whitespace-nowrap [&>svg]:size-3 [&>svg]:shrink-0",
onDark ? "bg-white/10 text-white/90" : "bg-fg/8 text-muted",
onDark ? "bg-white/10 text-white/90" : TONES[tone],
className,
)}
>
@@ -261,6 +261,9 @@ describe("Button accessible-name contract (type level)", () => {
<Button icon={<span />} aria-label="by aria-label" />
<Button icon={<span />} title="by title" />
<Button>plain text needs none</Button>
{/* @ts-expect-error a toggle states itself through `pressed`, which also paints it (B3) */}
<Button aria-pressed>raw toggle</Button>
<Button pressed>toggle</Button>
</>,
);
expect(screen.getByRole("button", { name: "by label" })).toBeTruthy();
+5 -1
View File
@@ -15,7 +15,9 @@ import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
// A toggle says so through `pressed`, never a bare `aria-pressed`: the prop also paints the pressed
// state (B3), and a raw attribute left the Server log's Live toggle looking like any outline button.
interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-pressed"> {
variant?: ButtonVariant;
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
size?: ButtonSize;
@@ -28,6 +30,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
pressed?: boolean;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
* `media` variant. */
round?: boolean;
@@ -96,6 +96,10 @@ describe("IconButton", () => {
<IconButton label="named">
<svg />
</IconButton>
{/* @ts-expect-error a toggle states itself through `pressed`, which also tints it (B3) */}
<IconButton label="raw toggle" aria-pressed>
<svg />
</IconButton>
</>,
);
expect(screen.getByRole("button", { name: "named" })).toBeTruthy();
+5 -1
View File
@@ -11,7 +11,9 @@ import { iconButtonClass, type IconButtonSize, type IconButtonVariant } from "./
// pill field it sits inside — `round`), and nothing else. The glyph
// is sized here, so a row of them never mixes 14 and 16px icons.
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
// `pressed`, never a bare `aria-pressed`: the prop is what tints a toggled glyph (see Button).
export interface IconButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "aria-pressed"> {
/** The accessible name and the tooltip. Required: the button renders no text. */
label: string;
/** The glyph (a lucide element); the primitive sizes it. */
@@ -20,6 +22,8 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
size?: IconButtonSize;
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
pressed?: boolean;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
round?: boolean;
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */