From d794524adfde7f2fb00759f474975d73ac542054 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sun, 9 Aug 2026 01:03:53 +0200
Subject: [PATCH] =?UTF-8?q?feat(theme):=20R10=20S1=20=E2=80=94=20semantic?=
=?UTF-8?q?=20status=20colors=20(danger/warn/success)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add theme-scoped --danger/--warn/--success (+ -fg) tokens wired into the v4
@theme inline, replacing ~110 raw red/amber/emerald/green palette refs across
34 files. Fixes light-theme contrast failures (fixed Tailwind shades fell to
~1.7-3:1 on white). Status colors are scheme-independent — recognised by hue,
shifting only by theme to hold contrast (bright on dark bg, darkened on light).
Solid fills use the paired -fg text; @source inline guarantees the full -fg set
exists. IMDb rating gold kept as a deliberate decorative non-status exception.
---
frontend/src/components/AdminUsers.tsx | 14 ++++----
frontend/src/components/AuditLog.tsx | 2 +-
frontend/src/components/Banner.tsx | 6 ++--
frontend/src/components/ChannelPage.tsx | 2 +-
frontend/src/components/Channels.tsx | 2 +-
frontend/src/components/ConfigPanel.tsx | 4 +--
frontend/src/components/ConfirmProvider.tsx | 2 +-
frontend/src/components/DownloadCenter.tsx | 12 +++----
frontend/src/components/KeyGate.tsx | 8 ++---
frontend/src/components/OnboardingWizard.tsx | 4 +--
frontend/src/components/PlayerModal.tsx | 2 +-
frontend/src/components/Playlists.tsx | 4 +--
frontend/src/components/PlaylistsRail.tsx | 2 +-
frontend/src/components/PlexBrowse.tsx | 15 ++++----
.../src/components/PlexCollectionEditor.tsx | 2 +-
frontend/src/components/PlexInfo.tsx | 1 +
frontend/src/components/PlexPlayer.tsx | 4 +--
frontend/src/components/PlexPlaylistView.tsx | 10 +++---
frontend/src/components/ProfileEditor.tsx | 2 +-
frontend/src/components/QueryState.tsx | 2 +-
frontend/src/components/SavedViewsWidget.tsx | 2 +-
frontend/src/components/Scheduler.tsx | 18 +++++-----
frontend/src/components/SettingsPanel.tsx | 8 ++---
frontend/src/components/SetupWizard.tsx | 6 ++--
frontend/src/components/ShareDialog.tsx | 6 ++--
frontend/src/components/SyncStatus.tsx | 2 +-
frontend/src/components/TagManager.tsx | 2 +-
frontend/src/components/Toaster.tsx | 8 ++---
frontend/src/components/VideoCard.tsx | 4 +--
frontend/src/components/VideoEditor.tsx | 2 +-
frontend/src/components/WatchPage.tsx | 2 +-
frontend/src/components/Welcome.tsx | 6 ++--
frontend/src/components/playlistColumns.tsx | 2 +-
frontend/src/components/ui/DraftSaveBar.tsx | 4 +--
frontend/src/index.css | 36 +++++++++++++++++++
35 files changed, 123 insertions(+), 85 deletions(-)
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index 35df421c..d96df65c 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -58,7 +58,7 @@ function Badge({
tone === "accent"
? "border-accent/40 text-accent"
: tone === "warning"
- ? "border-amber-500/40 text-amber-500"
+ ? "border-warn/40 text-warn"
: "border-border text-muted";
return (
@@ -230,8 +230,8 @@ function UsersRoles({ me }: { me: Me }) {
}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
- ? "border-amber-500/40 text-amber-500 hover:bg-amber-500/10"
- : "border-border text-muted hover:text-amber-500"
+ ? "border-warn/40 text-warn hover:bg-warn/10"
+ : "border-border text-muted hover:text-warn"
}`}
>
{u.is_suspended ? (
@@ -248,7 +248,7 @@ function UsersRoles({ me }: { me: Me }) {
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={t("users.delete.action")}
- className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
+ className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
>
@@ -368,7 +368,7 @@ function AdminInvites() {
{decided.map((i) => (
{i.email}
-
+
{i.status}
@@ -445,7 +445,7 @@ function AdminDemo() {
onRemove(r)}
disabled={remove.isPending}
- className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
+ className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}
>
@@ -542,7 +542,7 @@ function InviteRow({
diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx
index 241c4b3c..6f061e0c 100644
--- a/frontend/src/components/AuditLog.tsx
+++ b/frontend/src/components/AuditLog.tsx
@@ -75,7 +75,7 @@ export default function AuditLog() {
{t("audit.clear")}
diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx
index 3656d4c5..f619538f 100644
--- a/frontend/src/components/Banner.tsx
+++ b/frontend/src/components/Banner.tsx
@@ -11,9 +11,9 @@ const TONES: Record =
action: "bg-accent text-accent-fg",
},
warning: {
- bar: "bg-amber-500/15 border-amber-500/30",
- icon: "text-amber-500",
- action: "bg-amber-500 text-black",
+ bar: "bg-warn/15 border-warn/30",
+ icon: "text-warn",
+ action: "bg-warn text-warn-fg",
},
};
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index f15ffc3e..e10ccde6 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -325,7 +325,7 @@ export default function ChannelPage({
) : (
ch?.explored &&
!ch?.subscribed && (
-
+
{t("channel.exploringBadge")}
)
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index db2ccd5f..1c533ce9 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -1095,7 +1095,7 @@ function ActionsCell({
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 3b61681f..a8661746 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -243,11 +243,11 @@ export default function ConfigPanel() {
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
-
+
{t("config.plexMediaOk")}
) : (
-
+
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx
index dc652199..28d0ac39 100644
--- a/frontend/src/components/ConfirmProvider.tsx
+++ b/frontend/src/components/ConfirmProvider.tsx
@@ -118,7 +118,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
multi ? "w-full" : ""
} ${
c.danger
- ? "bg-red-500 text-white hover:bg-red-600"
+ ? "bg-danger text-danger-fg hover:bg-danger"
: "bg-accent text-accent-fg hover:opacity-90"
}`}
>
diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx
index 533525f1..41a868cb 100644
--- a/frontend/src/components/DownloadCenter.tsx
+++ b/frontend/src/components/DownloadCenter.tsx
@@ -45,9 +45,9 @@ const GiB = 1024 ** 3;
const STATUS_CLS: Record = {
queued: "text-muted",
running: "text-accent",
- paused: "text-amber-400",
- done: "text-emerald-400",
- error: "text-red-400",
+ paused: "text-warn",
+ done: "text-success",
+ error: "text-danger",
canceled: "text-muted",
};
@@ -137,7 +137,7 @@ function IconBtn({
title={title}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition",
- danger ? "hover:text-red-400" : "hover:text-fg",
+ danger ? "hover:text-danger" : "hover:text-fg",
)}
>
{children}
@@ -253,7 +253,7 @@ function DownloadRow({
{job.status === "error" ? (
onShowError?.(job)}
- className="text-red-400 hover:text-red-300 inline-flex items-center gap-1 truncate"
+ className="text-danger hover:text-danger inline-flex items-center gap-1 truncate"
title={t("downloads.errors.fail.title")}
>
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
@@ -492,7 +492,7 @@ function UsageBar() {
{!u.unlimited && (
90 ? "bg-red-400" : "bg-accent")}
+ className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx
index 73168420..fff42753 100644
--- a/frontend/src/components/KeyGate.tsx
+++ b/frontend/src/components/KeyGate.tsx
@@ -93,7 +93,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
return (
-
+
{t("messages.resetTitle")}
{t("messages.resetWarn")}
@@ -107,7 +107,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
- {err &&
{err}
}
+ {err &&
{err}
}
{t("messages.resetButton")}
@@ -158,7 +158,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
- {err && {err}
}
+ {err && {err}
}
-
+
+
-
+
{t("player.unavailableTitle")}
{playerError === 101 || playerError === 150
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index c1b50999..8d465f50 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -550,7 +550,7 @@ export default function Playlists() {
return (
-
+
{t("playlists.loadFailed")}
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx
index ef119df0..b0405e14 100644
--- a/frontend/src/components/PlaylistsRail.tsx
+++ b/frontend/src/components/PlaylistsRail.tsx
@@ -216,7 +216,7 @@ export default function PlaylistsRail({
listQuery.refetch()}
- className="text-xs text-red-400 px-1 py-1 text-left hover:underline"
+ className="text-xs text-danger px-1 py-1 text-left hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 5ae2b44f..67d7925b 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -431,9 +431,9 @@ export default function PlexBrowse() {
}
const PLAYABLE_TINT: Record
= {
- direct: "bg-emerald-500",
- remux: "bg-amber-500",
- transcode: "bg-orange-600",
+ direct: "bg-success",
+ remux: "bg-warn",
+ transcode: "bg-warn",
};
function PlexPosterCard({
@@ -503,7 +503,7 @@ function PlexPosterCard({
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
)}
@@ -733,7 +733,7 @@ function SeasonCard({
}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
-
+
)}
{/* Add whole season to a playlist, on hover. */}
@@ -920,7 +920,7 @@ function EpisodeCard({
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
)}
@@ -930,7 +930,7 @@ function EpisodeCard({
onToggleWatched ? "group-hover:opacity-0" : ""
}`}
>
-
+
)}
{pct > 0 && (
@@ -1099,6 +1099,7 @@ function PlexShowView({
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
+ {/* decorative rating gold — intentionally NOT a status token */}
·
{show.imdb_rating}
diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx
index 89e1abd3..48c58080 100644
--- a/frontend/src/components/PlexCollectionEditor.tsx
+++ b/frontend/src/components/PlexCollectionEditor.tsx
@@ -193,7 +193,7 @@ export default function PlexCollectionEditor({
removeCollection(c.id, c.title)}
disabled={!!busy}
- className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-red-400 disabled:opacity-50"
+ className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-danger disabled:opacity-50"
>
{t("plex.collEditor.delete")}
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 436eb799..3817fb67 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -188,6 +188,7 @@ export default function PlexInfo({
)}
{detail.imdb_rating != null && (
+ // decorative rating gold — intentionally NOT a status token
{loadError ? (
- {loadError}
+ {loadError}
) : (
{t("plex.player.loading")}
)}
@@ -1235,7 +1235,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{detail?.markers.map((m, i) => (
ctx.onRemove([ep.id])}
disabled={ctx.busy}
- className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
+ className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
aria-label="remove"
>
@@ -222,7 +222,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
ctx.onRemove([item.id])}
disabled={ctx.busy}
- className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
+ className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
aria-label="remove"
>
@@ -285,7 +285,7 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy}
title={t("plex.playlist.removeShow")}
- className="shrink-0 rounded-sm p-1 text-muted hover:text-red-400 disabled:opacity-30"
+ className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
>
@@ -323,7 +323,7 @@ function ShowGroup({ block, ctx }: { block: Extract
; ct
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy}
title={t("plex.playlist.removeSeason")}
- className="ml-1 rounded-sm p-0.5 text-muted hover:text-red-400 disabled:opacity-30"
+ className="ml-1 rounded-sm p-0.5 text-muted hover:text-danger disabled:opacity-30"
>
@@ -572,7 +572,7 @@ export default function PlexPlaylistView({
diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx
index 88399970..685436eb 100644
--- a/frontend/src/components/ProfileEditor.tsx
+++ b/frontend/src/components/ProfileEditor.tsx
@@ -136,7 +136,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
onDelete(p)}
title={t("downloads.profiles.delete")}
- className="p-1 rounded-sm hover:bg-surface text-muted hover:text-red-400"
+ className="p-1 rounded-sm hover:bg-surface text-muted hover:text-danger"
>
diff --git a/frontend/src/components/QueryState.tsx b/frontend/src/components/QueryState.tsx
index 374413e1..fa397957 100644
--- a/frontend/src/components/QueryState.tsx
+++ b/frontend/src/components/QueryState.tsx
@@ -44,7 +44,7 @@ export function StateMessage({
aria-live={tone === "error" ? "assertive" : "polite"}
>
- {tone === "error" && }
+ {tone === "error" && }
{children}
{onRetry && (
diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx
index ec86b165..537bbb97 100644
--- a/frontend/src/components/SavedViewsWidget.tsx
+++ b/frontend/src/components/SavedViewsWidget.tsx
@@ -331,7 +331,7 @@ function ViewRow({
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx
index cc9b387c..74acbecf 100644
--- a/frontend/src/components/Scheduler.tsx
+++ b/frontend/src/components/Scheduler.tsx
@@ -46,9 +46,9 @@ function fmtCountdown(s: number): string {
const DOT_CLASS: Record = {
running: "bg-accent animate-pulse",
- ok: "bg-emerald-500",
- error: "bg-red-500",
- skipped: "bg-amber-500",
+ ok: "bg-success",
+ error: "bg-danger",
+ skipped: "bg-warn",
idle: "bg-border",
};
@@ -178,7 +178,7 @@ function JobRow({
@@ -210,7 +210,7 @@ function JobRow({
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
: t("scheduler.neverRun")}
{job.last_error ? (
- · {job.last_error}
+ · {job.last_error}
) : job.last_result ? (
· {job.last_result}
) : null}
@@ -448,7 +448,7 @@ export default function Scheduler() {
{data.paused
@@ -502,8 +502,8 @@ export default function Scheduler() {
{!data.running_here && (
-
-
+
+
{t("scheduler.notHereNote")}
)}
@@ -591,7 +591,7 @@ export default function Scheduler() {
90 ? "bg-red-500" : "bg-accent"}`}
+ className={`h-full rounded-full ${quotaPct > 90 ? "bg-danger" : "bg-accent"}`}
style={{ width: `${quotaPct}%` }}
/>
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 94a97b40..694ff330 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -117,12 +117,12 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
{t("settings.save.saving")}
) : state === "saved" ? (
-
+
{t("settings.save.saved")}
) : (
- {t("settings.save.failed")}
+ {t("settings.save.failed")}
)}
);
@@ -480,7 +480,7 @@ function SignInMethods({ me }: { me: Me }) {
autoComplete="new-password"
className={inputCls}
/>
- {err && {err}
}
+ {err && {err}
}
void }) {
{t("settings.account.deleteAccount")}
diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx
index cab9bd00..96ad5f73 100644
--- a/frontend/src/components/SetupWizard.tsx
+++ b/frontend/src/components/SetupWizard.tsx
@@ -253,10 +253,10 @@ export default function SetupWizard() {
)}
{testState === "ok" && (
-
{t("setup.smtp.testOk")}
+
{t("setup.smtp.testOk")}
)}
{testState === "fail" && (
-
{t("setup.smtp.testFail")}
+
{t("setup.smtp.testFail")}
)}
{t("setup.smtp.skipHint")}
@@ -266,7 +266,7 @@ export default function SetupWizard() {
)}
- {error &&
{error}
}
+ {error &&
{error}
}
void }
title={t("downloads.share.copy")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
- {copied ? : }
+ {copied ? : }
{
@@ -174,7 +174,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
revoke.mutate();
}}
title={t("downloads.share.revoke")}
- className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-red-400 transition"
+ className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-danger transition"
>
@@ -234,7 +234,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
savePass.mutate("");
}}
disabled={savePass.isPending}
- className="px-2 py-1 rounded-md text-xs text-muted hover:text-red-400 transition"
+ className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
>
{t("downloads.share.removePassword")}
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx
index 5e5095d5..c0f8cff1 100644
--- a/frontend/src/components/SyncStatus.tsx
+++ b/frontend/src/components/SyncStatus.tsx
@@ -83,7 +83,7 @@ export default function SyncStatus({
) : (
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
-
+
{t("header.sync.allSynced")}
diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx
index fbcafbf2..7ecb279f 100644
--- a/frontend/src/components/TagManager.tsx
+++ b/frontend/src/components/TagManager.tsx
@@ -68,7 +68,7 @@ function TagRow({
onClick={onDelete}
title={t("tagManager.delete")}
aria-label={t("tagManager.delete")}
- className="shrink-0 p-1.5 rounded-md text-muted hover:text-red-400 transition"
+ className="shrink-0 p-1.5 rounded-md text-muted hover:text-danger transition"
>
diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx
index b6bfc075..d87af574 100644
--- a/frontend/src/components/Toaster.tsx
+++ b/frontend/src/components/Toaster.tsx
@@ -5,10 +5,10 @@ import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../lib/not
export const LEVEL_STYLE: Record = {
info: { icon: Info, color: "text-accent", bar: "bg-accent" },
- success: { icon: CheckCircle2, color: "text-emerald-400", bar: "bg-emerald-400" },
- warning: { icon: AlertTriangle, color: "text-amber-400", bar: "bg-amber-400" },
- error: { icon: AlertCircle, color: "text-red-400", bar: "bg-red-400" },
- fatal: { icon: XCircle, color: "text-red-500", bar: "bg-red-500" },
+ success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" },
+ warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" },
+ error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" },
+ fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
};
export default function Toaster() {
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx
index 2b4c7f3d..cf42caaf 100644
--- a/frontend/src/components/VideoCard.tsx
+++ b/frontend/src/components/VideoCard.tsx
@@ -156,7 +156,7 @@ function RowMetaCells({ video }: { video: Video }) {
// "live" dot and a "stream" dot would be the same dot.
const status =
video.live_status === "live"
- ? { Icon: Radio, cls: "text-red-500", label: t("card.live") }
+ ? { Icon: Radio, cls: "text-danger", label: t("card.live") }
: video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live"
@@ -250,7 +250,7 @@ function Thumb({
) : video.live_status === "live" || video.live_status === "upcoming" ? (
{t(`card.${video.live_status}`)}
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index d2a0ed57..52312de2 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -460,7 +460,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
e.stopPropagation();
deleteBoundary(bi);
}}
- className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400"
+ className="absolute top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-danger"
title={t("editor.removeCut")}
>
diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx
index 48e6596b..13e51f58 100644
--- a/frontend/src/components/WatchPage.tsx
+++ b/frontend/src/components/WatchPage.tsx
@@ -192,7 +192,7 @@ export default function WatchPage() {
placeholder={T.passwordPlaceholder}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm outline-hidden focus:ring-2 focus:ring-accent/40"
/>
- {error && {error}
}
+ {error && {error}
}
)}
- {error && {error}
}
+ {error && {error}
}
- {demoError &&
{demoError}
}
+ {demoError &&
{demoError}
}
)}
>
@@ -605,7 +605,7 @@ function AuthCard() {
}
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
- const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-amber-500/15 text-fg";
+ const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
return (
{children}
);
diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx
index 1303f06b..b5b7d604 100644
--- a/frontend/src/components/playlistColumns.tsx
+++ b/frontend/src/components/playlistColumns.tsx
@@ -106,7 +106,7 @@ export function playlistColumns({
diff --git a/frontend/src/components/ui/DraftSaveBar.tsx b/frontend/src/components/ui/DraftSaveBar.tsx
index 47cfb8a5..9c15fd20 100644
--- a/frontend/src/components/ui/DraftSaveBar.tsx
+++ b/frontend/src/components/ui/DraftSaveBar.tsx
@@ -47,12 +47,12 @@ export function DraftSaveBar({
{state === "saved" ? (
-
+
{labels.saved}
) : state === "error" ? (
- {labels.failed}
+ {labels.failed}
) : (
labels.unsaved
)}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 35122b31..b836f451 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,5 +1,11 @@
@import "tailwindcss";
+/* Guarantee the full status fill-text set exists even before first use, so any
+ bg-{status} fill always has a readable text-{status}-fg partner available
+ (Tailwind v4 only emits a utility when it sees it used; success-fg has no fill
+ consumer today). Keeps the semantic token set complete + future-proof. */
+@source inline("text-danger-fg text-warn-fg text-success-fg");
+
/* `inline` is load-bearing: the color vars (--bg/--accent/… below) are redefined at
runtime per html[data-scheme]/[data-theme], so utilities must inline the value and
resolve it at the USE site, not reference a once-computed --color-* token. This also
@@ -14,6 +20,12 @@
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-fg: var(--accent-fg);
+ --color-danger: var(--danger);
+ --color-danger-fg: var(--danger-fg);
+ --color-warn: var(--warn);
+ --color-warn-fg: var(--warn-fg);
+ --color-success: var(--success);
+ --color-success-fg: var(--success-fg);
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@@ -657,6 +669,30 @@ html[data-scheme="matrix"][data-theme="light"] {
--accent-fg: #ffffff;
}
+/* ===== Semantic status colors (danger / warn / success) =====
+ Scheme-INDEPENDENT, theme-scoped: a status color is recognised by its conventional
+ hue (red=danger), so it stays constant across schemes and only shifts by theme to
+ hold contrast — bright on the near-black dark bg, darkened for ≥4.5:1 on the white
+ light bg. The scheme blocks above deliberately do NOT set these (lower specificity
+ here is fine — no scheme overrides them). Per-scheme override only if a real clash
+ surfaces. `-fg` is readable text ON a solid status fill. */
+html[data-theme="dark"] {
+ --danger: #f87171;
+ --danger-fg: #2a0a0a;
+ --warn: #fbbf24;
+ --warn-fg: #1f1400;
+ --success: #34d399;
+ --success-fg: #052016;
+}
+html[data-theme="light"] {
+ --danger: #dc2626;
+ --danger-fg: #ffffff;
+ --warn: #b45309;
+ --warn-fg: #ffffff;
+ --success: #047857;
+ --success-fg: #ffffff;
+}
+
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@keyframes toastbar {
from {