a ratchet on hardcoded colour, and the docs to match

The three previous attempts at this job died of endlessness, not difficulty. Nothing
stopped the next component arriving with its own `text-emerald-400` while an old one
was being fixed, so the number never fell for long.

`bun run check:theme` counts palette classes per directory against a committed
baseline. It does not care what the number is, only which way it moves. 942 across 21
directories today. Every directory can be paid down independently and none can quietly
regrow — which turns an infinite job into a finite one.

A baseline rather than a ban, deliberately: a rule forbidding palette classes outright
fails on its first run, and a check nobody can make pass is a check everybody turns
off.

Proven by adding one `text-emerald-400` and watching it fail with the directory, the
delta, and what to use instead.

── Documentation, which is part of the work rather than after it ──

`src/workspaces/theme/README.md` is new and is the contract: how to write a theme, all
84 declarations by family, why colours are unwrapped HSL triplets while surfaces are
complete CSS values, why assets are quoted paths, and the rules for components. Start
from Plain, not from Duck — every value you change is then one you decided about.

`plugins/EXTRACTING-A-PLUGIN.md` gains a rule beside the others: a plugin never
hardcodes a colour, and pays its debt down DURING the extraction. The counts are named
there — Soulseek 313, Invoices 95, Wallet 68 — because "some colours" does not convey
that it is two thirds of the platform's total. The kickoff prompt says the same thing
in the form a fresh agent will meet it.

That is what makes the plugin exemption safe rather than a way of losing the work: the
ratchet stops covering a directory the moment it leaves, so the rule has to travel with
it.

