Files
platform/src/apps/officer-web/state/useThemeSync.ts
T
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

64 lines
2.8 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useTheme } from '@/components/ui/ThemeProvider';
import { themeFromLegacyColorMode, isKnownTheme } from 'theme';
import { useSettings } from 'state/useSettings';
/**
* Keep the account's theme and this browser's theme agreed, in both directions.
*
* ── Why it is two effects and not one ──
*
* They answer different questions. HYDRATE asks "this browser has never chosen — what does the account
* say?" and runs once. PERSIST asks "the person just chose — tell the account", and runs on every change.
* Collapsing them produces the bug this file used to have: the server's value re-applied on every settings
* fetch, so choosing Plain and reloading silently reverted it.
*
* A local choice wins on load because it is the more recent statement of intent AND it is already on the
* screen — re-applying the server's value would be a visible flip a second after the page settles.
*
* ── Migration ──
*
* 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_KEY = 'officer-theme';
export const useColorModeSync = () => {
const { settings, settingsLoaded, saveSettings } = useSettings();
const { themeId, setTheme } = useTheme();
const hydrated = useRef(false);
// HYDRATE — once, and only into a browser that has not chosen for itself.
useEffect(() => {
// `settingsLoaded`, not `settings` — see its note. `settings` is the DEFAULTS until the request
// 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;
try {
if (window.localStorage.getItem(LOCAL_KEY)) return;
} catch {
// Storage unavailable. Honouring the account is the best guess left.
}
const saved = settings.appearance?.theme;
if (isKnownTheme(saved)) {
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]);
};