release: 0.60.5 (r10-status-colors)
This commit is contained in:
@@ -58,7 +58,7 @@ function Badge({
|
|||||||
tone === "accent"
|
tone === "accent"
|
||||||
? "border-accent/40 text-accent"
|
? "border-accent/40 text-accent"
|
||||||
: tone === "warning"
|
: tone === "warning"
|
||||||
? "border-amber-500/40 text-amber-500"
|
? "border-warn/40 text-warn"
|
||||||
: "border-border text-muted";
|
: "border-border text-muted";
|
||||||
return (
|
return (
|
||||||
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>
|
<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 ${
|
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
||||||
u.is_suspended
|
u.is_suspended
|
||||||
? "border-amber-500/40 text-amber-500 hover:bg-amber-500/10"
|
? "border-warn/40 text-warn hover:bg-warn/10"
|
||||||
: "border-border text-muted hover:text-amber-500"
|
: "border-border text-muted hover:text-warn"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{u.is_suspended ? (
|
{u.is_suspended ? (
|
||||||
@@ -248,7 +248,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
onClick={() => onDelete(u)}
|
onClick={() => onDelete(u)}
|
||||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||||
aria-label={t("users.delete.action")}
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -368,7 +368,7 @@ function AdminInvites() {
|
|||||||
{decided.map((i) => (
|
{decided.map((i) => (
|
||||||
<div key={i.id} className="flex items-center justify-between gap-2">
|
<div key={i.id} className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate">{i.email}</span>
|
<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}
|
{i.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,7 +445,7 @@ function AdminDemo() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => onRemove(r)}
|
onClick={() => onRemove(r)}
|
||||||
disabled={remove.isPending}
|
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")}
|
aria-label={t("settings.demo.remove")}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -542,7 +542,7 @@ function InviteRow({
|
|||||||
<button
|
<button
|
||||||
onClick={onDeny}
|
onClick={onDeny}
|
||||||
disabled={busy}
|
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")}
|
aria-label={t("settings.invites.deny")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function AuditLog() {
|
|||||||
<button
|
<button
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
disabled={clear.isPending}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
{t("audit.clear")}
|
{t("audit.clear")}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> =
|
|||||||
action: "bg-accent text-accent-fg",
|
action: "bg-accent text-accent-fg",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
bar: "bg-amber-500/15 border-amber-500/30",
|
bar: "bg-warn/15 border-warn/30",
|
||||||
icon: "text-amber-500",
|
icon: "text-warn",
|
||||||
action: "bg-amber-500 text-black",
|
action: "bg-warn text-warn-fg",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export default function ChannelPage({
|
|||||||
) : (
|
) : (
|
||||||
ch?.explored &&
|
ch?.explored &&
|
||||||
!ch?.subscribed && (
|
!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")}
|
{t("channel.exploringBadge")}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1095,7 +1095,7 @@ function ActionsCell({
|
|||||||
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={onUnsubscribe}
|
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")}
|
aria-label={t("channels.row.unsubscribeOnYoutube")}
|
||||||
>
|
>
|
||||||
<UserMinus className="w-4 h-4" />
|
<UserMinus className="w-4 h-4" />
|
||||||
|
|||||||
@@ -243,11 +243,11 @@ export default function ConfigPanel() {
|
|||||||
</p>
|
</p>
|
||||||
{plexResult.media_check?.checked &&
|
{plexResult.media_check?.checked &&
|
||||||
(plexResult.media_check.ok ? (
|
(plexResult.media_check.ok ? (
|
||||||
<p className="text-[11px] text-emerald-500 mb-2">
|
<p className="text-[11px] text-success mb-2">
|
||||||
{t("config.plexMediaOk")}
|
{t("config.plexMediaOk")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[11px] text-red-400 mb-2">
|
<p className="text-[11px] text-danger mb-2">
|
||||||
{t("config.plexMediaMissing", {
|
{t("config.plexMediaMissing", {
|
||||||
path: plexResult.media_check.local_path ?? "",
|
path: plexResult.media_check.local_path ?? "",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|||||||
multi ? "w-full" : ""
|
multi ? "w-full" : ""
|
||||||
} ${
|
} ${
|
||||||
c.danger
|
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"
|
: "bg-accent text-accent-fg hover:opacity-90"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ const GiB = 1024 ** 3;
|
|||||||
const STATUS_CLS: Record<string, string> = {
|
const STATUS_CLS: Record<string, string> = {
|
||||||
queued: "text-muted",
|
queued: "text-muted",
|
||||||
running: "text-accent",
|
running: "text-accent",
|
||||||
paused: "text-amber-400",
|
paused: "text-warn",
|
||||||
done: "text-emerald-400",
|
done: "text-success",
|
||||||
error: "text-red-400",
|
error: "text-danger",
|
||||||
canceled: "text-muted",
|
canceled: "text-muted",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ function IconBtn({
|
|||||||
title={title}
|
title={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
"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}
|
{children}
|
||||||
@@ -253,7 +253,7 @@ function DownloadRow({
|
|||||||
{job.status === "error" ? (
|
{job.status === "error" ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => onShowError?.(job)}
|
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")}
|
title={t("downloads.errors.fail.title")}
|
||||||
>
|
>
|
||||||
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
||||||
@@ -492,7 +492,7 @@ function UsageBar() {
|
|||||||
{!u.unlimited && (
|
{!u.unlimited && (
|
||||||
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
return (
|
return (
|
||||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<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 className="font-semibold text-sm">{t("messages.resetTitle")}</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
|
<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}
|
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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={cancelReset}
|
onClick={cancelReset}
|
||||||
@@ -119,7 +119,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
<button
|
<button
|
||||||
onClick={doReset}
|
onClick={doReset}
|
||||||
disabled={busy}
|
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" />
|
<AlertTriangle className="w-4 h-4" />
|
||||||
{t("messages.resetButton")}
|
{t("messages.resetButton")}
|
||||||
@@ -158,7 +158,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{err && <div className="text-sm text-red-400">{err}</div>}
|
{err && <div className="text-sm text-danger">{err}</div>}
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy || !pass}
|
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.
|
// A small reusable heads-up so the Google consent warning never feels like a surprise.
|
||||||
function ConsentHeadsUp() {
|
function ConsentHeadsUp() {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
|
<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-amber-400" />
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-warn" />
|
||||||
<p className="text-xs leading-relaxed text-fg/80">
|
<p className="text-xs leading-relaxed text-fg/80">
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="onboarding.consent"
|
i18nKey="onboarding.consent"
|
||||||
|
|||||||
@@ -1077,7 +1077,7 @@ export default function PlayerModal({
|
|||||||
{playerError != null && (
|
{playerError != null && (
|
||||||
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
|
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
|
||||||
<div className="max-w-sm">
|
<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>
|
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
|
||||||
<p className="text-sm text-muted mt-1.5 mb-4">
|
<p className="text-sm text-muted mt-1.5 mb-4">
|
||||||
{playerError === 101 || playerError === 150
|
{playerError === 101 || playerError === 150
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ export default function Playlists() {
|
|||||||
return (
|
return (
|
||||||
<div className="text-sm p-4 flex flex-col items-start gap-3">
|
<div className="text-sm p-4 flex flex-col items-start gap-3">
|
||||||
<span className="inline-flex items-center gap-2 text-muted">
|
<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")}
|
{t("playlists.loadFailed")}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -673,7 +673,7 @@ export default function Playlists() {
|
|||||||
onClick={deletePlaylist}
|
onClick={deletePlaylist}
|
||||||
title={t("playlists.delete")}
|
title={t("playlists.delete")}
|
||||||
aria-label={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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function PlaylistsRail({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => listQuery.refetch()}
|
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")}
|
{t("common.loadError")} — {t("common.retry")}
|
||||||
</button>
|
</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> = {
|
const PLAYABLE_TINT: Record<string, string> = {
|
||||||
direct: "bg-emerald-500",
|
direct: "bg-success",
|
||||||
remux: "bg-amber-500",
|
remux: "bg-warn",
|
||||||
transcode: "bg-orange-600",
|
transcode: "bg-danger",
|
||||||
};
|
};
|
||||||
|
|
||||||
function PlexPosterCard({
|
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"
|
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
|
<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>
|
</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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Add whole season to a playlist, on hover. */}
|
{/* 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"
|
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
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -930,7 +933,7 @@ function EpisodeCard({
|
|||||||
onToggleWatched ? "group-hover:opacity-0" : ""
|
onToggleWatched ? "group-hover:opacity-0" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pct > 0 && (
|
{pct > 0 && (
|
||||||
@@ -1099,6 +1102,7 @@ function PlexShowView({
|
|||||||
}
|
}
|
||||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
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" />
|
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||||
{show.imdb_rating}
|
{show.imdb_rating}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export default function PlexCollectionEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeCollection(c.id, c.title)}
|
onClick={() => removeCollection(c.id, c.title)}
|
||||||
disabled={!!busy}
|
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")}
|
{t("plex.collEditor.delete")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export default function PlexInfo({
|
|||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
{detail.imdb_rating != null && (
|
{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">
|
<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
|
<button
|
||||||
onClick={
|
onClick={
|
||||||
|
|||||||
@@ -1095,7 +1095,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
{!ready && (
|
{!ready && (
|
||||||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||||||
{loadError ? (
|
{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>
|
<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) => (
|
{detail?.markers.map((m, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute inset-y-0 bg-amber-400/50"
|
className="absolute inset-y-0 bg-warn/50"
|
||||||
style={{
|
style={{
|
||||||
left: `${(m.start_s / duration) * 100}%`,
|
left: `${(m.start_s / duration) * 100}%`,
|
||||||
width: `${((m.end_s - 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
|
<button
|
||||||
onClick={() => ctx.onRemove([ep.id])}
|
onClick={() => ctx.onRemove([ep.id])}
|
||||||
disabled={ctx.busy}
|
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"
|
aria-label="remove"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -222,7 +222,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => ctx.onRemove([item.id])}
|
onClick={() => ctx.onRemove([item.id])}
|
||||||
disabled={ctx.busy}
|
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"
|
aria-label="remove"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<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))}
|
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
|
||||||
disabled={ctx.busy}
|
disabled={ctx.busy}
|
||||||
title={t("plex.playlist.removeShow")}
|
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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -323,7 +323,7 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
|||||||
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
|
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
|
||||||
disabled={ctx.busy}
|
disabled={ctx.busy}
|
||||||
title={t("plex.playlist.removeSeason")}
|
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" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -572,7 +572,7 @@ export default function PlexPlaylistView({
|
|||||||
<button
|
<button
|
||||||
onClick={del}
|
onClick={del}
|
||||||
title={t("plex.playlist.delete")}
|
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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => onDelete(p)}
|
onClick={() => onDelete(p)}
|
||||||
title={t("downloads.profiles.delete")}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function StateMessage({
|
|||||||
aria-live={tone === "error" ? "assertive" : "polite"}
|
aria-live={tone === "error" ? "assertive" : "polite"}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 text-center">
|
<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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ function ViewRow({
|
|||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
title={t("views.delete")}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ function fmtCountdown(s: number): string {
|
|||||||
|
|
||||||
const DOT_CLASS: Record<string, string> = {
|
const DOT_CLASS: Record<string, string> = {
|
||||||
running: "bg-accent animate-pulse",
|
running: "bg-accent animate-pulse",
|
||||||
ok: "bg-emerald-500",
|
ok: "bg-success",
|
||||||
error: "bg-red-500",
|
error: "bg-danger",
|
||||||
skipped: "bg-amber-500",
|
skipped: "bg-warn",
|
||||||
idle: "bg-border",
|
idle: "bg-border",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ function JobRow({
|
|||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={saving}
|
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")}
|
aria-label={t("common.save")}
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
@@ -210,7 +210,7 @@ function JobRow({
|
|||||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
|
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
|
||||||
: t("scheduler.neverRun")}
|
: t("scheduler.neverRun")}
|
||||||
{job.last_error ? (
|
{job.last_error ? (
|
||||||
<span className="text-red-400"> · {job.last_error}</span>
|
<span className="text-danger"> · {job.last_error}</span>
|
||||||
) : job.last_result ? (
|
) : job.last_result ? (
|
||||||
<span> · {job.last_result}</span>
|
<span> · {job.last_result}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -448,7 +448,7 @@ export default function Scheduler() {
|
|||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full ${
|
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
|
{data.paused
|
||||||
@@ -502,8 +502,8 @@ export default function Scheduler() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!data.running_here && (
|
{!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">
|
<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-amber-500 shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-warn shrink-0" />
|
||||||
<span className="text-muted">{t("scheduler.notHereNote")}</span>
|
<span className="text-muted">{t("scheduler.notHereNote")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -591,7 +591,7 @@ export default function Scheduler() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
|
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${quotaPct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
|
|||||||
{t("settings.save.saving")}
|
{t("settings.save.saving")}
|
||||||
</span>
|
</span>
|
||||||
) : state === "saved" ? (
|
) : 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" />
|
<Check className="w-4 h-4" />
|
||||||
{t("settings.save.saved")}
|
{t("settings.save.saved")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-red-500">{t("settings.save.failed")}</span>
|
<span className="text-danger">{t("settings.save.failed")}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -480,7 +480,7 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
{err && <p className="text-xs text-red-400">{err}</p>}
|
{err && <p className="text-xs text-danger">{err}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || !next}
|
disabled={busy || !next}
|
||||||
@@ -680,7 +680,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteAccount}
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
{t("settings.account.deleteAccount")}
|
{t("settings.account.deleteAccount")}
|
||||||
|
|||||||
@@ -253,10 +253,10 @@ export default function SetupWizard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{testState === "ok" && (
|
{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" && (
|
{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>
|
<p className="text-xs text-muted">{t("setup.smtp.skipHint")}</p>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -266,7 +266,7 @@ export default function SetupWizard() {
|
|||||||
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
|
<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">
|
<div className="flex items-center justify-between mt-6">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
title={t("downloads.share.copy")}
|
title={t("downloads.share.copy")}
|
||||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -174,7 +174,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
revoke.mutate();
|
revoke.mutate();
|
||||||
}}
|
}}
|
||||||
title={t("downloads.share.revoke")}
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -234,7 +234,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
savePass.mutate("");
|
savePass.mutate("");
|
||||||
}}
|
}}
|
||||||
disabled={savePass.isPending}
|
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")}
|
{t("downloads.share.removePassword")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function SyncStatus({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
|
// 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" />
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
<span className="text-muted">{t("header.sync.allSynced")}</span>
|
<span className="text-muted">{t("header.sync.allSynced")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function TagRow({
|
|||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
title={t("tagManager.delete")}
|
title={t("tagManager.delete")}
|
||||||
aria-label={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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "luci
|
|||||||
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../lib/notifications";
|
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../lib/notifications";
|
||||||
|
|
||||||
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
|
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
|
||||||
info: { icon: Info, color: "text-accent", bar: "bg-accent" },
|
info: { icon: Info, color: "text-info", bar: "bg-info" },
|
||||||
success: { icon: CheckCircle2, color: "text-emerald-400", bar: "bg-emerald-400" },
|
success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" },
|
||||||
warning: { icon: AlertTriangle, color: "text-amber-400", bar: "bg-amber-400" },
|
warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" },
|
||||||
error: { icon: AlertCircle, color: "text-red-400", bar: "bg-red-400" },
|
error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" },
|
||||||
fatal: { icon: XCircle, color: "text-red-500", bar: "bg-red-500" },
|
fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Toaster() {
|
export default function Toaster() {
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ function RowMetaCells({ video }: { video: Video }) {
|
|||||||
// "live" dot and a "stream" dot would be the same dot.
|
// "live" dot and a "stream" dot would be the same dot.
|
||||||
const status =
|
const status =
|
||||||
video.live_status === "live"
|
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"
|
: video.live_status === "upcoming"
|
||||||
? { Icon: Clock, cls: "", label: t("card.upcoming") }
|
? { Icon: Clock, cls: "", label: t("card.upcoming") }
|
||||||
: video.live_status === "was_live"
|
: video.live_status === "was_live"
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteBoundary(bi);
|
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")}
|
title={t("editor.removeCut")}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export default function WatchPage() {
|
|||||||
placeholder={T.passwordPlaceholder}
|
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"
|
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
|
<button
|
||||||
onClick={unlock}
|
onClick={unlock}
|
||||||
disabled={!password || unlocking}
|
disabled={!password || unlocking}
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ function AuthCard() {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -593,7 +593,7 @@ function AuthCard() {
|
|||||||
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
|
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{demoError && <p className="text-xs text-red-400">{demoError}</p>}
|
{demoError && <p className="text-xs text-danger">{demoError}</p>}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -605,7 +605,7 @@ function AuthCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
|
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 (
|
return (
|
||||||
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
|
<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
|
<span
|
||||||
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||||
v.live_status === "live"
|
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"
|
: "bg-card border border-border text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ export function DraftSaveBar({
|
|||||||
<div data-testid="draft-save-bar" className={wrap}>
|
<div data-testid="draft-save-bar" className={wrap}>
|
||||||
<span data-testid="draft-save-status" data-state={state} className="text-sm text-muted">
|
<span data-testid="draft-save-status" data-state={state} className="text-sm text-muted">
|
||||||
{state === "saved" ? (
|
{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" />
|
<Check className="w-4 h-4" />
|
||||||
{labels.saved}
|
{labels.saved}
|
||||||
</span>
|
</span>
|
||||||
) : state === "error" ? (
|
) : state === "error" ? (
|
||||||
<span className="text-red-500">{labels.failed}</span>
|
<span className="text-danger">{labels.failed}</span>
|
||||||
) : (
|
) : (
|
||||||
labels.unsaved
|
labels.unsaved
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@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
|
/* `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
|
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
|
resolve it at the USE site, not reference a once-computed --color-* token. This also
|
||||||
@@ -14,6 +20,14 @@
|
|||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-fg: var(--accent-fg);
|
--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;
|
--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;
|
--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) */
|
/* Toast auto-dismiss countdown bar (shrinks left-to-right to zero) */
|
||||||
@keyframes toastbar {
|
@keyframes toastbar {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.60.4",
|
||||||
date: "2026-08-09",
|
date: "2026-08-09",
|
||||||
|
|||||||
Reference in New Issue
Block a user