feat(e10): re-aim the Button variants at the paint the app actually uses, and migrate the first 15 sites
Counting the un-migrated call sites showed the primitive and the app had drifted into two different looks: the majority of hand-rolled buttons hover to accent (outline) and fill with card (ghost), while Button hovered to fg and filled with surface. That split is the inconsistency this epic exists to remove, so the primitive follows the majority rather than the other way round -- 16 existing call sites shift slightly, ~90 hand-rolled ones stop being special. Adds the variants the call sites prove are missing (outline-accent, outline-danger, outline-warn, link), an icon-sm size for the p-1.5 icon family, and a size=none so a link owns no button padding; renames danger-outline to danger-soft so it cannot be confused with outline-danger (red at rest vs red on hover). Two new tests lock the link-size default and the rule that a variant never adds a second font-weight utility -- without tailwind-merge, two font-* utilities are decided by stylesheet order, not class order, so that is a real hazard. Migrates AdminUsers, AuditLog, Channels, TagManager, Playlists, PlaylistsRail and SettingsPanel (Button call sites 37 -> 52). Left alone deliberately: conditional-paint sites, video-overlay white buttons, drag handles, chip removers and card-click targets -- the families Button's own docstring excludes. 3x tsc, eslint, knip, prettier and 689 vitest green; visual UAT on Users, Access requests and Playlists at 1280x900, no console errors.
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import InlineNotice from "./ui/InlineNotice";
|
import InlineNotice from "./ui/InlineNotice";
|
||||||
import { Section } from "./ui/form";
|
import { Section } from "./ui/form";
|
||||||
@@ -244,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
: t("users.roles.makeAdmin")
|
: t("users.roles.makeAdmin")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0"
|
||||||
onClick={() => onToggle(u)}
|
onClick={() => onToggle(u)}
|
||||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
|
icon={<Shield className="w-3.5 h-3.5" />}
|
||||||
>
|
>
|
||||||
<Shield className="w-3.5 h-3.5" />
|
|
||||||
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
||||||
</button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
hint={
|
hint={
|
||||||
@@ -264,36 +267,36 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
: t("users.suspend.action")
|
: t("users.suspend.action")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||||
|
size="icon-sm"
|
||||||
|
className="shrink-0"
|
||||||
onClick={() => onSuspend(u)}
|
onClick={() => onSuspend(u)}
|
||||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||||
aria-label={
|
aria-label={
|
||||||
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
||||||
}
|
}
|
||||||
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
icon={
|
||||||
u.is_suspended
|
u.is_suspended ? (
|
||||||
? "border-warn/40 text-warn hover:bg-warn/10"
|
<RotateCcw className="w-4 h-4" />
|
||||||
: "border-border text-muted hover:text-warn"
|
) : (
|
||||||
}`}
|
<Ban className="w-4 h-4" />
|
||||||
>
|
)
|
||||||
{u.is_suspended ? (
|
}
|
||||||
<RotateCcw className="w-4 h-4" />
|
/>
|
||||||
) : (
|
|
||||||
<Ban className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="icon-sm"
|
||||||
|
className="shrink-0"
|
||||||
onClick={() => onDelete(u)}
|
onClick={() => onDelete(u)}
|
||||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||||
aria-label={t("users.delete.action")}
|
aria-label={t("users.delete.action")}
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
|
icon={<Trash2 className="w-4 h-4" />}
|
||||||
>
|
/>
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -435,25 +438,28 @@ function PendingAccessRowView({
|
|||||||
</div>
|
</div>
|
||||||
{!row.email_verified && (
|
{!row.email_verified && (
|
||||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
className="shrink-0"
|
||||||
onClick={onResend}
|
onClick={onResend}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
|
||||||
aria-label={t("users.pendingAccess.resend")}
|
aria-label={t("users.pendingAccess.resend")}
|
||||||
>
|
icon={<Send className="w-4 h-4" />}
|
||||||
<Send className="w-4 h-4" />
|
/>
|
||||||
</button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{row.can_activate && (
|
{row.can_activate && (
|
||||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline-accent"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0"
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
|
||||||
>
|
>
|
||||||
{t("users.pendingAccess.activate")}
|
{t("users.pendingAccess.activate")}
|
||||||
</button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -673,12 +679,14 @@ function AdminInvites() {
|
|||||||
placeholder={t("settings.invites.addPlaceholder")}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
variant="glass"
|
||||||
|
className="shrink-0"
|
||||||
|
icon={<UserPlus className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
{t("settings.invites.add")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{decided.length > 0 && (
|
{decided.length > 0 && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Trash2 } from "lucide-react";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { LS } from "../lib/storage";
|
import { LS } from "../lib/storage";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { Section } from "./ui/form";
|
import { Section } from "./ui/form";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import DataTable from "./ui/DataTable";
|
import DataTable from "./ui/DataTable";
|
||||||
@@ -72,14 +73,15 @@ export default function AuditLog() {
|
|||||||
persistKey={LS.auditTable}
|
persistKey={LS.auditTable}
|
||||||
controlsPosition="top"
|
controlsPosition="top"
|
||||||
controlsLeading={
|
controlsLeading={
|
||||||
<button
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="xs"
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
disabled={clear.isPending}
|
disabled={clear.isPending}
|
||||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
{t("audit.clear")}
|
{t("audit.clear")}
|
||||||
</button>
|
</Button>
|
||||||
}
|
}
|
||||||
emptyText={t("audit.empty")}
|
emptyText={t("audit.empty")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
import DataTable from "./ui/DataTable";
|
import DataTable from "./ui/DataTable";
|
||||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
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
|
{/* 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. */}
|
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0 uppercase tracking-wide"
|
||||||
onClick={() => setTagManagerOpen(true)}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
icon={<Pencil className="h-3 w-3" />}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
{t("channels.tags.label")}
|
{t("channels.tags.label")}
|
||||||
</button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||||
{tagFilter.length > 0 && (
|
{tagFilter.length > 0 && (
|
||||||
@@ -518,12 +521,9 @@ export default function Channels() {
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{highConfidence.length > 0 && (
|
{highConfidence.length > 0 && (
|
||||||
<button
|
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||||
onClick={acceptAllHighConfidence}
|
|
||||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
|
||||||
>
|
|
||||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { cycleSort, type SortState } from "../lib/columnSort";
|
|||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { applyView, sameOrder } from "../lib/playlistView";
|
import { applyView, sameOrder } from "../lib/playlistView";
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import UndoToolbar from "./ui/UndoToolbar";
|
import UndoToolbar from "./ui/UndoToolbar";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
import { PageToolbar } from "./ui/PageShell";
|
||||||
import { useChoice, useConfirm } from "./ConfirmProvider";
|
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||||
@@ -585,12 +586,9 @@ export default function Playlists() {
|
|||||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||||
{t("playlists.loadFailed")}
|
{t("playlists.loadFailed")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||||
onClick={() => detailQuery.refetch()}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
|
|
||||||
>
|
|
||||||
{t("common.retry")}
|
{t("common.retry")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -703,25 +701,26 @@ export default function Playlists() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{linked && detail.dirty && (
|
{linked && detail.dirty && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
onClick={revertToYoutube}
|
onClick={revertToYoutube}
|
||||||
disabled={reverting}
|
disabled={reverting}
|
||||||
title={t("playlists.revert")}
|
title={t("playlists.revert")}
|
||||||
aria-label={t("playlists.revert")}
|
aria-label={t("playlists.revert")}
|
||||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="icon-sm"
|
||||||
onClick={deletePlaylist}
|
onClick={deletePlaylist}
|
||||||
title={t("playlists.delete")}
|
title={t("playlists.delete")}
|
||||||
aria-label={t("playlists.delete")}
|
aria-label={t("playlists.delete")}
|
||||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||||
>
|
/>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,12 +754,14 @@ export default function Playlists() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canSaveOrder && (
|
{canSaveOrder && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline-accent"
|
||||||
|
size="xs"
|
||||||
onClick={saveOrder}
|
onClick={saveOrder}
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
icon={<Check className="w-3.5 h-3.5" />}
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
{t("playlists.saveOrder")}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
{/* 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
|
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
|||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||||
import { type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
import FilterRail from "./FilterRail";
|
import FilterRail from "./FilterRail";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import { type PanelItem } from "./ui/PanelGroups";
|
import { type PanelItem } from "./ui/PanelGroups";
|
||||||
import SortControl from "./ui/SortControl";
|
import SortControl from "./ui/SortControl";
|
||||||
import { type SortOption } from "../lib/columnSort";
|
import { type SortOption } from "../lib/columnSort";
|
||||||
@@ -147,14 +148,10 @@ export default function PlaylistsRail({
|
|||||||
title: t("playlists.manageGroup"),
|
title: t("playlists.manageGroup"),
|
||||||
body: (
|
body: (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
|
||||||
onClick={syncYoutube}
|
|
||||||
disabled={syncing}
|
|
||||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||||
{t("playlists.syncYoutube")}
|
{t("playlists.syncYoutube")}
|
||||||
</button>
|
</Button>
|
||||||
{playlists.length > 1 && (
|
{playlists.length > 1 && (
|
||||||
<SortControl
|
<SortControl
|
||||||
label={t("playlists.sortLabel")}
|
label={t("playlists.sortLabel")}
|
||||||
|
|||||||
@@ -324,12 +324,9 @@ function AccessRow({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip hint={t("settings.account.enableHint")}>
|
<Tooltip hint={t("settings.account.enableHint")}>
|
||||||
<button
|
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
||||||
onClick={onEnable}
|
|
||||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
||||||
>
|
|
||||||
{t("settings.account.enable")}
|
{t("settings.account.enable")}
|
||||||
</button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -676,7 +673,7 @@ export function DownloadAuth() {
|
|||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{h.configured && (
|
{h.configured && (
|
||||||
<Button
|
<Button
|
||||||
variant="danger-outline"
|
variant="danger-soft"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRemove(h.host_key)}
|
onClick={() => onRemove(h.host_key)}
|
||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import { api, type Tag } from "../lib/api";
|
import { api, type Tag } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
import { Button } from "./ui/Button";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||||
@@ -203,13 +204,15 @@ export default function TagManager({
|
|||||||
placeholder={t("tagManager.newPlaceholder")}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
disabled={!newName.trim()}
|
disabled={!newName.trim()}
|
||||||
className="shrink-0 px-3 py-1.5 rounded-md border border-border text-sm text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-40 transition"
|
|
||||||
>
|
>
|
||||||
{t("tagManager.add")}
|
{t("tagManager.add")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -71,6 +71,33 @@ describe("Button primitive (S5)", () => {
|
|||||||
expect(onClick).not.toHaveBeenCalled();
|
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(<Button variant="link">Show all</Button>);
|
||||||
|
let btn = screen.getByRole("button", { name: "Show all" });
|
||||||
|
expect(btn.className).toContain("text-accent");
|
||||||
|
expect(btn.className).not.toContain("px-3");
|
||||||
|
rerender(
|
||||||
|
<Button variant="link" size="sm">
|
||||||
|
Show all
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
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(<Button variant={variant}>X</Button>);
|
||||||
|
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", () => {
|
it("fullWidth adds the w-full utility", () => {
|
||||||
render(<Button fullWidth>Wide</Button>);
|
render(<Button fullWidth>Wide</Button>);
|
||||||
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
||||||
|
|||||||
@@ -17,25 +17,45 @@ type ButtonVariant =
|
|||||||
| "primary" // solid accent — the main affirmative action
|
| "primary" // solid accent — the main affirmative action
|
||||||
| "danger" // solid danger — destructive confirm
|
| "danger" // solid danger — destructive confirm
|
||||||
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
| "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)
|
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
|
||||||
| "glass" // glass card — the Plex/overlay action buttons
|
| "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 =
|
type ButtonSize =
|
||||||
| "xs" // dense inline action
|
| "xs" // dense inline action
|
||||||
| "sm" // the btnCls geometry (px-3 py-1.5)
|
| "sm" // the btnCls geometry (px-3 py-1.5)
|
||||||
| "md" // default action button (px-3 py-2)
|
| "md" // default action button (px-3 py-2)
|
||||||
| "lg" // prominent CTA
|
| "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<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
|
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
|
||||||
primary: "btn-primary",
|
primary: "btn-primary",
|
||||||
danger: "btn-danger",
|
danger: "btn-danger",
|
||||||
outline: "border border-border text-muted hover:text-fg hover:border-fg/40",
|
// E10 S1b: the neutral variants were RE-AIMED at the paint the app actually uses. Counting the
|
||||||
ghost: "text-muted hover:text-fg hover:bg-surface",
|
// 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",
|
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<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
@@ -44,6 +64,8 @@ const SIZES: Record<ButtonSize, string> = {
|
|||||||
md: "px-3 py-2 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",
|
lg: "px-4 py-2.5 text-base rounded-lg gap-2",
|
||||||
icon: "w-9 h-9 rounded-lg justify-center",
|
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 =
|
const BASE =
|
||||||
@@ -51,6 +73,7 @@ const BASE =
|
|||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
|
/** Defaults to "md", except for `variant="link"`, whose default is "none" (a link owns no padding). */
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
|
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
@@ -67,7 +90,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
{
|
{
|
||||||
variant = "primary",
|
variant = "primary",
|
||||||
size = "md",
|
size,
|
||||||
icon,
|
icon,
|
||||||
iconOnly = false,
|
iconOnly = false,
|
||||||
label,
|
label,
|
||||||
@@ -81,6 +104,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
},
|
},
|
||||||
ref,
|
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 ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
|
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -93,7 +120,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
aria-busy={loading || undefined}
|
aria-busy={loading || undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={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}
|
{...rest}
|
||||||
>
|
>
|
||||||
{leading}
|
{leading}
|
||||||
|
|||||||
Reference in New Issue
Block a user