diff --git a/THEMES-NOTES.md b/THEMES-NOTES.md new file mode 100644 index 00000000..7f18c088 --- /dev/null +++ b/THEMES-NOTES.md @@ -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='']` 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 +``) 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. diff --git a/bun.lock b/bun.lock index 5bcec52c..c8fd65d1 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 7e7f4378..0269b1f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/theme-preview-server.ts b/scripts/theme-preview-server.ts new file mode 100644 index 00000000..ed2fd89f --- /dev/null +++ b/scripts/theme-preview-server.ts @@ -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})`); diff --git a/scripts/theme-snapshot.ts b/scripts/theme-snapshot.ts index fe97ad51..7fbb6027 100644 --- a/scripts/theme-snapshot.ts +++ b/scripts/theme-snapshot.ts @@ -91,10 +91,14 @@ async function capture(base: string, out: string, token: string): Promise 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 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 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}`); diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx index e28e1e2a..531da9a2 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx @@ -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() { + {/* + 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) => ( + setTheme(theme.id)} className="cursor-pointer"> + + {theme.name} + + ))} + diff --git a/src/apps/officer-web/state/useThemeSync.ts b/src/apps/officer-web/state/useThemeSync.ts index cf8bdf9c..c02dea75 100644 --- a/src/apps/officer-web/state/useThemeSync.ts +++ b/src/apps/officer-web/state/useThemeSync.ts @@ -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]); }; diff --git a/src/apps/officer-web/styles/globals.css b/src/apps/officer-web/styles/globals.css deleted file mode 100644 index 0a2a9736..00000000 --- a/src/apps/officer-web/styles/globals.css +++ /dev/null @@ -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; -} diff --git a/src/apps/officer-web/styles/index.css b/src/apps/officer-web/styles/index.css index 4551d1d0..36c02fc0 100644 --- a/src/apps/officer-web/styles/index.css +++ b/src/apps/officer-web/styles/index.css @@ -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'; diff --git a/src/workspaces/components/ui/ThemeProvider.tsx b/src/workspaces/components/ui/ThemeProvider.tsx index 682262ed..7a21d5cb 100644 --- a/src/workspaces/components/ui/ThemeProvider.tsx +++ b/src/workspaces/components/ui/ThemeProvider.tsx @@ -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( - () => (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 {children}; -} - -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'; diff --git a/src/workspaces/components/ui/tailwind.config.ts b/src/workspaces/components/ui/tailwind.config.ts deleted file mode 100644 index e5ae931d..00000000 --- a/src/workspaces/components/ui/tailwind.config.ts +++ /dev/null @@ -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')], -}; diff --git a/src/workspaces/components/ui/tailwind.css b/src/workspaces/components/ui/tailwind.css deleted file mode 100644 index 62e6a492..00000000 --- a/src/workspaces/components/ui/tailwind.css +++ /dev/null @@ -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; -} diff --git a/src/workspaces/theme/css/base.css b/src/workspaces/theme/css/base.css new file mode 100644 index 00000000..3d3fb298 --- /dev/null +++ b/src/workspaces/theme/css/base.css @@ -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 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; + } +} diff --git a/src/workspaces/theme/css/chrome.css b/src/workspaces/theme/css/chrome.css new file mode 100644 index 00000000..3e5e39a1 --- /dev/null +++ b/src/workspaces/theme/css/chrome.css @@ -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; +} diff --git a/src/workspaces/theme/css/index.css b/src/workspaces/theme/css/index.css new file mode 100644 index 00000000..cd933494 --- /dev/null +++ b/src/workspaces/theme/css/index.css @@ -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'; diff --git a/src/workspaces/theme/css/themes/duck-dark.css b/src/workspaces/theme/css/themes/duck-dark.css new file mode 100644 index 00000000..adb92c23 --- /dev/null +++ b/src/workspaces/theme/css/themes/duck-dark.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); +} diff --git a/src/workspaces/theme/css/themes/duck-light.css b/src/workspaces/theme/css/themes/duck-light.css new file mode 100644 index 00000000..66081e26 --- /dev/null +++ b/src/workspaces/theme/css/themes/duck-light.css @@ -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); +} diff --git a/src/workspaces/theme/css/themes/plain.css b/src/workspaces/theme/css/themes/plain.css new file mode 100644 index 00000000..1d6b63ca --- /dev/null +++ b/src/workspaces/theme/css/themes/plain.css @@ -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); +} diff --git a/src/workspaces/theme/src/ThemeProvider.tsx b/src/workspaces/theme/src/ThemeProvider.tsx new file mode 100644 index 00000000..df144bfc --- /dev/null +++ b/src/workspaces/theme/src/ThemeProvider.tsx @@ -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(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(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( + () => ({ theme, themeId: theme.id, setTheme, themes: THEMES }), + [theme, setTheme], + ); + + return {children}; +}; + +/** + * 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'), + }; +} diff --git a/src/workspaces/theme/src/index.ts b/src/workspaces/theme/src/index.ts new file mode 100644 index 00000000..62759562 --- /dev/null +++ b/src/workspaces/theme/src/index.ts @@ -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'; diff --git a/src/workspaces/theme/src/themes.ts b/src/workspaces/theme/src/themes.ts new file mode 100644 index 00000000..f4ffff1f --- /dev/null +++ b/src/workspaces/theme/src/themes.ts @@ -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;