Compare commits

..
2 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 997f7e7a87 the account stores a theme, not a colour mode
Your theme now follows the account instead of living in one browser's localStorage.
Choosing Plain on a laptop reaches the phone.

`appearance.colorMode` is superseded by `appearance.theme`. Along the way this
resolves a mismatch that had been sitting there: the server's default was
`appearance: { theme: 'light' }` while the client read `appearance.colorMode`, so that
default had never once been read by anything. Both sides say `theme` now.

The Light/Dark item is gone from the user menu. Light and dark are two themes, both
already in the list above it, and that toggle was the last writer of the legacy key.

── Two bugs found by testing this, both mine, both silent ──

**A default that looked like a choice.** `useSettings` returns `DEFAULT_SETTINGS`
until the request lands — `settings` is never null, it is a complete valid object. So
"wait for settings" fired on the first render against the defaults, marked itself
done, applied the default theme and never saw the real one. Then the persist side
wrote that default over the account's actual preference. It overwrote the owner's own
`dark` while I was testing it; restored by hand from the `colorMode` it had not yet
destroyed. `useSettings` now exposes `settingsLoaded` so the difference is expressible.

**Defaults masking a migration.** With that fixed, an account holding only
`colorMode: 'dark'` STILL came up light. The client merges `DEFAULT_SETTINGS` over
whatever arrives, so a missing `theme` is filled in with the default before any
consumer sees it — and a default is indistinguishable from a choice. The migration
therefore belongs on the SERVER, which sees the stored object before anything is
merged over it. It is read-only: a GET that rewrites the row on every call is a
surprise, and the client persists on its next change anyway.

Verified by putting the account into the pre-themes shape and asking the new handler
directly, rather than by restarting to find out:

  stored: {"colorMode":"dark"}  ->  returned: {"colorMode":"dark","theme":"duck-dark"}

── Needs a restart ──

The first server-side change in this work. Everything until now was frontend and a
rebuild was enough.

950 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:31:22 +00:00
pastilhasandClaude Opus 5 ecb6864ff9 delete the landing app
Built and shipped from somewhere else now, so this copy was dead weight that still
carried its own third copy of the palette — `duck-teal` had three different definitions
across three apps, and this was one of them.

Removed: `src/apps/landing`, `scripts/build/landing.ts`, the `build:landing` script,
and the tree entry in CLAUDE.md.

`/landing-page-data` STAYS and is unrelated despite the name — it is the API the
sign-in screen asks whether registration is open. Worth saying because a grep for
"landing" hits both and only one of them was meant.

