a sixth tone, and no raw palette left in the core apps

The five core apps — FileBrowser, FileViewer, SystemMonitor, Chat, Plugins — now
contain ZERO raw Tailwind palette classes. Every colour in them names a state or a
surface, so all of it follows the theme.

── Busy ──

The 38 ambers were the blocker, and the answer was not to pick between two wrong
mappings. They mean RUNNING — Chat's own comment says "the amber a RUNNING task chip
wears" — and the vocabulary had no word for it. `warning` would have made a working
job look like a failing one, and `--status-in-progress` is blue.

So `tone.ts` has six tones instead of five. It was always written as a closed set that
could grow when a genuine sixth state appeared; this is that.

`--busy-ink` comes with it, and generalises: a fill bright enough to carry black text
is far too light to BE text. The other five tones are dark enough to do both jobs,
which is why the problem had not surfaced. `text-amber-950` — ink ON the fill — became
`text-busy-foreground`, which is what it always was.

── On fidelity, which stopped being the constraint ──

Duck's exact colours were preserved up to this point because an extraction has to be
provably invisible. The owner has since said the palette is not precious: the
wallpaper, the graph-paper grid and the mark are what matter, and everything else can
move. So shades collapse — one busy, one red, one green — rather than being
approximated to keep old pixels.

The verification built for the old constraint keeps its value: it proves the token
layer resolves, which is a different claim from "the colours did not change".

Verified: the utilities compile (`text-busy-ink`, `bg-busy`, `border-busy`,
`text-busy-foreground`) and resolve in the browser.

