one red and one green, in the core apps

82 raw palette classes in FileBrowser, FileViewer, SystemMonitor, Chat and Plugins now
name a state instead of a colour. `docs/design-language-interface.md` has said to do
this for a long time and `tone.ts` implements it; eight files were following it.

Only the families whose semantic token IS the colour already in use:

  destructive  #ef4444  ==  text-red-500    exact
  success      #16a34a  ==  text-green-600  exact

So `red-500` and `green-600` are byte-identical renames, and the neighbouring shades
(`red-400`, `red-600`, `green-500`, `emerald-500`) shift by a little on purpose —
having one red is the point. Verified on screen, not just typechecked.

Three greys became `muted-foreground`, which is what "quieter than the text" means
here.

── Amber is deliberately untouched, and needs a decision ──

38 amber classes remain and a regex must not have them. They are not warnings.
Chat's own comment says it plainly: "the amber a RUNNING task chip wears". It is a
BUSY tone, and the token set has no such thing — `--status-in-progress` exists and is
blue.

Mapping them to `warning` would change both meaning and colour: Duck's `--warning` is
#fbbd23 (amber-400) while the code uses amber-600 #d97706 as text and amber-950 as
ink on a fill. Text at that lightness on white is not readable, and "running" would
start looking like "something is wrong".

Two ways out, both the owner's call: add a sixth tone for busy/queued, or accept
warning's colour and meaning for it.

── Deliberately out of scope ──

The plugin candidates — Soulseek 308, Invoices 95, Wallet 68, Jellyfin 48, Photos 47,
Transmission 41 — are two thirds of the debt and are leaving this repository. Their
colours belong to their extraction, when the rules exist and their imports are being
rewritten anyway.

