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() { )} - + 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" /> - +
@@ -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")}

{isDemo ? ( - + ) : ( <> - + + {configured && ( - + )} @@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () > {t("onboarding.write.enableUnsubscribe")} - + )} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 6fae8a9c..eb5cdd5b 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; @@ -283,13 +284,9 @@ function MaintenanceSettings({ aria-label={t("scheduler.maintenance.batchLabel")} className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - +
{t("scheduler.maintenance.batchNote", { diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 26c77128..e473d7b8 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -495,13 +495,15 @@ function SignInMethods({ me }: { me: Me }) { {t("settings.account.sessions.hint")}

- + @@ -830,13 +832,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {

{t("settings.account.deleteHint")}

- + )} 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} /> - + )} {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 && ( - + )} ); 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. */}
- + {iconExpanded ?? {icon}} {onTitleClick ? ( + icon={} + /> )} {onToggleEditing && (