950 pass / 7 fail unchanged. tsgo clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:13:19 +00:00
co-authored by Claude Opus 5
parent a52d3e06f3
commit dfb8a6d6cb
12 changed files with 56 additions and 30 deletions
+7 -2
View File
@@ -1,6 +1,6 @@
// The state vocabulary for data surfaces. See docs/design-language-interface.md § 3.
//
// Five tones and no others. The point of a closed set is that colour keeps meaning something: when a
// Six tones and no others. The point of a closed set is that colour keeps meaning something: when a
// view can reach for any of Tailwind's twenty-two palettes, green stops reading as "open" and starts
// reading as "the author liked green here". Every tone maps to a semantic token, never a palette
// number, so it follows the theme and survives dark mode.
@@ -8,7 +8,7 @@
// Class strings are written out in full rather than composed (`text-${tone}`) because Tailwind's
// scanner reads source text — an interpolated class name is not in the output CSS at all.
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'busy';
/** Foreground only — for an icon or a word carrying state inside otherwise normal text. */
export const toneText: Record<Tone, string> = {
@@ -17,6 +17,9 @@ export const toneText: Record<Tone, string> = {
warning: 'text-warning',
danger: 'text-destructive',
info: 'text-info',
// `-ink`, not the fill: busy's fill is bright enough to carry black text, which makes it unreadable AS
// text. The other five are dark enough to serve both jobs.
busy: 'text-busy-ink',
};
/**
@@ -29,6 +32,7 @@ export const toneFill: Record<Tone, string> = {
warning: 'bg-warning/15 text-warning',
danger: 'bg-destructive/15 text-destructive',
info: 'bg-info/15 text-info',
busy: 'bg-busy/15 text-busy-ink',
};
/** A left border or rule carrying state, for rows that mark status without a pill. */
@@ -38,4 +42,5 @@ export const toneBorder: Record<Tone, string> = {
warning: 'border-warning',
danger: 'border-destructive',
info: 'border-info',
busy: 'border-busy',
};
@@ -144,7 +144,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
size="icon"
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
className={`shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer disabled:opacity-40 ${
isGenerating ? 'bg-amber-500 hover:bg-amber-500/90 text-amber-950' : 'bg-duck-teal hover:bg-duck-teal/90'
isGenerating ? 'bg-busy hover:bg-busy/90 text-busy-foreground' : 'bg-duck-teal hover:bg-duck-teal/90'
}`}
>
{isGenerating ? <ListPlus className="h-4 w-4" /> : <Send className="h-4 w-4" />}
@@ -264,16 +264,13 @@ const QueuedList = ({
type="button"
onClick={onSendNow}
title="Send these into the turn that is already running"
className="cursor-pointer self-start rounded px-1 text-[11px] text-amber-700/80 transition-colors hover:bg-amber-500/10 hover:text-amber-700"
className="cursor-pointer self-start rounded px-1 text-[11px] text-busy-ink/80 transition-colors hover:bg-busy/10 hover:text-busy-ink"
>
Send now — press Enter on an empty box to add {queued.length === 1 ? 'this' : 'these'} to the running turn
</button>
{queued.map((item) => (
<div
key={item.id}
className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1"
>
<ListPlus className="h-3 w-3 shrink-0 text-amber-600" />
<div key={item.id} className="flex items-center gap-2 rounded-md border border-busy/30 bg-busy/10 px-2 py-1">
<ListPlus className="h-3 w-3 shrink-0 text-busy-ink" />
<span className="min-w-0 flex-1 truncate text-xs text-foreground/80">{item.raw.trim()}</span>
<button
type="button"
@@ -569,7 +569,7 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
if (fileCount === 0) {
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-busy-ink dark:text-busy-ink">
<AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
</div>
);
@@ -588,7 +588,7 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
if (!onKeepAllChange) {
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1.5">
<span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<span className="flex items-center gap-2 text-sm text-busy-ink dark:text-busy-ink">
<AlertCircle className="h-4 w-4 shrink-0" />
{majorityCount} of {fileCount} videos share this layout and will be processed.
</span>
@@ -608,7 +608,7 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-2">
<span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<span className="flex items-center gap-2 text-sm text-busy-ink dark:text-busy-ink">
<AlertCircle className="h-4 w-4 shrink-0" />
{majorityCount} of {fileCount} videos share a track layout.
</span>
@@ -740,7 +740,7 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
}
if (groups.length === 0) {
return (
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-busy-ink dark:text-busy-ink">
<AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
</div>
);
@@ -1524,7 +1524,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
</span>
)}
{pipeline.currentStep.status === 'running' && (
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-busy animate-pulse" />
)}
{pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-success">done</span>}
</div>
@@ -1542,7 +1542,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
{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" />
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-busy animate-pulse" />
)}
{pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-success">done</span>}
</div>
@@ -1556,7 +1556,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
{totalTokens > 0 && <span>{totalTokens.toLocaleString()} tok</span>}
{rc.totalUSD > 0 && <span>${rc.totalUSD.toFixed(3)}</span>}
{pipeline.waitingStatus && (
<span className="ml-auto flex items-center gap-1.5 text-amber-500">
<span className="ml-auto flex items-center gap-1.5 text-busy-ink">
<Loader2 className="h-3 w-3 animate-spin" />
Waiting for model ({formatElapsed(pipeline.waitingStatus.elapsed)})
</span>
@@ -1574,7 +1574,7 @@ const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerPr
{it.status === 'pending' && (
<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 === 'running' && <Loader2 className="h-4 w-4 text-busy-ink animate-spin 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
@@ -86,12 +86,12 @@ 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-success' : 'text-amber-600'}>
<span aria-hidden className={item.present ? 'text-success' : 'text-busy-ink'}>
{item.present ? '✓' : '•'}
</span>
<span className="min-w-0">
<code className="text-duck-dark">{item.binary}</code>{' '}
<span className={item.present ? 'text-duck-dark/50' : 'text-amber-700'}>
<span className={item.present ? 'text-duck-dark/50' : 'text-busy-ink'}>
{item.present ? 'present' : `missing${item.packageName ? ` — ${item.packageName}` : ''}`}
</span>
<span className="block text-xs text-duck-dark/50">{item.reason}</span>
@@ -112,8 +112,8 @@ const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
as root first.
</p>
) : (
<div className="mt-3 rounded border border-amber-500/30 bg-amber-500/5 p-2.5">
<p className="text-xs text-amber-800">{deps.blockedReason ?? 'These cannot be installed automatically.'}</p>
<div className="mt-3 rounded border border-busy/30 bg-busy/5 p-2.5">
<p className="text-xs text-busy-ink">{deps.blockedReason ?? 'These cannot be installed automatically.'}</p>
{deps.manualHint ? <code className="mt-1.5 block text-xs text-duck-dark">{deps.manualHint}</code> : null}
</div>
)}
@@ -391,7 +391,7 @@ export const PluginDetail = () => {
<Row label="Version">
{plugin.version}
{plugin.outdated ? (
<span className="ml-2 text-amber-600">on disk — installed {plugin.installedVersion}</span>
<span className="ml-2 text-busy-ink">on disk — installed {plugin.installedVersion}</span>
) : null}
</Row>
<Row label="Needs platform">{plugin.platform}</Row>
@@ -405,7 +405,7 @@ export const PluginDetail = () => {
<span
className={
plugin.enabled && plugin.processStatus !== 'online' && plugin.installed
? 'text-amber-600'
? 'text-busy-ink'
: 'text-duck-dark'
}
>
@@ -11,8 +11,8 @@ import { resolveIcon } from '../../utils/resolve-icon';
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>;
if (!plugin.enabled) return <span className="text-xs text-busy-ink">disabled</span>;
if (plugin.outdated) return <span className="text-xs text-busy-ink">update available</span>;
return <span className="text-xs text-success">enabled</span>;
};
@@ -132,7 +132,7 @@ const Orphans = ({ orphans }: { orphans: OrphanTable[] }) => {
return (
<div className="border-t border-duck-dark/10 px-3 py-3">
<div className="flex items-start gap-2">
<Database className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600" />
<Database className="mt-0.5 h-3.5 w-3.5 shrink-0 text-busy-ink" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-duck-dark/70">
{orphans.length} table{orphans.length === 1 ? '' : 's'} with no plugin on this machine
@@ -33,8 +33,8 @@ type Stats = {
const POLL_MS = 2000;
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 barColor = (pct: number) => (pct >= 85 ? 'bg-destructive' : pct >= 60 ? 'bg-busy' : 'bg-success');
const tempColor = (c: number) => (c >= 80 ? 'text-destructive' : c >= 65 ? 'text-busy-ink' : 'text-success');
const fmtRate = (bps: number) => `${fmtBytes(bps)}/s`;
const Bar = ({ pct }: { pct: number }) => (
@@ -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-success' : s === 'exited' ? 'bg-destructive' : 'bg-amber-500');
const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-busy');
export const DockerView = () => {
const { get } = useClient();
@@ -23,7 +23,7 @@ const statusColor = (s: string) =>
? 'bg-muted-foreground/40'
: s === 'errored'
? 'bg-destructive'
: 'bg-amber-500';
: 'bg-busy';
const COLS: { key: SortKey; label: string; align: 'left' | 'right' }[] = [
{ key: 'id', label: '#', align: 'right' },
+9
View File
@@ -88,6 +88,15 @@
--color-info: hsl(var(--info));
--color-info-foreground: hsl(var(--info-foreground));
/* The sixth state, and the reason it exists: `running` is neither good news nor a warning. The chat
tray, the send button and the queue all wore amber for it, and the token set had nothing to offer —
`--status-in-progress` is blue, and `warning` would make a working job look like a failing one.
`-ink` is the readable-on-a-tint variant: the fill is bright enough to sit behind black text, which
makes it far too light to BE text. Every tone will want this eventually; busy needed it first. */
--color-busy: hsl(var(--busy));
--color-busy-foreground: hsl(var(--busy-foreground));
--color-busy-ink: hsl(var(--busy-ink));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
@@ -122,6 +122,11 @@
--info: 263 90% 70%;
--info-foreground: 0 0% 100%;
/* Busy: running, queued, in flight. See base.css. */
--busy: 43 96% 56%;
--busy-foreground: 20 14% 8%;
--busy-ink: 43 96% 70%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
@@ -128,6 +128,11 @@
--info: 263 70% 50%;
--info-foreground: 0 0% 100%;
/* Busy: running, queued, in flight. See base.css. */
--busy: 43 96% 50%;
--busy-foreground: 20 14% 8%;
--busy-ink: 32 81% 29%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
@@ -126,6 +126,11 @@
--info: 0 0% 35%;
--info-foreground: 0 0% 100%;
/* Busy: running, queued, in flight. See base.css. */
--busy: 0 0% 45%;
--busy-foreground: 0 0% 100%;
--busy-ink: 0 0% 25%;
--border: 0 0% 0%;
--input: 0 0% 0%;
--ring: 0 0% 0%;