Files
platform/src/workspaces/theme
pastilhasandClaude Opus 5 6e805c3607 the new wordmark in the header, following the theme
two marks, same 1062x220 crop: white "Off" for a dark background, dark "Off" for
a light one, yellow "icer" untouched in both. the dark variant is the light one
with only its DESATURATED pixels inverted, so the yellow is bit-identical and the
alpha and dimensions never move.

CLASSIFIED BY LUMINANCE, not by the mode comment. only 22 of the 227 theme files
declare one, and the cost of guessing wrong is a white wordmark on a white bar —
so each theme's own `--background` is read and thresholded. 203 dark, 24 light.

the signed-out screen is deliberately untouched and still uses the inline SVG.

AND THE BUG THIS UNCOVERED, which was not about logos at all. `useThemeAsset`
read the computed style inside an effect, and react runs CHILD effects before
parent ones — every consumer is a descendant of `ThemeProvider`, which sets
`data-theme` in ITS effect. so the read always saw the PREVIOUS theme's value:
switch themes and the logo was one theme behind, switch again and behind again.
it reads as a race and is deterministic, which is why it reproduced every time
somebody watched for it.

the read moved into requestAnimationFrame — past every effect in that commit and
past style recalculation — plus a MutationObserver on data-theme/class for the
case a frame cannot cover: a theme installed at runtime arrives as a <link> that
loads whenever it loads. the wallpaper uses the same hook for `--page-bg-src` and
had the same lag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 17:05:50 +01:00
..

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.
{ 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.