feat(design): the header search is a 40 glass pill; a wrapper field dims for its input only (E28 S3c)

This commit is contained in:
2026-09-25 00:05:31 +02:00
parent dac9d89f91
commit db10bfbbca
13 changed files with 78 additions and 39 deletions
+5 -1
View File
@@ -112,7 +112,8 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
picture, a round, dark translucent button, readable on any thumbnail. Round means "floats over something":
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB). A text
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB); a button
inside a pill field is round too, so the radii nest. A text
button on a picture is a pill (the card's Resume in the accent, From the start dark translucent, C5), and
the one 48px glyph is the Play in the middle of a not-started card (C5).
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
@@ -123,6 +124,8 @@ The chosen rendering of each rule is in the lab under "Decided". The primitives
`color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus
turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height
(32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill;
the page's one hero field is `lg` (40): the header search, a glass pill floating over the page (the
YouTube/Gmail shape), and the command palette's field;
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
@@ -335,5 +338,6 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+3 -3
View File
@@ -1237,7 +1237,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<IconButton
variant="overlay"
size="player"
onMedia
round
onClick={handleBack}
label={t("plex.player.back")}
>
@@ -1485,7 +1485,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<IconButton
variant="overlay"
size="player"
onMedia
round
ref={tracksTriggerRef}
onClick={() => setTracksOpen((o) => !o)}
aria-expanded={tracksOpen}
@@ -1555,7 +1555,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<IconButton
variant="overlay"
size="player"
onMedia
round
ref={menuTriggerRef}
onClick={() => setMenuOpen((o) => !o)}
aria-expanded={menuOpen}
+1 -1
View File
@@ -118,7 +118,7 @@ export default function PlexSearchBox() {
leading={
<ActionMenu
ariaLabel={t("plex.searchField.aria")}
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
className="min-w-0 shrink inline-flex items-center gap-1 rounded-full px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
trigger={
<>
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
+14 -5
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import {
useRef,
type FocusEventHandler,
@@ -12,10 +13,12 @@ import { PaletteKeycap } from "./PaletteLauncher";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
// (feed, Plex, channels, playlists): a 40px glass pill with the A4 field edge (the page's one hero
// field, the YouTube/Gmail shape), holding a search icon, the input, a clear button and a "Go"
// trigger (Enter also fires it). What sits inside the pill is round too, so the radii nest. The feed passes its live-YouTube
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
//
// Optional slots keep the box reusable while letting one module enrich it: `leading` replaces the
@@ -72,10 +75,15 @@ export default function SearchBar({
// small pills (module name + Plex scope toggle / feed sync chip) share the line above it.
return (
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
<div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
{/* `free` + its own padding: the Go button sits 6px in from the pill's end, not a field's 12. */}
<div
className={clsx(
fieldClass({ size: "free", pill: true, floating: true }),
"flex h-10 items-center gap-2 pr-1.5 pl-3 text-sm pointer-coarse:text-base",
)}
>
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
<InnerInput
className="text-sm"
{...inputProps}
ref={inputRef}
value={value}
@@ -94,7 +102,7 @@ export default function SearchBar({
placeholder={placeholder}
/>
{value && (
<IconButton onClick={() => onChange("")} label={t("header.clearSearch")}>
<IconButton round onClick={() => onChange("")} label={t("header.clearSearch")}>
<X />
</IconButton>
)}
@@ -102,6 +110,7 @@ export default function SearchBar({
{onGo && (
<Button
size="sm"
round
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
+2 -2
View File
@@ -86,7 +86,7 @@ export function UpNextCard({
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
<div className="mt-auto flex items-center gap-2 pt-2.5">
<Button size="sm" onMedia onClick={onAdvance} className="text-xs font-semibold">
<Button size="sm" round onClick={onAdvance} className="text-xs font-semibold">
<span className="relative grid h-5.5 w-5.5 flex-none place-items-center">
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
<circle
@@ -117,7 +117,7 @@ export function UpNextCard({
<Button
variant="overlay"
size="sm"
onMedia
round
onClick={onCancel}
className="border border-white/25 text-xs"
>
+3 -3
View File
@@ -280,7 +280,7 @@ function Thumb({
<IconButton
variant="primary"
size="md"
onMedia
round
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
@@ -290,7 +290,7 @@ function Thumb({
) : (
<Button
size="sm"
onMedia
round
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
@@ -335,7 +335,7 @@ function Thumb({
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
rest of the hover chrome, dropped on touch. */}
<VideoInfoCard video={video} onMedia className={INFO_OVERLAY} />
<VideoInfoCard video={video} round className={INFO_OVERLAY} />
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
+3 -3
View File
@@ -32,12 +32,12 @@ import { IconButton } from "./ui/IconButton";
// so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({
video,
onMedia = false,
round = false,
className,
}: {
video: Video;
/** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
onMedia?: boolean;
round?: boolean;
/** Trigger placement + hover-reveal. The reveal/touch-suppression rules live with the host, which
* knows its surface (overlay vs inline); the skin is the IconButton's. */
className?: string;
@@ -95,7 +95,7 @@ export default function VideoInfoCard({
return (
<>
<IconButton
variant={onMedia ? "media" : "ghost"}
variant={round ? "media" : "ghost"}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
@@ -138,7 +138,7 @@ export function Ctrl({
<IconButton
variant="overlay"
size="player"
onMedia
round
noTitle
onClick={onClick}
disabled={disabled}
+7 -5
View File
@@ -69,7 +69,8 @@ const PRESSED =
// 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 is a pill instead (A2: round means "on media", and nothing else).
// 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",
@@ -97,8 +98,9 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
pressed?: boolean;
/** Sits on a picture (a thumbnail, a poster): the pill shape. Implied by the `media` variant. */
onMedia?: boolean;
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
* `media` variant. */
round?: boolean;
fullWidth?: boolean;
}
@@ -119,7 +121,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
label,
loading = false,
pressed,
onMedia = false,
round = false,
fullWidth = false,
className,
children,
@@ -158,7 +160,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
VARIANTS[variant],
pressed !== undefined && PRESSED,
SIZES[resolvedSize],
resolvedSize !== "none" && (onMedia || variant === "media" ? "rounded-full" : "rounded-lg"),
resolvedSize !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
touchTarget && "touch-target",
fullWidth && "w-full",
className,
@@ -194,9 +194,7 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
return (
<Modal title={t("palette.title")} onClose={onClose} maxWidth="max-w-xl" placement="top">
<div
className={clsx(fieldClass({ size: "free" }), "flex h-10 items-center gap-2 px-3 text-sm")}
>
<div className={clsx(fieldClass({ size: "lg" }), "flex items-center gap-2")}>
<Search className="w-4 h-4 shrink-0 text-muted" />
<InnerInput
autoFocus
+6 -5
View File
@@ -6,7 +6,8 @@ import { FocusSafeButton } from "./FocusSafeButton";
// 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
// translucent (`media`), readable on any thumbnail; round means "on media" and nothing else. The glyph
// translucent (`media`), readable on any thumbnail; round means "floats over something" (media, or a
// 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 =
@@ -67,8 +68,8 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
size?: IconButtonSize;
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
pressed?: boolean;
/** Sits on a picture: round (A2). Implied by the `media` variant. */
onMedia?: boolean;
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
round?: boolean;
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */
loading?: boolean;
/** Leave the tooltip to a richer one the caller renders (a `Tooltip` around the button). */
@@ -82,7 +83,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
variant = "ghost",
size = "sm",
pressed,
onMedia = false,
round = false,
loading = false,
noTitle = false,
type,
@@ -102,7 +103,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
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],
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && PRESSED,
SIZES[size],
className,
@@ -4,6 +4,7 @@
// back values, and the dropdown trigger by context.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { fieldClass } from "./fieldStyles";
import { Input, Textarea } from "./Input";
import { CheckRow, Slider } from "./NativeControls";
import { SelectTrigger } from "./SelectTrigger";
@@ -29,6 +30,15 @@ describe("Input", () => {
expect(search.getAttribute("aria-invalid")).toBe("true");
});
it("dims a wrapper frame for its disabled FIELD, never for a disabled button beside it", () => {
// The header search holds a Go button that is disabled while the query is empty; a bare
// `has-disabled:` faded the whole search box to 50% on the feed.
const frame = fieldClass({ size: "free", pill: true, floating: true });
expect(frame).not.toMatch(/(^| )has-disabled:/);
expect(frame).not.toMatch(/(^| )has-aria-invalid:/);
expect(frame).toContain("has-[input:disabled]:opacity-50");
});
it("lets a caller lay it out but not re-paint its edge", () => {
render(<Textarea aria-label="body" className="w-full" />);
const body = screen.getByRole("textbox", { name: "body" });
+22 -7
View File
@@ -6,6 +6,7 @@ import clsx from "clsx";
// itself. Its own file so `Input.tsx` exports components only (react-refresh).
export type FieldSize =
| "lg" // 40 — the page's one hero field: the header search, the command palette
| "md" // 32 — a form field, lines up with a `md` Button
| "sm" // 28 — a toolbar search, a field in a popover or an inline rename, lines up with the toolbar row
| "xs" // 24 — inside a table row or a pager
@@ -15,6 +16,7 @@ export type FieldSize =
// On a coarse pointer the text is 1rem, so a tablet browser does not zoom the page on focus (not on a
// heading, whose type is already larger).
const SIZES: Record<FieldSize, string> = {
lg: "h-10 px-3 text-sm pointer-coarse:text-base",
md: "h-8 px-3 text-sm pointer-coarse:text-base",
sm: "h-7 px-2.5 text-sm pointer-coarse:text-base",
xs: "h-6 px-2 text-xs pointer-coarse:text-base",
@@ -24,18 +26,31 @@ const SIZES: Record<FieldSize, string> = {
// The edge is --edge-strong (the fg mixed 55% into the bg, ≥ 3:1 on every scheme — WCAG 1.4.11); focus
// turns it into a 2px accent edge (the border plus a 1px ring), the outlined-field convention. Invalid
// is the danger edge, focused or not. On a coarse pointer the box grows to the 44 touch target.
// is the danger edge, focused or not. A wrapper frame keys both states to its own INPUT, not to any
// descendant: the header search's Go button is disabled while the query is empty, and a bare
// `has-disabled:` faded the whole box. On a coarse pointer the box grows to the 44 touch target.
const BASE =
"min-w-0 border edge-strong bg-transparent text-fg placeholder:text-muted transition-colors " +
"min-w-0 border edge-strong text-fg placeholder:text-muted transition-colors " +
"focus-within:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:outline-hidden " +
"aria-invalid:border-danger aria-invalid:ring-danger has-aria-invalid:border-danger has-aria-invalid:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 has-disabled:cursor-not-allowed has-disabled:opacity-50 " +
"aria-invalid:border-danger aria-invalid:ring-danger " +
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-danger " +
"disabled:cursor-not-allowed disabled:opacity-50 " +
"has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 " +
"pointer-coarse:min-h-(--touch-target)";
// `floating`: the field hovers over the scrolling page (the header search), so it keeps the glass-menu
// surface under its edge instead of a see-through one (A2: what floats over content is glass). The
// shadow is a utility so the focus ring's box-shadow composes with it instead of dropping it.
export function fieldClass({
size = "md",
pill = false,
}: { size?: FieldSize; pill?: boolean } = {}) {
// A4: a toolbar search is a pill; every other field r8.
return clsx(BASE, SIZES[size], pill ? "rounded-full" : "rounded-lg");
floating = false,
}: { size?: FieldSize; pill?: boolean; floating?: boolean } = {}) {
// A4: a search is a pill; every other field r8.
return clsx(
BASE,
SIZES[size],
pill ? "rounded-full" : "rounded-lg",
floating ? "glass-menu shadow-[0_18px_44px_-16px_rgba(0,0,0,0.6)]" : "bg-transparent",
);
}