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 -22
View File
@@ -17,7 +17,7 @@ import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -218,14 +218,15 @@ export default function ConfigPanel() {
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
disabled={dirty}
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</FocusSafeButton>
</Button>
</Tooltip>
</div>
)}
@@ -233,14 +234,15 @@ export default function ConfigPanel() {
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
disabled={dirty}
onClick={() => testIssues.mutate()}
disabled={testIssues.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
{t("config.issuesTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
</div>
@@ -249,14 +251,15 @@ export default function ConfigPanel() {
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
disabled={dirty}
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{plexResult && (
@@ -404,15 +407,14 @@ function Field({
)}
{showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}>
<button
<Button
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
icon={<RotateCcw className="size-3.5!" />}
onClick={onResetField}
className={`inline-flex items-center gap-1 text-xs transition ${
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
}`}
className="text-xs gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("config.reset")}
</button>
</Button>
</Tooltip>
)}
</div>