refactor(ui): R10 — .btn-primary recipe for the primary/action buttons

Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text +
hover:opacity dim) in index.css @layer components, alongside the selection-paint
recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24
files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping
each site's geometry (padding/radius/font/gap/disabled/shadow).

Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble,
selection toggles (tableHeader — R8 popover territory), and the round play FAB
(hover:scale, not the opacity-dim primary idiom). Three previously hover-less
primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover
dim for consistency.

Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated
button computes to accent/accent-fg and both recipe rules load.
This commit is contained in:
2026-08-11 04:04:06 +02:00
parent 87da6155e1
commit 76f8de258c
25 changed files with 46 additions and 41 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ export default function About({
<button
onClick={onOpenReleaseNotes}
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
</button>
+1 -1
View File
@@ -8,7 +8,7 @@ const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> =
accent: {
bar: "bg-accent/15 border-accent/30",
icon: "text-accent",
action: "bg-accent text-accent-fg hover:opacity-90",
action: "btn-primary",
},
warning: {
bar: "bg-warn/15 border-warn/30",
+1 -1
View File
@@ -460,7 +460,7 @@ export default function ChannelPage({
<button
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm btn-primary disabled:opacity-50"
>
<Plus className="w-4 h-4" />
{t("channel.subscribe")}
+1 -1
View File
@@ -216,7 +216,7 @@ export default function ChatThread({
<button
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="shrink-0 p-2.5 rounded-xl bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
aria-label={t("messages.send")}
title={t("messages.send")}
>
+1 -5
View File
@@ -116,11 +116,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
onClick={() => close(c.id)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
multi ? "w-full" : ""
} ${
c.danger
? "bg-danger text-danger-fg hover:opacity-90"
: "bg-accent text-accent-fg hover:opacity-90"
}`}
} ${c.danger ? "bg-danger text-danger-fg hover:opacity-90" : "btn-primary"}`}
>
{c.label || t("common.confirm")}
</button>
+4 -4
View File
@@ -390,7 +390,7 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.edit.save")}
</button>
@@ -448,7 +448,7 @@ function ErrorModal({
onRetry();
onClose();
}}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition"
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
>
{t("downloads.actions.retry")}
</button>
@@ -607,7 +607,7 @@ function QuotaModal({
<button
onClick={() => save.mutate()}
disabled={save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.admin.save")}
</button>
@@ -894,7 +894,7 @@ export default function DownloadCenter() {
</button>
<button
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition shrink-0"
>
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
</button>
+1 -1
View File
@@ -98,7 +98,7 @@ export default function DownloadDialog({
<button
onClick={submit}
disabled={busy || !chosen}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{t("downloads.dialog.submit")}
</button>
+1 -1
View File
@@ -14,7 +14,7 @@ export default function ErrorDialog() {
<div className="flex justify-end mt-5">
<button
onClick={dismissError}
className="px-4 py-2 rounded-lg bg-accent text-accent-fg text-sm font-medium hover:opacity-90 transition"
className="px-4 py-2 rounded-lg btn-primary text-sm font-medium transition"
>
{t("errors.ok")}
</button>
+3 -3
View File
@@ -522,7 +522,7 @@ export default function Feed({
{isDemo ? (
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
>
{t("feed.browseLibrary")}
</button>
@@ -530,7 +530,7 @@ export default function Feed({
<>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
>
{t("feed.setUp")}
</button>
@@ -711,7 +711,7 @@ export default function Feed({
{filters.q.trim() && !isDemo && (
<button
onClick={() => onYtSearch(filters.q.trim())}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Youtube className="w-4 h-4" />
{t("feed.yt.searchFor", { query: filters.q.trim() })}
+1 -1
View File
@@ -160,7 +160,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
<button
onClick={submit}
disabled={busy || !pass}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition disabled:opacity-40"
>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
+1 -1
View File
@@ -125,7 +125,7 @@ function ConversationList({
{ready && (
<button
onClick={onNew}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-accent text-accent-fg hover:opacity-90 transition"
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg btn-primary transition"
>
<SquarePen className="w-4 h-4" />
{t("messages.new")}
+3 -3
View File
@@ -155,7 +155,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("read")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
>
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
@@ -221,7 +221,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mt-5 flex flex-col gap-2">
<button
onClick={() => beginGrant("write")}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
@@ -252,7 +252,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
endOnboarding(false);
onClose();
}}
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition w-full"
>
{t("onboarding.done.done")}
</button>
+2 -2
View File
@@ -1088,7 +1088,7 @@ export default function PlayerModal({
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
@@ -1314,7 +1314,7 @@ export default function PlayerModal({
className={
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
? "bg-accent text-accent-fg shadow-xs hover:opacity-90"
? "btn-primary shadow-xs"
: "text-muted hover:text-fg hover:bg-surface border border-border")
}
>
+1 -1
View File
@@ -630,7 +630,7 @@ export default function Playlists() {
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
+1 -1
View File
@@ -673,7 +673,7 @@ function ActionBtn({
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
primary ? "bg-accent text-accent-fg shadow-xs hover:opacity-90" : "glass-card glass-hover"
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
+2 -2
View File
@@ -147,7 +147,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
{!formOpen ? (
<button
onClick={startNew}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
>
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
</button>
@@ -272,7 +272,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
>
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
</button>
+1 -1
View File
@@ -480,7 +480,7 @@ function SignInMethods({ me }: { me: Me }) {
<button
type="submit"
disabled={busy || !next}
className="px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
className="px-3 py-1.5 rounded-lg text-sm btn-primary font-medium disabled:opacity-40 transition"
>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</button>
+1 -1
View File
@@ -278,7 +278,7 @@ export default function SetupWizard() {
<button
onClick={next}
disabled={busy}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-50 transition"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-50 transition"
>
{busy ? (
<Loader2 className="w-4 h-4 animate-spin" />
+2 -2
View File
@@ -216,7 +216,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<button
onClick={() => savePass.mutate(newPass.trim())}
disabled={savePass.isPending || !newPass.trim()}
className="px-2 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-40 transition"
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition"
>
{t("downloads.share.savePassword")}
</button>
@@ -352,7 +352,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
<button
onClick={() => create.mutate()}
disabled={create.isPending}
className={clsx(btnCls, "bg-accent text-accent-fg hover:opacity-90")}
className={clsx(btnCls, "btn-primary")}
>
{t("downloads.share.createLink")}
</button>
+1 -1
View File
@@ -277,7 +277,7 @@ function Thumb({
title={t("card.continueTitle")}
aria-label={t("card.continue")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition",
"inline-flex items-center justify-center gap-1.5 font-semibold btn-primary shadow-lg transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm",
)}
>
+1 -4
View File
@@ -630,10 +630,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<button
onClick={() => create.mutate()}
disabled={!valid || create.isPending}
className={clsx(
btnCls,
"bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5",
)}
className={clsx(btnCls, "btn-primary inline-flex items-center gap-1.5")}
>
{create.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
+1 -1
View File
@@ -196,7 +196,7 @@ export default function WatchPage() {
<button
onClick={unlock}
disabled={!password || unlocking}
className="mt-4 w-full rounded-lg bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 py-2 text-sm font-medium transition"
className="mt-4 w-full rounded-lg btn-primary disabled:opacity-50 py-2 text-sm font-medium transition"
>
{T.watch}
</button>
+1 -1
View File
@@ -501,7 +501,7 @@ function AuthCard() {
<button
type="submit"
disabled={busy}
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
className="px-4 py-2.5 rounded-xl font-semibold btn-primary disabled:opacity-50 transition"
>
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
</button>
+1 -1
View File
@@ -71,7 +71,7 @@ export function DraftSaveBar({
data-testid="draft-save"
onClick={onSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saving ? labels.saving : labels.save}
+12
View File
@@ -496,6 +496,18 @@ body {
color: var(--accent-fg);
border-color: var(--accent);
}
/* .btn-primary — the primary/ACTION button paint (solid accent). Distinct from the selection
recipes above: this is "the primary action", not "selected among options". PAINT-ONLY like
.chip-solid — a site keeps its own geometry (padding/radius/font/gap/disabled:opacity/transition).
Migrated sites MUST drop `bg-accent text-accent-fg hover:opacity-90` so this @layer-components
rule isn't beaten by those utilities. No focus-visible — @layer base already rings every button. */
.btn-primary {
background-color: var(--accent);
color: var(--accent-fg);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
}
.glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);