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
+24 -48
View File
@@ -27,11 +27,9 @@ import {
ExternalLink,
FolderInput,
History,
GripVertical,
ListPlus,
Pencil,
Play,
RefreshCw,
RotateCcw,
Trash2,
Upload,
@@ -68,8 +66,8 @@ import { ColumnHead } from "./ui/tableHeader";
import { alignClass } from "./ui/tableAlign";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -146,21 +144,12 @@ function Row({
// The one cell the column defs can't render: the handle needs this row's sortable
// attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
// put and can explain itself instead of silently doing nothing.
<button
<DragHandle
{...attributes}
{...(dragDisabled ? {} : listeners)}
// `disabled` would drop it from the tab order AND suppress the tooltip, so the
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
aria-disabled={dragDisabled || undefined}
onClick={(e) => dragDisabled && e.preventDefault()}
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
aria-label={
dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })
}
className={`text-muted ${dragDisabled ? "opacity-30 cursor-not-allowed" : "hover:text-fg cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="w-4 h-4" />
</button>
unavailable={dragDisabled}
label={dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })}
/>
) : (
col.render(video)
)}
@@ -896,17 +885,15 @@ export default function Playlists() {
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
<IconButton
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
label={t("playlists.rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<Pencil />
</IconButton>
)}
{detail.yt_playlist_id && (
<a
@@ -932,37 +919,25 @@ export default function Playlists() {
{t("playlists.unsynced")}
</span>
)}
<FocusSafeButton
<Button
size="sm"
onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
icon={<Play className="fill-current" />}
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</FocusSafeButton>
{t("playlists.playAll")}
</Button>
{editable && canWrite && (
<FocusSafeButton
<IconButton
variant={detail.dirty ? "outline-accent" : "outline"}
onClick={pushToYoutube}
disabled={pushing}
title={
loading={pushing}
label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</FocusSafeButton>
{linked ? <Youtube /> : <Upload />}
</IconButton>
)}
{linked && detail.dirty && (
<IconButton
@@ -1009,12 +984,13 @@ export default function Playlists() {
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
<Button
variant="link-muted"
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
className="text-xs underline underline-offset-2"
>
{t("playlists.clearView")}
</button>
</Button>
)}
{canSaveOrder && (
<Button