Fourth chord for the launcher, and the last move is a return: ctrl-space → shift-space → alt-space → ctrl-space. Every one was the owner naming a real collision, and this one is the interesting direction — alt-space is the GNOME window menu, he rebound the desktop to keep it, disliked what he rebound it to, and would rather move the app than the window manager. ctrl-space was ruled out on 2026-08-20 as "focuses the address bar in Brave". That observation was made before anything preventDefault'ed it, and the trade has changed: rebinding a browser is cheaper than rebinding a desktop. Written down in the file, because if the address bar still wins the key never reaches the handler and nothing in this repository can help — the fix would be in Brave. `!ev.shiftKey && !ev.altKey` matters: ctrl+SHIFT+space is the theme browser, and one chord opening both is the bug that needed a capture-phase listener to fix on the theme side. Verified in the built bundle that the two conditions are disjoint. CTRL+? renders every shortcut from a registry, ported in shape from vimazing's gameInfo.ts — controls declared as data, help screen renders them. Its renderer was a recursive `any` walk, right for a game whose help also carries rules and scoring; here the content is one shape, so the renderer is typed and boring and the value is in state/shortcuts.ts. The registry is deliberately NOT authoritative yet, and says so: the handlers still hold their own conditions, so changing a chord here does not move it. Each entry names the file that implements it, so the pair is one search apart. Making it the source of truth is the configurable-shortcuts work, and doing it now would rewrite three handlers for nothing visible. Also removed a line I put in the theme dropdown yesterday promising "alt+space to search every theme by name". Themes are not Spotlight items — that was an instruction for a feature nobody built, sitting in the UI as though it worked. It now points at ctrl+?, which exists. Two comments naming ctrl+alt+space for the theme browser were corrected to ctrl+shift+space with the older chord noted as still accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
- 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. - Rename the selector:
[data-theme='plain']→[data-theme='your-theme']. - Add it to
css/index.cssand toTHEMESinsrc/themes.ts. - 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.