release: 0.60.5 (r10-status-colors)

This commit is contained in:
2026-08-09 02:41:13 +02:00
37 changed files with 144 additions and 86 deletions
+1 -1
View File
@@ -1 +1 @@
0.60.4
0.60.5
+7 -7
View File
@@ -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 (
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>
@@ -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"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -368,7 +368,7 @@ function AdminInvites() {
{decided.map((i) => (
<div key={i.id} className="flex items-center justify-between gap-2">
<span className="truncate">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
{i.status}
</span>
</div>
@@ -445,7 +445,7 @@ function AdminDemo() {
<button
onClick={() => 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")}
>
<Trash2 className="w-4 h-4" />
@@ -542,7 +542,7 @@ function InviteRow({
<button
onClick={onDeny}
disabled={busy}
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.invites.deny")}
>
<X className="w-4 h-4" />
+1 -1
View File
@@ -75,7 +75,7 @@ export default function AuditLog() {
<button
onClick={onClear}
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-red-400 disabled:opacity-50 transition"
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"
>
<Trash2 className="w-3.5 h-3.5" />
{t("audit.clear")}
+3 -3
View File
@@ -11,9 +11,9 @@ const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> =
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",
},
};
+1 -1
View File
@@ -325,7 +325,7 @@ export default function ChannelPage({
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
<span className="text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
{t("channel.exploringBadge")}
</span>
)
+1 -1
View File
@@ -1095,7 +1095,7 @@ function ActionsCell({
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-red-400 shrink-0"
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
+2 -2
View File
@@ -243,11 +243,11 @@ export default function ConfigPanel() {
</p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-[11px] text-emerald-500 mb-2">
<p className="text-[11px] text-success mb-2">
{t("config.plexMediaOk")}
</p>
) : (
<p className="text-[11px] text-red-400 mb-2">
<p className="text-[11px] text-danger mb-2">
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
+1 -1
View File
@@ -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:opacity-90"
: "bg-accent text-accent-fg hover:opacity-90"
}`}
>
+6 -6
View File
@@ -45,9 +45,9 @@ const GiB = 1024 ** 3;
const STATUS_CLS: Record<string, string> = {
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" ? (
<button
onClick={() => onShowError?.(job)}
className="text-red-400 hover:text-red-300 inline-flex items-center gap-1 truncate"
className="text-danger hover:opacity-80 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 && (
<div className="h-2 rounded-full bg-surface overflow-hidden">
<div
className={clsx("h-full transition-all", pct > 90 ? "bg-red-400" : "bg-accent")}
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
style={{ width: `${pct}%` }}
/>
</div>
+4 -4
View File
@@ -93,7 +93,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0" />
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
</div>
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
@@ -107,7 +107,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
{err && <div className="text-sm text-danger">{err}</div>}
<div className="flex gap-2">
<button
onClick={cancelReset}
@@ -119,7 +119,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
<button
onClick={doReset}
disabled={busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-red-500/90 text-white hover:bg-red-500 transition disabled:opacity-40"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-danger/90 text-danger-fg hover:bg-danger transition disabled:opacity-40"
>
<AlertTriangle className="w-4 h-4" />
{t("messages.resetButton")}
@@ -158,7 +158,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
className={inputCls}
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
{err && <div className="text-sm text-danger">{err}</div>}
<button
onClick={submit}
disabled={busy || !pass}
+2 -2
View File
@@ -20,8 +20,8 @@ import Overlay from "./Overlay";
// A small reusable heads-up so the Google consent warning never feels like a surprise.
function ConsentHeadsUp() {
return (
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
<div className="flex gap-2.5 rounded-xl border border-warn/30 bg-warn/5 p-3 text-left">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-warn" />
<p className="text-xs leading-relaxed text-fg/80">
<Trans
i18nKey="onboarding.consent"
+1 -1
View File
@@ -1077,7 +1077,7 @@ export default function PlayerModal({
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
+2 -2
View File
@@ -550,7 +550,7 @@ export default function Playlists() {
return (
<div className="text-sm p-4 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 text-muted">
<AlertTriangle className="w-4 h-4 text-red-400" />
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}
</span>
<button
@@ -673,7 +673,7 @@ export default function Playlists() {
onClick={deletePlaylist}
title={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-red-400 hover:border-red-400/50 transition"
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
+1 -1
View File
@@ -216,7 +216,7 @@ export default function PlaylistsRail({
<button
type="button"
onClick={() => 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")}
</button>
+11 -7
View File
@@ -430,10 +430,13 @@ export default function PlexBrowse() {
);
}
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
// all three stay visually distinct.
const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-emerald-500",
remux: "bg-amber-500",
transcode: "bg-orange-600",
direct: "bg-success",
remux: "bg-warn",
transcode: "bg-danger",
};
function PlexPosterCard({
@@ -503,7 +506,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"
>
<CheckCircle2
className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`}
className={`w-4 h-4 ${card.status === "watched" ? "text-success" : "text-white"}`}
/>
</button>
)}
@@ -733,7 +736,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"
>
<CheckCircle2 className={`w-4 h-4 ${watched ? "text-emerald-400" : "text-white"}`} />
<CheckCircle2 className={`w-4 h-4 ${watched ? "text-success" : "text-white"}`} />
</button>
)}
{/* Add whole season to a playlist, on hover. */}
@@ -920,7 +923,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"
>
<CheckCircle2
className={`w-4 h-4 ${ep.status === "watched" ? "text-emerald-400" : "text-white"}`}
className={`w-4 h-4 ${ep.status === "watched" ? "text-success" : "text-white"}`}
/>
</button>
)}
@@ -930,7 +933,7 @@ function EpisodeCard({
onToggleWatched ? "group-hover:opacity-0" : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<CheckCircle2 className="w-4 h-4 text-success" />
</span>
)}
{pct > 0 && (
@@ -1099,6 +1102,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 */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</button>
@@ -193,7 +193,7 @@ export default function PlexCollectionEditor({
<button
onClick={() => 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")}
</button>
+1
View File
@@ -188,6 +188,7 @@ export default function PlexInfo({
</Filterable>
)}
{detail.imdb_rating != null && (
// decorative rating gold — intentionally NOT a status token
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={
+2 -2
View File
@@ -1095,7 +1095,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{!ready && (
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
{loadError ? (
<span className="max-w-md text-red-300">{loadError}</span>
<span className="max-w-md text-danger">{loadError}</span>
) : (
<span className="text-white/70">{t("plex.player.loading")}</span>
)}
@@ -1235,7 +1235,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{detail?.markers.map((m, i) => (
<div
key={i}
className="absolute inset-y-0 bg-amber-400/50"
className="absolute inset-y-0 bg-warn/50"
style={{
left: `${(m.start_s / duration) * 100}%`,
width: `${((m.end_s - m.start_s) / duration) * 100}%`,
+5 -5
View File
@@ -179,7 +179,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
<button
onClick={() => 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"
>
<X className="h-4 w-4" />
@@ -222,7 +222,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
<button
onClick={() => 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"
>
<X className="h-4 w-4" />
@@ -285,7 +285,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; 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"
>
<X className="h-4 w-4" />
</button>
@@ -323,7 +323,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; 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"
>
<X className="h-3.5 w-3.5" />
</button>
@@ -572,7 +572,7 @@ export default function PlexPlaylistView({
<button
onClick={del}
title={t("plex.playlist.delete")}
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-red-400"
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-danger"
>
<Trash2 className="h-4 w-4" />
</button>
+1 -1
View File
@@ -136,7 +136,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
<button
onClick={() => 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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
+1 -1
View File
@@ -44,7 +44,7 @@ export function StateMessage({
aria-live={tone === "error" ? "assertive" : "polite"}
>
<span className="inline-flex items-center gap-2 text-center">
{tone === "error" && <AlertTriangle className="w-4 h-4 shrink-0 text-red-400" />}
{tone === "error" && <AlertTriangle className="w-4 h-4 shrink-0 text-danger" />}
{children}
</span>
{onRetry && (
+1 -1
View File
@@ -331,7 +331,7 @@ function ViewRow({
<button
onClick={onDelete}
title={t("views.delete")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-red-400 transition"
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-danger transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
+9 -9
View File
@@ -46,9 +46,9 @@ function fmtCountdown(s: number): string {
const DOT_CLASS: Record<string, string> = {
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({
<button
onClick={save}
disabled={saving}
className="text-emerald-400 hover:opacity-80 disabled:opacity-50"
className="text-success hover:opacity-80 disabled:opacity-50"
aria-label={t("common.save")}
>
<Check className="w-3.5 h-3.5" />
@@ -210,7 +210,7 @@ function JobRow({
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
: t("scheduler.neverRun")}
{job.last_error ? (
<span className="text-red-400"> · {job.last_error}</span>
<span className="text-danger"> · {job.last_error}</span>
) : job.last_result ? (
<span> · {job.last_result}</span>
) : null}
@@ -448,7 +448,7 @@ export default function Scheduler() {
<span className="inline-flex items-center gap-1">
<span
className={`w-2 h-2 rounded-full ${
data.paused ? "bg-amber-500" : data.running_here ? "bg-emerald-500" : "bg-border"
data.paused ? "bg-warn" : data.running_here ? "bg-success" : "bg-border"
}`}
/>
{data.paused
@@ -502,8 +502,8 @@ export default function Scheduler() {
</div>
{!data.running_here && (
<div className="flex items-center gap-2 text-sm bg-amber-500/15 border border-amber-500/30 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
<div className="flex items-center gap-2 text-sm bg-warn/15 border border-warn/30 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 text-warn shrink-0" />
<span className="text-muted">{t("scheduler.notHereNote")}</span>
</div>
)}
@@ -591,7 +591,7 @@ export default function Scheduler() {
</div>
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full ${quotaPct > 90 ? "bg-red-500" : "bg-accent"}`}
className={`h-full rounded-full ${quotaPct > 90 ? "bg-danger" : "bg-accent"}`}
style={{ width: `${quotaPct}%` }}
/>
</div>
+4 -4
View File
@@ -117,12 +117,12 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
{t("settings.save.saving")}
</span>
) : state === "saved" ? (
<span className="inline-flex items-center gap-1.5 text-emerald-500">
<span className="inline-flex items-center gap-1.5 text-success">
<Check className="w-4 h-4" />
{t("settings.save.saved")}
</span>
) : (
<span className="text-red-500">{t("settings.save.failed")}</span>
<span className="text-danger">{t("settings.save.failed")}</span>
)}
</div>
);
@@ -480,7 +480,7 @@ function SignInMethods({ me }: { me: Me }) {
autoComplete="new-password"
className={inputCls}
/>
{err && <p className="text-xs text-red-400">{err}</p>}
{err && <p className="text-xs text-danger">{err}</p>}
<button
type="submit"
disabled={busy || !next}
@@ -680,7 +680,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</p>
<button
onClick={onDeleteAccount}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-red-500/40 text-red-400 hover:bg-red-500/10 transition"
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-danger/40 text-danger hover:bg-danger/10 transition"
>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
+3 -3
View File
@@ -253,10 +253,10 @@ export default function SetupWizard() {
</div>
)}
{testState === "ok" && (
<p className="text-xs text-emerald-500">{t("setup.smtp.testOk")}</p>
<p className="text-xs text-success">{t("setup.smtp.testOk")}</p>
)}
{testState === "fail" && (
<p className="text-xs text-red-400">{t("setup.smtp.testFail")}</p>
<p className="text-xs text-danger">{t("setup.smtp.testFail")}</p>
)}
<p className="text-xs text-muted">{t("setup.smtp.skipHint")}</p>
</Section>
@@ -266,7 +266,7 @@ export default function SetupWizard() {
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
)}
{error && <p className="text-sm text-red-400 mt-3">{error}</p>}
{error && <p className="text-sm text-danger mt-3">{error}</p>}
<div className="flex items-center justify-between mt-6">
<button
+3 -3
View File
@@ -142,7 +142,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
title={t("downloads.share.copy")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={() => {
@@ -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"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -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")}
</button>
+1 -1
View File
@@ -83,7 +83,7 @@ export default function SyncStatus({
</span>
) : (
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
<span className="flex items-center gap-1 text-emerald-500">
<span className="flex items-center gap-1 text-success">
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="text-muted">{t("header.sync.allSynced")}</span>
</span>
+1 -1
View File
@@ -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"
>
<Trash2 className="w-4 h-4" />
</button>
+5 -5
View File
@@ -4,11 +4,11 @@ import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "luci
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../lib/notifications";
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
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" },
info: { icon: Info, color: "text-info", bar: "bg-info" },
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() {
+2 -1
View File
@@ -156,7 +156,8 @@ 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") }
? // fixed live-red, not the status token — "live" is a broadcast indicator, not an error
{ Icon: Radio, cls: "text-red-500", label: t("card.live") }
: video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live"
+1 -1
View File
@@ -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")}
>
<X className="w-3 h-3" />
+1 -1
View File
@@ -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 && <div className="text-sm text-red-400 mt-2">{error}</div>}
{error && <div className="text-sm text-danger mt-2">{error}</div>}
<button
onClick={unlock}
disabled={!password || unlocking}
+3 -3
View File
@@ -498,7 +498,7 @@ function AuthCard() {
className={inputCls}
/>
)}
{error && <p className="text-xs text-red-400">{error}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
<button
type="submit"
disabled={busy}
@@ -593,7 +593,7 @@ function AuthCard() {
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
</button>
</div>
{demoError && <p className="text-xs text-red-400">{demoError}</p>}
{demoError && <p className="text-xs text-danger">{demoError}</p>}
</form>
)}
</>
@@ -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 (
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
);
+1 -1
View File
@@ -106,7 +106,7 @@ export function playlistColumns({
<span
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live"
? "bg-red-500/90 text-white"
? "bg-red-500/90 text-white" /* fixed live-red, not the status token */
: "bg-card border border-border text-muted"
}`}
>
+2 -2
View File
@@ -47,12 +47,12 @@ export function DraftSaveBar({
<div data-testid="draft-save-bar" className={wrap}>
<span data-testid="draft-save-status" data-state={state} className="text-sm text-muted">
{state === "saved" ? (
<span className="text-emerald-500 inline-flex items-center gap-1.5">
<span className="text-success inline-flex items-center gap-1.5">
<Check className="w-4 h-4" />
{labels.saved}
</span>
) : state === "error" ? (
<span className="text-red-500">{labels.failed}</span>
<span className="text-danger">{labels.failed}</span>
) : (
labels.unsaved
)}
+42
View File
@@ -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 text-info-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,14 @@
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-fg: var(--accent-fg);
--color-info: var(--info);
--color-info-fg: var(--info-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 +671,34 @@ 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;
--info: #60a5fa;
--info-fg: #08172e;
}
html[data-theme="light"] {
--danger: #dc2626;
--danger-fg: #ffffff;
--warn: #b45309;
--warn-fg: #ffffff;
--success: #047857;
--success-fg: #ffffff;
--info: #2563eb;
--info-fg: #ffffff;
}
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
@keyframes toastbar {
from {
+10
View File
@@ -14,6 +14,16 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.60.5",
date: "2026-08-09",
summary:
"Status colours across the app are now consistent and readable in both light and dark themes.",
fixes: [
"Success, warning and error colours now hold their contrast in the light theme — some were previously too faint to read on a white background.",
"Info notifications now use a distinct blue, so an info message is no longer mistaken for an error in schemes with a red or orange accent.",
],
},
{
version: "0.60.4",
date: "2026-08-09",