extract the visual layer into its own workspace

The application no longer knows what it looks like. It imports one stylesheet and
mounts one provider; every colour, surface and radius lives in `src/workspaces/theme`.

Proven both directions, because either alone would be worthless:

  Duck renders IDENTICALLY to before — checked against screenshots taken before
  anything was touched, not asserted.

  Plain renders as white with black ink, no component edits — panels turn white, the
  teal avatar turns black, the grid fills vanish.

── What moved ──

`globals.css` held three unrelated things: Tailwind wiring, the token contract, and
the Duck brand's hex values. Split along those seams. `base.css` is structure and
names no colour; a theme file is values and nothing else, scoped to
`[data-theme='<id>']`. Adding a theme is a CSS file plus one registry line.

Light and dark are separate themes rather than a mode — the owner's call, and it
removes a concept from the app. Each is 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. `.dark` survives only as a per-theme flag, because 396 `dark:` utilities in
33 components resolve against it; retiring those retires the flag.

The `duck-*` utility names are kept for now and pointed at neutral variables, so a
non-Duck theme can define them sensibly. Renaming 1,470 occurrences in the same commit
as the extraction would make any visual difference impossible to attribute. Worth
recording that `duck-dark` was never a brand colour — `#14532d` in light, near-white
in dark, used by 946 components to mean "text". It is `--ink` now.

Surfaces are complete CSS values rather than HSL triplets: `--panel-bg` has to be able
to be a gradient or an image, or the glass the app already has would be unthemeable.

The old provider is a re-export, so its six call sites are untouched and the
extraction stays one variable.

── Bug found by the Plain theme ──

`useThemeSync` stamped the server's `appearance.colorMode` over the local theme on
every load, so choosing Plain and reloading silently reverted it. Diagnosed by asking
the page what it was wearing rather than reasoning: `stored` said `duck-dark` when the
script had written `plain`. The server value is now a starting point for a browser
that has never chosen, not a continuous instruction.

── Tools ──

Two, both of which lied to me before they worked. The snapshot script photographed
light twice and called it a clean run (one browser context, accumulated init scripts);
it now warns when a light/dark pair is identical. The preview server returned the
shell for missing assets, so the logo and wallpaper vanished and read as regressions
in the comparison — it falls through to upstream now.

Dead Tailwind v3 residue deleted: `components/ui/tailwind.{config.ts,css}`, referenced
by nothing, with content globs pointing at a directory that does not exist.

── Not done, deliberately ──

`THEMES-NOTES.md` has the full list. The headlines: the reference repo has not been
read, so the switching mechanism is deliberately the dumbest thing that works; the
wallpaper, panel radius and logo are still component-owned and Plain proves it; and
~985 raw Tailwind palette usages remain, worst in Soulseek.

