feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" : ""}`}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user