diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index ab70e155..9d6b9b0b 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -26,6 +26,7 @@ import {
import { notify } from "../lib/notifications";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
+import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { Section } from "./ui/form";
@@ -244,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.roles.makeAdmin")
}
>
-
+
-
+ icon={
+ u.is_suspended ? (
+
+ ) : (
+
+ )
+ }
+ />
-
+ icon={}
+ />
);
@@ -435,25 +438,28 @@ function PendingAccessRowView({
{!row.email_verified && (
-
+ icon={}
+ />
)}
{row.can_activate && (
-
+
)}
@@ -673,12 +679,14 @@ function AdminInvites() {
placeholder={t("settings.invites.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.invites.add")}
-
+ {t("settings.invites.add")}
+
{decided.length > 0 && (
diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx
index 0631b678..e382a24a 100644
--- a/frontend/src/components/AuditLog.tsx
+++ b/frontend/src/components/AuditLog.tsx
@@ -6,6 +6,7 @@ import { Trash2 } from "lucide-react";
import { api } from "../lib/api";
import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
+import { Button } from "./ui/Button";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import DataTable from "./ui/DataTable";
@@ -72,14 +73,15 @@ export default function AuditLog() {
persistKey={LS.auditTable}
controlsPosition="top"
controlsLeading={
- }
>
-
{t("audit.clear")}
-
+
}
emptyText={t("audit.empty")}
/>
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 2edc6817..1fff9b9c 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
import { notify } from "../lib/notifications";
+import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs";
@@ -489,13 +490,15 @@ export default function Channels() {
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
-
+
{tagFilter.length > 0 && (
@@ -518,12 +521,9 @@ export default function Channels() {
{highConfidence.length > 0 && (
-
)}
)}
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index 2d82d550..2dc648a0 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -42,6 +42,7 @@ import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
+import { Button } from "./ui/Button";
import UndoToolbar from "./ui/UndoToolbar";
import { PageToolbar } from "./ui/PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
@@ -585,12 +586,9 @@ export default function Playlists() {
{t("playlists.loadFailed")}
- detailQuery.refetch()}
- className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
- >
+ detailQuery.refetch()}>
{t("common.retry")}
-
+
);
}
@@ -703,25 +701,26 @@ export default function Playlists() {
)}
{linked && detail.dirty && (
-
-
+
)}
{editable && (
-
-
-
+ icon={}
+ />
)}
@@ -755,12 +754,14 @@ export default function Playlists() {
)}
{canSaveOrder && (
- }
>
- {t("playlists.saveOrder")}
-
+ {t("playlists.saveOrder")}
+
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx
index a23aab7f..918ab859 100644
--- a/frontend/src/components/PlaylistsRail.tsx
+++ b/frontend/src/components/PlaylistsRail.tsx
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
+import { Button } from "./ui/Button";
import { type PanelItem } from "./ui/PanelGroups";
import SortControl from "./ui/SortControl";
import { type SortOption } from "../lib/columnSort";
@@ -147,14 +148,10 @@ export default function PlaylistsRail({
title: t("playlists.manageGroup"),
body: (
-
+
{t("playlists.syncYoutube")}
-
+
{playlists.length > 1 && (
) : (
-
+
{t("settings.account.enable")}
-
+
)}
@@ -676,7 +673,7 @@ export function DownloadAuth() {
{h.configured && (
onRemove(h.host_key)}
disabled={remove.isPending}
diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx
index 156e457e..3870887c 100644
--- a/frontend/src/components/TagManager.tsx
+++ b/frontend/src/components/TagManager.tsx
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade";
+import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
@@ -203,13 +204,15 @@ export default function TagManager({
placeholder={t("tagManager.newPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
-
{t("tagManager.add")}
-
+
diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx
index 6db53608..dc20d45a 100644
--- a/frontend/src/components/ui/Button.dom.test.tsx
+++ b/frontend/src/components/ui/Button.dom.test.tsx
@@ -71,6 +71,33 @@ describe("Button primitive (S5)", () => {
expect(onClick).not.toHaveBeenCalled();
});
+ it("a link gets no button padding, and an explicit size still wins", () => {
+ // The size default is variant-dependent (link → "none"). Locking it because the failure is
+ // silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
+ const { rerender } = render(Show all);
+ let btn = screen.getByRole("button", { name: "Show all" });
+ expect(btn.className).toContain("text-accent");
+ expect(btn.className).not.toContain("px-3");
+ rerender(
+
+ Show all
+ ,
+ );
+ btn = screen.getByRole("button", { name: "Show all" });
+ expect(btn.className).toContain("px-3");
+ });
+
+ it("keeps one font-weight utility whatever the variant", () => {
+ // Two font-* utilities of equal specificity are decided by stylesheet order, not class order,
+ // so a variant must never add a second one (there is no tailwind-merge here).
+ for (const variant of ["primary", "link", "glass", "outline"] as const) {
+ const { unmount } = render(X);
+ const cls = screen.getByRole("button", { name: "X" }).className;
+ expect(cls.split(/\s+/).filter((c) => c.startsWith("font-")).length).toBe(1);
+ unmount();
+ }
+ });
+
it("fullWidth adds the w-full utility", () => {
render(Wide);
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
index 12b44960..31baf14b 100644
--- a/frontend/src/components/ui/Button.tsx
+++ b/frontend/src/components/ui/Button.tsx
@@ -17,25 +17,45 @@ type ButtonVariant =
| "primary" // solid accent — the main affirmative action
| "danger" // solid danger — destructive confirm
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
+ | "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
+ | "outline-danger" // neutral at rest, danger on hover — the row-level Delete icon
+ | "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons
- | "danger-outline"; // bordered danger — a destructive action that shouldn't shout
+ | "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
+ | "link"; // inline text link — "Show all", "Learn more", a resend-mail prompt inside a sentence
type ButtonSize =
| "xs" // dense inline action
| "sm" // the btnCls geometry (px-3 py-1.5)
| "md" // default action button (px-3 py-2)
| "lg" // prominent CTA
- | "icon"; // square icon-only (w-9 h-9)
+ | "icon" // square icon-only (w-9 h-9)
+ | "icon-sm" // square icon-only, dense (w-8 h-8) — toolbars and table rows
+ | "none"; // no padding/radius of its own — the `link` variant's default
const VARIANTS: Record = {
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
primary: "btn-primary",
danger: "btn-danger",
- outline: "border border-border text-muted hover:text-fg hover:border-fg/40",
- ghost: "text-muted hover:text-fg hover:bg-surface",
+ // E10 S1b: the neutral variants were RE-AIMED at the paint the app actually uses. Counting the
+ // un-migrated call sites showed the majority hovering to `accent` (outline) and filling with `card`
+ // (ghost), while this primitive hovered to `fg` and filled with `surface` — so the 37 migrated sites
+ // and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
+ // inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
+ outline: "border border-border text-muted hover:text-accent hover:border-accent",
+ "outline-accent": "border border-accent/40 text-accent hover:bg-accent/10",
+ // Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
+ // is whether the button should advertise the destruction before the pointer arrives.
+ "outline-danger": "border border-border text-muted hover:text-danger hover:border-danger/40",
+ "outline-warn": "border border-warn/40 text-warn hover:bg-warn/10",
+ ghost: "text-muted hover:text-fg hover:bg-card",
glass: "glass-card glass-hover hover:text-accent",
- "danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
+ "danger-soft": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
+ // No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
+ // be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
+ // the winner is decided by the generated stylesheet order, not by the class list order).
+ link: "text-accent hover:underline",
};
const SIZES: Record = {
@@ -44,6 +64,8 @@ const SIZES: Record = {
md: "px-3 py-2 text-sm rounded-lg gap-1.5",
lg: "px-4 py-2.5 text-base rounded-lg gap-2",
icon: "w-9 h-9 rounded-lg justify-center",
+ "icon-sm": "w-8 h-8 rounded-lg justify-center",
+ none: "gap-1.5",
};
const BASE =
@@ -51,6 +73,7 @@ const BASE =
export interface ButtonProps extends ButtonHTMLAttributes {
variant?: ButtonVariant;
+ /** Defaults to "md", except for `variant="link"`, whose default is "none" (a link owns no padding). */
size?: ButtonSize;
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
icon?: ReactNode;
@@ -67,7 +90,7 @@ export interface ButtonProps extends ButtonHTMLAttributes {
export const Button = forwardRef(function Button(
{
variant = "primary",
- size = "md",
+ size,
icon,
iconOnly = false,
label,
@@ -81,6 +104,10 @@ export const Button = forwardRef(function Button
},
ref,
) {
+ // A link must not inherit the action-button padding/radius, so its size default differs. Resolved
+ // here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
+ // a pairing everyone would eventually forget, which is how a "link" grows a button's box.
+ const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
const leading = loading ? : icon;
return (
(function Button
aria-busy={loading || undefined}
aria-label={label}
title={label}
- className={clsx(BASE, VARIANTS[variant], SIZES[size], fullWidth && "w-full", className)}
+ className={clsx(
+ BASE,
+ VARIANTS[variant],
+ SIZES[resolvedSize],
+ fullWidth && "w-full",
+ className,
+ )}
{...rest}
>
{leading}