From 47e2293de392fe06d2188c919b18f198d2016756 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 5 Sep 2026 18:39:10 +0200 Subject: [PATCH] feat(e10): migrate the notification, message, auth and Plex action buttons Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical accent text links, three ghost icon buttons, two ghost text actions), Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread, ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd, PlexCollectionEditor and plexViews. Moves the font-weight out of Button's BASE and into each variant, so the `link` variant can be semibold (which is what every text link in this app looks like) while the action variants stay medium, and no element can end up with two font-* utilities. Welcome is the one signed-out surface this session cannot see, so only its byte-identical link swaps were taken; its primary submit and the bg-card button family are left for a pass that can UAT the screen. 3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications, Messages and the nav rail, clean console in a fresh tab. --- frontend/src/components/ChannelPage.tsx | 10 +- frontend/src/components/ChatThread.tsx | 12 +- frontend/src/components/KeyGate.tsx | 17 +-- frontend/src/components/Messages.tsx | 28 ++-- frontend/src/components/NavSidebar.tsx | 21 +-- .../src/components/NotificationsPanel.tsx | 129 ++++++++++-------- .../src/components/PlexCollectionEditor.tsx | 9 +- frontend/src/components/PlexPlaylistAdd.tsx | 9 +- frontend/src/components/PlexPlaylistView.tsx | 8 +- frontend/src/components/ShareDialog.tsx | 8 +- frontend/src/components/Welcome.tsx | 25 ++-- frontend/src/components/plexViews.tsx | 15 +- frontend/src/components/ui/Button.tsx | 27 ++-- 13 files changed, 165 insertions(+), 153 deletions(-) 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;