feat(e10): finish the mappable Button migration, including the ui/ layer itself

Third sweep, 20 more sites: the rest of AdminUsers (the access-request
and invite clusters), Scheduler, SettingsPanel, SetupWizard, About, Feed,
KeyGate, OnboardingWizard -- and, importantly, four platform components
that were still hand-rolling their own buttons: Modal's close, Banner's
dismiss, SidePanel's two toggles and QueryState's retry. A platform layer
that bypasses its own primitive is the same defect one level up.

Hand-rolled buttons with their own geometry: 110 -> 39; Button call sites
37 -> 106. What is left is deliberate, and it is the list Button's own
docstring excludes plus two families this pass refused to force: video
overlay controls painted on white/N, drag handles, chip removers,
accent-solid pills, the bg-card family on the signed-out screen, and
sites whose paint is conditional (`suspended ? warn : neutral`) or
otherwise has no variant yet. Forcing those through a className override
would be unsafe without tailwind-merge, which is exactly the hazard this
sprint documented.

3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Settings and the
About dialog (its close button and full-width CTA are both migrated),
clean console.
This commit is contained in:
2026-09-05 18:41:41 +02:00
parent 47e2293de3
commit 721467ef69
12 changed files with 90 additions and 93 deletions
+7 -5
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react";
import { Button } from "./Button";
type BannerTone = "accent" | "warning";
@@ -56,13 +57,14 @@ export default function Banner({
</button>
)}
{onDismiss && (
<button
<Button
variant="ghost"
size="icon-sm"
className="ml-auto"
onClick={onDismiss}
title={dismissTitle}
className="ml-auto p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
)}
</div>
);
+7 -5
View File
@@ -5,6 +5,7 @@ import { X } from "lucide-react";
import { useBackToClose } from "../../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
import { useFullscreenElement } from "../../lib/fullscreen";
import { Button } from "./Button";
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
@@ -141,14 +142,15 @@ export default function Modal({
<h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
<button
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onClose}
title={t("common.close")}
aria-label={t("common.close")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
</div>
<div className="px-5 pb-5">{children}</div>
</div>
+3 -6
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "./Button";
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
// visual for a page/section's first load, so all of them read the same (and can be swapped for
@@ -48,13 +49,9 @@ export function StateMessage({
{children}
</span>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
>
<Button variant="outline" size="xs" type="button" onClick={onRetry}>
{t("common.retry")}
</button>
</Button>
)}
</div>
);
+12 -8
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { Button } from "./Button";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
@@ -101,14 +102,16 @@ export default function SidePanel({
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
<button
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 -ml-1"
onClick={onToggleCollapse}
title={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
</Button>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? (
<button
@@ -133,13 +136,14 @@ export default function SidePanel({
</span>
)}
{editing && onReset && (
<button
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onReset}
title={t("sidebar.resetDefaults")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<RotateCcw className="w-4 h-4" />
</button>
icon={<RotateCcw className="w-4 h-4" />}
/>
)}
{onToggleEditing && (
<button