diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 2932ca7d..e5b91992 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -416,14 +416,14 @@ export default function ChannelPage({ {ch?.subscribed && ( - + icon={} + /> )} {ch?.subscribed && !me.is_demo && (
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index da6d128d..cfcd63b8 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -6,6 +6,7 @@ import { Send } from "lucide-react"; import { api, HttpError, type Message, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { useScrollFade } from "../lib/useScrollFade"; +import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; @@ -213,15 +214,16 @@ export default function ChatThread({ placeholder={t("messages.writePlaceholder")} className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - + icon={} + />
)} diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index 1b7a7dc2..423a6933 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Lock, ShieldCheck } from "lucide-react"; import { api, HttpError } from "../lib/api"; import * as e2ee from "../lib/e2ee"; +import { Button } from "./ui/Button"; import { inputCls } from "./ui/form"; // Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device. @@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa )} {err &&
{err}
}
- - +
); diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index d7ed17a6..b61695bc 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { api, type Conversation, type MessageUser } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { relativeTime } from "../lib/format"; import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging"; @@ -123,13 +124,10 @@ function ConversationList({ {t("messages.conversations")} {ready && ( - + )} {(keyState === "setup" || keyState === "unlock") && } @@ -213,12 +211,12 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac return (
- + icon={} + />
{t("messages.newTitle")}
- + icon={} + />
- - + icon={} + />
- +
)}
@@ -305,43 +308,45 @@ function NotificationRow({ )} {isScheduler && ( - + )} {isDownloadDone && ( - + )}
{!n.read && ( - + icon={} + /> )} - + icon={} + />
); @@ -396,47 +401,52 @@ function ClientActivityRow({ {hidden ? (
- - +
) : watched ? (
- - +
) : subscribed ? (
- +
) : access ? ( - + ) : ( n.action && !n.dismissed && ( - + ) )} - + icon={} + /> ); } diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 16855b9a..6182d8b5 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -6,6 +6,7 @@ import { Check, Plus } from "lucide-react"; import { api } from "../lib/api"; import { notifyCollectionEditError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; +import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; import { useConfirm } from "./ConfirmProvider"; @@ -138,14 +139,16 @@ export default function PlexCollectionEditor({ placeholder={t("plex.collEditor.newPlaceholder")} className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - + {/* Filter */} diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index 7f7f9be2..76d37f58 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Minus, Plus } from "lucide-react"; import { api, type PlexPlaylist } from "../lib/api"; import { useScrollFade } from "../lib/useScrollFade"; +import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a @@ -102,14 +103,16 @@ export default function PlexPlaylistAdd({ placeholder={t("plex.playlistAdd.newPlaceholder")} className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - +
- +
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 5a44bf9d..e95a0b29 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -138,13 +138,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden" /> - + + )} {/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply @@ -564,9 +565,9 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { {verifyOutcome === "invalid" && ( {t("welcome.auth.verifyInvalid")}{" "} - + )} {bounced === "requested" && @@ -679,15 +680,15 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
{mode === "signin" ? ( <> - + ) : ( - + )}
@@ -820,9 +821,9 @@ function StatePanel({

{children}
- + {cta && ( +
{q.isError && !q.data ? ( q.refetch()}> @@ -166,13 +164,10 @@ export function PlexPersonView({ return (
- +
{q.isError && !d ? ( q.refetch()}> diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 31baf14b..e7b47d7a 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -36,26 +36,28 @@ type ButtonSize = const VARIANTS: Record = { // .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components). - primary: "btn-primary", - danger: "btn-danger", + primary: "font-medium btn-primary", + danger: "font-medium btn-danger", // 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", + outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent", + "outline-accent": "font-medium 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-soft": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger", + "outline-danger": + "font-medium border border-border text-muted hover:text-danger hover:border-danger/40", + "outline-warn": "font-medium border border-warn/40 text-warn hover:bg-warn/10", + ghost: "font-medium text-muted hover:text-fg hover:bg-card", + glass: "font-medium glass-card glass-hover hover:text-accent", + "danger-soft": + "font-medium 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", + link: "font-semibold text-accent hover:underline", }; const SIZES: Record = { @@ -68,8 +70,11 @@ const SIZES: Record = { none: "gap-1.5", }; +// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text +// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on +// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test. const BASE = - "inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"; + "inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed"; export interface ButtonProps extends ButtonHTMLAttributes { variant?: ButtonVariant;