`THEMES-NOTES.md` rewritten to match reality — it still listed done things as open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:42:56 +00:00
co-authored by Claude Opus 5
parent df92a985d2
commit f977d26fef
7 changed files with 375 additions and 116 deletions
+81 -115
View File
@@ -1,143 +1,109 @@
# Themes decoupling — working notes
# Themes — where this got to
Branch `themes-decoupling`, worktree `platform-themes`. Nothing here is on master.
Read this before the diff. It is the state of the work, the decisions taken and why, and the things
deliberately left alone.
Merged to master. The reference implementation is `src/workspaces/theme/README.md`; this file is the state
of the work — what was done, what was decided and why, and what is still open.
---
## Where it got to
## Done
The visual layer is out of the app and into `src/workspaces/theme`. The application no longer contains any
colour: it imports one stylesheet and mounts one provider.
**The visual layer is out of the application.** It imports one stylesheet and mounts one provider, and
contains no colours of its own. A theme is one CSS file scoped to `[data-theme='<id>']` plus one line in
`src/workspaces/theme/src/themes.ts`.
Proven by the two things that matter:
**Three themes ship**: Duck Light, Duck Dark, Plain. Switch from the avatar menu. Light and dark are
separate themes, not modes — the owner's call, and it removes a concept from the app.
- **Duck renders identically to before.** Panels, wallpaper, grid fills, glass, logo — checked against
screenshots taken before anything was touched.
- **Plain renders as white with black ink**, with no component edits. Panels turn white, the teal avatar
turns black, grid fills disappear. That is the decoupling working.
**Themed and no longer component-owned**: the page wallpaper, the graph-paper grid, the header, the logo,
the panel surface (background, border, radius, blur, the fullscreen and header-less variants), the dock,
cards, glass, scrollbars.
Switch themes from the avatar menu, top right.
**Zero raw palette classes in the five core apps** — FileBrowser, FileViewer, SystemMonitor, Chat, Plugins
— for state colour. `text-white`/`bg-black` remain where they sit over media, which is correct in every
theme.
**A sixth tone, `busy`.** The 38 ambers meant *running*, not *warning* — Chat's own comment said so. The
vocabulary had no word for it. `--busy-ink` came with it and generalises: a fill bright enough to carry
black text is too light to BE text.
**The account stores a theme id**, so the choice follows the account rather than one browser's
localStorage. Accounts predating themes migrate from `appearance.colorMode` on the server, which is the
only place that can tell "never chose" from "chose the default".
**A ratchet**: `bun run check:theme`. Per-directory counts of palette classes that may fall and never
rise. 942 across 21 directories today. This is what makes the job finite — the previous attempts died of
endlessness, not difficulty.
**Build failures name their cause.** `Bun.build` throws an `AggregateError` whose message is the string
"Bundle failed" with the real diagnostics in `.errors`, so the `result.success` branch was unreachable and
the catch reported the wrapper. `throw: false` fixed years of `Bundle failed`.
**The landing app is deleted** — built elsewhere now, and it carried a third copy of the palette.
---
## The shape
## Decisions worth not relitigating
```
src/workspaces/theme/
css/base.css structure — Tailwind, the @theme contract, element defaults. NO values.
css/themes/duck-light.css the app's original appearance, moved unchanged
css/themes/duck-dark.css ditto, the old .dark block
css/themes/plain.css white/black — an instrument, not a design
css/chrome.css scrollbars and overlays, now in theme variables
css/index.css the single import an app makes
src/themes.ts the registry: id, name, family, applyDarkClass. Nothing about appearance.
src/ThemeProvider.tsx sets data-theme, toggles .dark, persists the choice
```
**`duck-*` utility names are kept, pointed at neutral variables.** ~1,470 occurrences. Renaming them in the
same change as the extraction would have made a visual difference impossible to attribute. `--duck-teal`
and friends survive as aliases because `prose.css` reads them directly, eleven times, for chat markdown.
A theme is a CSS file scoped to `[data-theme='<id>']` plus one line in the registry. Nothing else.
**`duck-dark` is `--ink`.** It was never a brand colour: `#14532d` in light, near-white in dark, used ~950
times to mean "text". That misnaming is why it is the most-used token in the app.
**Surfaces are complete CSS values, not HSL triplets.** `--panel-bg` must be able to be a gradient or
`rgba()`; a flat colour would rule out the glass Duck already has.
**Assets are quoted paths, not `url()`.** Bun's CSS bundler resolves `url()` at build time while these
files are served from `public/` at runtime. `useThemeAsset` reads the path and wraps it.
**`.dark` survives as a per-theme flag.** 396 `dark:` utilities in 33 components resolve against it.
Removing them retires the flag.
**Fidelity stopped being the constraint partway through.** Duck's exact colours were preserved while the
extraction needed to be provably invisible. The owner then said the palette is not precious — the
wallpaper, the grid and the mark are what matter — so shades collapse rather than being approximated.
---
## Decisions, and why
## Open
**Light and dark are separate themes, not a mode.** The owner's call, and it removes a concept from the
app. `Duck Light` and `Duck Dark` are siblings. Written out in full rather than as a diff — a theme that
only makes sense in the presence of another cannot be authored by a third party or sold to a customer.
**1. `vimazing`, the reference implementation, has not been read.** The switching mechanism (`data-theme`
on `<html>`) and the packaging format are deliberately the dumbest thing that works, because that repo
will dictate them.
**`.dark` survives as a compatibility flag.** 396 `dark:` utilities are written into 33 components and they
resolve against that class. A theme sets `applyDarkClass` and they keep working. Shim with a known end
date; removing those 396 retires it.
**2. The plugin candidates carry ~600 palette classes** — Soulseek 313, Invoices 95, Wallet 68, Jellyfin
48, Photos 47, Transmission 41. Deliberately untouched: they are leaving this repository, and their colour
belongs to their extraction, when the imports are being rewritten anyway. The rule is now written into
`plugins/EXTRACTING-A-PLUGIN.md` and the kickoff prompt so each extraction pays down its own.
**`duck-*` utility names are kept, pointed at neutral variables.** ~1,470 occurrences across 108 files.
Renaming them and extracting in the same change would make a visual difference impossible to attribute.
They now resolve through `--brand-primary`, `--ink`, and friends, so a non-Duck theme can define them
sensibly even while the spelling is wrong.
**3. `src/apps/officer-web/Screens` holds 144**, and `src/workspaces/components` 47 — the latter is the
higher-leverage one, being shared. `MetricCard.tsx` is the single worst shared component.
**`duck-dark` is `--ink`.** It was never a brand colour: `#14532d` in light, near-white in dark. 946
components use it to mean "text". That misnaming is why it is the most-used token in the app.
**4. Theme asset packaging for third parties is not designed.** A theme references its wallpaper and logo
by path, which works for themes we ship. Where a stranger's assets live — the intent is a convention under
`public/` — is not settled.
**Surfaces are complete CSS values, not HSL triplets.** `--panel-bg` can be `rgba()`, a gradient or an
image. A panel background that could only be a flat colour would rule out the glass the app has now.
**5. The marketplace app has its own palette copy.** A skeleton awaiting a rewrite, so not worth folding
in yet.
**The old provider file is a re-export.** Six call sites keep working untouched, so the extraction is one
variable.
**6. An appearance settings screen does not exist.** The theme picker is a list in the avatar menu,
deliberately the crudest control that proves the point.
---
## Blockers and open questions — for the morning
## Verifying theme work
**1. The reference repo (`vimazing`) has not been read yet.** The switching mechanism (`data-theme` on
`<html>`) and the packaging format are deliberately the dumbest thing that works, because that repo will
dictate them. Everything above is independent of that choice.
- `scripts/theme-vardiff.ts` — computed variables of two running builds. **The instrument that matters.**
It found the one real regression of the whole refactor: six `--duck-*` variables resolving to EMPTY,
because the utilities were remapped while `prose.css` read the variables directly.
- `scripts/theme-snapshot.ts` — screenshots, 8 routes × 2 modes, or a pinned theme.
- `scripts/theme-preview-server.ts` — this checkout's frontend against a running platform's API.
**2. Leaks the Plain theme exposed.** These are components painting themselves rather than asking the
theme. The screenshots in `.snapshots/plain/` are the punch list:
**Screenshots are noisier than they look.** The wallpaper rotates per page load, so a control capture of
one build against itself differs on 4 of 16 screens — and a different 4 each time. Two rounds were spent
chasing that noise. Use them to find *which* screens changed, then look; use the variable diff to decide
whether anything actually did.
- **The wallpaper.** Still renders under Plain, so it is set by a component, not `--page-bg`. Probably the
dashboard layout. Needs to become a theme-owned surface.
- **Panel radius.** `--panel-radius: 0` had no effect — panels hardcode `rounded-*`. Shape is themeable in
principle and is not yet.
- **The logo.** A fixed image. Fine for Duck, wrong for white-labelling: a customer's build needs its own.
- **~985 raw Tailwind palette usages** (`text-emerald-400`, `bg-black/10`) and **~119 hex literals**. The
worst concentration by far is `officerdev/src/apps/Soulseek/` (~285). `MetricCard.tsx` is the
highest-leverage single fix — a shared component hardcoding its palette.
**3. Server settings still store `appearance.colorMode`, not a theme id.** `useThemeSync` used to stamp the
server's colour mode over the local theme on every load — picking Plain and reloading silently reverted it.
Now the server value is only honoured when the browser has never chosen. The proper fix is storing a theme
id, which needs a settings migration.
**4. A pre-existing bug, unrelated but adjacent.** The server's default is `appearance: { theme: 'light' }`
while the client reads `appearance.colorMode` — so a fresh account's server default has never been read.
Left alone.
**5. `rebuildFrontend()` reports `"Bundle failed"` and discards the bundler's actual errors.** Cost twenty
minutes tonight. Same shape as the agent bugs from earlier — a failure that refuses to say why.
**6. Theme ASSETS have no packaging story yet — deferred deliberately.** A theme publishes an asset as a
quoted path (`--logo-src: '/officer-logo.svg'`) and `useThemeAsset` wraps it, because Bun's CSS bundler
resolves `url()` at build time while these files are served from `public/` at runtime. That works for
themes we ship. It does not answer where a THIRD PARTY's wallpaper and logo live. The owner's intent is a
convention under `public/` — something like `public/themes/<name>/` — so an author drops files in a known
structure and references them by path. Not designed yet; the token shape above already fits it.
**7. Landing and marketplace are untouched.** They have their own copies of the palette; `duck-teal` has
three different definitions across the three apps. Folding them into this layer is a natural follow-up and
was not in tonight's scope.
---
## On verification, and what the screenshots can and cannot tell you
The byte comparison is **dominated by wallpaper noise**. The dashboard background rotates per page load, so
any screen showing it differs from itself between runs — a control capture of the same build against itself
had 4 of 16 differ, and which 4 changes each time. Between 5 and 6 screens come out byte-identical to the
pre-refactor baseline on any given run; the rest are the wallpaper.
So the authoritative instrument is `scripts/theme-vardiff.ts`, which compares the COMPUTED value of every
theme variable between the old and new builds in the same colour mode. It reports **all 53 identical**.
That is a much stronger statement than a screenshot: it says the token layer resolves to exactly the values
the old stylesheet produced.
It also found the one real regression, which no amount of looking would have: six `--duck-*` variables came
out EMPTY in the new build. The utilities were remapped, but `prose.css` reads `var(--duck-teal)` DIRECTLY
in eleven places for chat markdown — links, blockquotes, inline code, checkboxes — and `landing/styles.css`
does the same. Fixed by keeping the legacy names as aliases in every theme, which also protects any plugin
or inline style doing the same thing. They retire with the utility rename.
## Tools built
- `scripts/theme-snapshot.ts` — captures 8 routes × 2 modes, or a pinned theme with `--theme`. Compares two
directories byte-for-byte. Its first version silently photographed light twice; it now warns when a
light/dark pair is identical.
- `scripts/theme-vardiff.ts` — computed theme variables, old build vs new, side by side. The instrument
that actually proved the extraction faithful.
- `scripts/theme-preview-server.ts` — serves this worktree's build against the RUNNING platform's API, so
the new frontend can be seen with real data without starting a second platform beside the live one.
Byte comparison is too strict for before/after across two builds: this worktree has no plugins, so the dock
differs on every page, and live data moves between captures. Use it to find *which* screens changed, then
look at them.
**Render under Plain to find what is not themed.** Anything still coloured is a component painting itself.
That is how the wallpaper, the header and the logo were found.
+2 -1
View File
@@ -24,7 +24,8 @@
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/install.sh"
"setup": "bash scripts/install.sh",
"check:theme": "bun scripts/check-theme-tokens.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
+14
View File
@@ -82,3 +82,17 @@ still open.
Then update `plugins/EXTRACTING-A-PLUGIN.md`: add what this extraction taught, and correct anything it
told you that turned out to be wrong. The runbook is the thing that makes the next one cheaper, and it is
only worth that if each extraction leaves it more accurate than it found it.
## Colour is part of the job
The feature you are extracting almost certainly hardcodes Tailwind palette classes — `text-emerald-400`,
`bg-zinc-800`, `border-red-500/30`. Replace them with semantic tokens as you go: `text-success`,
`bg-muted`, `border-destructive`, `bg-busy/15`. They follow the theme; a palette number does not.
Read `src/workspaces/theme/README.md` for the contract and `src/workspaces/components/Data/tone.ts` for the
closed set of state tones. `bun run check:theme` counts what is left.
Do it during the extraction rather than after. You are already rewriting every import in these files, and
once the directory leaves the platform the ratchet stops covering it.
+16
View File
@@ -37,6 +37,22 @@ file is **refused at discovery, by name**. Non-compliance is unrepresentable, no
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
resource is shared or per-user — is the plugin's own job, in its own queries.
**A plugin never hardcodes a colour.** Every colour comes from a semantic token — `bg-destructive`,
`text-muted-foreground`, `bg-busy/15`, `border-border` — and never from Tailwind's palette
(`text-emerald-400`, `bg-zinc-800`, `border-red-500/30`). Palette classes do not follow the theme, so a
plugin using them looks wrong under every theme but the one its author happened to have. The contract is
`src/workspaces/theme/README.md`; the closed set of state tones is `components/Data/tone.ts`.
Pay this down **as part of the extraction**, not afterwards. You are already rewriting the imports, and the
counts are not small: Soulseek carries 313 palette classes, Invoices 95, Wallet 68, Jellyfin 48, Photos 47,
Transmission 41. Left alone, an extraction exports its colour debt into a repository nobody will revisit —
and the platform's own ratchet (`bun run check:theme`) stops covering it the moment the directory leaves.
Two shortcuts that are not shortcuts. `text-white` and `bg-black/60` are fine ONLY over media — a video, a
photograph — where they sit on someone's content rather than on a themed surface; everywhere else they are
palette classes like any other. And a colour that carries no state (a brand accent, a chart series) belongs
in the plugin's own variables, declared once, not sprinkled as literals.
**Say `permissions`, never the other word.** It already means three things in this codebase.
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
+120
View File
@@ -0,0 +1,120 @@
/**
* A ratchet on hardcoded colour: the count per directory may fall, never rise.
*
* ── Why a baseline instead of a ban ──
*
* A rule that forbids raw palette classes outright fails on the first run — there are hundreds, most of
* them in code that is leaving this repository, and a check nobody can make pass is a check everybody
* turns off. A baseline is enforceable today: it does not care what the number is, only which way it
* moves. Every directory can be paid down independently, and none can quietly regrow.
*
* That is the difference between this and the three previous attempts at the same job. The work was never
* hard, it was endless — because nothing stopped the next component arriving with its own `text-emerald-400`
* while an old one was being fixed.
*
* ── What counts, and what does not ──
*
* Any Tailwind PALETTE colour: `bg-emerald-500`, `text-zinc-400`, `border-red-500/30`, `text-white`,
* `bg-black/60`. Semantic tokens do not count — `bg-destructive`, `text-muted-foreground`, `bg-busy/15` —
* because those follow the theme, which is the entire point.
*
* `text-white` and `bg-black` ARE counted even where they are currently correct (media overlays sitting on
* a photograph rather than on a themed surface). They stay in the baseline rather than being exempted: an
* exemption list is a second thing to keep honest, and a number that cannot rise is enough.
*
* bun scripts/check-theme-tokens.ts # verify against the baseline
* bun scripts/check-theme-tokens.ts --update # accept the current counts (after paying some down)
*/
import { readdirSync, existsSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const BASELINE = 'scripts/theme-baseline.json';
const PALETTE = [
'white', 'black', 'zinc', 'gray', 'slate', 'neutral', 'stone', 'red', 'orange', 'amber', 'yellow',
'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia',
'pink', 'rose',
];
const PROPS = [
'bg', 'text', 'border', 'ring', 'from', 'to', 'via', 'fill', 'stroke', 'divide', 'outline',
'decoration', 'accent', 'shadow', 'placeholder', 'caret',
];
const RE = new RegExp(`\\b(${PROPS.join('|')})-(${PALETTE.join('|')})(-\\d{2,3})?(/\\d+)?\\b`, 'g');
/** The directories worth tracking separately, so one can be paid down without hiding another's growth. */
function targets(): string[] {
const out: string[] = [];
const appsRoot = 'src/workspaces/officerdev/src/apps';
if (existsSync(appsRoot)) {
for (const name of readdirSync(appsRoot)) {
const path = join(appsRoot, name);
if (statSync(path).isDirectory()) out.push(path);
}
}
for (const extra of ['src/apps/officer-web/Screens', 'src/workspaces/components', 'src/workspaces/officerdev/src/components']) {
if (existsSync(extra)) out.push(extra);
}
return out.sort();
}
function countIn(dir: string): number {
let total = 0;
const walk = (path: string) => {
for (const entry of readdirSync(path, { withFileTypes: true })) {
const full = join(path, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules') walk(full);
} else if (/\.(tsx|ts)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
total += (readFileSync(full, 'utf-8').match(RE) ?? []).length;
}
}
};
walk(dir);
return total;
}
const counts: Record<string, number> = {};
for (const dir of targets()) {
const n = countIn(dir);
if (n > 0) counts[dir] = n;
}
if (process.argv.includes('--update')) {
writeFileSync(BASELINE, JSON.stringify(counts, null, 2) + '\n');
console.log(` baseline updated: ${Object.keys(counts).length} directories, ${Object.values(counts).reduce((a, b) => a + b, 0)} usages`);
process.exit(0);
}
if (!existsSync(BASELINE)) {
console.log(` no baseline at ${BASELINE} — run with --update to create one`);
process.exit(1);
}
const baseline: Record<string, number> = JSON.parse(readFileSync(BASELINE, 'utf-8'));
const grew: string[] = [];
const shrank: string[] = [];
for (const [dir, n] of Object.entries(counts)) {
const was = baseline[dir] ?? 0;
if (n > was) grew.push(` ${dir}: ${was} -> ${n} (+${n - was})`);
else if (n < was) shrank.push(` ${dir}: ${was} -> ${n}`);
}
if (shrank.length) {
console.log(' paid down:');
for (const line of shrank) console.log(line);
console.log(' run with --update to lock the improvement in.\n');
}
if (grew.length) {
console.log(' Hardcoded colour increased:');
for (const line of grew) console.log(line);
console.log('');
console.log(' Use a semantic token instead of a palette colour: bg-destructive, text-muted-foreground,');
console.log(' bg-busy/15, border-border. They follow the theme; a palette number does not.');
console.log(' See src/workspaces/theme/README.md and docs/design-language-interface.md.');
process.exit(1);
}
const total = Object.values(counts).reduce((a, b) => a + b, 0);
console.log(` no new hardcoded colour (${total} known usages across ${Object.keys(counts).length} directories)`);
+23
View File
@@ -0,0 +1,23 @@
{
"src/apps/officer-web/Screens": 144,
"src/workspaces/components": 47,
"src/workspaces/officerdev/src/apps/AppStore": 6,
"src/workspaces/officerdev/src/apps/Chat": 12,
"src/workspaces/officerdev/src/apps/ChatHistory": 1,
"src/workspaces/officerdev/src/apps/CodeEditor": 5,
"src/workspaces/officerdev/src/apps/Dashboards": 11,
"src/workspaces/officerdev/src/apps/Dav": 7,
"src/workspaces/officerdev/src/apps/FileBrowser": 15,
"src/workspaces/officerdev/src/apps/FileViewer": 38,
"src/workspaces/officerdev/src/apps/Invoices": 95,
"src/workspaces/officerdev/src/apps/Jellyfin": 48,
"src/workspaces/officerdev/src/apps/Photos": 47,
"src/workspaces/officerdev/src/apps/Preview": 4,
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
"src/workspaces/officerdev/src/apps/Soulseek": 313,
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
"src/workspaces/officerdev/src/apps/Terminal": 10,
"src/workspaces/officerdev/src/apps/Transmission": 43,
"src/workspaces/officerdev/src/apps/Wallet": 68,
"src/workspaces/officerdev/src/components": 22
}
+119
View File
@@ -0,0 +1,119 @@
# Themes
Everything about how Officer looks lives here. The application imports one stylesheet and mounts one
provider, and contains no colours of its own.
A theme is **one CSS file plus one line in a registry**. Nothing else.
---
## Writing one
1. Copy `css/themes/plain.css` — not a Duck theme. Plain is white, black and grey throughout, so every
value you change is one you decided about, with nothing inherited from someone else's brand.
2. Rename the selector: `[data-theme='plain']` → `[data-theme='your-theme']`.
3. Add it to `css/index.css` and to `THEMES` in `src/themes.ts`.
4. Reload. It appears in the avatar menu.
```ts
{ id: 'your-theme', name: 'Your Theme', family: 'Yours', applyDarkClass: false }
```
`applyDarkClass` puts `.dark` on the document. Set it for a dark theme: 396 `dark:` utilities are still
written into components and they resolve against that class. It is a compatibility shim with an end date,
not part of the design.
**Light and dark are separate themes, not modes.** `Duck Light` and `Duck Dark` are siblings, each written
out in full. A theme that only makes sense as a diff against another cannot be authored by a stranger or
sold to a customer.
---
## The contract
84 declarations. Copying Plain gives you all of them; these are the families and what they are for.
### Colour — HSL triplets, unwrapped
Written `0 0% 100%`, not `hsl(0 0% 100%)`, so `hsl(var(--x) / 0.5)` gives opacity for free. That is what
makes `bg-card/50` work.
| Family | Tokens |
|---|---|
| Ground | `--background`, `--foreground` |
| Surfaces | `--card(-foreground)`, `--popover(-foreground)`, `--muted(-foreground)`, `--accent(-foreground)` |
| Actions | `--primary(-foreground)`, `--secondary(-foreground)`, `--cta(-foreground)`, `--ctahover` |
| State | `--success`, `--warning`, `--destructive`, `--info`, `--busy` — each with `-foreground` |
| Lines | `--border`, `--input`, `--ring`, `--radius` |
| Data | `--chart-primary/secondary`, `--status-success/warning/in-progress` |
**`--busy-ink`** is the odd one and generalises: a fill bright enough to carry black text is far too light
to *be* text. Busy needed it first because its fill is amber; the other tones are dark enough to do both
jobs. Use `-ink` for text on a tint, `-foreground` for text on the solid fill.
### Surfaces — complete CSS values
Not triplets, because a panel background has to be able to be `rgba()`, a gradient, or `none`. A flat
colour would rule out the glass Duck has.
| Token | What it is |
|---|---|
| `--page-bg` | the page itself |
| `--panel-bg`, `--panel-border` | a tiled panel — the cornerstone of the layout |
| `--panel-border-bare` | a panel with no header, which has nothing else separating it from what it sits on |
| `--panel-raised-bg/-border` | a fullscreen panel: DENSER glass, not darker — panel chrome is dark text, and a heavy dark fill hides every icon |
| `--panel-blur`, `--header-blur` | `0px` switches the backdrop blur off, which a flat theme should do — the compositor does that work for nothing on a solid surface |
| `--card-bg`, `--glass-bg/-border`, `--dock-bg/-border/-tooltip-bg` | the other surfaces |
| `--header-bg/-border/-fg` | the top bar |
| `--pixel-grid-color`, `--card-grid-color`, `--panel-grid-color` | the graph-paper grid; `transparent` removes it |
| `--scrollbar-*` | the app's own scrollbars |
### Assets — quoted paths
`--page-bg-src`, `--logo-src`.
A path in quotes, **not** `url(...)`. Bun's CSS bundler resolves `url()` at build time while these files
are served from `public/` at runtime, so a `url()` token fails the build outright. `useThemeAsset` reads
the path and wraps it. `''` means none.
Where a third party's assets live is not settled — the intent is a convention under `public/`.
### Brand slots
`--brand-primary`, `--brand-accent`, `--brand-warm`, `--brand-deep`, `--ink`, `--surface-warm`, and the
legacy `--duck-*` aliases.
`--ink` is the app's text colour. It was called `duck-dark` and was never a brand green — `#14532d` in
light, near-white in dark, used ~950 times to mean "text". The `duck-*` names are kept as aliases because
`prose.css` and some plugins read them directly; they retire when the utility rename lands.
---
## Rules for components
**Never a palette colour.** `text-emerald-400`, `bg-zinc-800`, `border-red-500` do not follow the theme.
Use the semantic token: `text-success`, `bg-muted`, `border-destructive`.
**Colour means state.** Five tones plus busy, and no others — see `components/Data/tone.ts` and
`docs/design-language-interface.md`. When a view can reach for any of Tailwind's palettes, green stops
meaning "open" and starts meaning "the author liked green".
**`bun scripts/check-theme-tokens.ts` enforces it.** Per-directory counts that may fall and never rise. Not
a ban — hundreds exist, most in code that is leaving — but a ratchet. Pay some down, run `--update`, and
the improvement is locked in.
**Plugins are held to the same rule.** See `plugins/EXTRACTING-A-PLUGIN.md`.
---
## Verifying a change
- `scripts/theme-snapshot.ts` — screenshots of 8 routes in both modes, or a pinned theme via `--theme`.
- `scripts/theme-vardiff.ts` — computed variables of two running builds, side by side. **The instrument
that matters**: screenshots are dominated by the wallpaper, which rotates per load, and a control run of
one build against itself differs on 4 of 16 screens.
- `scripts/theme-preview-server.ts` — this checkout's frontend against a running platform's API, so a build
can be seen with real data without starting a second server beside a live one.
Render the app under **Plain** to find what is not themed yet. Anything still coloured is a component
painting itself instead of asking the theme.