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
+39 -27
View File
@@ -82,6 +82,9 @@ import {
anyBlockingLayerAbove,
escapeHandled,
} from "../lib/layerStack";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -1231,13 +1234,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}`}
>
<div className="flex items-center gap-3 text-white">
<button
<IconButton
variant="overlay"
size="player"
onMedia
onClick={handleBack}
className="p-1.5 rounded-lg hover:bg-white/15"
title={t("plex.player.back")}
label={t("plex.player.back")}
>
<ArrowLeft className="w-5 h-5" />
</button>
<ArrowLeft />
</IconButton>
<div className="min-w-0">
<div className="font-semibold truncate">
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
@@ -1270,9 +1275,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
{activeMarker && (
<button
<Button
variant="media"
onClick={() => doSkip(activeMarker)}
className={`absolute right-6 bottom-28 overflow-hidden rounded-lg bg-white/90 px-4 py-2 text-sm font-medium text-black transition hover:bg-white ${
className={`absolute right-6 bottom-28 overflow-hidden ${
uiVisible ? "opacity-100" : "opacity-80"
}`}
>
@@ -1295,7 +1301,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
? t("plex.player.skipIntro")
: t("plex.player.skipCredits")}
</span>
</button>
</Button>
)}
{/* Bottom controls */}
@@ -1476,14 +1482,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
<div className="relative">
<button
<IconButton
variant="overlay"
size="player"
onMedia
ref={tracksTriggerRef}
onClick={() => setTracksOpen((o) => !o)}
aria-label={t("plex.player.tracks")}
className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`}
aria-expanded={tracksOpen}
label={t("plex.player.tracks")}
>
<Languages className="w-5 h-5" />
</button>
<Languages />
</IconButton>
{tracksOpen && (
<div
ref={setTracksNode}
@@ -1496,42 +1505,42 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<>
<PanelHead>{t("plex.player.audio")}</PanelHead>
{detail.audio_streams.map((a) => (
<button
<Pressable
key={a.ord}
onClick={() => changeAudio(a.ord)}
aria-pressed={(audioOrd ?? 0) === a.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
>
{a.label}
</button>
</Pressable>
))}
</>
)}
{textSubs.length > 0 && (
<>
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<button
<Pressable
onClick={() => changeSubtitle(null)}
aria-pressed={subOrd == null}
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
className={`block w-full rounded px-2 py-1 hover:bg-white/10 ${
subOrd == null ? "text-accent" : "text-white"
}`}
>
{t("plex.player.subOff")}
</button>
</Pressable>
{textSubs.map((s) => (
<button
<Pressable
key={s.ord}
onClick={() => changeSubtitle(s.ord)}
aria-pressed={subOrd === s.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
className={`block w-full truncate rounded px-2 py-1 hover:bg-white/10 ${
subOrd === s.ord ? "text-accent" : "text-white"
}`}
>
{s.label}
</button>
</Pressable>
))}
</>
)}
@@ -1543,14 +1552,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
{detail && (
<div className="relative">
<button
<IconButton
variant="overlay"
size="player"
onMedia
ref={menuTriggerRef}
onClick={() => setMenuOpen((o) => !o)}
aria-label={t("plex.player.settings")}
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
aria-expanded={menuOpen}
label={t("plex.player.settings")}
>
<Settings className="w-5 h-5" />
</button>
<Settings />
</IconButton>
{menuOpen && (
<PlexPlayerSettings
menuRef={menuRef}