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:
+116
@@ -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.
|
||||||
@@ -129,6 +129,7 @@
|
|||||||
"state": "workspace:*",
|
"state": "workspace:*",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"theme": "workspace:*",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"types": "workspace:*",
|
"types": "workspace:*",
|
||||||
"varuint-bitcoin": "^2.0.0",
|
"varuint-bitcoin": "^2.0.0",
|
||||||
@@ -286,6 +287,10 @@
|
|||||||
"officerdev": "workspace:*",
|
"officerdev": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"src/workspaces/theme": {
|
||||||
|
"name": "theme",
|
||||||
|
"version": "0.0.1",
|
||||||
|
},
|
||||||
"src/workspaces/types": {
|
"src/workspaces/types": {
|
||||||
"name": "types",
|
"name": "types",
|
||||||
"version": "0.0.1",
|
"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=="],
|
"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": ["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=="],
|
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
"state": "workspace:*",
|
"state": "workspace:*",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"theme": "workspace:*",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"types": "workspace:*",
|
"types": "workspace:*",
|
||||||
"varuint-bitcoin": "^2.0.0",
|
"varuint-bitcoin": "^2.0.0",
|
||||||
|
|||||||
@@ -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})`);
|
||||||
@@ -91,10 +91,14 @@ async function capture(base: string, out: string, token: string): Promise<void>
|
|||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
colorScheme: mode,
|
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(`
|
await context.addInitScript(`
|
||||||
window.localStorage.setItem('BEARER_TOKEN', ${JSON.stringify(token)});
|
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)});
|
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();
|
const page = await context.newPage();
|
||||||
|
|
||||||
@@ -105,11 +109,13 @@ async function capture(base: string, out: string, token: string): Promise<void>
|
|||||||
await settle(page);
|
await settle(page);
|
||||||
// Asserted AFTER settling, because the provider sets this class in an effect and would otherwise
|
// 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.
|
// win the race. Re-checked rather than assumed: a wrong class here silently produces the bug above.
|
||||||
const applied = await page.evaluate((m) => {
|
if (!arg('theme')) {
|
||||||
document.documentElement.classList.toggle('dark', m === 'dark');
|
const applied = await page.evaluate((m) => {
|
||||||
return document.documentElement.classList.contains('dark');
|
document.documentElement.classList.toggle('dark', m === 'dark');
|
||||||
}, mode as string);
|
return document.documentElement.classList.contains('dark');
|
||||||
if (applied !== (mode === 'dark')) failures.push(`${name}-${mode}: colour mode did not apply`);
|
}, mode as string);
|
||||||
|
if (applied !== (mode === 'dark')) failures.push(`${name}-${mode}: colour mode did not apply`);
|
||||||
|
}
|
||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
await page.screenshot({ path: file, fullPage: false });
|
await page.screenshot({ path: file, fullPage: false });
|
||||||
console.log(` captured ${name}-${mode}`);
|
console.log(` captured ${name}-${mode}`);
|
||||||
@@ -197,7 +203,9 @@ if (compareArgs !== -1) {
|
|||||||
const token = arg('token') ?? process.env.OFFICER_SNAPSHOT_TOKEN;
|
const token = arg('token') ?? process.env.OFFICER_SNAPSHOT_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.log(' --token <jwt> is required (or OFFICER_SNAPSHOT_TOKEN). Mint one from a checkout with deps:');
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(` base ${base} -> ${out}`);
|
console.log(` base ${base} -> ${out}`);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
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 { useAuth } from 'hooks/useAuth';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
|
|
||||||
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
|
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
|
||||||
@@ -13,6 +13,7 @@ export function UserMenu() {
|
|||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { colorMode, setColorMode } = useColorMode();
|
const { colorMode, setColorMode } = useColorMode();
|
||||||
|
const { themeId, setTheme, themes } = useTheme();
|
||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
@@ -35,6 +36,18 @@ export function UserMenu() {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="z-10 w-48">
|
<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">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/profile">
|
<Link to="/settings/profile">
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,13 +1,46 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
|
||||||
import { useSettings } from 'state/useSettings';
|
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 = () => {
|
export const useColorModeSync = () => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { setColorMode } = useColorMode();
|
const { setColorMode } = useColorMode();
|
||||||
|
const { themeId } = useTheme();
|
||||||
|
const applied = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) return;
|
if (!settings || applied.current) return;
|
||||||
setColorMode(settings.appearance.colorMode);
|
applied.current = true;
|
||||||
}, [settings?.appearance?.colorMode]);
|
|
||||||
|
// 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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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';
|
@import './prose.css';
|
||||||
|
|||||||
@@ -1,40 +1,16 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
/**
|
||||||
|
* Moved. The implementation now lives in the `theme` workspace.
|
||||||
type ColorMode = 'light' | 'dark';
|
*
|
||||||
|
* This file is a re-export so the six existing call sites — `frontend.tsx`, `useThemeSync`, `UserMenu`,
|
||||||
const initialState = {
|
* `MapSection` and two unused toggles — keep working without being edited. That is not laziness: the
|
||||||
colorMode: 'light' as ColorMode,
|
* extraction has to be provably invisible, and a diff that also rewrites every consumer makes any visual
|
||||||
setColorMode: (_mode: ColorMode) => {},
|
* 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
|
||||||
type ColorModeProviderProps = { children: React.ReactNode; defaultMode?: ColorMode; storageKey?: string };
|
* one manages a theme, and light/dark are now two themes. `useColorMode` maps between the two worlds — see
|
||||||
|
* its note in the package.
|
||||||
const ColorModeContext = createContext(initialState);
|
*
|
||||||
|
* New code should import from `theme` directly. This file exists to be deleted once nothing points here.
|
||||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
*/
|
||||||
const { children, defaultMode = 'light', storageKey = 'officer-color-mode' } = props;
|
export { ThemeProvider as ColorModeProvider, ThemeProvider, useTheme, useColorMode } from 'theme';
|
||||||
const [colorMode, setColorModeState] = useState<ColorMode>(
|
export type { Theme, ThemeId } from 'theme';
|
||||||
() => (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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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')],
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user