White and black are also untouched: they are media overlays in the file viewer and
photo surfaces, correct in every theme because they sit on someone's photograph rather
than on a themed surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:07:09 +00:00
co-authored by Claude Opus 5
parent a0013e4f28
commit a52d3e06f3
21 changed files with 221 additions and 92 deletions
@@ -139,7 +139,7 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => {
if (s.id === sessionId) setSessionId(null);
void deleteSession(s.id);
}}
className="p-0.5 opacity-50 hover:opacity-100 text-red-500 cursor-pointer"
className="p-0.5 opacity-50 hover:opacity-100 text-destructive cursor-pointer"
>
<Trash2 className="h-3 w-3" />
</button>
@@ -131,7 +131,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
aria-label="Copy the code"
className={CONTROL}
>
{copied === 'raw' ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
{copied === 'raw' ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<button
type="button"
@@ -141,7 +141,7 @@ export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered
className={CONTROL}
>
{copied === 'md' ? (
<Check className="h-3.5 w-3.5 text-green-400" />
<Check className="h-3.5 w-3.5 text-success" />
) : (
// The label is what it produces. Backtick glyphs sit near cap height, so the nudge is what
// optically centres them in the box — flex centring alone leaves them riding high.
@@ -23,7 +23,7 @@ export const CopyButton = ({ text, className = '' }: CopyButtonProps) => {
className={`p-1 rounded text-foreground opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity cursor-pointer ${className}`}
title="Copy"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
};
@@ -160,7 +160,7 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
)}
{error && (
<div className="px-5 py-3 flex items-center gap-2 text-sm text-red-500">
<div className="px-5 py-3 flex items-center gap-2 text-sm text-destructive">
<CircleX className="h-4 w-4 shrink-0" />
{error}
</div>
@@ -187,7 +187,7 @@ export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
<div className="flex flex-col items-center gap-4">
<canvas ref={canvasRef} width={460} height={120} className="w-full rounded-md bg-duck-dark/5" />
<div className="flex items-center gap-2">
{recording && <span className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />}
{recording && <span className="h-3 w-3 rounded-full bg-destructive animate-pulse" />}
<span className="text-sm text-duck-dark/60">{recording ? 'Recording...' : 'Starting...'}</span>
</div>
<div className="flex justify-end w-full gap-2">
@@ -413,7 +413,7 @@ const MenuItems = ({
</Item>
)}
<Separator />
<Item onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Item onClick={() => onDelete(entry)} className="text-destructive cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Item>
@@ -138,7 +138,7 @@ export const NewEntryDialog = ({ kind, onClose, onSubmit, existingNames }: NewEn
/>
{/* Reserves its own line whether or not there is a problem, so the dialog does not jump as you
type past a colliding name. */}
<p className="min-h-[1.25rem] text-xs text-red-600">{problem ?? ''}</p>
<p className="min-h-[1.25rem] text-xs text-destructive">{problem ?? ''}</p>
</div>
<DialogFooter>
@@ -240,18 +240,18 @@ const AgenticTaskRunner = ({
{phase === 'running' ? (
<button
onClick={chat.stopGeneration}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer"
>
<Square className="h-3.5 w-3.5" />
Stop
</button>
) : accRef.current.some((m) => m.role === 'error') ? (
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
<CircleX className="h-4 w-4" />
Task failed
</span>
) : (
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
<span className="flex items-center gap-2 text-sm text-success font-medium">
<CircleCheck className="h-4 w-4" />
Task complete
</span>
@@ -544,9 +544,7 @@ const AudioMetaPanel = ({ meta, fileName }: { meta: AudioMeta; fileName: string
{dur ? <span className="tabular-nums">{dur}</span> : null}
<span
className={`rounded-md px-1.5 py-0.5 font-medium ${
meta.hasLyrics
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
: 'bg-duck-dark/10 dark:bg-foreground/10'
meta.hasLyrics ? 'bg-success/15 text-success dark:text-success' : 'bg-duck-dark/10 dark:bg-foreground/10'
}`}
>
Lyrics: {meta.hasLyrics ? 'Yes' : 'No'}
@@ -1268,7 +1266,7 @@ const ScriptRunner = ({
<button
onClick={() => submitJob('start')}
disabled={jobDisabled}
className={`${base} text-white ${jobRunning ? 'bg-red-500 hover:bg-red-500/90' : 'bg-duck-teal hover:bg-duck-teal/90'}`}
className={`${base} text-white ${jobRunning ? 'bg-destructive hover:bg-destructive/90' : 'bg-duck-teal hover:bg-duck-teal/90'}`}
>
<Play className="h-4 w-4" />{' '}
{canChooseRunMode ? (jobRunning ? 'Run now as job' : 'Run as job') : jobRunning ? 'Run now' : 'Run'}
@@ -1290,9 +1288,9 @@ const ScriptRunner = ({
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 text-white/40 hover:text-white hover:bg-white/20 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer z-10"
title="Copy output"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-[#0d1117] text-gray-200 rounded-lg p-4 pr-10">
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-[#0d1117] text-muted-foreground rounded-lg p-4 pr-10">
<code>
{runner.output.map((line, i) => (
<span
@@ -1302,7 +1300,7 @@ const ScriptRunner = ({
// light mode.
className={
line.stream === 'stderr'
? 'text-red-400'
? 'text-destructive'
: line.stream === 'system'
? 'text-duck-teal/70'
: undefined
@@ -1320,18 +1318,18 @@ const ScriptRunner = ({
{runner.phase === 'running' ? (
<button
onClick={runner.stop}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer"
>
<Square className="h-3.5 w-3.5" />
Stop
</button>
) : runner.exitCode !== 0 ? (
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
<CircleX className="h-4 w-4" />
Task failed (exit {runner.exitCode})
</span>
) : (
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
<span className="flex items-center gap-2 text-sm text-success font-medium">
<CircleCheck className="h-4 w-4" />
Task complete
</span>
@@ -1528,7 +1526,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
{pipeline.currentStep.status === 'running' && (
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
{pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-green-600">done</span>}
{pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-success">done</span>}
</div>
</div>
)}
@@ -1541,12 +1539,12 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
<span className="text-duck-dark/50 text-xs">
{pDone}/{pTotal} done
{pRunning > 0 && <span className="ml-1">· {pRunning} running</span>}
{pError > 0 && <span className="ml-1 text-red-500">· {pError} failed</span>}
{pError > 0 && <span className="ml-1 text-destructive">· {pError} failed</span>}
</span>
{pDone + pError < pTotal && (
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
{pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-green-600">done</span>}
{pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-success">done</span>}
</div>
</div>
)}
@@ -1577,10 +1575,10 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
<span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />
)}
{it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-success shrink-0" />}
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-destructive shrink-0" />}
<span
className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}
className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-destructive' : 'text-duck-dark/60'}`}
>
{it.label}
</span>
@@ -1590,7 +1588,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
</span>
)}
{it.error && (
<span className="ml-auto text-xs text-red-400 truncate max-w-[200px]" title={it.error}>
<span className="ml-auto text-xs text-destructive truncate max-w-[200px]" title={it.error}>
{it.error}
</span>
)}
@@ -1618,18 +1616,18 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
{pipeline.phase === 'running' ? (
<button
onClick={pipeline.stop}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-destructive/10 text-destructive text-sm font-medium hover:bg-destructive/20 transition-colors cursor-pointer"
>
<Square className="h-3.5 w-3.5" />
Stop Pipeline
</button>
) : pipeline.hasError ? (
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
<span className="flex items-center gap-2 text-sm text-destructive font-medium">
<CircleX className="h-4 w-4" />
Pipeline failed
</span>
) : (
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
<span className="flex items-center gap-2 text-sm text-success font-medium">
<CircleCheck className="h-4 w-4" />
Pipeline complete
</span>
@@ -56,7 +56,7 @@ export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps)
<button
onClick={handleDeleteSelected}
title="Delete"
className="p-1.5 rounded-md text-red-500 hover:bg-red-50 cursor-pointer transition-colors"
className="p-1.5 rounded-md text-destructive hover:bg-destructive cursor-pointer transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
@@ -249,7 +249,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
{e.status === 'loading' ? (
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
) : e.status === 'error' ? (
<AlertCircle size={16} className="text-red-500/70" />
<AlertCircle size={16} className="text-destructive/70" />
) : e.thumbnail && !audioOnly ? (
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
) : (
@@ -265,7 +265,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div>
) : e.status === 'error' ? (
<>
<p className="truncate text-sm font-medium text-red-500">Could not fetch</p>
<p className="truncate text-sm font-medium text-destructive">Could not fetch</p>
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
</>
) : (
@@ -276,7 +276,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
<p className="truncate text-xs text-duck-dark/50">
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
</p>
{e.dl === 'error' && <p className="truncate text-xs text-red-500">{e.dlError}</p>}
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
</>
)}
</div>
@@ -43,7 +43,7 @@ export const FileViewerBody = () => {
// action that still works, and it is offered rather than left to be guessed at.
<div className="flex flex-col items-center justify-center h-full gap-3 px-6 text-center">
<FileWarning className="h-8 w-8 text-duck-dark/30" />
<span className="text-sm text-red-500">{error}</span>
<span className="text-sm text-destructive">{error}</span>
<button
onClick={handleDownload}
className="inline-flex items-center gap-1.5 rounded-md border border-duck-dark/20 px-3 py-1.5 text-xs text-duck-dark transition-colors hover:bg-duck-dark/5 cursor-pointer"
@@ -50,7 +50,7 @@ export const FileViewerHeader = () => {
) : fileType === 'image' ? (
<Image className="h-4 w-4 text-duck-yellow shrink-0" />
) : fileType === 'pdf' ? (
<FileType2 className="h-4 w-4 text-red-500 shrink-0" />
<FileType2 className="h-4 w-4 text-destructive shrink-0" />
) : fileType === 'archive' ? (
<FolderArchive className="h-4 w-4 text-duck-orange shrink-0" />
) : (
@@ -80,7 +80,7 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">Failed to load audio file</span>
<span className="text-sm text-destructive">Failed to load audio file</span>
</div>
);
}
@@ -110,7 +110,10 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
</div>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<button onClick={toggleMute} className="p-1.5 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer">
<button
onClick={toggleMute}
className="p-1.5 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
@@ -78,7 +78,7 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">Failed to load image</span>
<span className="text-sm text-destructive">Failed to load image</span>
</div>
);
}
@@ -213,7 +213,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
if (error) {
return (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-red-500">Failed to load video</span>
<span className="text-sm text-destructive">Failed to load video</span>
</div>
);
}
@@ -227,9 +227,22 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
if (playing) setShowControls(false);
}}
>
<video ref={videoRef} src={videoSrc} preload="metadata" playsInline className="w-full h-full object-contain" onClick={togglePlay}>
<video
ref={videoRef}
src={videoSrc}
preload="metadata"
playsInline
className="w-full h-full object-contain"
onClick={togglePlay}
>
{subtitles.map((t) => (
<track key={t.id} kind="subtitles" src={getSubtitleVttUrl(filePath, t.id, root)} srcLang={t.lang} label={t.label} />
<track
key={t.id}
kind="subtitles"
src={getSubtitleVttUrl(filePath, t.id, root)}
srcLang={t.lang}
label={t.label}
/>
))}
</video>
@@ -294,7 +307,10 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
</button>
<div className="flex items-center gap-1.5">
<button onClick={toggleMute} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
<button
onClick={toggleMute}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
@@ -348,7 +364,10 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
</div>
)}
<button onClick={toggleFullscreen} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
<button
onClick={toggleFullscreen}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
@@ -41,7 +41,7 @@ const Button = ({
const tones = {
primary: 'bg-duck-teal text-duck-yellow hover:opacity-90',
ghost: 'border border-duck-dark/20 text-duck-dark hover:bg-duck-dark/5',
danger: 'border border-red-300 text-red-600 hover:bg-red-50',
danger: 'border border-destructive text-destructive hover:bg-destructive',
};
return (
<button
@@ -86,7 +86,7 @@ const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
<ul className="mt-2 space-y-2">
{deps.items.map((item) => (
<li key={item.binary} className="flex gap-2 text-sm">
<span aria-hidden className={item.present ? 'text-emerald-600' : 'text-amber-600'}>
<span aria-hidden className={item.present ? 'text-success' : 'text-amber-600'}>
{item.present ? '✓' : '•'}
</span>
<span className="min-w-0">
@@ -152,7 +152,7 @@ const ActionLog = ({
{running}…
</>
) : (
<span className={result?.ok ? 'text-emerald-600' : 'text-red-600'}>{result?.ok ? 'Done' : 'Failed'}</span>
<span className={result?.ok ? 'text-success' : 'text-destructive'}>{result?.ok ? 'Done' : 'Failed'}</span>
)}
</div>
{/* Raw output is dimmer and indented under the step that produced it — present when you want it,
@@ -165,7 +165,7 @@ const ActionLog = ({
</li>
))}
</ul>
{result?.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
{result?.error ? <div className="mt-2 text-xs text-destructive">{result.error}</div> : null}
</div>
);
};
@@ -309,7 +309,7 @@ const Purge = ({ plugin, busy, onPurge }: { plugin: PluginItem; busy: boolean; o
type="button"
disabled={busy}
onClick={() => setConfirming(true)}
className="cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-red-600 disabled:opacity-40"
className="cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-destructive disabled:opacity-40"
>
Delete {plugin.label} and all its data
</button>
@@ -318,7 +318,7 @@ const Purge = ({ plugin, busy, onPurge }: { plugin: PluginItem; busy: boolean; o
}
return (
<div className="mt-8 rounded-lg border border-red-500/30 bg-red-500/5 p-4">
<div className="mt-8 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm font-medium text-duck-dark">Delete {plugin.label} completely?</p>
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs text-duck-dark/60">
<li>
@@ -13,7 +13,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
if (!plugin.installed) return <span className="text-xs text-duck-dark/40">not installed</span>;
if (!plugin.enabled) return <span className="text-xs text-amber-600">disabled</span>;
if (plugin.outdated) return <span className="text-xs text-amber-600">update available</span>;
return <span className="text-xs text-emerald-600">enabled</span>;
return <span className="text-xs text-success">enabled</span>;
};
export const PluginsList = () => {
@@ -97,10 +97,10 @@ export const PluginsList = () => {
manifest looks exactly like a plugin nobody wrote. */}
{broken.map((b) => (
<div key={b.appName} className="flex items-start gap-3 px-3 py-2.5 border-b border-duck-dark/5">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-duck-dark">{b.appName}</div>
<div className="text-xs text-red-600">{b.error}</div>
<div className="text-xs text-destructive">{b.error}</div>
</div>
</div>
))}
@@ -10,29 +10,53 @@ type Stats = {
uptimeSec: number;
loadavg: number[];
cpu: { model: string; cores: number; usagePct: number; perCore: number[] };
mem: { totalBytes: number; usedBytes: number; freeBytes: number; usedPct: number; swapTotalBytes: number; swapUsedBytes: number } | null;
mem: {
totalBytes: number;
usedBytes: number;
freeBytes: number;
usedPct: number;
swapTotalBytes: number;
swapUsedBytes: number;
} | null;
disks: { mount: string; fsType: string; totalBytes: number; usedBytes: number; usedPct: number }[];
processes: { pid: number; user: string; cpuPct: number; memPct: number; command: string }[];
temp: { cpuC: number | null; cpuLabel: string | null; sensors: Sensor[] } | null;
gpu: { busyPct: number; vramUsedBytes: number; vramTotalBytes: number } | null;
net: { rxBytesPerSec: number; txBytesPerSec: number; interfaces: { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] } | null;
net: {
rxBytesPerSec: number;
txBytesPerSec: number;
interfaces: { name: string; rxBytesPerSec: number; txBytesPerSec: number }[];
} | null;
power: { cpuWatts: number | null; gpuWatts: number | null } | null;
timestamp: number;
};
const POLL_MS = 2000;
const barColor = (pct: number) => (pct >= 85 ? 'bg-red-500' : pct >= 60 ? 'bg-amber-500' : 'bg-emerald-500');
const tempColor = (c: number) => (c >= 80 ? 'text-red-500' : c >= 65 ? 'text-amber-500' : 'text-emerald-500');
const barColor = (pct: number) => (pct >= 85 ? 'bg-destructive' : pct >= 60 ? 'bg-amber-500' : 'bg-success');
const tempColor = (c: number) => (c >= 80 ? 'text-destructive' : c >= 65 ? 'text-amber-500' : 'text-success');
const fmtRate = (bps: number) => `${fmtBytes(bps)}/s`;
const Bar = ({ pct }: { pct: number }) => (
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full rounded-full transition-all ${barColor(pct)}`} style={{ width: `${Math.min(100, pct)}%` }} />
<div
className={`h-full rounded-full transition-all ${barColor(pct)}`}
style={{ width: `${Math.min(100, pct)}%` }}
/>
</div>
);
const Card = ({ title, icon: Icon, children, className }: { title: string; icon: typeof Cpu; children: React.ReactNode; className?: string }) => (
const Card = ({
title,
icon: Icon,
children,
className,
}: {
title: string;
icon: typeof Cpu;
children: React.ReactNode;
className?: string;
}) => (
<div className={`flex flex-col gap-3 rounded-xl border border-border bg-card p-4 ${className ?? ''}`}>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon size={16} className="text-primary" />
@@ -77,12 +101,16 @@ export const BtopView = () => {
<div className="flex items-center gap-4">
<span>up {fmtDuration(stats.uptimeSec * 1000)}</span>
<span>load {stats.loadavg.map((l) => l.toFixed(2)).join(' ')}</span>
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-success" title="live" />
</div>
)}
</div>
{error && <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{stats && (
<>
@@ -93,7 +121,9 @@ export const BtopView = () => {
<span className="text-xs text-muted-foreground">{stats.cpu.cores} cores</span>
</div>
<Bar pct={stats.cpu.usagePct} />
<p className="truncate text-xs text-muted-foreground" title={stats.cpu.model}>{stats.cpu.model}</p>
<p className="truncate text-xs text-muted-foreground" title={stats.cpu.model}>
{stats.cpu.model}
</p>
<div className="grid grid-cols-8 gap-1 pt-1">
{stats.cpu.perCore.map((c, i) => (
<div key={i} className="flex h-10 items-end rounded bg-muted" title={`core ${i}: ${c}%`}>
@@ -130,8 +160,12 @@ export const BtopView = () => {
{stats.disks.map((d) => (
<div key={d.mount} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="truncate font-mono text-foreground" title={`${d.mount} (${d.fsType})`}>{d.mount}</span>
<span className="text-muted-foreground">{fmtBytes(d.usedBytes)} / {fmtBytes(d.totalBytes)}</span>
<span className="truncate font-mono text-foreground" title={`${d.mount} (${d.fsType})`}>
{d.mount}
</span>
<span className="text-muted-foreground">
{fmtBytes(d.usedBytes)} / {fmtBytes(d.totalBytes)}
</span>
</div>
<Bar pct={d.usedPct} />
</div>
@@ -147,10 +181,14 @@ export const BtopView = () => {
<>
<div className="flex items-end justify-between">
<span className="text-3xl font-semibold text-foreground">{stats.gpu.busyPct}%</span>
<span className="text-xs text-muted-foreground">VRAM {fmtBytes(stats.gpu.vramUsedBytes)} / {fmtBytes(stats.gpu.vramTotalBytes)}</span>
<span className="text-xs text-muted-foreground">
VRAM {fmtBytes(stats.gpu.vramUsedBytes)} / {fmtBytes(stats.gpu.vramTotalBytes)}
</span>
</div>
<Bar pct={stats.gpu.busyPct} />
{stats.gpu.vramTotalBytes > 0 && <Bar pct={(stats.gpu.vramUsedBytes / stats.gpu.vramTotalBytes) * 100} />}
{stats.gpu.vramTotalBytes > 0 && (
<Bar pct={(stats.gpu.vramUsedBytes / stats.gpu.vramTotalBytes) * 100} />
)}
</>
) : (
<p className="text-sm text-muted-foreground">no gpu</p>
@@ -161,7 +199,9 @@ export const BtopView = () => {
{stats.temp?.cpuC != null ? (
<>
<div className="flex items-end justify-between">
<span className={`text-3xl font-semibold ${tempColor(stats.temp.cpuC)}`}>{stats.temp.cpuC.toFixed(0)}°C</span>
<span className={`text-3xl font-semibold ${tempColor(stats.temp.cpuC)}`}>
{stats.temp.cpuC.toFixed(0)}°C
</span>
<span className="truncate pl-2 text-xs text-muted-foreground">{stats.temp.cpuLabel}</span>
</div>
<div className="flex flex-col gap-1 pt-1">
@@ -170,7 +210,9 @@ export const BtopView = () => {
.slice(0, 6)
.map((s, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="truncate text-muted-foreground">{s.name} · {s.label}</span>
<span className="truncate text-muted-foreground">
{s.name} · {s.label}
</span>
<span className={`font-mono ${tempColor(s.celsius)}`}>{s.celsius.toFixed(0)}°C</span>
</div>
))}
@@ -192,7 +234,9 @@ export const BtopView = () => {
{stats.net.interfaces.slice(0, 4).map((i) => (
<div key={i.name} className="flex items-center justify-between text-xs text-muted-foreground">
<span className="truncate font-mono">{i.name}</span>
<span className="font-mono">↓{fmtRate(i.rxBytesPerSec)} ↑{fmtRate(i.txBytesPerSec)}</span>
<span className="font-mono">
↓{fmtRate(i.rxBytesPerSec)} ↑{fmtRate(i.txBytesPerSec)}
</span>
</div>
))}
{stats.net.interfaces.length === 0 && <span className="text-xs text-muted-foreground">idle</span>}
@@ -208,11 +252,15 @@ export const BtopView = () => {
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">CPU package</span>
<span className="font-mono text-foreground">{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}</span>
<span className="font-mono text-foreground">
{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">GPU</span>
<span className="font-mono text-foreground">{stats.power.gpuWatts != null ? `${stats.power.gpuWatts} W` : '—'}</span>
<span className="font-mono text-foreground">
{stats.power.gpuWatts != null ? `${stats.power.gpuWatts} W` : '—'}
</span>
</div>
</div>
) : (
@@ -239,8 +287,12 @@ export const BtopView = () => {
<td className="py-1.5 pr-4 font-mono text-muted-foreground">{p.pid}</td>
<td className="py-1.5 pr-4 text-muted-foreground">{p.user}</td>
<td className="py-1.5 pr-4 text-right font-mono text-foreground">{p.cpuPct.toFixed(1)}</td>
<td className="py-1.5 pr-4 text-right font-mono text-muted-foreground">{p.memPct.toFixed(1)}</td>
<td className="max-w-0 truncate py-1.5 font-mono text-foreground" title={p.command}>{p.command}</td>
<td className="py-1.5 pr-4 text-right font-mono text-muted-foreground">
{p.memPct.toFixed(1)}
</td>
<td className="max-w-0 truncate py-1.5 font-mono text-foreground" title={p.command}>
{p.command}
</td>
</tr>
))}
</tbody>
@@ -5,7 +5,7 @@ import { LogStream } from './LogStream';
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
const POLL_MS = 3000;
const stateColor = (s: string) => (s === 'running' ? 'bg-emerald-500' : s === 'exited' ? 'bg-red-500' : 'bg-amber-500');
const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-amber-500');
export const DockerView = () => {
const { get } = useClient();
@@ -45,7 +45,9 @@ export const DockerView = () => {
);
const q = filter.trim().toLowerCase();
const filtered = (containers ?? []).filter((c) => !q || c.name.toLowerCase().includes(q) || c.image.toLowerCase().includes(q));
const filtered = (containers ?? []).filter(
(c) => !q || c.name.toLowerCase().includes(q) || c.image.toLowerCase().includes(q),
);
return (
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
@@ -61,7 +63,11 @@ export const DockerView = () => {
className="w-56 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{error && <div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
{error && (
<div className="mb-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-4">
{filtered.map((c) => (
<button
@@ -74,9 +80,17 @@ export const DockerView = () => {
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${stateColor(c.state)}`} />
<span className="truncate text-sm font-medium text-foreground">{c.name}</span>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground" title={c.image}>{c.image}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground/70" title={c.status}>{c.status}</div>
{c.ports && <div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/60" title={c.ports}>{c.ports}</div>}
<div className="mt-1 truncate text-xs text-muted-foreground" title={c.image}>
{c.image}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground/70" title={c.status}>
{c.status}
</div>
{c.ports && (
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground/60" title={c.ports}>
{c.ports}
</div>
)}
</button>
))}
</div>
@@ -6,7 +6,17 @@ const MAX_LINES = 1200;
// Generic live log viewer: SSE-tails `${streamUrl}&token=…` (server sends one `data:` frame per line),
// on a solid-black terminal-style pane. Used for both pm2 and docker logs.
export const LogStream = ({ streamUrl, title, backLabel, onBack }: { streamUrl: string; title: string; backLabel: string; onBack: () => void }) => {
export const LogStream = ({
streamUrl,
title,
backLabel,
onBack,
}: {
streamUrl: string;
title: string;
backLabel: string;
onBack: () => void;
}) => {
const { token } = useClient();
const [lines, setLines] = useState<string[]>([]);
const scrollRef = useRef<HTMLPreElement | null>(null);
@@ -25,14 +35,21 @@ export const LogStream = ({ streamUrl, title, backLabel, onBack }: { streamUrl:
return (
<div className="flex h-full w-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-border p-3">
<button type="button" onClick={onBack} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ChevronLeft size={16} /> {backLabel}
</button>
<span className="truncate font-mono text-sm text-foreground">{title}</span>
<span className="ml-auto inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
<span className="ml-auto inline-flex h-2 w-2 animate-pulse rounded-full bg-success" title="live" />
</div>
<pre ref={scrollRef} className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words bg-black p-3 font-mono text-xs text-neutral-200">
{lines.length === 0 ? <span className="text-neutral-500">connecting…</span> : lines.join('\n')}
<pre
ref={scrollRef}
className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words bg-black p-3 font-mono text-xs text-muted-foreground"
>
{lines.length === 0 ? <span className="text-muted-foreground">connecting…</span> : lines.join('\n')}
</pre>
</div>
);
@@ -3,12 +3,27 @@ import { useClient } from 'hooks/useClient';
import { fmtBytes, fmtDuration } from './shared';
import { LogStream } from './LogStream';
type Proc = { id: number; name: string; status: string; pid: number | null; cpuPct: number; memBytes: number; restarts: number; uptimeMs: number };
type Proc = {
id: number;
name: string;
status: string;
pid: number | null;
cpuPct: number;
memBytes: number;
restarts: number;
uptimeMs: number;
};
type SortKey = 'id' | 'name' | 'status' | 'cpuPct' | 'memBytes' | 'restarts' | 'uptimeMs' | 'pid';
const POLL_MS = 2000;
const statusColor = (s: string) =>
s === 'online' ? 'bg-emerald-500' : s === 'stopped' ? 'bg-muted-foreground/40' : s === 'errored' ? 'bg-red-500' : 'bg-amber-500';
s === 'online'
? 'bg-success'
: s === 'stopped'
? 'bg-muted-foreground/40'
: s === 'errored'
? 'bg-destructive'
: 'bg-amber-500';
const COLS: { key: SortKey; label: string; align: 'left' | 'right' }[] = [
{ key: 'id', label: '#', align: 'right' },
@@ -62,7 +77,8 @@ export const Pm2View = () => {
arr.sort((a, b) => {
const av = a[sortKey] ?? 0;
const bv = b[sortKey] ?? 0;
const cmp = typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : (av as number) - (bv as number);
const cmp =
typeof av === 'string' && typeof bv === 'string' ? av.localeCompare(bv) : (av as number) - (bv as number);
return sortDir === 'asc' ? cmp : -cmp;
});
return arr;
@@ -81,8 +97,14 @@ export const Pm2View = () => {
return (
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-5xl">
<div className="mb-3 text-xs text-muted-foreground">pm2 · {procs?.length ?? 0} processes · click a name for live logs</div>
{error && <div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
<div className="mb-3 text-xs text-muted-foreground">
pm2 · {procs?.length ?? 0} processes · click a name for live logs
</div>
{error && (
<div className="mb-3 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="overflow-x-auto rounded-xl border border-border bg-card">
<table className="w-full text-left text-sm">
<thead>
@@ -95,7 +117,9 @@ export const Pm2View = () => {
className={`inline-flex items-center gap-1 hover:text-foreground ${c.align === 'right' ? 'flex-row-reverse' : ''} ${sortKey === c.key ? 'text-foreground' : ''}`}
>
{c.label}
<span className="w-2 text-[10px]">{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}</span>
<span className="w-2 text-[10px]">
{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
</span>
</button>
</th>
))}
@@ -129,7 +153,9 @@ export const Pm2View = () => {
))}
{procs && procs.length === 0 && (
<tr>
<td colSpan={8} className="p-3 text-muted-foreground">no processes</td>
<td colSpan={8} className="p-3 text-muted-foreground">
no processes
</td>
</tr>
)}
</tbody>