feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)

The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.

- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
  floor, 44 on touch) replace five chip geometries; FilterChipGroup
  renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
  round, overlay; 44 on touch); the YT modal bar is on the sm rung with
  pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
  new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
  add) announce checked/mixed; a one-letter tag chip was 21px wide;
  descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
  control-primitive files is a lint error (contract-tested, seen red).
This commit is contained in:
2026-09-24 23:03:23 +02:00
parent 86f467bd65
commit 81936e9f56
94 changed files with 1327 additions and 1093 deletions
+24 -13
View File
@@ -2,6 +2,8 @@ import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
@@ -209,19 +211,28 @@ export default function ActionMenu({
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={
className ??
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button>
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
{className ? (
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={className}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</Pressable>
) : (
<IconButton
variant="glass"
size="md"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
label={ariaLabel}
>
{trigger ?? <MoreHorizontal aria-hidden="true" />}
</IconButton>
)}
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
<MenuItemList items={items} onClose={() => setOpen(false)} />
</PopoverSurface>
+4 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Pressable } from "./Pressable";
// A floating "back to top" button that fades in once the current page's scroll container is
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
@@ -50,7 +51,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
return createPortal(
<button
<Pressable
align="center"
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
@@ -58,7 +60,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
>
<ArrowUp className="w-7 h-7" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</button>,
</Pressable>,
document.body,
);
}
+10 -7
View File
@@ -2,20 +2,21 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
type BannerTone = "accent" | "warning";
const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> = {
const TONES: Record<BannerTone, { bar: string; icon: string; action: "primary" | "warn" }> = {
accent: {
bar: "bg-accent/15 border-accent/30",
icon: "text-accent",
action: "btn-primary",
action: "primary",
},
warning: {
bar: "bg-warn/15 border-warn/30",
icon: "text-warn",
action: "btn-warn",
action: "warn",
},
};
@@ -54,12 +55,14 @@ export default function Banner({
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
<span className="min-w-0">{children}</span>
{action && (
<button
<Button
variant={c.action}
size="sm"
onClick={action.onClick}
className={`ml-1 px-2.5 py-1 rounded-lg font-semibold transition ${c.action}`}
className="ml-1 font-semibold"
>
{action.label}
</button>
</Button>
)}
{onDismiss && (
<IconButton
@@ -69,7 +72,7 @@ export default function Banner({
onClick={onDismiss}
className="ml-auto"
>
<X className="w-4 h-4" />
<X />
</IconButton>
)}
</div>
+2
View File
@@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton";
type ButtonVariant =
| "primary" // solid accent — the main affirmative action
| "danger" // solid danger — destructive confirm
| "warn" // solid warn — the action of a warning bar (reload for a new version)
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
@@ -43,6 +44,7 @@ const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<ButtonVariant, string> = {
primary: "font-medium state-filled",
danger: "font-medium state-danger",
warn: "font-medium state-warn",
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
+19 -18
View File
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges";
import { useListNav } from "../../lib/listNav";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
@@ -95,10 +97,10 @@ export function Carousel({
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
// Round, dark translucent: they sit over the pictures (A2).
const arrowBtn =
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100";
return (
<div className={clsx("group/car relative", className)}>
@@ -106,27 +108,25 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<button
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</button>
</Button>
)}
</div>
)}
<div className="relative">
{left && (
<button
type="button"
<IconButton
variant="media"
size="md"
onClick={() => page(-1)}
aria-label={t("common.scrollLeft")}
label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
<ChevronLeft className="h-5 w-5" />
</button>
<ChevronLeft />
</IconButton>
)}
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
@@ -140,14 +140,15 @@ export function Carousel({
{children}
</div>
{right && (
<button
type="button"
<IconButton
variant="media"
size="md"
onClick={() => page(1)}
aria-label={t("common.scrollRight")}
label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")}
>
<ChevronRight className="h-5 w-5" />
</button>
<ChevronRight />
</IconButton>
)}
</div>
</div>
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
//
// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
// filter, an applied narrowing, a tag and a suggestion, where five files had five.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Chip, DismissibleChip } from "./Chip";
import { chipClass } from "./chipStyles";
afterEach(cleanup);
describe("Chip", () => {
it("is a pill on the 24px floor that never submits a form, and a toggle announces its state", () => {
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Chip pressed={false}>2024</Chip>
</form>,
);
const chip = screen.getByRole("button", { name: "2024" });
expect(chip.className).toContain("rounded-full");
expect(chip.className).toContain("h-6");
expect(chip.className).toContain("touch-target");
expect(chip.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(chip);
expect(onSubmit).not.toHaveBeenCalled();
});
it("a removing chip is named by the removal and shows the ×", () => {
const onClick = vi.fn();
render(
<Chip variant="token" size="md" removeLabel="Remove Music" onClick={onClick}>
Music
</Chip>,
);
const chip = screen.getByRole("button", { name: "Remove Music" });
expect(chip.getAttribute("title")).toBe("Remove Music");
expect(chip.className).toContain("h-7");
expect(chip.querySelector("svg")).not.toBeNull();
fireEvent.click(chip);
expect(onClick).toHaveBeenCalledOnce();
});
it("the measuring mirror lays out exactly like the chip it measures", () => {
render(
<Chip variant="tag" size="xs">
tag
</Chip>,
);
const cls = screen.getByRole("button", { name: "tag" }).className;
for (const c of chipClass("tag", "xs").split(" ")) expect(cls).toContain(c);
});
});
describe("DismissibleChip", () => {
it("is two sibling controls at most, never a button in a button", () => {
const onAccept = vi.fn();
const onDismiss = vi.fn();
const { container } = render(
<DismissibleChip
variant="suggest"
onClick={onAccept}
bodyLabel="Add the tag Music"
onDismiss={onDismiss}
dismissLabel="Not now"
>
Music
</DismissibleChip>,
);
expect(container.querySelector("button button")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Add the tag Music" }));
fireEvent.click(screen.getByRole("button", { name: "Not now" }));
expect(onAccept).toHaveBeenCalledOnce();
expect(onDismiss).toHaveBeenCalledOnce();
});
it("with no body action the label is text, and only the × is a control", () => {
render(
<DismissibleChip onDismiss={() => {}} dismissLabel="Remove Year: 2024">
Year: 2024
</DismissibleChip>,
);
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(screen.getByText("Year: 2024").closest("button")).toBeNull();
});
});
+125
View File
@@ -0,0 +1,125 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { X } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles";
// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that
// is a filter, an applied narrowing, a tag, or a suggestion. It was hand-painted in five files with
// five geometries; this is the one. `FilterChipGroup` renders a single-select row of these.
//
// filter — a selectable option (a year, a tag filter, a content type). Toggles with `pressed`: the
// `.pill` recipe in index.css paints the solid accent when on. Also the "+N" overflow and
// "More…" triggers, which light up while their popover is open (aria-expanded).
// token — an applied narrowing, tinted accent (the `.chip` recipe): it exists because it is on.
// tag — a user's tag, the solid accent (`.chip-solid`). Dense: it sits in table rows.
// suggest — dashed accent: a suggestion not yet taken.
// ghost — dashed neutral: the "add a tag" trigger, quiet in every table row until reached for.
//
// Nothing goes under 24px (B1, WCAG 2.5.8) and a coarse pointer grows every chip to 44 (A3).
type ChipProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ChipVariant;
size?: ChipSize;
/** A filter chip that toggles: aria-pressed, painted solid when on. */
pressed?: boolean;
/** Leading glyph (a lucide element). */
icon?: ReactNode;
/** The whole chip removes what it stands for: a trailing × and the removal as its name. */
removeLabel?: string;
};
/** A chip that is itself the control (a toggle, a removal, an accept, a "+N" trigger). */
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
{
variant = "filter",
size = "sm",
pressed,
icon,
removeLabel,
type,
className,
children,
...rest
},
ref,
) {
return (
<FocusSafeButton
ref={ref}
type={type ?? "button"}
aria-pressed={pressed}
aria-label={removeLabel}
title={removeLabel}
className={clsx(chipClass(variant, size), removeLabel && "group pr-1.5", className)}
{...rest}
>
{icon}
{children}
{removeLabel && (
<X className="size-3 opacity-60 group-hover:opacity-100" aria-hidden="true" />
)}
</FocusSafeButton>
);
});
/** A chip whose label is not the control but which carries its own trailing × (a captured filter in
* a saved view, a suggestion with a dismiss). Two sibling controls at most, never a button in a
* button: `onClick` makes the body one (an accept), `onDismiss` the ×. */
export function DismissibleChip({
variant = "token",
size = "sm",
icon,
children,
onClick,
bodyLabel,
bodyTitle,
onDismiss,
dismissLabel,
className,
}: {
variant?: Exclude<ChipVariant, "filter">;
size?: ChipSize;
icon?: ReactNode;
children: ReactNode;
onClick?: () => void;
bodyLabel?: string;
bodyTitle?: string;
onDismiss: () => void;
dismissLabel: string;
className?: string;
}) {
const body = (
<>
{icon}
<span className="truncate">{children}</span>
</>
);
return (
<span className={clsx(chipClass(variant, size), "pr-0.5 min-w-0", className)}>
{onClick ? (
<FocusSafeButton
type="button"
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"
>
{body}
</FocusSafeButton>
) : (
<span className="inline-flex min-w-0 items-center gap-1 [&>svg]:size-3">{body}</span>
)}
<FocusSafeButton
type="button"
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"
>
<X className="size-3" aria-hidden="true" />
</FocusSafeButton>
</span>
);
}
@@ -4,6 +4,7 @@ import { Search } from "lucide-react";
import Modal from "./Modal";
import { closeTopOverlayThenRun } from "../../lib/history";
import { pageStep, pageTarget } from "../../lib/listPaging";
import { Pressable } from "./Pressable";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
@@ -243,14 +244,13 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
{g.label}
</div>
{g.items.map((item) => (
<button
<Pressable
key={item.id}
id={`${listId}-${item.id}`}
ref={item.id === activeId ? activeRef : undefined}
data-cmd-id={item.id}
role="option"
aria-selected={item.id === activeId}
type="button"
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
// these rows. Leaving them tabbable put DOM focus on a row while
// aria-activedescendant named a different one — two competing "current" options —
@@ -268,14 +268,14 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
if (e.movementX === 0 && e.movementY === 0) return;
setPicked(item.id);
}}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm ${
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
</button>
</Pressable>
))}
</div>
))}
@@ -4,7 +4,7 @@
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterChipGroup from "./FilterChipGroup";
const OPTS = [
{ value: "all", label: "All", testId: "chip-all" },
@@ -56,8 +56,12 @@ describe("FilterChipGroup", () => {
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
});
it("exports the canonical chip class for multi-select siblings to reuse", () => {
expect(filterChipClass).toContain("rounded-full");
expect(filterChipClass).toContain("pill");
it("renders its options as Chips: the pill, on the 24px rung", () => {
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
const chip = screen.getByTestId("chip-all");
expect(chip.className).toContain("rounded-full");
expect(chip.className).toContain("pill");
expect(chip.className).toContain("h-6");
expect(chip.getAttribute("type")).toBe("button");
});
});
+6 -14
View File
@@ -1,13 +1,6 @@
import { type ReactNode } from "react";
import CountBadge from "./CountBadge";
// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
export const filterChipClass =
"pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
import { Chip } from "./Chip";
export interface FilterChipOption<T extends string> {
value: T;
@@ -19,7 +12,7 @@ export interface FilterChipOption<T extends string> {
}
// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
// render `Chip`s with `pressed` instead — same look, different (independent) semantics.
export default function FilterChipGroup<T extends string>({
options,
value,
@@ -39,18 +32,17 @@ export default function FilterChipGroup<T extends string>({
{options.map((o) => {
const on = value === o.value;
return (
<button
<Chip
key={o.value}
data-testid={o.testId}
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={on}
className={filterChipClass}
pressed={on}
icon={o.icon}
>
{o.icon}
<span>{o.label}</span>
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
</button>
</Chip>
);
})}
</div>
+8 -2
View File
@@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton";
type IconButtonVariant =
| "ghost" // the default: quiet glyph, a state layer on hover
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
| "outline" // bordered — an icon action that stands on its own in a form or a header
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
| "danger" // quiet at rest, danger on hover — a row's delete/remove
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
| "glass" // glass card — beside the glass Buttons on a Plex page
| "media" // round, dark translucent — on a thumbnail or a poster
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
@@ -24,17 +26,20 @@ type IconButtonVariant =
type IconButtonSize =
| "sm" // 28 — rows and dense toolbars
| "md" // 32 — on its own
| "lg"; // 48 — only the Play in the middle of a picture (C5: a not-started card)
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
const ACT = "enabled:not-aria-disabled:";
const VARIANTS: Record<IconButtonVariant, string> = {
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
"ghost-accent": "text-accent state-layer",
primary: "state-filled",
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
"outline-accent": "border border-accent/40 text-accent state-layer",
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "border border-warn/40 text-warn state-layer",
danger: `text-muted state-layer ${ACT}hover:text-danger`,
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
@@ -50,6 +55,7 @@ const SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
md: "size-8 [&>svg]:size-4",
lg: "size-12 [&>svg]:size-6",
player: "size-9 [&>svg]:size-5",
};
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
@@ -94,7 +100,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
aria-pressed={pressed}
title={noTitle ? undefined : label}
className={clsx(
"inline-grid shrink-0 place-items-center transition touch-target disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
VARIANTS[variant],
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
+10 -8
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
import { FocusSafeButton } from "./FocusSafeButton";
import SegmentedToggle from "./SegmentedToggle";
import { Button } from "./Button";
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
@@ -66,14 +66,15 @@ export default function Pager({
<div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={<ChevronLeft />}
onClick={() => goToPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("datatable.pager.prev")}
</FocusSafeButton>
</Button>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<input
@@ -89,14 +90,15 @@ export default function Pager({
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
<FocusSafeButton
<Button
variant="glass"
size="sm"
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
{t("datatable.pager.next")}
<ChevronRight className="w-4 h-4" />
</FocusSafeButton>
</Button>
</div>
)}
{total > 0 && (
+22 -21
View File
@@ -1,8 +1,11 @@
import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
import { ChevronDown, Eye, EyeOff, Pencil } from "lucide-react";
import { CSS } from "@dnd-kit/utilities";
import { useSortableItem } from "./sortableA11y";
import { DragHandle } from "./DragHandle";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
@@ -46,7 +49,7 @@ export default function PanelGroup({
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
const titleCls =
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
"flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide";
return (
<div
@@ -58,55 +61,53 @@ export default function PanelGroup({
>
<div className="flex items-center gap-1.5 px-3 py-2">
{editing && (
<button
<DragHandle
{...attributes}
{...listeners}
title={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
>
<GripVertical className="w-4 h-4" />
</button>
label={t("sidebar.dragToReorder")}
className="-ml-1"
/>
)}
{collapsible && !editing ? (
<button
<Pressable
onClick={onToggleCollapse}
aria-expanded={!collapsed}
className={`${titleCls} text-muted hover:text-fg transition`}
>
<span className="truncate">{title}</span>
</button>
</Pressable>
) : titleIsTrigger ? (
<button
<Pressable
onClick={onTitleClick}
title={titleActionLabel}
className={`${titleCls} group text-muted hover:text-accent transition`}
>
<span className="truncate">{title}</span>
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
</button>
</Pressable>
) : (
<span className={`${titleCls} text-muted`}>
<span className="truncate">{title}</span>
</span>
)}
{editing ? (
<button
<IconButton
onClick={onToggleHidden}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
label={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
>
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
{hidden ? <EyeOff /> : <Eye />}
</IconButton>
) : (
collapsible && (
<button
<IconButton
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
aria-expanded={!collapsed}
label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
</IconButton>
)
)}
</div>
+4 -4
View File
@@ -39,7 +39,7 @@ domain-free.
| area | components |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
@@ -51,9 +51,9 @@ are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Devic
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` and `IconButton`'s sizes are the
rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll
padding on a primitive.
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
and player rungs). Pick a rung; do not hand-roll padding on a primitive.
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
+11 -12
View File
@@ -4,6 +4,7 @@ import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
@@ -72,7 +73,7 @@ export default function SidePanel({
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<button
<Pressable
{...peek.hoverHandlers}
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
@@ -92,7 +93,7 @@ export default function SidePanel({
>
{title}
</span>
</button>
</Pressable>
{expanded && (
<aside
@@ -113,14 +114,14 @@ export default function SidePanel({
</IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? (
<button
<Pressable
onClick={onTitleClick}
title={titleActionLabel}
aria-label={titleActionLabel}
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
>
{title}
</button>
</Pressable>
) : (
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
@@ -145,15 +146,13 @@ export default function SidePanel({
</IconButton>
)}
{onToggleEditing && (
<button
<IconButton
variant={editing ? "ghost-accent" : "ghost"}
onClick={onToggleEditing}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
label={editing ? t("sidebar.done") : t("sidebar.customize")}
>
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
</button>
{editing ? <Check /> : <Pencil />}
</IconButton>
)}
</div>
+11 -8
View File
@@ -6,6 +6,8 @@ import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
@@ -170,24 +172,25 @@ export default function Toaster({
)}
</div>
{toast.action && (
<button
<Button
variant="link"
onClick={() => {
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
className="mt-1 text-sm"
>
{toast.action.label}
</button>
</Button>
)}
</div>
<button
<IconButton
onClick={() => dismiss(toast.id)}
className="shrink-0 text-muted hover:text-fg"
title={t("notifications.dismiss")}
label={t("notifications.dismiss")}
className="-m-1.5"
>
<X className="w-4 h-4" />
</button>
<X />
</IconButton>
{toast.duration && (
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
+7 -21
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redo2, Undo2 } from "lucide-react";
import { dispatch, useKeyScope } from "../../lib/keymap";
import { FocusSafeButton } from "./FocusSafeButton";
import { IconButton } from "./IconButton";
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
@@ -44,28 +44,14 @@ export default function UndoToolbar({
return () => window.removeEventListener("keydown", onKey);
}, [canUndo, canRedo, onUndo, onRedo]);
const btn =
"p-1.5 rounded-lg border border-border text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:border-accent disabled:opacity-30 transition";
return (
<div className="inline-flex items-center gap-1">
<FocusSafeButton
onClick={onUndo}
disabled={!canUndo}
title={t("common.undo")}
aria-label={t("common.undo")}
className={btn}
>
<Undo2 className="w-4 h-4" />
</FocusSafeButton>
<FocusSafeButton
onClick={onRedo}
disabled={!canRedo}
title={t("common.redo")}
aria-label={t("common.redo")}
className={btn}
>
<Redo2 className="w-4 h-4" />
</FocusSafeButton>
<IconButton variant="outline" onClick={onUndo} disabled={!canUndo} label={t("common.undo")}>
<Undo2 />
</IconButton>
<IconButton variant="outline" onClick={onRedo} disabled={!canRedo} label={t("common.redo")}>
<Redo2 />
</IconButton>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import clsx from "clsx";
// The chip family's geometry and paint, apart from the component (`Chip.tsx`) so the hidden measuring
// mirror of an overflowing chip lane can lay out exactly like the chips it measures.
export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
export type ChipSize =
| "md" // 28, text-sm — the applied-filter row above the feed
| "sm" // 24, text-xs — a filter row
| "xs"; // 24, text-2xs, tight — a tag in a table row
const ACT = "enabled:not-aria-disabled:";
const PAINT: Record<ChipVariant, string> = {
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
// fill, so the open-popover state is the only one spelled out.
filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent",
token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`,
// B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim.
tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`,
suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`,
ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`,
};
const SIZES: Record<ChipSize, string> = {
md: "h-7 px-2.5 text-sm gap-1",
sm: "h-6 px-2.5 text-xs gap-1.5",
xs: "h-6 px-1.5 text-2xs gap-1",
};
const BASE =
"inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
* chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
export function chipClass(variant: ChipVariant = "filter", size: ChipSize = "sm") {
return clsx(BASE, PAINT[variant], SIZES[size]);
}
-3
View File
@@ -14,9 +14,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
export const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({
+16 -13
View File
@@ -8,6 +8,9 @@ import { type SortState } from "../../lib/columnSort";
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
import { alignClass } from "./tableAlign";
import { useStickyTop } from "./stickyTop";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
@@ -75,17 +78,16 @@ function FilterFunnel<T>({
return (
<>
<button
<IconButton
variant={filterOn ? "ghost-accent" : "ghost"}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("datatable.filter")}
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
className={`-m-1.5 p-1.5 transition ${
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
label={t("datatable.filter")}
// The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
className="-my-1.5"
>
<ListFilter className="w-3.5 h-3.5" />
</button>
<ListFilter className="size-3.5!" />
</IconButton>
<PopoverSurface
popover={pop}
ariaLabel={col.header}
@@ -167,12 +169,13 @@ function FilterFunnel<T>({
</FilterOptionList>
)}
{filterOn && (
<button
<Button
variant="link-muted"
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
className="mt-1.5 w-full text-xs"
>
{t("datatable.clear")}
</button>
</Button>
)}
</PopoverSurface>
</>
@@ -229,7 +232,7 @@ export function ColumnHead<T>({
focusable no-op button, which put unlabeled dead stops in the tab order —
two of them per playlist table, whose grip and actions headers are empty. */}
{col.sortable ? (
<button
<Pressable
onClick={() => onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
>
@@ -241,7 +244,7 @@ export function ColumnHead<T>({
) : (
<ArrowDown className="w-3 h-3" />
))}
</button>
</Pressable>
) : (
<span>{col.header}</span>
)}