diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index 80ebce67..682388d3 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -34,6 +34,7 @@ import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
+import { Button } from "./ui/Button";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -441,21 +442,18 @@ export default function ChannelPage({
it) so a large channel's paced tail reads as visible progress rather than a stuck
spinner. */}
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
- !ch.deep_requested && deepBackfill.mutate()}
disabled={ch.deep_requested || deepBackfill.isPending}
+ loading={ch.deep_requested}
+ icon={ }
title={
ch.deep_requested
? t("channel.fullHistoryLoadingHint")
: t("channel.fullHistory")
}
- className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-60 disabled:hover:text-muted disabled:hover:border-border transition"
>
- {ch.deep_requested ? (
-
- ) : (
-
- )}
{!ch.deep_requested
? t("channel.fullHistory")
@@ -466,7 +464,7 @@ export default function ChannelPage({
})
: t("channel.fullHistoryLoading")}
-
+
)}
{!me.is_demo && (
}
>
-
{t("channel.subscribedState")}
-
+
) : (
- subscribe.mutate()}
disabled={subscribe.isPending}
- className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm btn-primary disabled:opacity-50"
+ icon={ }
>
-
{t("channel.subscribe")}
-
+
))}
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx
index b649feda..41e58e19 100644
--- a/frontend/src/components/ShareDialog.tsx
+++ b/frontend/src/components/ShareDialog.tsx
@@ -9,7 +9,8 @@ import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { api, type DownloadJob, type ShareLink } from "../lib/api";
-import { inputCls, btnCls } from "./ui/form";
+import { inputCls } from "./ui/form";
+import { Button } from "./ui/Button";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
@@ -343,31 +344,29 @@ function LinkShare({ job }: { job: DownloadJob }) {
autoComplete="new-password"
/>
- setCreating(false)}
- className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
- >
+ setCreating(false)}>
{t("common.cancel")}
-
-
+ create.mutate()}
disabled={create.isPending}
- className={clsx(btnCls, "btn-primary")}
>
{t("downloads.share.createLink")}
-
+
) : (
- setCreating(true)}
- className={clsx(
- btnCls,
- "mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5",
- )}
+ icon={ }
+ className="mt-2"
>
- {t("downloads.share.newLink")}
-
+ {t("downloads.share.newLink")}
+
)}
);
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index b23de353..bab983f6 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -2,22 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- Crop,
- Eye,
- EyeOff,
- Pause,
- Play,
- Scissors,
- SplitSquareHorizontal,
- Loader2,
- X,
-} from "lucide-react";
+import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
import clsx from "clsx";
import Modal from "./Modal";
import { notify } from "../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { inputCls, btnCls } from "./ui/form";
+import { Button } from "./ui/Button";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
@@ -621,28 +612,23 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
-
+
{t("common.cancel")}
-
-
+ create.mutate()}
- disabled={!valid || create.isPending}
- className={clsx(btnCls, "btn-primary inline-flex items-center gap-1.5")}
+ disabled={!valid}
+ loading={create.isPending}
+ icon={ }
>
- {create.isPending ? (
-
- ) : (
-
- )}
{willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
-
+
diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx
new file mode 100644
index 00000000..ec414b66
--- /dev/null
+++ b/frontend/src/components/ui/Button.dom.test.tsx
@@ -0,0 +1,62 @@
+// @vitest-environment jsdom
+//
+// S5: the shared primitive. These lock the load-bearing contract the 120+ migrated call
+// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
+// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
+// stops an action button from submitting a surrounding form.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { render, screen, cleanup, fireEvent } from "@testing-library/react";
+import { Button } from "./Button";
+
+afterEach(cleanup);
+
+describe("Button primitive (S5)", () => {
+ it("defaults to type=button so it never submits a form", () => {
+ render(Go );
+ expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button");
+ });
+
+ it("applies the variant paint recipe", () => {
+ render(
+
+ Delete
+ ,
+ );
+ const btn = screen.getByRole("button", { name: "Delete" });
+ expect(btn.className).toContain("btn-danger");
+ expect(btn.className).toContain("py-1.5");
+ });
+
+ it("exposes an accessible name for an icon-only button while dropping the text", () => {
+ render(
+ } label="Block channel">
+ this text is dropped
+ ,
+ );
+ const btn = screen.getByRole("button", { name: "Block channel" });
+ expect(btn.getAttribute("title")).toBe("Block channel");
+ expect(btn.textContent).not.toContain("this text is dropped");
+ expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
+ });
+
+ it("loading disables the button, marks aria-busy, and blocks clicks", () => {
+ const onClick = vi.fn();
+ render(
+ } onClick={onClick}>
+ Save
+ ,
+ );
+ const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(btn.disabled).toBe(true);
+ expect(btn.getAttribute("aria-busy")).toBe("true");
+ // the leading icon is swapped for a spinner
+ expect(btn.querySelector("[data-testid=ic]")).toBeNull();
+ fireEvent.click(btn);
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ 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
new file mode 100644
index 00000000..024adca5
--- /dev/null
+++ b/frontend/src/components/ui/Button.tsx
@@ -0,0 +1,100 @@
+import { forwardRef } from "react";
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+import clsx from "clsx";
+import { Loader2 } from "lucide-react";
+
+// The ONE action-button primitive. Before this there were 385 raw s styling themselves with
+// ad-hoc utility strings over the CSS paint-recipes (.btn-primary/.btn-danger, index.css) + the btnCls
+// constant. This centralises the geometry+paint so an "action button" (submit/cancel/subscribe/delete…)
+// is one component with named variants. It deliberately does NOT cover the app's OTHER button families,
+// which keep their own primitives: aria-pressed segmented pills (SegmentedToggle/SortControl), the
+// Switch toggle, .chip removers, dropdown/popover trigger pills, and slider/overlay/card-click buttons.
+//
+// No focus-visible here on purpose: @layer base in index.css already rings every for keyboard
+// users. Adding one would double the ring.
+
+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)
+ | "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
+
+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)
+
+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",
+ glass: "glass-card glass-hover hover:text-accent",
+ "danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
+};
+
+const SIZES: Record = {
+ xs: "px-2 py-1 text-xs rounded-md gap-1",
+ sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
+ 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",
+};
+
+const BASE =
+ "inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed";
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ /** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
+ icon?: ReactNode;
+ /** Render only the icon: children are dropped and `label` supplies the accessible name. */
+ iconOnly?: boolean;
+ /** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
+ label?: string;
+ /** Disable + swap the icon for a spinner + mark aria-busy. */
+ loading?: boolean;
+ fullWidth?: boolean;
+}
+
+/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
+export const Button = forwardRef(function Button(
+ {
+ variant = "primary",
+ size = "md",
+ icon,
+ iconOnly = false,
+ label,
+ loading = false,
+ fullWidth = false,
+ className,
+ children,
+ disabled,
+ type,
+ ...rest
+ },
+ ref,
+) {
+ const leading = loading ? : icon;
+ return (
+
+ {leading}
+ {!iconOnly && children}
+
+ );
+});