Nothing imported from the app. tsgo clean, frontend builds, the live app serves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:24:35 +00:00
13 changed files with 106 additions and 480 deletions
+1 -2
View File
@@ -80,8 +80,7 @@ deliberate — it is the owner's own machine. Do not add a jail without being as
src/ src/
├── server.tsx # the single entrypoint: routes, WS upgrades, static files ├── server.tsx # the single entrypoint: routes, WS upgrades, static files
├── apps/ ├── apps/
│ ├── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard │ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
│ └── landing/ # marketing landing page
├── servers/ ├── servers/
│ ├── hono.ts # router composition; everything under /api │ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting │ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
-1
View File
@@ -17,7 +17,6 @@
"prebuild": "bun run ./scripts/prebuild.ts", "prebuild": "bun run ./scripts/prebuild.ts",
"build:web": "bun run ./scripts/build/web.ts", "build:web": "bun run ./scripts/build/web.ts",
"build:dashboard": "bun run ./scripts/build/dashboard.ts", "build:dashboard": "bun run ./scripts/build/dashboard.ts",
"build:landing": "bun run ./scripts/build/landing.ts",
"db:gen": "cd src/databases/officer_db && bun run generate", "db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push", "db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
"db:migrate": "cd src/databases/officer_db && bun run migrate", "db:migrate": "cd src/databases/officer_db && bun run migrate",
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bun
import plugin from 'bun-plugin-tailwind';
import { existsSync } from 'fs';
import { rm } from 'fs/promises';
import path from 'path';
const outdir = path.join(process.cwd(), 'dist_landing');
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
}
console.log('\n🚀 Starting landing page build...\n');
const start = performance.now();
const entrypoints = [...new Bun.Glob('**.html').scanSync('src/apps/landing')]
.map((a) => path.resolve('src/apps/landing', a))
.filter((dir) => !dir.includes('node_modules'));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`);
const formatFileSize = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: 'browser',
sourcemap: 'linked',
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
});
const end = performance.now();
const outputTable = result.outputs.map((output) => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
console.log(`\n✅ Build completed in ${(end - start).toFixed(2)}ms\n`);
-48
View File
@@ -1,48 +0,0 @@
import { PixelGrid } from '../../workspaces/components/PixelGrid';
import { DuckAvatar } from './DuckAvatar';
import { WaitlistForm } from './WaitlistForm';
const landscapeBg = '/landscape1.webp';
const logoSrc = '/officer-logo.svg';
export const App = () => {
return (
<div className="relative overflow-hidden h-dvh outline-none inset-0">
{/* Background */}
<div
className="absolute inset-0 z-0"
style={{
backgroundImage: `url(${landscapeBg})`,
backgroundSize: 'cover',
backgroundPosition: 'center center',
}}
>
<PixelGrid />
<DuckAvatar />
</div>
{/* Logo */}
<header className="pt-12 md:pt-20 lg:pt-24 xl:pt-0 z-20 flex justify-center">
<div className="hidden md:block md:pt-28">
<img
src={logoSrc}
alt="Officer Logo"
style={{ width: '1500px', height: 'auto', transform: 'skew(-15deg, -2deg)' }}
/>
</div>
<div className="block md:hidden">
<img
src={logoSrc}
alt="Officer Logo"
style={{ width: '900px', height: 'auto', transform: 'skew(-15deg, -2deg)' }}
/>
</div>
</header>
{/* Waitlist form */}
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-18 md:pb-30">
<WaitlistForm />
</div>
</div>
);
};
-64
View File
@@ -1,64 +0,0 @@
import { Canvas, useThree } from '@react-three/fiber';
import { OrbitControls, useGLTF, useAnimations } from '@react-three/drei';
import { useEffect, useState, useRef } from 'react';
function DuckModel({ onLoaded }: { onLoaded: () => void }) {
const character = useGLTF('/duck3D/Character_output.glb');
const animations = useGLTF('/duck3D/Meshy_Merged_Animations.glb');
const meshRef = useRef<any>(null);
const { actions } = useAnimations(animations.animations, meshRef);
useEffect(() => {
onLoaded();
}, [character, animations, onLoaded]);
useEffect(() => {
if (!actions) return;
const action = actions['Walking'];
if (!action) return;
action.reset();
action.setLoop(2201, Infinity);
action.clampWhenFinished = false;
action.fadeIn(0.4);
action.play();
}, [actions]);
return <primitive ref={meshRef} object={character.scene} position={[0, -0.2, 0]} scale={4.5} rotation={[-0.1, (10 * Math.PI) / 180, 0]} />;
}
function Camera() {
const { camera } = useThree();
const controlsRef = useRef<any>(null);
useEffect(() => {
if (controlsRef.current) {
controlsRef.current.target.set(0.3, 3.29, -0.4);
camera.position.set(2.89, 5.24, 7.38);
controlsRef.current.update();
}
}, [camera]);
return <OrbitControls ref={controlsRef} enabled={false} target={[0.3, 3.29, -0.4]} />;
}
export function DuckAvatar() {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div
className="fixed top-12 md:top-auto md:bottom-0 left-1/2 -translate-x-1/2 w-[60vh] h-[75vh] overflow-visible z-1 pointer-events-none"
style={{ background: 'transparent', opacity: isLoaded ? 1 : 0 }}
>
<div className="w-full h-full pointer-events-none overflow-visible" style={{ background: 'transparent' }}>
<Canvas camera={{ position: [2.89, 5.24, 7.38], fov: 65, near: 0.1, far: 1000 }} style={{ background: 'transparent' }}>
<ambientLight intensity={1.5} />
<directionalLight position={[10, 10, 5]} intensity={2} />
<directionalLight position={[-10, -10, -5]} intensity={1} />
<pointLight position={[0, 5, 0]} intensity={1.5} />
<Camera />
<DuckModel onLoaded={() => setIsLoaded(true)} />
</Canvas>
</div>
</div>
);
}
-117
View File
@@ -1,117 +0,0 @@
import type { CSSProperties, FormEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
const cardStyle: CSSProperties = {
backgroundColor: 'var(--card-bg)',
backgroundImage: `
linear-gradient(to right, var(--card-grid-color) 1px, transparent 1px),
linear-gradient(to bottom, var(--card-grid-color) 1px, transparent 1px)
`,
backgroundSize: '20px 20px',
};
export function WaitlistForm() {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [focused, setFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const isValid = email.trim().length > 0 && email.includes('@');
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const reset = () => {
window.scrollTo(0, 0);
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
};
vv.addEventListener('resize', reset);
vv.addEventListener('scroll', reset);
return () => {
vv.removeEventListener('resize', reset);
vv.removeEventListener('scroll', reset);
};
}, []);
const handleFocus = () => setFocused(true);
const handleBlur = () => {
setFocused(false);
window.scrollTo(0, 0);
};
const handleSubmit = async (ev: FormEvent) => {
ev.preventDefault();
inputRef.current?.blur();
const trimmed = email.trim();
if (!trimmed || isSubmitting) return;
setIsSubmitting(true);
try {
await fetch('https://alpha.officer.dev/api/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: trimmed }),
});
setDone(true);
} catch {
setDone(true);
} finally {
setIsSubmitting(false);
}
};
return (
<div
ref={wrapperRef}
className="rounded-xl border-2 border-duck-dark/30 shadow-lg p-5 transition-all duration-300 ease-out"
style={{
...cardStyle,
...(focused && window.innerWidth < 768 ? { position: 'fixed', bottom: '40%', top: 'auto', left: '50%', transform: 'translateX(-50%)', zIndex: 50, width: wrapperRef.current?.offsetWidth } : {}),
}}
>
{done ? (
<div className="text-center">
<h2 className="text-2xl font-bold text-duck-dark mb-2">You're on the list!</h2>
<div className="text-sm text-duck-dark/60">
<p>
We'll notify <strong>{email}</strong> when Officer is ready.
</p>
</div>
</div>
) : (
<>
<h2 className="text-2xl font-bold text-duck-dark mb-2">Join the Waitlist</h2>
<p className="text-sm text-duck-dark/60 mb-6">
Be the first to know when Officer launches.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<input
ref={inputRef}
type="email"
name="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="you@example.com"
className="flex-1 h-10 rounded-md border border-input bg-background px-4 py-2.5 text-base text-duck-dark placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<button
type="submit"
disabled={!isValid || isSubmitting}
className="w-full bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-6 py-2.5 rounded-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Joining...' : 'Join'}
</button>
</form>
</>
)}
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=overlays-content" />
<title>Officer Dev (Alpha)</title>
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
<!-- OpenGraph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Officer Dev (Alpha)" />
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
<meta property="og:image" content="https://officer.dev/og-image-v3.jpg" />
<meta property="og:image:secure_url" content="https://officer.dev/og-image-v3.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:url" content="https://officer.dev" />
<meta property="og:site_name" content="Officer Dev" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Officer Dev (Alpha)" />
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
<meta name="twitter:image" content="https://officer.dev/og-image-v3.jpg" />
<!-- Favicons & App Icons -->
<link rel="icon" type="image/x-icon" href="https://officer.dev/favicon.ico" />
<link rel="icon" type="image/png" sizes="96x96" href="https://officer.dev/favicon-96x96.png" />
<link rel="apple-touch-icon" sizes="180x180" href="https://officer.dev/apple-touch-icon.png" />
<link rel="manifest" href="https://officer.dev/site.webmanifest" />
<meta name="theme-color" content="#1F2620" />
<script type="module" src="./main.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
-92
View File
@@ -1,92 +0,0 @@
@import "tailwindcss";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@theme {
--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-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
@layer base {
:root {
--duck-yellow: #F4C430;
--duck-orange: #EA580C;
--duck-teal: #0891B2;
--duck-forest: #166534;
--duck-dark: #14532D;
--duck-beige: #E7D4B5;
--pixel-grid-color: rgba(20, 83, 45, 0.1);
--card-bg: rgba(255, 255, 255, 0.9);
--card-grid-color: rgba(20, 83, 45, 0.08);
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--duck-teal: #22d3ee;
--duck-dark: #f1f5f9;
--duck-forest: #4ade80;
--duck-beige: #292524;
--pixel-grid-color: rgba(200, 230, 210, 0.08);
--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%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
html, body {
@apply bg-background text-foreground;
margin: 0;
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
}
@@ -1,28 +1,19 @@
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, Palette } from 'lucide-react'; import { User, LogOut, Settings, Puzzle, 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, useTheme } from '@/components/ui/ThemeProvider'; import { useTheme } from '@/components/ui/ThemeProvider';
import { useSettings } from 'state/useSettings';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown; const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
export function UserMenu() { export function UserMenu() {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
const { t } = useTranslation(); const { t } = useTranslation();
const { colorMode, setColorMode } = useColorMode();
const { themeId, setTheme, themes } = useTheme(); const { themeId, setTheme, themes } = useTheme();
const { settings, saveSettings } = useSettings();
if (isLoading) return null; if (isLoading) return null;
const toggleColorMode = () => {
const next = colorMode === 'dark' ? 'light' : 'dark';
setColorMode(next);
saveSettings({ ...settings, appearance: { ...settings.appearance, colorMode: next } });
};
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -37,9 +28,12 @@ export function UserMenu() {
</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 Every theme this build ships. This replaced a Light/Dark toggle, which had become a second way to
is proving the app can wear a different skin at all, not designing a picker. A real one belongs in do the same thing — light and dark are two themes now, both already in this list, and the toggle
an appearance settings screen, which does not exist yet. was the last thing writing the legacy `appearance.colorMode`.
Deliberately the crudest possible control: proving the app can wear another skin, not designing a
picker. A real one belongs in an appearance settings screen, which does not exist yet.
*/} */}
{themes.map((theme) => ( {themes.map((theme) => (
<DropdownMenuItem key={theme.id} onClick={() => setTheme(theme.id)} className="cursor-pointer"> <DropdownMenuItem key={theme.id} onClick={() => setTheme(theme.id)} className="cursor-pointer">
@@ -87,11 +81,6 @@ export function UserMenu() {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
{colorMode === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
{colorMode === 'dark' ? 'Light Mode' : 'Dark Mode'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout"> <Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
+44 -27
View File
@@ -1,46 +1,63 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider'; import { useTheme } from '@/components/ui/ThemeProvider';
import { themeFromLegacyColorMode, isKnownTheme } from 'theme';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
/** /**
* Bring the account's saved appearance down from the server, once. * Keep the account's theme and this browser's theme agreed, in both directions.
* *
* ── Why "once" is the whole point ── * ── Why it is two effects and not one ──
* *
* This used to re-apply `settings.appearance.colorMode` whenever the settings query resolved, which was * They answer different questions. HYDRATE asks "this browser has never chosen — what does the account
* correct while colour mode was the ONLY appearance state — there was nothing else for it to overwrite. * say?" and runs once. PERSIST asks "the person just chose — tell the account", and runs on every change.
* With themes there is: picking Plain and reloading put Plain in localStorage, and then this hook stamped * Collapsing them produces the bug this file used to have: the server's value re-applied on every settings
* `colorMode: 'dark'` back over it a moment later. The theme changed and then silently changed back, which * fetch, so choosing Plain and reloading silently reverted it.
* 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 — * A local choice wins on load because it is the more recent statement of intent AND it is already on the
* rather than as a continuous instruction. A local choice wins, because it is the more recent statement of * screen — re-applying the server's value would be a visible flip a second after the page settles.
* 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 * ── Migration ──
* 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. * Accounts predating themes stored `appearance.colorMode`. It is read once, mapped to the matching Duck
* theme, and never written again. Somebody who chose dark a year ago keeps dark.
*
* Worth knowing: the server's default was `appearance: { theme: 'light' }` while the client read
* `appearance.colorMode`, so that default had never been read by anything. Both sides say `theme` now.
*/ */
const LOCAL_CHOICE_KEY = 'officer-theme'; const LOCAL_KEY = 'officer-theme';
export const useColorModeSync = () => { export const useColorModeSync = () => {
const { settings } = useSettings(); const { settings, settingsLoaded, saveSettings } = useSettings();
const { setColorMode } = useColorMode(); const { themeId, setTheme } = useTheme();
const { themeId } = useTheme(); const hydrated = useRef(false);
const applied = useRef(false);
// HYDRATE — once, and only into a browser that has not chosen for itself.
useEffect(() => { useEffect(() => {
if (!settings || applied.current) return; // `settingsLoaded`, not `settings` — see its note. `settings` is the DEFAULTS until the request
applied.current = true; // lands, and acting on those is how this hook overwrote the account's real preference with the
// default one and then saved it back.
if (!settingsLoaded || hydrated.current) return;
hydrated.current = true;
// This browser has already been told what to wear. The server's colour mode is older information.
try { try {
if (window.localStorage.getItem(LOCAL_CHOICE_KEY)) return; if (window.localStorage.getItem(LOCAL_KEY)) return;
} catch { } catch {
// Storage unavailable — fall through and honour the server, which is the best guess left. // Storage unavailable. Honouring the account is the best guess left.
} }
const saved = settings.appearance?.colorMode; const saved = settings.appearance?.theme;
if (saved === 'light' || saved === 'dark') setColorMode(saved); if (isKnownTheme(saved)) {
}, [settings, setColorMode, themeId]); setTheme(saved);
return;
}
const migrated = themeFromLegacyColorMode(settings.appearance?.colorMode);
if (migrated) setTheme(migrated);
}, [settingsLoaded, settings, setTheme]);
// PERSIST — so the choice follows the account to another browser rather than living in one localStorage.
useEffect(() => {
if (!settingsLoaded || !hydrated.current) return;
if (settings.appearance?.theme === themeId) return;
saveSettings({ ...settings, appearance: { ...settings.appearance, theme: themeId } });
}, [themeId, settingsLoaded, settings, saveSettings]);
}; };
+31 -2
View File
@@ -11,7 +11,9 @@ const DEFAULT_SETTINGS = {
defaultPwd: '~', defaultPwd: '~',
}, },
appearance: { appearance: {
theme: 'light', // A theme id, not a colour mode. `'light'` sat here for a long time while the client read
// `appearance.colorMode`, so this default was never once read by anything.
theme: 'duck-light',
}, },
}; };
@@ -22,6 +24,33 @@ export const settingsRouter = createRouter();
settingsRouter.route('/', selfPermissionsRouter); settingsRouter.route('/', selfPermissionsRouter);
// GET /settings — return user settings from DB, default if empty // GET /settings — return user settings from DB, default if empty
/**
* An account from before themes existed, expressed in the vocabulary that replaced it.
*
* Themes superseded `appearance.colorMode`, and an existing account still has one. The migration has to
* happen HERE rather than in the client, and that is the whole point of this function: the client merges
* `DEFAULT_SETTINGS` over whatever arrives, so by the time it sees the data, a missing `theme` has already
* been filled in with the default — and a default is indistinguishable from a choice. Verified the wrong
* way round first: an account holding only `colorMode: 'dark'` came up light, because the client's own
* default `theme` masked it.
*
* The server sees the stored object before anything is merged over it, so it is the only place that can
* tell "never chose a theme" from "chose the default one".
*
* Read-only: nothing is written back here. A GET that rewrites the row on every call is a surprise, and
* the client persists the resolved theme on its next change anyway.
*/
function withThemeMigrated(settings: Record<string, unknown>): Record<string, unknown> {
const appearance = settings.appearance as { theme?: unknown; colorMode?: unknown } | undefined;
if (!appearance || typeof appearance.theme === 'string') return settings;
const migrated =
appearance.colorMode === 'dark' ? 'duck-dark' : appearance.colorMode === 'light' ? 'duck-light' : undefined;
if (!migrated) return settings;
return { ...settings, appearance: { ...appearance, theme: migrated } };
}
settingsRouter.get('/settings', async (ctx) => { settingsRouter.get('/settings', async (ctx) => {
const userId = ctx.get('user').id; const userId = ctx.get('user').id;
const settings = await getUserSettings(userId); const settings = await getUserSettings(userId);
@@ -31,7 +60,7 @@ settingsRouter.get('/settings', async (ctx) => {
return ctx.json(DEFAULT_SETTINGS); return ctx.json(DEFAULT_SETTINGS);
} }
return ctx.json(settings); return ctx.json(withThemeMigrated(settings as Record<string, unknown>));
}); });
// PUT /settings — full replacement // PUT /settings — full replacement
+22 -4
View File
@@ -23,7 +23,19 @@ export const useSettings = () => {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: settings = DEFAULT_SETTINGS } = useQuery<UserSettings>({ const {
data: settings = DEFAULT_SETTINGS,
/**
* Whether the ACCOUNT's settings have arrived, as opposed to the defaults standing in for them.
*
* `settings` is never null — it falls back to `DEFAULT_SETTINGS`, which is a complete and entirely
* valid object. So a consumer that wants to act once on the saved value cannot tell "not loaded yet"
* from "loaded, and it happens to equal the defaults". `useColorModeSync` could not, and applied the
* default theme on first render, marked itself done, and never saw the real one — then persisted that
* default over the account's actual preference.
*/
isSuccess: settingsLoaded,
} = useQuery<UserSettings>({
queryKey: QUERY_KEY, queryKey: QUERY_KEY,
enabled: isAuthenticated, enabled: isAuthenticated,
queryFn: async () => { queryFn: async () => {
@@ -41,7 +53,7 @@ export const useSettings = () => {
[client, queryClient], [client, queryClient],
); );
return { settings, saveSettings }; return { settings, settingsLoaded, saveSettings };
}; };
export type UseSettingsType = ReturnType<typeof useSettings>; export type UseSettingsType = ReturnType<typeof useSettings>;
@@ -66,7 +78,13 @@ export type UserSettings = {
defaultModel: string | null; defaultModel: string | null;
}; };
appearance: { appearance: {
colorMode: 'light' | 'dark'; /** The active theme's id — `duck-light`, `duck-dark`, `plain`, … */
theme: string;
/**
* The pre-themes preference, read once to migrate an existing account and never written again.
* Optional because a new account has only `theme`.
*/
colorMode?: 'light' | 'dark';
}; };
languages: { languages: {
spoken: string[]; spoken: string[];
@@ -99,7 +117,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
defaultModel: 'claude-code', defaultModel: 'claude-code',
}, },
appearance: { appearance: {
colorMode: 'light', theme: 'duck-light',
}, },
languages: { languages: {
spoken: ['en'], spoken: ['en'],