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:
+5
-1
@@ -190,7 +190,10 @@ Also in force:
|
|||||||
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
|
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
|
||||||
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
|
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
|
||||||
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
|
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
|
||||||
A genre that filters the library is a chip; the same genre where it can't is a badge.
|
A genre that filters the library is a chip; the same genre where it can't is a badge. A badge's `tone`
|
||||||
|
(neutral / accent / warn) is its own colour at 6%, measured at 4.5:1 in every scheme; a `<span>` drawn with a
|
||||||
|
round edge is a lint error (F29). A toggle button states itself through `pressed` (the solid accent), never a
|
||||||
|
bare `aria-pressed`, which the type refuses (F28).
|
||||||
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
|
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
|
||||||
`IconLink`: still an `<a>`, wearing the button's own face.
|
`IconLink`: still an `<a>`, wearing the button's own face.
|
||||||
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
||||||
@@ -373,5 +376,6 @@ Wherever there is text, the user can select it and copy it, and no click action
|
|||||||
| 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-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-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||||||
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||||||
|
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||||||
| 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 | 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -372,6 +372,14 @@ const PALETTE =
|
|||||||
const PALETTE_MESSAGE =
|
const PALETTE_MESSAGE =
|
||||||
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
||||||
|
|
||||||
|
// A static label with an edge (DESIGN.md "Static vs pressable", audit F29): a <span> drawn round with a
|
||||||
|
// border reads as a chip, is clicked and does nothing. A label that does nothing is ui/Badge (flat).
|
||||||
|
// A small `rounded` box (a check mark drawn inside a menu row) is a glyph, not a label: only the label
|
||||||
|
// radii match.
|
||||||
|
const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\s)border(\\s|$))/";
|
||||||
|
const EDGED_LABEL_MESSAGE =
|
||||||
|
"a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing.";
|
||||||
|
|
||||||
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`).
|
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`).
|
||||||
const SPLIT_CLASS = "/^[a-z][\\w-]*:[\\w\\[!-]/";
|
const SPLIT_CLASS = "/^[a-z][\\w-]*:[\\w\\[!-]/";
|
||||||
const SPLIT_CLASS_MESSAGE =
|
const SPLIT_CLASS_MESSAGE =
|
||||||
@@ -503,6 +511,14 @@ export default tseslint.config(
|
|||||||
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||||||
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
|
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||||||
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
|
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||||||
|
{
|
||||||
|
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] Literal[value=${EDGED_LABEL}]`,
|
||||||
|
message: EDGED_LABEL_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] TemplateElement[value.raw=${EDGED_LABEL}]`,
|
||||||
|
message: EDGED_LABEL_MESSAGE,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -308,10 +308,10 @@ export default function AdminLogs() {
|
|||||||
/>
|
/>
|
||||||
<span className="ml-auto flex gap-1.5">
|
<span className="ml-auto flex gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
variant={polling ? "outline-accent" : "outline"}
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
icon={<Radio className="h-3.5 w-3.5" />}
|
icon={<Radio className="h-3.5 w-3.5" />}
|
||||||
aria-pressed={polling}
|
pressed={polling}
|
||||||
onClick={toggleLive}
|
onClick={toggleLive}
|
||||||
>
|
>
|
||||||
{t("adminLogs.live")}
|
{t("adminLogs.live")}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { notify } from "../lib/notifications";
|
|||||||
import { formatDateTime, relativeTime } from "../lib/format";
|
import { formatDateTime, relativeTime } from "../lib/format";
|
||||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||||
|
import { Badge } from "./ui/Badge";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
import Tooltip from "./ui/Tooltip";
|
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 }) {
|
function UsersRoles({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -245,13 +228,13 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||||
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
{u.is_demo && <Badge>{t("users.roles.demo")}</Badge>}
|
||||||
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
|
||||||
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</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
|
{/* A Google sign-in proves the email, so "unverified" only applies to a
|
||||||
password-only account that hasn't clicked its verification link. */}
|
password-only account that hasn't clicked its verification link. */}
|
||||||
{u.is_active && !u.email_verified && !u.has_google && (
|
{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
|
<Tooltip
|
||||||
always={u.is_demo || self}
|
always={u.is_demo || self}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { Badge } from "./ui/Badge";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import DataTable from "./ui/DataTable";
|
import DataTable from "./ui/DataTable";
|
||||||
@@ -538,10 +539,10 @@ export default function Channels() {
|
|||||||
{visibleSuggestions.length > 0 && (
|
{visibleSuggestions.length > 0 && (
|
||||||
<div className="shrink-0 flex items-center gap-1.5">
|
<div className="shrink-0 flex items-center gap-1.5">
|
||||||
<Tooltip hint={t("channels.tags.suggestionsHint")}>
|
<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">
|
<Badge tone="accent">
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles />
|
||||||
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
||||||
</span>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{highConfidence.length > 0 && (
|
{highConfidence.length > 0 && (
|
||||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export function PaletteLauncher() {
|
|||||||
>
|
>
|
||||||
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
|
<Command className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<span>{t("palette.title")}</span>
|
<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}
|
{cap}
|
||||||
</span>
|
</kbd>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Avatar from "./ui/Avatar";
|
|||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import { Section, SwitchRow } from "./ui/form";
|
import { Section, SwitchRow } from "./ui/form";
|
||||||
|
import { Badge } from "./ui/Badge";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { type SaveState } from "./ui/DraftSaveBar";
|
import { type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
@@ -320,9 +321,7 @@ function AccessRow({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{granted ? (
|
{granted ? (
|
||||||
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
<Badge tone="accent">{t("settings.account.granted")}</Badge>
|
||||||
{t("settings.account.granted")}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<Tooltip hint={t("settings.account.enableHint")}>
|
<Tooltip hint={t("settings.account.enableHint")}>
|
||||||
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
||||||
@@ -422,9 +421,7 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{me.has_google ? (
|
{me.has_google ? (
|
||||||
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
<Badge tone="accent">{t("settings.account.googleLink.connected")}</Badge>
|
||||||
{t("settings.account.googleLink.connected")}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="glass"
|
variant="glass"
|
||||||
|
|||||||
@@ -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).
|
// 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
|
// 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
|
// 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({
|
export function Badge({
|
||||||
|
tone = "neutral",
|
||||||
onDark = false,
|
onDark = false,
|
||||||
title,
|
title,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
tone?: keyof typeof TONES;
|
||||||
onDark?: boolean;
|
onDark?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Layout only (margin); the paint is the primitive's. */
|
/** Layout only (margin); the paint is the primitive's. */
|
||||||
@@ -22,7 +32,7 @@ export function Badge({
|
|||||||
title={title}
|
title={title}
|
||||||
className={clsx(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ describe("Button accessible-name contract (type level)", () => {
|
|||||||
<Button icon={<span />} aria-label="by aria-label" />
|
<Button icon={<span />} aria-label="by aria-label" />
|
||||||
<Button icon={<span />} title="by title" />
|
<Button icon={<span />} title="by title" />
|
||||||
<Button>plain text needs none</Button>
|
<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();
|
expect(screen.getByRole("button", { name: "by label" })).toBeTruthy();
|
||||||
|
|||||||
@@ -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
|
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
|
||||||
// users. Adding one would double the ring.
|
// 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;
|
variant?: ButtonVariant;
|
||||||
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
|
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
@@ -28,6 +30,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
|
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
|
||||||
pressed?: boolean;
|
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
|
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
|
||||||
* `media` variant. */
|
* `media` variant. */
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ describe("IconButton", () => {
|
|||||||
<IconButton label="named">
|
<IconButton label="named">
|
||||||
<svg />
|
<svg />
|
||||||
</IconButton>
|
</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();
|
expect(screen.getByRole("button", { name: "named" })).toBeTruthy();
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { iconButtonClass, type IconButtonSize, type IconButtonVariant } from "./
|
|||||||
// pill field it sits inside — `round`), and nothing else. The glyph
|
// 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.
|
// 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. */
|
/** The accessible name and the tooltip. Required: the button renders no text. */
|
||||||
label: string;
|
label: string;
|
||||||
/** The glyph (a lucide element); the primitive sizes it. */
|
/** The glyph (a lucide element); the primitive sizes it. */
|
||||||
@@ -20,6 +22,8 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
|||||||
size?: IconButtonSize;
|
size?: IconButtonSize;
|
||||||
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
|
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
|
||||||
pressed?: boolean;
|
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. */
|
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */
|
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function TouchRow() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* eslint-disable-next-line no-restricted-syntax -- a drawing of the segmented control, not a label */}
|
||||||
<span className="inline-flex rounded-full border border-border bg-card p-0.5 text-xs">
|
<span className="inline-flex rounded-full border border-border bg-card p-0.5 text-xs">
|
||||||
<span className={clsx(pill, "rounded-full bg-accent text-accent-fg")}>Mine</span>
|
<span className={clsx(pill, "rounded-full bg-accent text-accent-fg")}>Mine</span>
|
||||||
<span className={clsx(pill, "rounded-full text-muted")}>Library</span>
|
<span className={clsx(pill, "rounded-full text-muted")}>Library</span>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type SchemeDef = { name: string; dark: Pal; light: Pal };
|
|||||||
|
|
||||||
const STATUS: Record<Mode, Record<"danger" | "dangerFg" | "warn" | "success", string>> = {
|
const STATUS: Record<Mode, Record<"danger" | "dangerFg" | "warn" | "success", string>> = {
|
||||||
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
|
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
|
||||||
light: { danger: "#b91c1c", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
|
light: { danger: "#b91c1c", dangerFg: "#ffffff", warn: "#a14a07", success: "#047857" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const pal = (
|
const pal = (
|
||||||
@@ -137,8 +137,8 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5 text-2xs">
|
<div className="flex flex-wrap gap-1.5 text-2xs">
|
||||||
<span className="chip px-2 py-0.5">Filter</span>
|
<span className="chip px-2 py-0.5">Filter</span>
|
||||||
<span className="rounded-full border border-warn/40 px-2 py-0.5 text-warn">Retrying</span>
|
<span className="rounded-full bg-warn/6 px-2 py-0.5 text-warn">Retrying</span>
|
||||||
<span className="rounded-full border border-success/40 px-2 py-0.5 text-success">Done</span>
|
<span className="rounded-full bg-success/6 px-2 py-0.5 text-success">Done</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex flex-col rounded bg-surface px-1.5 py-1 text-2xs">
|
<span className="flex flex-col rounded bg-surface px-1.5 py-1 text-2xs">
|
||||||
<Metric ok={worst.g >= 30}>{`closest status: ${worst.k} ${worst.g}°`}</Metric>
|
<Metric ok={worst.g >= 30}>{`closest status: ${worst.k} ${worst.g}°`}</Metric>
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function Field({ label, value, focused }: { label: string; value: string; focuse
|
|||||||
<span className="font-medium">{label}</span>
|
<span className="font-medium">{label}</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- a drawing of a field, not a label
|
||||||
"flex items-center rounded-lg border px-3 text-sm",
|
"flex items-center rounded-lg border px-3 text-sm",
|
||||||
H.field,
|
H.field,
|
||||||
focused && "ring-2 ring-accent",
|
focused && "ring-2 ring-accent",
|
||||||
|
|||||||
@@ -1049,7 +1049,9 @@ html[data-theme="light"] {
|
|||||||
light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */
|
light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */
|
||||||
--danger: #b91c1c;
|
--danger: #b91c1c;
|
||||||
--danger-fg: #ffffff;
|
--danger-fg: #ffffff;
|
||||||
--warn: #b45309;
|
/* A shade under amber-700 (#b45309): that one was 4.58:1 as text on the tinted pages and fell under
|
||||||
|
4.5 on a warn Badge's own tint (F29). */
|
||||||
|
--warn: #a14a07;
|
||||||
--warn-fg: #ffffff;
|
--warn-fg: #ffffff;
|
||||||
--success: #047857;
|
--success: #047857;
|
||||||
--success-fg: #ffffff;
|
--success-fg: #ffffff;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ async function messages(source: string, starts: string): Promise<number> {
|
|||||||
const swaps = (src: string) => messages(src, "hover by a colour swap");
|
const swaps = (src: string) => messages(src, "hover by a colour swap");
|
||||||
const glyphs = (src: string) => messages(src, "a glyph-only <a>");
|
const glyphs = (src: string) => messages(src, "a glyph-only <a>");
|
||||||
const palette = (src: string) => messages(src, "a palette colour");
|
const palette = (src: string) => messages(src, "a palette colour");
|
||||||
|
const edged = (src: string) => messages(src, "a static label with an edge");
|
||||||
|
|
||||||
describe("a hover by a colour swap", () => {
|
describe("a hover by a colour swap", () => {
|
||||||
// Assembled, so this file does not trip the very rule it probes.
|
// Assembled, so this file does not trip the very rule it probes.
|
||||||
@@ -73,3 +74,36 @@ describe("a palette colour", () => {
|
|||||||
expect(await palette(`export const g = "bg-accent text-danger border-border";\n`)).toBe(0);
|
expect(await palette(`export const g = "bg-accent text-danger border-border";\n`)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F29: DESIGN.md "Static vs pressable": a label that does nothing is a flat Badge. A span drawn with a
|
||||||
|
// rounded edge reads as a chip, gets clicked and does nothing (three sites and a private Badge had it).
|
||||||
|
describe("a static label with an edge", () => {
|
||||||
|
const b = "border"; // assembled, as above
|
||||||
|
|
||||||
|
it("is refused, in a plain class and inside clsx", async () => {
|
||||||
|
const src = [
|
||||||
|
'import clsx from "clsx";',
|
||||||
|
"export const A = ({ on }: { on: boolean }) => (",
|
||||||
|
" <>",
|
||||||
|
` <span className="px-2 rounded-full ${b} ${b}-accent/40 text-accent">x</span>`,
|
||||||
|
` <span className={clsx("rounded-lg ${b} ${b}-dashed", on && "text-accent")}>y</span>`,
|
||||||
|
" </>",
|
||||||
|
");",
|
||||||
|
].join("\n");
|
||||||
|
expect(await edged(src)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a flat tint, an edge on a non-label element, a span with no rounding, a drawn check mark", async () => {
|
||||||
|
const src = [
|
||||||
|
"export const B = () => (",
|
||||||
|
" <>",
|
||||||
|
' <span className="rounded-full bg-fg/8 text-muted">x</span>',
|
||||||
|
` <div className="rounded-lg ${b} ${b}-border">y</div>`,
|
||||||
|
` <span className="${b}-b ${b}-border">z</span>`,
|
||||||
|
` <span className="grid h-3.5 w-3.5 rounded ${b}">✓</span>`,
|
||||||
|
" </>",
|
||||||
|
");",
|
||||||
|
].join("\n");
|
||||||
|
expect(await edged(src)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
|
|||||||
// A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text.
|
// A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text.
|
||||||
rating: hexContrast(s.rating!, bg),
|
rating: hexContrast(s.rating!, bg),
|
||||||
ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)),
|
ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)),
|
||||||
|
// A Badge (a role, a status, a count, F29): the neutral one's muted text on the fg at 8%, a toned
|
||||||
|
// one's text on its own colour at 6% (ui/Badge).
|
||||||
|
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, bg, 0.08)),
|
||||||
|
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, bg, 0.06)),
|
||||||
|
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, bg, 0.06)),
|
||||||
live: hexContrast(media().live!, bg),
|
live: hexContrast(media().live!, bg),
|
||||||
};
|
};
|
||||||
const nonText = new Set(["edge", "live"]);
|
const nonText = new Set(["edge", "live"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user