From 721467ef69982f4ee3f5a07fdce6c18cfb88cb95 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 5 Sep 2026 18:41:41 +0200
Subject: [PATCH] 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.
---
frontend/src/components/About.tsx | 8 ++-
frontend/src/components/AdminUsers.tsx | 51 ++++++++++----------
frontend/src/components/Feed.tsx | 15 +++---
frontend/src/components/KeyGate.tsx | 8 +--
frontend/src/components/OnboardingWizard.tsx | 15 +++---
frontend/src/components/Scheduler.tsx | 9 ++--
frontend/src/components/SettingsPanel.tsx | 15 +++---
frontend/src/components/SetupWizard.tsx | 9 ++--
frontend/src/components/ui/Banner.tsx | 12 +++--
frontend/src/components/ui/Modal.tsx | 12 +++--
frontend/src/components/ui/QueryState.tsx | 9 ++--
frontend/src/components/ui/SidePanel.tsx | 20 +++++---
12 files changed, 90 insertions(+), 93 deletions(-)
diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx
index 1b28abeb..9da74868 100644
--- a/frontend/src/components/About.tsx
+++ b/frontend/src/components/About.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
+import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
function fmtDate(d?: string | null): string {
@@ -70,12 +71,9 @@ export default function About({
))}
-
);
}
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index 9d6b9b0b..55333a27 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -828,14 +828,15 @@ function AdminDemo() {
)}
- onRemove(r)}
disabled={remove.isPending}
- className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}
- >
-
-
+ icon={}
+ />
))}
@@ -856,12 +857,9 @@ function AdminDemo() {
placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
-
+ {t("settings.demo.add")}
-
+
@@ -917,24 +915,26 @@ function InviteRow({
)}
-
-
-
+ icon={}
+ />
-
-
-
+ icon={}
+ />
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
@@ -954,14 +954,15 @@ function InviteRow({
) : (
-
-
-
+ icon={}
+ />
)}
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index 46fb5979..9d55c5bf 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -38,6 +38,7 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
+import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { useWizard } from "./WizardProvider";
@@ -530,20 +531,18 @@ export default function Feed({
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
-
+
{t("settings.account.deleteAccount")}
-
+
)}
>
diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx
index 9b53654d..705e53fe 100644
--- a/frontend/src/components/SetupWizard.tsx
+++ b/frontend/src/components/SetupWizard.tsx
@@ -4,6 +4,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
import { api, HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
+import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
@@ -239,15 +240,17 @@ export default function SetupWizard() {
placeholder={t("setup.smtp.testTo")}
className={inputCls}
/>
-
{t("setup.smtp.test")}
-
+
)}
{testState === "ok" && (
diff --git a/frontend/src/components/ui/Banner.tsx b/frontend/src/components/ui/Banner.tsx
index e175ab9b..afcba0a1 100644
--- a/frontend/src/components/ui/Banner.tsx
+++ b/frontend/src/components/ui/Banner.tsx
@@ -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({
)}
{onDismiss && (
-
-
-
+ icon={}
+ />
)}
);
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
index fb819d8b..20db87a0 100644
--- a/frontend/src/components/ui/Modal.tsx
+++ b/frontend/src/components/ui/Modal.tsx
@@ -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({
{title}
-
-
-
+ icon={}
+ />
{children}
diff --git a/frontend/src/components/ui/QueryState.tsx b/frontend/src/components/ui/QueryState.tsx
index fa397957..2aa85523 100644
--- a/frontend/src/components/ui/QueryState.tsx
+++ b/frontend/src/components/ui/QueryState.tsx
@@ -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}
{onRetry && (
-
+
{t("common.retry")}
-
+
)}
);
diff --git a/frontend/src/components/ui/SidePanel.tsx b/frontend/src/components/ui/SidePanel.tsx
index 91cdbdee..99169f37 100644
--- a/frontend/src/components/ui/SidePanel.tsx
+++ b/frontend/src/components/ui/SidePanel.tsx
@@ -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. */}