release: 0.60.5 (r10-status-colors)
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 ?? "",
|
||||
})}
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user