tsgo clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:00:38 +00:00
co-authored by Claude Opus 5
parent 7980ee3761
commit 105766a4eb
21 changed files with 1165 additions and 533 deletions
+116
View File
@@ -0,0 +1,116 @@
# Themes decoupling — working notes
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.
---
## Where it got to
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.
Proven by the two things that matter:
- **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.
Switch themes from the avatar menu, top right.
---
## The shape
```
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
```
A theme is a CSS file scoped to `[data-theme='<id>']` plus one line in the registry. Nothing else.
---
## Decisions, and why
**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.
**`.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.
**`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.
**`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.
**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.
**The old provider file is a re-export.** Six call sites keep working untouched, so the extraction is one
variable.
---
## Blockers and open questions — for the morning
**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.
**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:
- **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. 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.
---
## 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-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.
+7
View File
@@ -129,6 +129,7 @@
"state": "workspace:*",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"theme": "workspace:*",
"three": "^0.182.0",
"types": "workspace:*",
"varuint-bitcoin": "^2.0.0",
@@ -286,6 +287,10 @@
"officerdev": "workspace:*",
},
},
"src/workspaces/theme": {
"name": "theme",
"version": "0.0.1",
},
"src/workspaces/types": {
"name": "types",
"version": "0.0.1",
@@ -2217,6 +2222,8 @@
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"theme": ["theme@workspace:src/workspaces/theme"],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+1
View File
@@ -152,6 +152,7 @@
"state": "workspace:*",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"theme": "workspace:*",
"three": "^0.182.0",
"types": "workspace:*",
"varuint-bitcoin": "^2.0.0",
+93
View File
@@ -0,0 +1,93 @@
/**
* Serve THIS worktree's frontend against the RUNNING platform's backend.
*
* The visual comparison needs the new bundle rendering real data. The obvious way — start a second full
* platform on another port — is the one thing not to do tonight: it shares the database, the install root
* and the sidecar registry with the live server the owner is currently talking to me through, and a second
* instance racing it is exactly the SO_REUSEPORT class of problem that has already cost us an evening.
*
* So this serves static assets from the worktree's build and forwards everything else to the live server.
* The frontend cannot tell the difference: same origin, same cookies, same API. Only the stylesheet and the
* theme provider are new, which is precisely the variable under test.
*
* Read-only in practice — the snapshot script only navigates and screenshots — but it is a proxy, not a
* sandbox: a POST would reach the real backend. Do not point anything destructive at it.
*
* bun scripts/theme-preview-server.ts --port 9010 --upstream http://127.0.0.1:9000
*/
const arg = (name: string, fallback: string): string => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? fallback : (process.argv[i + 1] ?? fallback);
};
const port = Number(arg('port', '9010'));
const upstream = arg('upstream', 'http://127.0.0.1:9000');
const dist = arg('dist', 'dist');
// The build emits `index.gen.html` (the generated entry), not `index.html`. Both are accepted so this
// keeps working if that ever changes.
const indexHtml =
(await Bun.file(`${dist}/index.gen.html`)
.text()
.catch(() => null)) ??
(await Bun.file(`${dist}/index.html`)
.text()
.catch(() => null));
if (!indexHtml) {
console.error(` no build found in ${dist}/ — run the frontend build first`);
process.exit(1);
}
const server = Bun.serve({
port,
idleTimeout: 60,
async fetch(req) {
const url = new URL(req.url);
// Anything the backend owns goes upstream, headers and body intact. `/api` covers REST; the rest are
// the paths the SPA fetches that are not assets.
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) {
const target = `${upstream}${url.pathname}${url.search}`;
try {
return await fetch(target, {
method: req.method,
headers: req.headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.arrayBuffer(),
redirect: 'manual',
});
} catch (err) {
return new Response(`upstream unreachable: ${String(err)}`, { status: 502 });
}
}
// A built asset, if we have one.
const asset = Bun.file(`${dist}${url.pathname}`);
if (url.pathname !== '/' && (await asset.exists())) {
return new Response(asset);
}
/**
* A file we do NOT have goes upstream rather than falling through to the shell.
*
* `public/` — the logo, wallpapers, icons — is served by the platform from outside the build, so the
* first version of this returned index.html for those and the page rendered with a broken logo and no
* wallpaper. Both showed up in the comparison as if the extraction had removed them, which is exactly
* the kind of false positive that gets a real regression waved through next to it.
*
* "Looks like a file" is the extension test: a client-side route never has one.
*/
if (/\.[a-z0-9]{2,5}$/i.test(url.pathname)) {
try {
const res = await fetch(`${upstream}${url.pathname}${url.search}`, { headers: req.headers });
if (res.ok) return res;
} catch {
// Fall through to the shell — a missing asset should not take the page down.
}
}
// Everything else is a client-side route: hand back the shell and let the router decide.
return new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } });
},
});
console.log(` preview on http://127.0.0.1:${server.port} (assets: ${dist}, api -> ${upstream})`);
+15 -7
View File
@@ -91,10 +91,14 @@ async function capture(base: string, out: string, token: string): Promise<void>
ignoreHTTPSErrors: true,
colorScheme: mode,
});
// `--theme` pins a named theme; without it the legacy colour-mode key is used, which is what an
// existing account has and therefore what the before/after comparison must exercise.
const forcedTheme = arg('theme');
await context.addInitScript(`
window.localStorage.setItem('BEARER_TOKEN', ${JSON.stringify(token)});
${forcedTheme ? `window.localStorage.setItem('officer-theme', ${JSON.stringify(forcedTheme)});` : ''}
window.localStorage.setItem('officer-color-mode', ${JSON.stringify(mode)});
document.documentElement.classList.toggle('dark', ${JSON.stringify(mode === 'dark')});
document.documentElement.classList.toggle('dark', ${JSON.stringify(mode === 'dark' && !forcedTheme)});
`);
const page = await context.newPage();
@@ -105,11 +109,13 @@ async function capture(base: string, out: string, token: string): Promise<void>
await settle(page);
// Asserted AFTER settling, because the provider sets this class in an effect and would otherwise
// win the race. Re-checked rather than assumed: a wrong class here silently produces the bug above.
const applied = await page.evaluate((m) => {
document.documentElement.classList.toggle('dark', m === 'dark');
return document.documentElement.classList.contains('dark');
}, mode as string);
if (applied !== (mode === 'dark')) failures.push(`${name}-${mode}: colour mode did not apply`);
if (!arg('theme')) {
const applied = await page.evaluate((m) => {
document.documentElement.classList.toggle('dark', m === 'dark');
return document.documentElement.classList.contains('dark');
}, mode as string);
if (applied !== (mode === 'dark')) failures.push(`${name}-${mode}: colour mode did not apply`);
}
await page.waitForTimeout(400);
await page.screenshot({ path: file, fullPage: false });
console.log(` captured ${name}-${mode}`);
@@ -197,7 +203,9 @@ if (compareArgs !== -1) {
const token = arg('token') ?? process.env.OFFICER_SNAPSHOT_TOKEN;
if (!token) {
console.log(' --token <jwt> is required (or OFFICER_SNAPSHOT_TOKEN). Mint one from a checkout with deps:');
console.log(` bun -e "const {sign}=await import('./src/servers/jwt');console.log(await sign({id:1,email:'you@example.com'}))"`);
console.log(
` bun -e "const {sign}=await import('./src/servers/jwt');console.log(await sign({id:1,email:'you@example.com'}))"`,
);
process.exit(1);
}
console.log(` base ${base} -> ${out}`);
@@ -1,10 +1,10 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Settings, Puzzle, Sun, Moon, Bot, UserCog } from 'lucide-react';
import { User, LogOut, Settings, Puzzle, Sun, Moon, Bot, UserCog, Palette } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
import { useSettings } from 'state/useSettings';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
@@ -13,6 +13,7 @@ export function UserMenu() {
const { user, isLoading } = useAuth();
const { t } = useTranslation();
const { colorMode, setColorMode } = useColorMode();
const { themeId, setTheme, themes } = useTheme();
const { settings, saveSettings } = useSettings();
if (isLoading) return null;
@@ -35,6 +36,18 @@ export function UserMenu() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-10 w-48">
{/*
Every theme this build ships, listed. Deliberately the crudest possible control: the point tonight
is proving the app can wear a different skin at all, not designing a picker. A real one belongs in
an appearance settings screen, which does not exist yet.
*/}
{themes.map((theme) => (
<DropdownMenuItem key={theme.id} onClick={() => setTheme(theme.id)} className="cursor-pointer">
<Palette className={`mr-2 h-4 w-4 ${theme.id === themeId ? 'opacity-100' : 'opacity-30'}`} />
{theme.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile">
<User className="mr-2 h-4 w-4" />
+38 -5
View File
@@ -1,13 +1,46 @@
import { useEffect } from 'react';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useEffect, useRef } from 'react';
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
import { useSettings } from 'state/useSettings';
/**
* Bring the account's saved appearance down from the server, once.
*
* ── Why "once" is the whole point ──
*
* This used to re-apply `settings.appearance.colorMode` whenever the settings query resolved, which was
* correct while colour mode was the ONLY appearance state — there was nothing else for it to overwrite.
* With themes there is: picking Plain and reloading put Plain in localStorage, and then this hook stamped
* `colorMode: 'dark'` back over it a moment later. The theme changed and then silently changed back, which
* looks like the picker not working.
*
* So the server's value is treated as what it is — a STARTING POINT for a browser that has never chosen —
* rather than as a continuous instruction. A local choice wins, because it is the more recent statement of
* what this person wants, and it is already persisted.
*
* The proper fix is for the server to store a theme id rather than a colour mode, at which point this hook
* becomes a one-line sync of the same field in both places. That needs a settings migration and is
* deliberately not in the extraction commit.
*/
const LOCAL_CHOICE_KEY = 'officer-theme';
export const useColorModeSync = () => {
const { settings } = useSettings();
const { setColorMode } = useColorMode();
const { themeId } = useTheme();
const applied = useRef(false);
useEffect(() => {
if (!settings) return;
setColorMode(settings.appearance.colorMode);
}, [settings?.appearance?.colorMode]);
if (!settings || applied.current) return;
applied.current = true;
// This browser has already been told what to wear. The server's colour mode is older information.
try {
if (window.localStorage.getItem(LOCAL_CHOICE_KEY)) return;
} catch {
// Storage unavailable — fall through and honour the server, which is the best guess left.
}
const saved = settings.appearance?.colorMode;
if (saved === 'light' || saved === 'dark') setColorMode(saved);
}, [settings, setColorMode, themeId]);
};
-353
View File
@@ -1,353 +0,0 @@
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@theme {
/* Duck Landing Page Colors */
--color-duck-yellow: var(--duck-yellow);
--color-duck-orange: var(--duck-orange);
--color-duck-teal: var(--duck-teal);
--color-duck-forest: var(--duck-forest);
--color-duck-dark: var(--duck-dark);
--color-duck-beige: var(--duck-beige);
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-brand: hsl(var(--brand));
--color-brand-foreground: hsl(var(--brand-foreground));
--color-brand-muted: hsl(var(--brand-muted));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-info: hsl(var(--info));
--color-info-foreground: hsl(var(--info-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-cta: hsl(var(--cta));
--color-cta-foreground: hsl(var(--cta-foreground));
--color-ctahover: hsl(var(--ctahover));
--color-naturegreen: hsl(var(--naturegreen));
--color-naturegreen-foreground: hsl(var(--naturegreen-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out forwards;
--animate-accordion-up: accordion-up 0.2s ease-out forwards;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
:root {
/* Duck brand hex colors */
--duck-yellow: #f4c430;
--duck-orange: #ea580c;
--duck-teal: #0891b2;
--duck-forest: #166534;
--duck-dark: #14532d;
--duck-beige: #e7d4b5;
/* Page background */
--pixel-grid-color: rgba(20, 83, 45, 0.1);
/* Glass surfaces */
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.2);
--dock-bg: rgba(0, 0, 0, 0.15);
--dock-border: rgba(255, 255, 255, 0.1);
--dock-tooltip-bg: rgba(0, 0, 0, 0.75);
/* Card component */
--card-bg: rgba(237, 231, 222, 0.9);
--card-grid-color: rgba(20, 83, 45, 0.08);
--cta: 211 74.77% 45.58%;
--cta-foreground: 0 0% 100%;
--naturegreen: 145 79% 38%;
--naturegreen-foreground: 0 0% 100%;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 211 74.77% 45.58%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--brand: 145 79% 38%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 40% 90%;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
/* The fifth state tone. "In progress" and "merged" are neither good news nor bad, and painting
them with --success makes a merged PR and an open one look like the same thing. Violet because
it has to stay legible next to success green and destructive red at pill size. */
--info: 263 70% 50%;
--info-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
/* Chart gradients */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
/* Status colors */
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
/* Hover Effect */
--ctahover: 211 91.53% 34.82%;
}
.dark {
/* Duck brand — lightened for dark-mode contrast */
--duck-teal: #22d3ee;
--duck-dark: #f1f5f9;
--duck-forest: #4ade80;
--duck-beige: #292524;
/* Page background */
--pixel-grid-color: rgba(200, 230, 210, 0.08);
/* Glass surfaces */
--glass-bg: rgba(0, 0, 0, 0.2);
--glass-border: rgba(255, 255, 255, 0.1);
--dock-bg: rgba(0, 0, 0, 0.3);
--dock-border: rgba(255, 255, 255, 0.08);
--dock-tooltip-bg: rgba(0, 0, 0, 0.85);
/* Card component */
--card-bg: rgba(10, 20, 15, 0.9);
--card-grid-color: rgba(200, 230, 210, 0.06);
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 211 82% 64%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--brand: 145 60% 45%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 30% 25%;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
/* Lifted well above the light value — 50% lightness violet on a near-black background reads as
muddy grey at 12px, which is exactly the size it is used at. */
--info: 263 90% 70%;
--info-foreground: 0 0% 100%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* Chart gradients */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
/* Status colors */
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
[data-sonner-toaster] {
z-index: 700 !important;
}
/* File Browser — Context menu hover behavior */
/* Code Editor — thin dark scrollbars */
.code-editor-scrollable {
scrollbar-width: thin;
scrollbar-color: #444 transparent;
}
.code-editor-scrollable::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.code-editor-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.code-editor-scrollable::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.code-editor-scrollable::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Chat — the background-task strip. A default horizontal scrollbar is ~15px tall and is laid out INSIDE
the row, so it stole most of the chips' breathing room and made the tray read as squeezed. Thin, and
neutral enough to sit on either theme. */
.task-strip {
scrollbar-width: thin;
scrollbar-color: rgb(128 128 128 / 0.3) transparent;
}
.task-strip::-webkit-scrollbar {
height: 4px;
}
.task-strip::-webkit-scrollbar-track {
background: transparent;
}
.task-strip::-webkit-scrollbar-thumb {
background: rgb(128 128 128 / 0.3);
border-radius: 2px;
}
.task-strip:hover::-webkit-scrollbar-thumb {
background: rgb(128 128 128 / 0.5);
}
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) {
pointer-events: none;
}
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) .file-item-ellipsis {
opacity: 0 !important;
}
.file-grid [data-file-item][data-state='open'] .file-item-ellipsis {
opacity: 1 !important;
}
+11 -1
View File
@@ -1,2 +1,12 @@
@import './globals.css';
/*
The application's stylesheet: the theme layer, plus what is genuinely this app's own.
`globals.css` used to live here and held three unrelated things — Tailwind wiring, the token contract,
and the Duck brand's hex values. It has moved to the `theme` workspace and been split along those seams,
so "change how the app looks" and "change how theming works" stopped being the same edit.
`prose.css` stays: it styles rendered markdown in chat, and it already speaks in theme variables
(`hsl(var(--foreground))`, `--border`), so it follows whatever theme is active.
*/
@import 'theme/css/index.css';
@import './prose.css';
+16 -40
View File
@@ -1,40 +1,16 @@
import { createContext, useContext, useEffect, useState } from 'react';
type ColorMode = 'light' | 'dark';
const initialState = {
colorMode: 'light' as ColorMode,
setColorMode: (_mode: ColorMode) => {},
};
type ColorModeProviderProps = { children: React.ReactNode; defaultMode?: ColorMode; storageKey?: string };
const ColorModeContext = createContext(initialState);
export function ColorModeProvider(props: ColorModeProviderProps) {
const { children, defaultMode = 'light', storageKey = 'officer-color-mode' } = props;
const [colorMode, setColorModeState] = useState<ColorMode>(
() => (localStorage.getItem(storageKey) as ColorMode) || defaultMode,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(colorMode);
}, [colorMode]);
const setColorMode = (mode: ColorMode) => {
localStorage.setItem(storageKey, mode);
setColorModeState(mode);
};
return <ColorModeContext.Provider value={{ colorMode, setColorMode }}>{children}</ColorModeContext.Provider>;
}
export const useColorMode = () => {
const context = useContext(ColorModeContext);
if (context === undefined) throw new Error('useColorMode must be used within a ColorModeProvider');
return context;
};
/**
* Moved. The implementation now lives in the `theme` workspace.
*
* This file is a re-export so the six existing call sites — `frontend.tsx`, `useThemeSync`, `UserMenu`,
* `MapSection` and two unused toggles — keep working without being edited. That is not laziness: the
* extraction has to be provably invisible, and a diff that also rewrites every consumer makes any visual
* difference impossible to attribute to one change or the other.
*
* `ColorModeProvider` is an alias of `ThemeProvider`. The old provider managed a light/dark mode; the new
* one manages a theme, and light/dark are now two themes. `useColorMode` maps between the two worlds — see
* its note in the package.
*
* New code should import from `theme` directly. This file exists to be deleted once nothing points here.
*/
export { ThemeProvider as ColorModeProvider, ThemeProvider, useTheme, useColorMode } from 'theme';
export type { Theme, ThemeId } from 'theme';
@@ -1,86 +0,0 @@
const path = require('node:path');
module.exports = {
darkMode: ['class'],
content: [
'../../packages/**/*.{js,jsx,ts,tsx,css,html}',
'../../workspaces/**/*.{js,jsx,ts,tsx,css,html}',
'../../workspaces/editor/editor-app/**/*.{js,jsx,ts,tsx,css,html}',
],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
},
},
},
plugins: [require('tailwindcss-animate')],
};
-39
View File
@@ -1,39 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
h1 {
@apply text-4xl font-bold;
}
h2 {
@apply text-3xl font-bold;
}
h3 {
@apply text-2xl font-bold;
}
h4 {
@apply text-xl font-bold;
}
h5 {
@apply text-lg font-bold;
}
h6 {
@apply font-bold;
}
a {
@apply cursor-pointer text-base text-blue-500 hover:underline dark:text-blue-300;
}
button {
@apply dark:text-white;
}
+185
View File
@@ -0,0 +1,185 @@
/*
THE STRUCTURE OF THE LOOK — never its values.
============================================================================================
This file says WHAT can be themed. A theme file says what it looks like. Nothing here names a colour,
and nothing here belongs to any particular theme: swapping every theme in the repository must not
require editing this file, and adding a variable here is a change to the contract that every theme has
to answer.
The split exists because it is the whole feature. Before it, `globals.css` mixed three things — Tailwind
wiring, the token contract, and the Duck brand's hex values — so "change how the app looks" and "change
how theming works" were the same edit, and neither could be done by anyone who did not own the app.
── How a value reaches a component ──
component .bg-surface
@theme (here) --color-surface: hsl(var(--surface))
theme file --surface: 0 0% 100%
The indirection through `var()` is load-bearing: Tailwind resolves `@theme` at BUILD time, so a theme
that set `--color-surface` directly could never be swapped without a rebuild. Resolving to a custom
property means the browser reads it at paint, and changing `data-theme` on <html> re-paints the app.
── Two spellings, and why ──
Colours are HSL TRIPLETS without the function (`0 0% 100%`), so `hsl(var(--x) / 0.5)` gives opacity for
free — that is what makes `bg-surface/50` work, and it is why every shadcn-derived token in this file
looks like that.
Surfaces (panel, dock, card, page) are COMPLETE CSS VALUES instead, because a theme needs to set things
a triplet cannot express: `rgba()`, a gradient, an image, `none`. A panel background that could only ever
be a flat colour would rule out the glass look the app has today.
*/
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
/*
Dark is still a CLASS, even though light and dark are now separate themes.
Two themes was the simplification the owner chose, and it is the right one — but 396 `dark:` utilities
are written into 33 components, and they all resolve against this variant. A theme that wants them
active declares `applyDarkClass` in its manifest and the provider adds the class; a theme that does not
simply never sees them. So the concept is gone from the app while the utilities keep working, and
removing those 396 becomes a later cleanup rather than a prerequisite.
*/
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@theme {
/* ── Semantic colour, the whole vocabulary a component is allowed to use ──────────────────────── */
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-brand: hsl(var(--brand));
--color-brand-foreground: hsl(var(--brand-foreground));
--color-brand-muted: hsl(var(--brand-muted));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-info: hsl(var(--info));
--color-info-foreground: hsl(var(--info-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-cta: hsl(var(--cta));
--color-cta-foreground: hsl(var(--cta-foreground));
--color-ctahover: hsl(var(--ctahover));
--color-naturegreen: hsl(var(--naturegreen));
--color-naturegreen-foreground: hsl(var(--naturegreen-foreground));
/*
── The brand slots ────────────────────────────────────────────────────────────────────────────
These are the six utilities that were `duck-*`, kept under their old names for now so ~1,470 existing
usages keep compiling, but pointed at variables a theme owns rather than at brand hex.
They are DEPRECATED as spellings. `duck-dark` is the clearest case of why: it is `#14532d` in light and
near-white in dark, so it was never a brand green — it is ink, and 946 components are using it to mean
"text colour". A theme called anything other than Duck cannot sensibly define `--duck-dark`.
The rename to semantic names is the largest mechanical piece of this refactor and is deliberately not
done in the same change as the extraction: one of them can be verified by screenshot, and doing both at
once would make a visual difference impossible to attribute.
*/
--color-duck-yellow: var(--brand-accent);
--color-duck-orange: var(--brand-warm);
--color-duck-teal: var(--brand-primary);
--color-duck-forest: var(--brand-deep);
--color-duck-dark: var(--ink);
--color-duck-beige: var(--surface-warm);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out forwards;
--animate-accordion-up: accordion-up 0.2s ease-out forwards;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
/*
Tailwind v4 changed the default border colour to `currentcolor`. Kept so an element with a border and no
explicit colour looks as it did under v3 — removing it means auditing every bordered element in the app.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
* {
@apply border-border;
}
/*
The page itself is a themed surface, not just a colour.
`--page-bg` is a complete `background` value so a theme can be a photograph, a gradient, or nothing at
all. Duck is a wallpaper; Plain is white. Both are the same line here.
*/
body {
@apply text-foreground;
background: var(--page-bg);
background-attachment: fixed;
background-size: cover;
}
}
+75
View File
@@ -0,0 +1,75 @@
/*
App furniture: the rules that are neither Tailwind nor a component, expressed in theme variables.
These came out of `globals.css` with hardcoded greys baked in — `#444` scrollbars, `rgb(128 128 128 / .3)`
thumbs. Small, but exactly the kind of thing that makes a light theme look broken in the code editor and
that a theme author has no way to reach. Now every value here is a variable a theme owns.
*/
/* Sonner renders at the document root, above everything the app draws. */
[data-sonner-toaster] {
z-index: 700 !important;
}
/* Code editor — thin scrollbars. Dark by default under Duck, because the editor surface is dark in both
Duck themes; a theme with a light editor sets these to suit. */
.code-editor-scrollable {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
.code-editor-scrollable::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.code-editor-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.code-editor-scrollable::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.code-editor-scrollable::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Chat — the background-task strip. A default horizontal scrollbar is ~15px tall and is laid out INSIDE
the row, so it stole most of the chips' breathing room and made the tray read as squeezed. */
.task-strip {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thin-thumb) transparent;
}
.task-strip::-webkit-scrollbar {
height: 4px;
}
.task-strip::-webkit-scrollbar-track {
background: transparent;
}
.task-strip::-webkit-scrollbar-thumb {
background: var(--scrollbar-thin-thumb);
border-radius: 2px;
}
.task-strip:hover::-webkit-scrollbar-thumb {
background: var(--scrollbar-thin-thumb-hover);
}
/* File browser — while a context menu is open, the rest of the grid stops responding. Behaviour, not
appearance, but it lives with the other furniture rather than in a component's stylesheet. */
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) {
pointer-events: none;
}
.file-grid:has([data-state='open']) [data-file-item]:not([data-state='open']) .file-item-ellipsis {
opacity: 0 !important;
}
.file-grid [data-file-item][data-state='open'] .file-item-ellipsis {
opacity: 1 !important;
}
+20
View File
@@ -0,0 +1,20 @@
/*
The stylesheet an application imports. One line in the app; everything else is in here.
Order matters and is the only reason this file has content:
1. `base.css` — Tailwind, the `@theme` contract, element defaults. Structure, no values.
2. themes — values, each scoped to its own `[data-theme='…']`, so they cannot collide.
3. `chrome.css` — app furniture (scrollbars, overlays) expressed in theme variables.
A theme file added below is a theme the app can wear. Nothing else has to change — which is the point of
the whole exercise, and the thing to protect when this grows to six or seven of them.
*/
@import './base.css';
@import './themes/duck-light.css';
@import './themes/duck-dark.css';
@import './themes/plain.css';
@import './chrome.css';
@@ -0,0 +1,121 @@
/*
DUCK — DARK
============================================================================================
A sibling of Duck Light, not a variant of it. The owner's decision: light and dark are two themes, which
removes "colour mode" from the application as a concept.
It is written out in full rather than as a diff against light. Overriding would be shorter and would
reintroduce exactly the coupling this refactor removes — a theme that only makes sense in the presence of
another is not something a third party can author or a customer can buy. A complete file is also the only
way an author can see what they have to supply.
`applyDarkClass` is set for this theme in the registry, so the 396 `dark:` utilities still written into
components keep resolving. That is a compatibility shim with a known end date, not part of the design.
Values are the `.dark` block of the original `globals.css`, unchanged. Where light defined something that
the original dark block did not override (`--duck-yellow`, `--duck-orange`), the light value is repeated
here — that is what the browser was computing before, made explicit.
*/
[data-theme='duck-dark'] {
/* ── Brand — lightened for contrast on a dark ground ──────────────────────────────────────────
`--ink` flipping to near-white is the clearest evidence that `duck-dark` was never a brand colour. */
--brand-primary: #22d3ee;
--brand-accent: #f4c430;
--brand-warm: #ea580c;
--brand-deep: #4ade80;
--ink: #f1f5f9;
--surface-warm: #292524;
/* ── The page ─────────────────────────────────────────────────────────────────────────────────── */
--page-bg: hsl(222.2 84% 4.9%);
--pixel-grid-color: rgba(200, 230, 210, 0.08);
/* ── Surfaces ─────────────────────────────────────────────────────────────────────────────────── */
--glass-bg: rgba(0, 0, 0, 0.2);
--glass-border: rgba(255, 255, 255, 0.1);
--panel-bg: rgba(0, 0, 0, 0.2);
--panel-border: rgba(255, 255, 255, 0.1);
--panel-radius: 0.75rem;
--panel-header-bg: rgba(0, 0, 0, 0.15);
--panel-header-fg: hsl(var(--foreground));
--panel-accent: var(--brand-primary);
--panel-grid-color: rgba(200, 230, 210, 0.06);
--dock-bg: rgba(0, 0, 0, 0.3);
--dock-border: rgba(255, 255, 255, 0.08);
--dock-tooltip-bg: rgba(0, 0, 0, 0.85);
--card-bg: rgba(10, 20, 15, 0.9);
--card-grid-color: rgba(200, 230, 210, 0.06);
/* ── Semantic colour ──────────────────────────────────────────────────────────────────────────── */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 211 82% 64%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--brand: 145 60% 45%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 30% 25%;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
/* Lifted well above the light value — 50% lightness violet on a near-black background reads as muddy
grey at 12px, which is exactly the size it is used at. */
--info: 263 90% 70%;
--info-foreground: 0 0% 100%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* The original dark block never overrode these, so the light values were in force. Written out because
a theme that silently inherits from another is the thing this file exists to stop. */
--cta: 211 74.77% 45.58%;
--cta-foreground: 0 0% 100%;
--ctahover: 211 91.53% 34.82%;
--naturegreen: 145 79% 38%;
--naturegreen-foreground: 0 0% 100%;
--radius: 0.5rem;
/* ── Data visualisation and status ────────────────────────────────────────────────────────────── */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
/* ── Chrome ───────────────────────────────────────────────────────────────────────────────────── */
--scrollbar-thumb: #444;
--scrollbar-thumb-hover: #555;
--scrollbar-thin-thumb: rgb(128 128 128 / 0.3);
--scrollbar-thin-thumb-hover: rgb(128 128 128 / 0.5);
}
@@ -0,0 +1,127 @@
/*
DUCK — LIGHT
============================================================================================
The application's original appearance, moved here unchanged. Every value in this file was lifted from
`apps/officer-web/styles/globals.css`; none of it was redesigned. That is deliberate — the extraction has
to be provably invisible, and a "small improvement" smuggled in alongside it makes the screenshot
comparison meaningless.
This file is the reference implementation of the theme contract. An author writing a new theme can copy
it, replace values, and change nothing else.
Everything is scoped to `[data-theme='duck-light']`, so themes cannot leak into one another and the
active one is legible in the DOM.
*/
[data-theme='duck-light'] {
/* ── Brand ────────────────────────────────────────────────────────────────────────────────────
The six slots the `duck-*` utilities resolve through. A theme names its own colours here; the app
never sees these directly.
`--ink` is the interesting one: components use it as their text colour ~950 times, which is why it
must be a near-black here and a near-white in the dark theme. It was called `duck-dark`, which is
what hid that fact for so long. */
--brand-primary: #0891b2;
--brand-accent: #f4c430;
--brand-warm: #ea580c;
--brand-deep: #166534;
--ink: #14532d;
--surface-warm: #e7d4b5;
/* ── The page ─────────────────────────────────────────────────────────────────────────────────
A complete `background` value, so a theme can be an image or a gradient rather than a colour. */
--page-bg: hsl(0 0% 100%);
--pixel-grid-color: rgba(20, 83, 45, 0.1);
/* ── Surfaces ─────────────────────────────────────────────────────────────────────────────────
Panels are the cornerstone of the layout, so they are themed as first-class surfaces rather than
inheriting whatever `card` happens to be. These were `bg-black/10` and `duck-teal` literals written
into the panel chrome, which is why panels could not be restyled without editing components. */
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.2);
--panel-bg: rgba(255, 255, 255, 0.1);
--panel-border: rgba(255, 255, 255, 0.2);
--panel-radius: 0.75rem;
--panel-header-bg: rgba(0, 0, 0, 0.05);
--panel-header-fg: hsl(var(--foreground));
--panel-accent: var(--brand-primary);
--panel-grid-color: rgba(20, 83, 45, 0.08);
--dock-bg: rgba(0, 0, 0, 0.15);
--dock-border: rgba(255, 255, 255, 0.1);
--dock-tooltip-bg: rgba(0, 0, 0, 0.75);
--card-bg: rgba(237, 231, 222, 0.9);
--card-grid-color: rgba(20, 83, 45, 0.08);
/* ── Semantic colour (HSL triplets — see base.css for why they are unwrapped) ─────────────────── */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 211 74.77% 45.58%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--brand: 145 79% 38%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 40% 90%;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
/* The fifth state tone. "In progress" and "merged" are neither good news nor bad, and painting them
with --success makes a merged PR and an open one look like the same thing. Violet because it has to
stay legible next to success green and destructive red at pill size. */
--info: 263 70% 50%;
--info-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--cta: 211 74.77% 45.58%;
--cta-foreground: 0 0% 100%;
--ctahover: 211 91.53% 34.82%;
--naturegreen: 145 79% 38%;
--naturegreen-foreground: 0 0% 100%;
--radius: 0.5rem;
/* ── Data visualisation and status ────────────────────────────────────────────────────────────── */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
/* ── Chrome that used hardcoded greys ─────────────────────────────────────────────────────────
Scrollbars were `#444`/`#555`/`rgb(128 128 128 / .3)` in the stylesheet, which meant the code editor
kept dark scrollbars under a light theme. Now a theme's problem. */
--scrollbar-thumb: #444;
--scrollbar-thumb-hover: #555;
--scrollbar-thin-thumb: rgb(128 128 128 / 0.3);
--scrollbar-thin-thumb-hover: rgb(128 128 128 / 0.5);
}
+130
View File
@@ -0,0 +1,130 @@
/*
PLAIN — white ground, black ink, visible borders
============================================================================================
Not a design. An instrument.
Every value here is white, black, or a grey, and every surface is flat and square. So when the app is
rendered under this theme, ANY colour still on the screen is a component painting itself instead of
asking the theme — a hardcoded `text-emerald-400`, a `bg-black/10`, a hex literal. The leaks light up.
That makes it the acceptance test for the whole decoupling effort, and a much better one than reading
code: the survey found ~985 raw Tailwind palette usages and ~119 hex literals, and no amount of grepping
tells you which of those are load-bearing. A screenshot does.
It is also the honest starting point for a third-party author — copy this, and every value you change is
one you have decided about, with nothing inherited from someone else's brand.
Deliberately NOT beautiful. A pleasant neutral theme would hide small leaks in its own greys; stark black
on stark white hides nothing.
*/
[data-theme='plain'] {
/* ── Brand slots ──────────────────────────────────────────────────────────────────────────────
All black. The `duck-*` utilities resolve through these, so anything still using them renders as flat
black rather than teal — visible as "this should have been a semantic token" without being colourful
enough to be mistaken for intent. */
--brand-primary: #000000;
--brand-accent: #000000;
--brand-warm: #000000;
--brand-deep: #000000;
--ink: #000000;
--surface-warm: #ffffff;
/* ── The page ─────────────────────────────────────────────────────────────────────────────────
Flat white and no grid. If a pixel grid or a wallpaper appears under this theme, something is drawing
it from outside the theme layer. */
--page-bg: #ffffff;
--pixel-grid-color: transparent;
/* ── Surfaces ─────────────────────────────────────────────────────────────────────────────────
Opaque white with a hard black border, and square corners. The app's panels are translucent with
rounded corners and a grid fill, so any panel that still looks like that under Plain is styling itself
rather than reading `--panel-*`. */
--glass-bg: #ffffff;
--glass-border: #000000;
--panel-bg: #ffffff;
--panel-border: #000000;
--panel-radius: 0;
--panel-header-bg: #f2f2f2;
--panel-header-fg: #000000;
--panel-accent: #000000;
--panel-grid-color: transparent;
--dock-bg: #ffffff;
--dock-border: #000000;
--dock-tooltip-bg: #000000;
--card-bg: #ffffff;
--card-grid-color: transparent;
/* ── Semantic colour ──────────────────────────────────────────────────────────────────────────
Greys only. The state tones (success/warning/destructive/info) are greys too, which is wrong for a
real theme and right for this one: it proves the app is asking for a token rather than reaching for a
green it likes. A theme that wanted real state colour would simply set them. */
--background: 0 0% 100%;
--foreground: 0 0% 0%;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
--primary: 0 0% 0%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 95%;
--secondary-foreground: 0 0% 0%;
--muted: 0 0% 95%;
--muted-foreground: 0 0% 35%;
--accent: 0 0% 90%;
--accent-foreground: 0 0% 0%;
--destructive: 0 0% 25%;
--destructive-foreground: 0 0% 100%;
--success: 0 0% 25%;
--success-foreground: 0 0% 100%;
--brand: 0 0% 0%;
--brand-foreground: 0 0% 100%;
--brand-muted: 0 0% 90%;
--warning: 0 0% 45%;
--warning-foreground: 0 0% 100%;
--info: 0 0% 35%;
--info-foreground: 0 0% 100%;
--border: 0 0% 0%;
--input: 0 0% 0%;
--ring: 0 0% 0%;
--cta: 0 0% 0%;
--cta-foreground: 0 0% 100%;
--ctahover: 0 0% 20%;
--naturegreen: 0 0% 0%;
--naturegreen-foreground: 0 0% 100%;
/* Square everything. Rounded corners surviving under Plain means a hardcoded `rounded-xl`, which is a
shape leak rather than a colour one — worth seeing too. */
--radius: 0;
/* ── Data visualisation and status ────────────────────────────────────────────────────────────── */
--chart-primary: 0 0% 20%;
--chart-secondary: 0 0% 60%;
--status-success: 0 0% 25%;
--status-warning: 0 0% 45%;
--status-in-progress: 0 0% 35%;
/* ── Chrome ───────────────────────────────────────────────────────────────────────────────────── */
--scrollbar-thumb: #000000;
--scrollbar-thumb-hover: #333333;
--scrollbar-thin-thumb: rgb(0 0 0 / 0.4);
--scrollbar-thin-thumb-hover: rgb(0 0 0 / 0.7);
}
+124
View File
@@ -0,0 +1,124 @@
import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
DEFAULT_THEME,
THEMES,
findTheme,
isKnownTheme,
themeFromLegacyColorMode,
type Theme,
type ThemeId,
} from './themes';
/**
* Which theme the application is wearing.
*
* The provider does exactly two things to the document: sets `data-theme`, and toggles `.dark` when the
* chosen theme asks for it. Everything else about appearance is CSS. That is the boundary — if this file
* ever starts computing a colour, the decoupling has sprung a leak.
*
* ── Persistence, and why localStorage is written first ──
*
* The choice is kept in localStorage AND in user settings. localStorage is what survives a reload without a
* round trip, which matters because the alternative is a flash of the default theme on every page load
* while the settings request is in flight. The server copy is what follows an account to another browser.
* They are written together; localStorage wins on read, because it is the one that is definitely about
* this machine.
*/
const STORAGE_KEY = 'officer-theme';
/** The pre-themes preference. Read once for migration, never written. */
const LEGACY_KEY = 'officer-color-mode';
type ThemeContextValue = {
theme: Theme;
themeId: ThemeId;
setTheme: (id: ThemeId) => void;
themes: readonly Theme[];
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
/**
* What to wear on the very first paint, decided synchronously.
*
* Deliberately not in an effect. An effect runs after the first render, so the app would paint the default
* theme and then swap — the "flash of wrong theme" that every theming system has to solve exactly once.
*/
function initialTheme(): ThemeId {
if (typeof window === 'undefined') return DEFAULT_THEME;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (isKnownTheme(stored)) return stored as ThemeId;
// An account from before themes existed. Honour what they chose rather than resetting them.
const migrated = themeFromLegacyColorMode(window.localStorage.getItem(LEGACY_KEY));
if (migrated) return migrated;
} catch {
// Private mode, or storage disabled. The default is a perfectly good answer.
}
return DEFAULT_THEME;
}
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [themeId, setThemeId] = useState<ThemeId>(initialTheme);
const theme = useMemo(() => findTheme(themeId) ?? findTheme(DEFAULT_THEME)!, [themeId]);
useEffect(() => {
const root = document.documentElement;
root.dataset.theme = theme.id;
root.classList.toggle('dark', theme.applyDarkClass);
// `color-scheme` makes the browser's own furniture — scrollbars, form controls, the space behind an
// overscroll — match the theme. Without it a dark theme has white scrollbar gutters.
root.style.colorScheme = theme.applyDarkClass ? 'dark' : 'light';
}, [theme]);
const setTheme = useCallback((id: ThemeId) => {
if (!isKnownTheme(id)) return;
setThemeId(id);
try {
window.localStorage.setItem(STORAGE_KEY, id);
} catch {
// Not fatal: the theme still applies for this session, it just will not survive a reload.
}
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ theme, themeId: theme.id, setTheme, themes: THEMES }),
[theme, setTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
/**
* Throws when used outside the provider, rather than falling back to a default.
*
* The old `ColorModeProvider` had a non-null default in `createContext`, so its "did you forget the
* provider" check could never fire and a component outside the tree silently rendered light mode forever.
* A null default makes the mistake loud at the moment it is made.
*/
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within a ThemeProvider');
return context;
}
/**
* The old two-state API, kept working while its callers are migrated.
*
* `UserMenu` and a couple of other places speak in light/dark. Rewriting them in the same change as the
* extraction would put visual differences and behavioural ones in the same diff, and the screenshot
* comparison is only meaningful if the extraction is the only variable. So: light means Duck Light, dark
* means Duck Dark, and a caller on Plain reads as light.
*
* This shim is a migration step, not an API. New code uses `useTheme`.
*/
export function useColorMode(): { colorMode: 'light' | 'dark'; setColorMode: (mode: 'light' | 'dark') => void } {
const { theme, setTheme } = useTheme();
return {
colorMode: theme.applyDarkClass ? 'dark' : 'light',
setColorMode: (mode) => setTheme(mode === 'dark' ? 'duck-dark' : 'duck-light'),
};
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Everything an application needs to wear a theme, and nothing about what a theme looks like.
*
* The CSS is a separate entry (`theme/css`) because stylesheets and modules are imported by different
* machinery — the app's stylesheet imports one, its React tree imports the other.
*/
export { ThemeProvider, useTheme, useColorMode } from './ThemeProvider';
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
export type { Theme, ThemeId } from './themes';
+62
View File
@@ -0,0 +1,62 @@
/**
* The themes this build ships, and what the application is allowed to know about them.
*
* Note how little that is: an id, a label, and one compatibility flag. Nothing here describes what a theme
* looks like — no colours, no surfaces, no "is it dark". That is the decoupling, stated as a type. If this
* list grows a field like `primaryColor`, the app has started knowing about appearance again.
*/
export type ThemeId = string;
export type Theme = {
/** Matches the `[data-theme='…']` selector in the theme's stylesheet, and is what gets persisted. */
id: ThemeId;
/** Shown to a person choosing one. */
name: string;
/** Grouped in the picker — a family is "Duck", its members are the light and dark themes. */
family: string;
/**
* Whether to put `.dark` on the document element.
*
* A compatibility shim with a known end date. Light and dark are separate themes now, so the app has no
* concept of mode — but 396 `dark:` utilities are still written into 33 components and they resolve
* against that class. A dark theme opts in and they keep working; a theme that does not opt in never
* sees them. Removing those utilities retires this flag.
*/
applyDarkClass: boolean;
};
export const THEMES: readonly Theme[] = [
{ id: 'duck-light', name: 'Duck Light', family: 'Duck', applyDarkClass: false },
{ id: 'duck-dark', name: 'Duck Dark', family: 'Duck', applyDarkClass: true },
{
id: 'plain',
name: 'Plain',
family: 'Plain',
// Not a dark theme, and deliberately does not opt in: the `dark:` utilities are a Duck-era mechanism,
// and Plain exists to show what the app looks like without any of them.
applyDarkClass: false,
},
] as const;
/** What the app wears when nobody has chosen — its appearance before this refactor. */
export const DEFAULT_THEME: ThemeId = 'duck-light';
export const findTheme = (id: string | null | undefined): Theme | undefined => THEMES.find((t) => t.id === id);
export const isKnownTheme = (id: string | null | undefined): boolean => Boolean(findTheme(id));
/**
* What an existing installation was wearing, expressed as a theme.
*
* Before this, the preference was `officer-color-mode: 'light' | 'dark'` in localStorage and in user
* settings. Those accounts must not be silently reset to the default on upgrade — someone who chose dark a
* year ago should still get dark. Read once, then superseded by the theme preference.
*/
export function themeFromLegacyColorMode(mode: string | null | undefined): ThemeId | undefined {
if (mode === 'dark') return 'duck-dark';
if (mode === 'light') return 'duck-light';
return undefined;
}
/** The reverse, for code that still speaks in colour modes while it is being migrated. */
export const isDarkTheme = (id: ThemeId): boolean => findTheme(id)?.applyDarkClass ?? false;