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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() })}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user