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

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

- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
  floor, 44 on touch) replace five chip geometries; FilterChipGroup
  renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
  round, overlay; 44 on touch); the YT modal bar is on the sm rung with
  pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
  new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
  add) announce checked/mixed; a one-letter tag chip was 21px wide;
  descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
  control-primitive files is a lint error (contract-tested, seen red).
This commit is contained in:
2026-09-24 23:03:23 +02:00
parent 86f467bd65
commit 81936e9f56
94 changed files with 1327 additions and 1093 deletions
+19 -18
View File
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges";
import { useListNav } from "../../lib/listNav";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
@@ -95,10 +97,10 @@ export function Carousel({
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
// Round, dark translucent: they sit over the pictures (A2).
const arrowBtn =
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100";
return (
<div className={clsx("group/car relative", className)}>
@@ -106,27 +108,25 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<button
onClick={onSeeAll}
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
>
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</button>
</Button>
)}
</div>
)}
<div className="relative">
{left && (
<button
type="button"
<IconButton
variant="media"
size="md"
onClick={() => page(-1)}
aria-label={t("common.scrollLeft")}
label={t("common.scrollLeft")}
className={clsx(arrowBtn, "left-1")}
>
<ChevronLeft className="h-5 w-5" />
</button>
<ChevronLeft />
</IconButton>
)}
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
@@ -140,14 +140,15 @@ export function Carousel({
{children}
</div>
{right && (
<button
type="button"
<IconButton
variant="media"
size="md"
onClick={() => page(1)}
aria-label={t("common.scrollRight")}
label={t("common.scrollRight")}
className={clsx(arrowBtn, "right-1")}
>
<ChevronRight className="h-5 w-5" />
</button>
<ChevronRight />
</IconButton>
)}
</div>
</div>