feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)

This commit is contained in:
2026-09-25 00:34:35 +02:00
parent db10bfbbca
commit c47c193ad1
89 changed files with 818 additions and 478 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// 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
@@ -17,7 +18,7 @@ import { Pressable } from "./Pressable";
/** Shared surface paint for every menu built on this file — the ⋯ menu, its submenus, and
* `ContextMenu`. One constant so a context menu can never look like a different component. */
export const MENU_SURFACE = "glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]";
export const MENU_SURFACE = `${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`;
// No `disabled:opacity-50` on the row itself: a disabled row may carry a `note`, and opacity on the
// row would dim the one line that explains it. The icon and the label dim instead (`dim`).
const ROW =
+2 -1
View File
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// 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
@@ -56,7 +57,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
<Pressable
align="center"
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} 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"
}`}
>
+32
View File
@@ -0,0 +1,32 @@
import clsx from "clsx";
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.
export function Badge({
onDark = false,
title,
className,
children,
}: {
onDark?: boolean;
title?: string;
/** Layout only (margin); the paint is the primitive's. */
className?: string;
children: ReactNode;
}) {
return (
<span
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",
className,
)}
>
{children}
</span>
);
}
+3 -85
View File
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode, RefAttributes } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from "./buttonStyles";
// The ONE action-button primitive: a labelled action (Save, Cancel, Subscribe, Delete, "Show all").
// docs/DESIGN.md decides its look (A1 shape, B1 ladder, B3 state layers); this file is where that
@@ -14,77 +15,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
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
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
| "overlay" // white text on the always-dark player chrome, whatever the theme
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
type ButtonSize =
| "xs" // 24 — inside a table row
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
| "md" // 32 — forms and dialog footers
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
| "none"; // no box of its own — the link variants' default
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
// or pressed button; the text-colour hovers here carry the same guard as a variant.
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
// is whether the button should advertise the destruction before the pointer arrives.
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
link: `font-semibold text-accent ${ACT}hover:underline`,
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
};
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
// means "floats over something", and a pill's inner button follows its radius).
const SIZES: Record<ButtonSize, string> = {
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
none: "gap-1.5",
};
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE =
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
@@ -110,8 +40,6 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
type NamedSomehow = { label: string } | { "aria-label": string } | { title: string };
export type ButtonProps = ButtonOwnProps & ({ children: ReactNode } | NamedSomehow);
const isLink = (v: ButtonVariant) => v === "link" || v === "link-muted";
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button(
{
@@ -137,11 +65,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
// A link must not inherit the action-button box, so its size default differs. Resolved here rather
// than by asking every call site to pass `size="none"` alongside a link variant — a pairing everyone
// would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (isLink(variant) ? "none" : "md");
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link is excluded on
// purpose — it is a run of text inside a sentence, and a 44px-tall inline link would wreck the
// paragraph it sits in rather than make anything easier to hit.
const touchTarget = !isLink(variant);
const resolvedSize: ButtonSize = size ?? (isLinkVariant(variant) ? "none" : "md");
const leading = loading ? <Loader2 className="animate-spin" /> : icon;
return (
<FocusSafeButton
@@ -156,13 +80,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
aria-pressed={pressed}
title={label}
className={clsx(
BASE,
VARIANTS[variant],
pressed !== undefined && PRESSED,
SIZES[resolvedSize],
resolvedSize !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
touchTarget && "touch-target",
fullWidth && "w-full",
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
className,
)}
{...rest}
+3 -2
View File
@@ -7,6 +7,7 @@ import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { InnerInput } from "./Input";
import { fieldClass } from "./fieldStyles";
import { SURFACE } from "./surfaces";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
@@ -240,7 +241,7 @@ export default function Combobox({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu w-full rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} w-full animate-[popIn_0.16s_ease]`}
>
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
@@ -262,7 +263,7 @@ export default function Combobox({
disabled={o.disabled}
onSelect={() => commit(o.value)}
label={o.label ?? o.value}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+2 -1
View File
@@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign";
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { SURFACE } from "./surfaces";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -323,7 +324,7 @@ export default function DataTable<T>({
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
className={`${SURFACE.card} p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => c.cardPrimary)
+2 -1
View File
@@ -1,6 +1,7 @@
import { createPortal } from "react-dom";
import { Check, RotateCcw, Save } from "lucide-react";
import { Button } from "./Button";
import { SURFACE } from "./surfaces";
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
// admin Configuration). Two visual variants share one state machine + markup:
@@ -38,7 +39,7 @@ export function DraftSaveBar({
const saving = state === "saving";
const floating = variant === "floating";
const wrap = floating
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
? `fixed bottom-4 left-1/2 -translate-x-1/2 z-rail ${SURFACE.panel} flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]`
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
// The floating variant is fixed to the viewport but renders from a page inside the page
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
+3 -2
View File
@@ -3,6 +3,7 @@ import { Check } from "lucide-react";
import { SelectTrigger } from "./SelectTrigger";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { SURFACE } from "./surfaces";
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
@@ -107,7 +108,7 @@ export default function FilterSelect({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
>
{/* Single = one exclusive choice (radios), multi = independent toggles (checkboxes). Both are
menuitem-family roles scoped by a group, so AT announces the checked state — the surface is a
@@ -124,7 +125,7 @@ export default function FilterSelect({
onSelect={() => commit(o.value)}
label={o.label}
data-testid={testId ? `${testId}-${o.value}` : undefined}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+2 -57
View File
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { iconButtonClass, type IconButtonSize, type IconButtonVariant } from "./buttonStyles";
// A button that is only a glyph (docs/DESIGN.md A2): two sizes, r8, and a name it cannot ship without
// (`label` is required: it is the accessible name and the tooltip). On a picture it is round and dark
@@ -10,55 +11,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
// 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.
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
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)
| "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).
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
};
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const PRESSED = "aria-pressed:text-accent";
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"> {
/** The accessible name and the tooltip. Required: the button renders no text. */
label: string;
@@ -100,14 +52,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
aria-label={label}
aria-pressed={pressed}
title={noTitle ? undefined : label}
className={clsx(
"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],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
SIZES[size],
className,
)}
className={clsx(iconButtonClass({ variant, size, round, pressed }), className)}
{...rest}
>
{loading ? <Loader2 className="animate-spin" /> : children}
+4 -3
View File
@@ -6,8 +6,9 @@ import { formatNumber } from "../../lib/format";
// the pill shows "1M+" and the tooltip carries the precise number.
const OVERFLOW_AT = 1_000_000;
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
// and tonal like a `Badge` — no edge, because it does nothing (E28 S3d: edged = pressable, flat = just
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
@@ -27,7 +28,7 @@ export default function ItemCount({ count }: { count?: number }) {
<span
data-testid="item-count"
title={count == null ? undefined : formatNumber(count)}
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="tabular-nums">{label}</span>
+72
View File
@@ -0,0 +1,72 @@
import clsx from "clsx";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import {
buttonClass,
iconButtonClass,
type ButtonSize,
type ButtonVariant,
type IconButtonSize,
type IconButtonVariant,
} from "./buttonStyles";
// A link that looks like a button (a download, a sign-in that leaves the app). It stays an <a> — the
// browser's own open-in-new-tab, copy-link and download behaviour, and the right role for a screen
// reader — and wears the `Button` / `IconButton` face from buttonStyles, so it can't drift from the
// buttons beside it. A link in a sentence is a plain `<a>` (or `Button variant="link"` for an action).
type Anchor = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "className" | "children">;
export function LinkButton({
variant = "outline",
size = "md",
icon,
fullWidth,
className,
children,
...rest
}: Anchor & {
variant?: ButtonVariant;
size?: ButtonSize;
/** Leading icon (a lucide element; sized here). */
icon?: ReactNode;
fullWidth?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<a
className={clsx(buttonClass({ variant, size, fullWidth, element: "link" }), className)}
{...rest}
>
{icon}
{children}
</a>
);
}
/** The glyph-only form: `label` is the accessible name and the tooltip, as on `IconButton`. */
export function IconLink({
label,
variant = "ghost",
size = "sm",
className,
children,
...rest
}: Anchor & {
label: string;
variant?: IconButtonVariant;
size?: IconButtonSize;
className?: string;
children: ReactNode;
}) {
return (
<a
aria-label={label}
title={label}
className={clsx(iconButtonClass({ variant, size, element: "link" }), className)}
{...rest}
>
{children}
</a>
);
}
+3 -2
View File
@@ -9,6 +9,7 @@ import { trapTab, useDialogChrome } from "./dialogFocus";
import { IconButton } from "./IconButton";
import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder";
import { SURFACE } from "./surfaces";
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
@@ -114,8 +115,8 @@ export default function Modal({
tabIndex={-1}
className={
drawer
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
? `${SURFACE.drawer} relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `${SURFACE.panel} relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
+2 -1
View File
@@ -6,6 +6,7 @@ import { useSortableItem } from "./sortableA11y";
import { DragHandle } from "./DragHandle";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// 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
@@ -55,7 +56,7 @@ export default function PanelGroup({
<div
ref={setNodeRef}
style={style}
className={`glass-card rounded-xl ${
className={`${SURFACE.card} ${
isDragging ? "relative z-base shadow-2xl ring-1 ring-accent" : ""
} ${hidden && editing ? "opacity-50" : ""}`}
>
+10 -7
View File
@@ -36,13 +36,16 @@ domain-free.
## What lives here
| 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`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `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` |
| 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`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `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` |
## The size contract (E10 S1c)
+3 -2
View File
@@ -5,6 +5,7 @@ import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// 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
@@ -77,7 +78,7 @@ export default function SidePanel({
{...peek.hoverHandlers}
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`glass glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 transition-opacity ${
expanded ? "opacity-0" : ""
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
>
@@ -98,7 +99,7 @@ export default function SidePanel({
{expanded && (
<aside
{...peek.hoverHandlers}
className="glass absolute top-3 bottom-3 left-0 z-panel w-64 rounded-2xl flex flex-col overflow-hidden"
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
>
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
+4 -3
View File
@@ -4,6 +4,7 @@ import { SelectTrigger } from "./SelectTrigger";
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { SURFACE } from "./surfaces";
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
@@ -93,7 +94,7 @@ export default function SortControl({
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-56 animate-[popIn_0.16s_ease]`}
>
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
@@ -107,7 +108,7 @@ export default function SortControl({
setOpen(false); // "no sort" is a final choice — nothing left to direct
}}
label={defaultLabel}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
active === null ? "text-fg" : "text-muted"
}`}
>
@@ -126,7 +127,7 @@ export default function SortControl({
checked={on}
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
+2 -1
View File
@@ -8,6 +8,7 @@ import { useOverlayOrder } from "./overlayOrder";
import { LEVEL_STYLE } from "./toastLevels";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { SURFACE } from "./surfaces";
// 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
@@ -158,7 +159,7 @@ export default function Toaster({
? undefined
: "status"
}
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} toast-card pointer-events-auto relative overflow-hidden px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]`}
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState, useSyncExternalStore } from "react";
import { PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { hintsEnabled, subscribeHints } from "../../lib/hints";
import { SURFACE } from "./surfaces";
type Side = "top" | "bottom" | "left" | "right";
@@ -50,7 +51,7 @@ export default function Tooltip({
<PopoverSurface
popover={pop}
passthrough
className="glass w-max max-w-60 px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
className={`${SURFACE.tooltip} w-max max-w-60 px-2.5 py-1.5 text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]`}
>
{hint}
</PopoverSurface>
+182
View File
@@ -0,0 +1,182 @@
import clsx from "clsx";
// The paint of the two button primitives (docs/DESIGN.md A1, A2, B1, B3), apart from the components so
// a LINK can wear exactly the same face (`LinkButton`): a download or a sign-in that navigates is an
// <a>, and a hand-painted look-alike beside the real buttons is how the two drifted. The hover guard is
// per element: `enabled:` never matches an <a>, so a link's hovers drop it (it has no disabled state).
const ACT_BUTTON = "enabled:not-aria-disabled:";
const ACT_LINK = "not-aria-disabled:";
export 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
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
| "overlay" // white text on the always-dark player chrome, whatever the theme
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
export type ButtonSize =
| "xs" // 24 — inside a table row
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
| "md" // 32 — forms and dialog footers
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
| "none"; // no box of its own — the link variants' default
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
// or pressed button; the text-colour hovers here carry the same guard as a variant.
const buttonVariants = (ACT: string): 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
// is whether the button should advertise the destruction before the pointer arrives.
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
link: `font-semibold text-accent ${ACT}hover:underline`,
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
});
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
// button on a picture, or nested in a pill (the header search's Go), is a pill instead (A2: round
// means "floats over something", and a pill's inner button follows its radius).
const SIZES: Record<ButtonSize, string> = {
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
none: "gap-1.5",
};
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE =
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
export 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
export 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)
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
const iconVariants = (ACT: string): 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).
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
});
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const ICON_PRESSED = "aria-pressed:text-accent";
const ICON_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",
};
const BUTTON_VARIANTS = { button: buttonVariants(ACT_BUTTON), link: buttonVariants(ACT_LINK) };
const ICON_VARIANTS = { button: iconVariants(ACT_BUTTON), link: iconVariants(ACT_LINK) };
export const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
export function buttonClass({
variant,
size,
round = false,
fullWidth = false,
pressed,
element = "button",
}: {
variant: ButtonVariant;
size: ButtonSize;
round?: boolean;
fullWidth?: boolean;
pressed?: boolean;
element?: "button" | "link";
}) {
return clsx(
BASE,
BUTTON_VARIANTS[element][variant],
pressed !== undefined && PRESSED,
SIZES[size],
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link variant is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
!isLinkVariant(variant) && "touch-target",
fullWidth && "w-full",
);
}
export function iconButtonClass({
variant,
size,
round = false,
pressed,
element = "button",
}: {
variant: IconButtonVariant;
size: IconButtonSize;
round?: boolean;
pressed?: boolean;
element?: "button" | "link";
}) {
return clsx(
"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",
ICON_VARIANTS[element][variant],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && ICON_PRESSED,
ICON_SIZES[size],
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import Tooltip from "./Tooltip";
import { FocusSafeButton } from "./FocusSafeButton";
import { SURFACE } from "./surfaces";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
@@ -51,7 +52,7 @@ export function Section({
children: ReactNode;
}) {
return (
<div className={card ? "glass rounded-2xl p-4 mb-4" : "mb-6"}>
<div className={card ? `${SURFACE.panel} p-4 mb-4` : "mb-6"}>
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
{title}
</div>
+30
View File
@@ -0,0 +1,30 @@
// B5 (docs/DESIGN.md): one glass class and one radius per surface ROLE, so corners nest and no site
// drifts its own alpha. A call site names the role and adds only its layout (padding, width, flex);
// the radius comes from here, never beside it (no tailwind-merge: two `rounded-*` is a coin flip). A
// raw `glass` / `glass-card` / `glass-menu` class anywhere else is a lint error (`local/surface-role`).
//
// What floats over undimmed content (a menu, a popover, a tooltip, a toast) is `.glass-menu`, the
// near-opaque tier, so it stays readable over any thumbnail; what sits in the page is `.glass-card`;
// a dialog, a drawer, a docked or side panel and a page section is `.glass`.
export const SURFACE = {
/** A dialog, a side panel, a dock, a page section: r16. */
panel: "glass rounded-2xl",
/** A dialog that slides in from the edge: square on a phone, r16 on its open side. */
drawer: "glass rounded-none sm:rounded-l-2xl",
/** A card or a row card in the page (a video, a user, a download): r12. */
card: "glass-card rounded-xl",
/** A menu or a listbox: r12, 4px padding, so its r8 rows nest (12 − 4). */
menu: "glass-menu rounded-xl p-1",
/** Floating content that is not a list of choices (a hover card, a status popover, a toast): r12. */
popover: "glass-menu rounded-xl",
/** A tooltip: r8. */
tooltip: "glass-menu rounded-lg",
/** A control bar floating over the page (the debug recorder's bar): a pill (A2: round floats). */
floating: "glass-menu rounded-full",
/** The pull tab on a side panel's edge: square on the wall side, r12 on the open side. */
edgeTab: "glass rounded-r-xl",
/** A full-width sticky band (the design lab's header): square. */
band: "glass",
} as const;
export type SurfaceRole = keyof typeof SURFACE;
+5 -4
View File
@@ -12,6 +12,7 @@ import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import { Input } from "./Input";
import { SURFACE } from "./surfaces";
// 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
@@ -93,7 +94,7 @@ function FilterFunnel<T>({
popover={pop}
ariaLabel={col.header}
initialFocus={isText ? inputRef : undefined}
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-52 p-2 animate-[popIn_0.16s_ease]`}
>
{f.kind === "text" && (
<Input
@@ -113,7 +114,7 @@ function FilterFunnel<T>({
keepSelectedPaint
onSelect={() => onApply(col.key, "")}
label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
@@ -127,7 +128,7 @@ function FilterFunnel<T>({
keepSelectedPaint
onSelect={() => onApply(col.key, o.value)}
label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
@@ -153,7 +154,7 @@ function FilterFunnel<T>({
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
label={o.label}
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>