diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 461b5779..57df0365 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,12 @@
import type { ReactNode } from 'react';
+import { useEffect, useRef } from 'react';
import { MotionConfig } from 'motion/react';
import { AuthProvider, useAuth } from './context/AuthContext';
import { useReducedMotion } from './hooks/use-theme';
+import { useUserPreferencesSync } from './hooks/useUserPreferencesSync';
+import { subscribeToUnsaved } from './lib/preferences/preferenceEvents';
+import { retryDomain } from './lib/preferences/syncBus';
+import { toast } from './components/ui/toast-store';
import { NodeProvider } from './context/NodeContext';
import { LicenseProvider } from './context/LicenseContext';
import { Login } from './components/Login';
@@ -14,6 +19,37 @@ import { ToastContainer } from './components/ui/toast';
import { Button } from './components/ui/button';
import { AlertCircle, RefreshCw } from 'lucide-react';
+/** Mounts the per-user preference sync owner. Lives OUTSIDE AppContent (a
+ * sibling of the toast layer) because AppContent unmounts on logout; the sync
+ * owner must survive auth transitions to finish or abandon in-flight work.
+ * Also hosts the unsaved-change toast: a failed write raises an episode and
+ * this renders ONE error toast per episode with a Retry action that re-runs
+ * the failed operation by kind. */
+function UserPreferencesSync() {
+ useUserPreferencesSync();
+ useUnsavedPreferenceToast();
+ return null;
+}
+
+/** Subscribes to the sync bus failure surface. A new episode number shows the
+ * toast; clearing the episode (success) is silent; a retry failure keeps the
+ * episode alive so the toast does not stack. */
+function useUnsavedPreferenceToast() {
+ const episodeRef = useRef(0);
+ useEffect(() => subscribeToUnsaved((episode) => {
+ if (episode === null) {
+ episodeRef.current = 0;
+ return;
+ }
+ if (episode.episode === episodeRef.current) return;
+ episodeRef.current = episode.episode;
+ const domain = episode.domain === 'appearance' ? 'appearance preferences' : 'navigation preferences';
+ toast.error(`Could not save your ${domain}. Your changes are kept on this device only.`, {
+ action: { label: 'Retry', onClick: () => retryDomain(episode.domain) },
+ });
+ }), []);
+}
+
/** Gates framer-motion animations on the "Reduced motion" appearance setting.
* 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS
* prefers-reduced-motion. Sonner toasts do not use framer-motion, so they are
@@ -84,6 +120,7 @@ function App() {
+
);
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
index 5630b907..f51b7b91 100644
--- a/frontend/src/context/AuthContext.tsx
+++ b/frontend/src/context/AuthContext.tsx
@@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, useCallback, useRef, ty
import { markMilestone } from '@/lib/hydrationTiming';
import { clearStackStatusesFetch } from '@/lib/stackStatusesFetch';
import { resolveCan } from '@/lib/resolveCan';
+import { bumpGeneration, resetPreferenceSync } from '@/lib/preferences/preferenceEvents';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated';
@@ -17,6 +18,10 @@ export type PermissionAction =
interface UserInfo {
username: string;
+ /** Numeric account id (stable across renames, changes when the account is
+ * deleted and recreated under the same name). Used by the preference sync
+ * layer to own cached per-user state. */
+ userId: number;
role: UserRole;
}
@@ -48,9 +53,26 @@ interface AuthContextType {
const AuthContext = createContext(undefined);
+/** localStorage signal key: login/logout writes it so other tabs re-run
+ * checkAuth (cookies are not visible across tabs via storage events). */
+const AUTH_SIGNAL_KEY = 'sencho.auth.signal';
+
+/** Called by login/logout paths to wake other tabs. */
+function signalAuthChanged(): void {
+ try {
+ localStorage.setItem(AUTH_SIGNAL_KEY, String(Date.now()));
+ } catch {
+ // ignore; cross-tab refresh is best-effort
+ }
+}
+
export function AuthProvider({ children }: { children: ReactNode }) {
const [appStatus, setAppStatus] = useState('loading');
const [user, setUser] = useState(null);
+ // The last resolved account id, so a re-check that lands on a different
+ // user (cross-tab login of another account, deleted-and-recreated account)
+ // is detectable as an identity transition.
+ const resolvedUserIdRef = useRef(null);
const [permissions, setPermissions] = useState(null);
const [permissionsStatus, setPermissionsStatus] = useState('loading');
const permissionRequestRef = useRef(0);
@@ -61,6 +83,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setPermissionsStatus('loading');
}, []);
+ // Identity transitions invalidate all async preference work captured under
+ // the previous identity AND clear the sync bus state (queued edits, failed
+ // operations, unsaved episodes): a stale Retry under a new account must
+ // never replay the previous account's writes.
+ const noteIdentityTransition = useCallback(() => {
+ bumpGeneration();
+ resetPreferenceSync();
+ }, []);
+
const loadPermissions = useCallback(async () => {
const requestId = ++permissionRequestRef.current;
setPermissions(null);
@@ -95,13 +126,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAppStatus('needsSetup');
setUser(null);
resetPermissions();
+ resolvedUserIdRef.current = null;
+ noteIdentityTransition();
return;
}
if (statusData.mfaPending) {
setUser(null);
resetPermissions();
+ resolvedUserIdRef.current = null;
setAppStatus('mfaChallenge');
+ noteIdentityTransition();
return;
}
@@ -110,16 +145,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await authResponse.json();
setUser(data.user ?? null);
setAppStatus('authenticated');
+ // A successful check that resolves to a DIFFERENT account than the
+ // last one (cross-tab login of another user, account swap) is an
+ // identity transition: stale preference work captured for the old
+ // account must be invalidated before the new account's state loads.
+ const nextUserId = typeof data.user?.userId === 'number' ? data.user.userId : null;
+ if (nextUserId !== resolvedUserIdRef.current) {
+ resolvedUserIdRef.current = nextUserId;
+ noteIdentityTransition();
+ }
await loadPermissions();
} else {
setUser(null);
resetPermissions();
+ resolvedUserIdRef.current = null;
setAppStatus('notAuthenticated');
+ noteIdentityTransition();
}
} catch {
setUser(null);
resetPermissions();
+ resolvedUserIdRef.current = null;
setAppStatus('notAuthenticated');
+ noteIdentityTransition();
}
};
@@ -136,12 +184,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearStackStatusesFetch();
setUser(null);
resetPermissions();
+ resolvedUserIdRef.current = null;
setAppStatus('notAuthenticated');
+ noteIdentityTransition();
};
window.addEventListener('sencho-unauthorized', handleUnauthorized);
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
}, []);
+ // Cross-tab identity changes: cookies are not storage events, so a second
+ // tab's login/logout writes a signal key and this tab re-runs checkAuth.
+ useEffect(() => {
+ function onStorage(event: StorageEvent) {
+ if (event.key !== AUTH_SIGNAL_KEY || event.newValue === null) return;
+ try {
+ localStorage.removeItem(AUTH_SIGNAL_KEY);
+ } catch {
+ // ignore; the signal still processed this tab
+ }
+ void checkAuth();
+ }
+ window.addEventListener('storage', onStorage);
+ return () => window.removeEventListener('storage', onStorage);
+ }, []);
+
const can = useCallback((
action: PermissionAction,
resourceType?: string,
@@ -166,6 +232,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await response.json();
if (response.ok && data.success) {
+ signalAuthChanged();
if (data.mfaRequired) {
await checkAuth();
return { success: true, mfaRequired: true };
@@ -193,6 +260,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await response.json();
if (response.ok && data.success) {
+ signalAuthChanged();
if (data.mfaRequired) {
await checkAuth();
return { success: true, mfaRequired: true };
@@ -240,7 +308,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearStackStatusesFetch();
setUser(null);
resetPermissions();
+ resolvedUserIdRef.current = null;
setAppStatus('notAuthenticated');
+ noteIdentityTransition();
}
};
@@ -256,7 +326,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearStackStatusesFetch();
setUser(null);
resetPermissions();
+ resolvedUserIdRef.current = null;
setAppStatus('notAuthenticated');
+ noteIdentityTransition();
+ signalAuthChanged();
}
};
diff --git a/frontend/src/hooks/use-density.ts b/frontend/src/hooks/use-density.ts
index 7b70ea30..b3228a3f 100644
--- a/frontend/src/hooks/use-density.ts
+++ b/frontend/src/hooks/use-density.ts
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
+import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
+import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
export type Density = 'comfortable' | 'compact';
const STORAGE_KEY = 'sencho.appearance.density';
const DEFAULT_DENSITY: Density = 'comfortable';
-function isDensity(value: unknown): value is Density {
+export function isDensity(value: unknown): value is Density {
return value === 'comfortable' || value === 'compact';
}
@@ -30,6 +32,26 @@ export function initializeDensity() {
applyDensityClass(readStoredDensity());
}
+/** Read the current density without subscribing (sync layer use). */
+export function currentDensityValue(): Density {
+ return readStoredDensity();
+}
+
+/** Apply a density through the same path a user commit uses (state for mounted
+ * instances arrives via the settings-changed event; DOM + localStorage are
+ * written here). Hydration-side writes do not notify the sync bus. */
+export function applyDensityValue(next: Density) {
+ applyDensityClass(next);
+ try {
+ if (window.localStorage.getItem(STORAGE_KEY) !== next) {
+ window.localStorage.setItem(STORAGE_KEY, next);
+ }
+ } catch {
+ // ignore; localStorage may be unavailable (private mode, quota)
+ }
+ window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+}
+
export function useDensity(): [Density, (next: Density) => void] {
const [density, setDensityState] = useState(readStoredDensity);
@@ -44,6 +66,14 @@ export function useDensity(): [Density, (next: Density) => void] {
}
}, [density]);
+ useEffect(() => {
+ function onSettingsChanged() {
+ setDensityState(readStoredDensity());
+ }
+ window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
+ return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
+ }, []);
+
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== STORAGE_KEY) return;
@@ -55,6 +85,7 @@ export function useDensity(): [Density, (next: Density) => void] {
const setDensity = useCallback((next: Density) => {
setDensityState(next);
+ notifyPreferenceWrite('appearance', ['density']);
}, []);
return [density, setDensity];
diff --git a/frontend/src/hooks/use-log-chip-color-mode.ts b/frontend/src/hooks/use-log-chip-color-mode.ts
index 1285cd63..e3228bf6 100644
--- a/frontend/src/hooks/use-log-chip-color-mode.ts
+++ b/frontend/src/hooks/use-log-chip-color-mode.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
+import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
export const LOG_CHIP_COLOR_KEY = 'sencho.log-chip-color-mode';
export type LogChipColorMode = 'unified' | 'per-service';
@@ -13,6 +14,26 @@ function readStored(): LogChipColorMode {
}
}
+export function isLogChipColorModeExport(v: unknown): v is LogChipColorMode {
+ return v === 'unified' || v === 'per-service';
+}
+
+/** Read the current mode without subscribing (sync layer use). */
+export function currentLogChipColorMode(): LogChipColorMode {
+ return readStored();
+}
+
+/** Apply a value through the same path a user commit uses. Hydration writes
+ * do not notify the sync bus. */
+export function applyLogChipColorMode(next: LogChipColorMode): void {
+ try {
+ window.localStorage.setItem(LOG_CHIP_COLOR_KEY, next);
+ } catch {
+ // ignore; localStorage may be unavailable (private mode, quota)
+ }
+ window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+}
+
export function useLogChipColorMode(): [LogChipColorMode, (next: LogChipColorMode) => void] {
const [mode, setModeState] = useState(readStored);
@@ -34,13 +55,9 @@ export function useLogChipColorMode(): [LogChipColorMode, (next: LogChipColorMod
}, []);
const setMode = useCallback((next: LogChipColorMode) => {
- try {
- window.localStorage.setItem(LOG_CHIP_COLOR_KEY, next);
- } catch {
- // ignore; localStorage may be unavailable (private mode, quota)
- }
+ applyLogChipColorMode(next);
setModeState(next);
- window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+ notifyPreferenceWrite('appearance', ['logChipColorMode']);
}, []);
return [mode, setMode];
diff --git a/frontend/src/hooks/use-theme.ts b/frontend/src/hooks/use-theme.ts
index 0156159f..0c87640b 100644
--- a/frontend/src/hooks/use-theme.ts
+++ b/frontend/src/hooks/use-theme.ts
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import { Moon, Zap, Sun, Monitor } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
+import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
// Shared theme store. Two live consumers (the topbar quick switch and the
// Settings → Appearance section) must reflect each other instantly, so the
@@ -176,6 +177,10 @@ function isChartStyle(v: unknown): v is ChartStyle {
function isBool(v: unknown): v is boolean {
return typeof v === 'boolean';
}
+// Exported guards: the preference documents layer reuses these to sanitize
+// server documents per-field (the sync layer must accept exactly what the
+// local read accepts).
+export { isMode, isAccent, isUiFont, isMonoFont, isVisualStyle, isHeadingStyle, isChartStyle, isBool };
// Numeric knobs: a persisted value must be finite and in range, otherwise fall
// back to the default (a NaN/Infinity/out-of-range value would silently no-op
// in CSS, which is harder to diagnose than a reset to default).
@@ -312,6 +317,19 @@ function setState(patch: Partial) {
emit();
}
+// ── preference sync exports ────────────────────────────────────────────────
+/** Read the current appearance state without subscribing (sync layer use). */
+export function currentThemeState(): ThemeState {
+ return persisted;
+}
+
+/** Apply a (partial) appearance state through the same internal write path a
+ * user commit uses: DOM, localStorage, and subscribers stay consistent. Used
+ * by hydration; hydration-side writes do not notify the sync bus. */
+export function applyThemeState(patch: Partial): void {
+ setState(patch);
+}
+
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
@@ -365,14 +383,18 @@ export function initializeTheme() {
export function useTheme() {
const s = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
- const setTheme = useCallback((theme: ThemeMode) => setState({ theme }), []);
- const setAccent = useCallback((accent: AccentId) => setState({ accent }), []);
- const setBorderBoost = useCallback((borderBoost: number) => setState({ borderBoost }), []);
- const setGlow = useCallback((glow: number) => setState({ glow }), []);
- const setContrast = useCallback((contrast: number) => setState({ contrast }), []);
- const setUiFont = useCallback((uiFont: UiFont) => setState({ uiFont }), []);
- const setMonoFont = useCallback((monoFont: MonoFont) => setState({ monoFont }), []);
- const setTypeScale = useCallback((typeScale: number) => setState({ typeScale }), []);
+ // User-facing setters write through the module store, then notify the sync
+ // bus with the fields they touched; hydration and cross-tab paths use
+ // applyThemeState / the storage listener instead, so they never queue a
+ // server write.
+ const setTheme = useCallback((theme: ThemeMode) => { setState({ theme }); notifyPreferenceWrite('appearance', ['theme']); }, []);
+ const setAccent = useCallback((accent: AccentId) => { setState({ accent }); notifyPreferenceWrite('appearance', ['accent']); }, []);
+ const setBorderBoost = useCallback((borderBoost: number) => { setState({ borderBoost }); notifyPreferenceWrite('appearance', ['borderBoost']); }, []);
+ const setGlow = useCallback((glow: number) => { setState({ glow }); notifyPreferenceWrite('appearance', ['glow']); }, []);
+ const setContrast = useCallback((contrast: number) => { setState({ contrast }); notifyPreferenceWrite('appearance', ['contrast']); }, []);
+ const setUiFont = useCallback((uiFont: UiFont) => { setState({ uiFont }); notifyPreferenceWrite('appearance', ['uiFont']); }, []);
+ const setMonoFont = useCallback((monoFont: MonoFont) => { setState({ monoFont }); notifyPreferenceWrite('appearance', ['monoFont']); }, []);
+ const setTypeScale = useCallback((typeScale: number) => { setState({ typeScale }); notifyPreferenceWrite('appearance', ['typeScale']); }, []);
// Macro: writes visualStyle + preset sub-axes including reducedMotion.
// Does NOT touch readability (sticky master the user releases by hand).
// Re-applying Signature clears Motion; re-applying Calm enables it.
@@ -385,12 +407,13 @@ export function useTheme() {
reducedEffects: preset.reducedEffects,
reducedMotion: preset.reducedMotion,
});
+ notifyPreferenceWrite('appearance', ['visualStyle', 'headingStyle', 'chartStyle', 'reducedEffects', 'reducedMotion']);
}, []);
- const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => setState({ headingStyle }), []);
- const setChartStyle = useCallback((chartStyle: ChartStyle) => setState({ chartStyle }), []);
- const setReducedEffects = useCallback((reducedEffects: boolean) => setState({ reducedEffects }), []);
- const setReducedMotion = useCallback((reducedMotion: boolean) => setState({ reducedMotion }), []);
- const setReadability = useCallback((readability: boolean) => setState({ readability }), []);
+ const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => { setState({ headingStyle }); notifyPreferenceWrite('appearance', ['headingStyle']); }, []);
+ const setChartStyle = useCallback((chartStyle: ChartStyle) => { setState({ chartStyle }); notifyPreferenceWrite('appearance', ['chartStyle']); }, []);
+ const setReducedEffects = useCallback((reducedEffects: boolean) => { setState({ reducedEffects }); notifyPreferenceWrite('appearance', ['reducedEffects']); }, []);
+ const setReducedMotion = useCallback((reducedMotion: boolean) => { setState({ reducedMotion }); notifyPreferenceWrite('appearance', ['reducedMotion']); }, []);
+ const setReadability = useCallback((readability: boolean) => { setState({ readability }); notifyPreferenceWrite('appearance', ['readability']); }, []);
const resolvedTheme = resolveWith(s.theme, s.systemDark);
return {
theme: s.theme,
diff --git a/frontend/src/hooks/use-top-nav-align.ts b/frontend/src/hooks/use-top-nav-align.ts
index e4936d4c..268eca42 100644
--- a/frontend/src/hooks/use-top-nav-align.ts
+++ b/frontend/src/hooks/use-top-nav-align.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
+import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
export const TOP_NAV_ALIGN_KEY = 'sencho.appearance.topNavAlign';
@@ -18,6 +19,26 @@ function readStored(): TopNavAlign {
}
}
+export function isTopNavAlignExport(v: unknown): v is TopNavAlign {
+ return v === 'left' || v === 'center';
+}
+
+/** Read the current align without subscribing (sync layer use). */
+export function currentTopNavAlign(): TopNavAlign {
+ return readStored();
+}
+
+/** Apply an align value through the same path a user commit uses. Hydration
+ * writes do not notify the sync bus. */
+export function applyTopNavAlign(next: TopNavAlign): void {
+ try {
+ window.localStorage.setItem(TOP_NAV_ALIGN_KEY, next);
+ } catch {
+ // ignore; localStorage may be unavailable (private mode, quota)
+ }
+ window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+}
+
export function useTopNavAlign(): [TopNavAlign, (next: TopNavAlign) => void] {
const [align, setAlignState] = useState(readStored);
@@ -39,13 +60,9 @@ export function useTopNavAlign(): [TopNavAlign, (next: TopNavAlign) => void] {
}, []);
const setAlign = useCallback((next: TopNavAlign) => {
- try {
- window.localStorage.setItem(TOP_NAV_ALIGN_KEY, next);
- } catch {
- // ignore; localStorage may be unavailable (private mode, quota)
- }
+ applyTopNavAlign(next);
setAlignState(next);
- window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+ notifyPreferenceWrite('navigation', ['align']);
}, []);
return [align, setAlign];
diff --git a/frontend/src/hooks/use-top-nav-labels.ts b/frontend/src/hooks/use-top-nav-labels.ts
index a3b1851c..ede53f49 100644
--- a/frontend/src/hooks/use-top-nav-labels.ts
+++ b/frontend/src/hooks/use-top-nav-labels.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
+import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
export const TOP_NAV_LABELS_KEY = 'sencho.appearance.topNavLabels';
@@ -15,6 +16,22 @@ function readStored(): boolean {
}
}
+/** Read the current labels flag without subscribing (sync layer use). */
+export function currentTopNavLabels(): boolean {
+ return readStored();
+}
+
+/** Apply a labels value through the same path a user commit uses. Hydration
+ * writes do not notify the sync bus. */
+export function applyTopNavLabels(next: boolean): void {
+ try {
+ window.localStorage.setItem(TOP_NAV_LABELS_KEY, next ? 'true' : 'false');
+ } catch {
+ // ignore; localStorage may be unavailable (private mode, quota)
+ }
+ window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+}
+
export function useTopNavLabels(): [boolean, (next: boolean) => void] {
const [showLabels, setShowLabelsState] = useState(readStored);
@@ -38,13 +55,9 @@ export function useTopNavLabels(): [boolean, (next: boolean) => void] {
}, []);
const setShowLabels = useCallback((next: boolean) => {
- try {
- window.localStorage.setItem(TOP_NAV_LABELS_KEY, next ? 'true' : 'false');
- } catch {
- // ignore; localStorage may be unavailable (private mode, quota)
- }
+ applyTopNavLabels(next);
setShowLabelsState(next);
- window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+ notifyPreferenceWrite('navigation', ['labels']);
}, []);
return [showLabels, setShowLabels];
diff --git a/frontend/src/hooks/use-top-nav-mode.ts b/frontend/src/hooks/use-top-nav-mode.ts
index 7b0008ce..7043992b 100644
--- a/frontend/src/hooks/use-top-nav-mode.ts
+++ b/frontend/src/hooks/use-top-nav-mode.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
+import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
export const TOP_NAV_MODE_KEY = 'sencho.appearance.topNavMode';
@@ -24,6 +25,22 @@ function readStored(): TopNavMode {
}
}
+/** Read the current nav mode without subscribing (sync layer use). */
+export function currentTopNavMode(): TopNavMode {
+ return readStored();
+}
+
+/** Apply a nav mode through the same path a user commit uses. Hydration-side
+ * writes do not notify the sync bus. */
+export function applyTopNavMode(next: TopNavMode): void {
+ try {
+ window.localStorage.setItem(TOP_NAV_MODE_KEY, next);
+ } catch {
+ // ignore; localStorage may be unavailable
+ }
+ window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+}
+
export function useTopNavMode(): [TopNavMode, (next: TopNavMode) => void] {
const [mode, setModeState] = useState(readStored);
@@ -45,13 +62,9 @@ export function useTopNavMode(): [TopNavMode, (next: TopNavMode) => void] {
}, []);
const setMode = useCallback((next: TopNavMode) => {
- try {
- window.localStorage.setItem(TOP_NAV_MODE_KEY, next);
- } catch {
- // ignore; localStorage may be unavailable
- }
+ applyTopNavMode(next);
setModeState(next);
- window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+ notifyPreferenceWrite('navigation', ['mode']);
}, []);
return [mode, setMode];
diff --git a/frontend/src/hooks/use-top-nav-quick-links.ts b/frontend/src/hooks/use-top-nav-quick-links.ts
index 63f0e3dc..f70d1eeb 100644
--- a/frontend/src/hooks/use-top-nav-quick-links.ts
+++ b/frontend/src/hooks/use-top-nav-quick-links.ts
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { isQuickLinkEligibleId } from '@/lib/navigation/appNavRegistry';
import type { ActiveView } from '@/lib/router/routeTypes';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
+import { notifyPreferenceWrite } from '@/lib/preferences/preferenceEvents';
+import { readQuickLinksProvenance, type QuickLinksProvenance } from '@/lib/preferences/quickLinksProvenance';
export const TOP_NAV_QUICK_LINKS_KEY = 'sencho.appearance.topNavQuickLinks';
export const MAX_QUICK_LINKS = 8;
@@ -29,6 +31,36 @@ type StoredQuickLinksState =
| { status: 'valid'; ids: ActiveView[] }
| { status: 'unset' }; // covers missing key, malformed JSON, and non-array JSON alike
+/** Current pin provenance without subscribing (sync layer use): 'unset' means
+ * never persisted; 'valid' includes a deliberately saved empty list. */
+export function currentQuickLinksProvenance(): QuickLinksProvenance {
+ return readQuickLinksProvenance();
+}
+
+/** Current pin list without subscribing (sync layer use). */
+export function currentQuickLinks(): ActiveView[] {
+ return sanitizeQuickLinkIds(readQuickLinksRaw());
+}
+
+function readQuickLinksRaw(): unknown {
+ if (typeof window === 'undefined') return [];
+ try {
+ const raw = window.localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY);
+ if (raw === null) return [];
+ return JSON.parse(raw);
+ } catch {
+ return [];
+ }
+}
+
+/** Apply a pin list through the same path a user commit uses. Hydration-side
+ * writes do not notify the sync bus (the caller decides). */
+export function applyQuickLinks(ids: ActiveView[]): void {
+ const sanitized = sanitizeQuickLinkIds(ids);
+ writeStored(sanitized);
+ window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
+}
+
function writeStored(ids: ActiveView[]): boolean {
try {
window.localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(ids));
@@ -135,6 +167,14 @@ export function useTopNavQuickLinks(defaultEligibleIds?: readonly ActiveView[] |
}
}, [applyState]);
+ // The user-facing setters mark the operation as a write (queued to the sync
+ // bus). The eligibility seed effect above intentionally does NOT: derived
+ // state is never uploaded as a user write.
+ const userCommit = useCallback((next: ActiveView[]) => {
+ commit(next);
+ notifyPreferenceWrite('navigation', ['quickLinks']);
+ }, [commit]);
+
// Seed defaults once eligibility is settled (even a confirmed-empty list: defaultEligibleIds
// is only ever non-null once proven, never merely "not yet failed") and no valid preference has
// ever been saved. Fires at most once in practice: the moment it commits, storage holds a valid
@@ -149,24 +189,26 @@ export function useTopNavQuickLinks(defaultEligibleIds?: readonly ActiveView[] |
const resetQuickLinks = useCallback(() => {
if (defaultEligibleIds == null) return; // guarded in the UI too; defense in depth
- commit([...defaultEligibleIds]);
- }, [commit, defaultEligibleIds]);
+ userCommit([...defaultEligibleIds]);
+ }, [userCommit, defaultEligibleIds]);
const addQuickLink = useCallback((value: ActiveView) => {
if (!isQuickLinkEligibleId(value)) return;
const prevIds = idsOf(stateRef.current);
if (prevIds.includes(value) || prevIds.length >= MAX_QUICK_LINKS) return;
- commit([...prevIds, value]);
- }, [commit]);
+ userCommit([...prevIds, value]);
+ }, [userCommit]);
const removeQuickLink = useCallback((value: ActiveView) => {
- commit(idsOf(stateRef.current).filter((id) => id !== value));
- }, [commit]);
+ userCommit(idsOf(stateRef.current).filter((id) => id !== value));
+ }, [userCommit]);
+
+ const setPersistedIds = userCommit;
return {
persistedIds: idsOf(state),
canReset: defaultEligibleIds != null,
- setPersistedIds: commit,
+ setPersistedIds,
addQuickLink,
removeQuickLink,
resetQuickLinks,
diff --git a/frontend/src/hooks/useUserPreferencesSync.ts b/frontend/src/hooks/useUserPreferencesSync.ts
new file mode 100644
index 00000000..d42db07a
--- /dev/null
+++ b/frontend/src/hooks/useUserPreferencesSync.ts
@@ -0,0 +1,502 @@
+/**
+ * The sync owner for per-user preference documents. Mounted outside the
+ * authenticated app tree (a sibling of AppContent in App.tsx) so it survives
+ * auth transitions; it derives its own lifecycle from useAuth().
+ *
+ * Responsibilities:
+ * - Cache ownership: the localStorage preference cache is keyed to the account
+ * (marker sencho.preferences.owner). A different account wipes the cache to
+ * defaults before hydration, so no account ever renders another's values.
+ * - Hydration: on authentication, GET both domains and hydrate through the
+ * hooks' apply paths: live doc → per-field sanitize + apply; tombstone →
+ * defaults via the apply paths; absent → migrate via the bus; corrupt →
+ * defaults + conditional repair PUT.
+ * - Reconciliation: when a GET response arrives after local edits, the
+ * server wins untouched fields; dirty (user-edited-since-hydration) fields
+ * are re-applied on top and written back conditionally. A 409 CONFLICT
+ * reconciles against `current`; a second consecutive conflict surfaces the
+ * error state instead of looping. A failed write keeps the dirty fields and
+ * raises the unsaved episode, so the merge is retried rather than lost.
+ * - Remote-reset precedence: a tombstone observed at a newer revision than
+ * a pending edit's baseline wins; the stale edit is discarded and the reset
+ * adopted. Only edits made after adopting the tombstone may un-tombstone.
+ * - Identity: async work captures the identity generation; results apply only
+ * if it is unchanged. Identity transitions mask the readiness publication
+ * (it stays stored for teardown scoping but reads as null) and reset the
+ * queue.
+ */
+
+import { useEffect, useRef } from 'react';
+import { useAuth } from '@/context/AuthContext';
+import { apiFetch } from '@/lib/api';
+import {
+ bumpGeneration,
+ currentGeneration,
+ setUnsavedEpisode,
+ subscribeToGenerations,
+ subscribeToPreferenceWrites,
+ type PreferenceDomain,
+} from '@/lib/preferences/preferenceEvents';
+import {
+ adoptKnownRevision,
+ discardQueuedEdit,
+ documentForDomain,
+ flushPendingWrites,
+ queueMigrate,
+ setCurrentSyncUser,
+ setHydratingDomains,
+ setReconcileHook,
+} from '@/lib/preferences/syncBus';
+import {
+ PREFERENCES_OWNER_KEY,
+ buildAppearanceDocument,
+ buildNavigationDocument,
+ clearPreferenceCache,
+ defaultAppearanceDocument,
+ hydrateAppearanceDocument,
+ hydrateNavigationDefaults,
+ hydrateNavigationDocument,
+} from '@/lib/preferences/preferencesDocuments';
+
+interface OwnerMarker {
+ userId: number;
+ schema: 1;
+}
+
+const DOMAINS: PreferenceDomain[] = ['appearance', 'navigation'];
+
+function isRecord(v: unknown): v is Record {
+ return !!v && typeof v === 'object' && !Array.isArray(v);
+}
+
+export function useUserPreferencesSync(): void {
+ const { user, appStatus } = useAuth();
+ const userId = appStatus === 'authenticated' ? user?.userId ?? null : null;
+
+ // Per-domain sync state (refs: the sync owner renders rarely and never
+ // derives render output from these).
+ const dirtyRef = useRef>>({
+ appearance: new Set(), navigation: new Set(),
+ });
+ const conflictCountRef = useRef>({
+ appearance: 0, navigation: 0,
+ });
+
+ // Clear per-domain sync state on every generation bump (identity
+ // transition): the next account's hydration starts from a clean slate.
+ useEffect(() => {
+ return subscribeToGenerations(() => {
+ for (const domain of DOMAINS) {
+ dirtyRef.current[domain] = new Set();
+ conflictCountRef.current[domain] = 0;
+ }
+ });
+ }, []);
+
+ // Dirty-field tracking: a user setter notification marks the fields the
+ // write touched as locally edited (hydration writes never notify; a reset
+ // notification arrives without a field list and marks the whole domain). The
+ // merge rule stays server-wins-untouched: on a late GET, only these fields
+ // are re-applied on top of the server document.
+ useEffect(() => {
+ return subscribeToPreferenceWrites((domain, fields) => {
+ const dirty = dirtyRef.current[domain];
+ for (const field of fields) dirty.add(field);
+ });
+ }, []);
+
+ // Cache ownership + hydration effect, keyed on the resolved identity.
+ useEffect(() => {
+ if (appStatus === 'loading') return; // boot in flight: touch nothing
+
+ if (userId === null) {
+ // Resolved unauthenticated (logout / 401 / boot failure): the cache may
+ // hold the previous account's values; drop it so a later login never
+ // renders them. (Do NOT clear when merely loading.)
+ setCurrentSyncUser(null);
+ return;
+ }
+
+ // Claim or verify cache ownership.
+ let marker: OwnerMarker | null = null;
+ try {
+ const raw = localStorage.getItem(PREFERENCES_OWNER_KEY);
+ // The marker is our own write and only selects between "same owner" and
+ // "wipe"; a malformed value behaves like a missing one.
+ if (raw) {
+ const parsed: unknown = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object'
+ && typeof (parsed as { userId?: unknown }).userId === 'number') {
+ marker = parsed as OwnerMarker;
+ }
+ }
+ } catch {
+ marker = null;
+ }
+ if (!marker || marker.userId !== userId) {
+ clearPreferenceCache();
+ try {
+ localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId, schema: 1 } satisfies OwnerMarker));
+ } catch {
+ // ignore; private mode
+ }
+ }
+
+ setCurrentSyncUser(userId);
+ void hydrateAll(userId);
+ // Re-hydrate only when the numeric identity changes; the generation guard
+ // on in-flight work means an unrelated re-render never refetches.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [userId, appStatus]);
+
+ // Flush queued writes when the tab is hidden or closing.
+ useEffect(() => {
+ const onHide = () => {
+ if (document.visibilityState === 'hidden') flushPendingWrites();
+ };
+ document.addEventListener('visibilitychange', onHide);
+ window.addEventListener('pagehide', onHide);
+ return () => {
+ document.removeEventListener('visibilitychange', onHide);
+ window.removeEventListener('pagehide', onHide);
+ };
+ }, []);
+
+ // ── hydration + reconciliation ───────────────────────────────────────────
+ async function hydrateAll(userId: number): Promise {
+ const generation = currentGeneration();
+ setHydratingDomains(new Set(DOMAINS));
+ try {
+ const response = await apiFetch('/user-preferences', {
+ method: 'GET',
+ localOnly: true,
+ headers: { 'x-sencho-pref-user': String(userId) },
+ });
+ if (generation !== currentGeneration()) return;
+ if (!response.ok) {
+ console.error(`[preferences] hydration request failed with status ${response.status}`);
+ raiseUnsavedForDomains(DOMAINS);
+ return;
+ }
+ const payload = (await response.json()) as { preferences?: Record };
+ if (generation !== currentGeneration()) return;
+ // A malformed body is a failed hydration, not an empty one: treating it
+ // as `{}` would migrate defaults over rows the server does hold.
+ if (!isRecord(payload) || !isRecord(payload.preferences)) {
+ console.error('[preferences] hydration response was malformed; skipping reconciliation');
+ raiseUnsavedForDomains(DOMAINS);
+ return;
+ }
+ const preferences = payload.preferences;
+ // Per-domain isolation: a failure reconciling one domain must not skip
+ // the other domain's reconciliation.
+ for (const domain of DOMAINS) {
+ try {
+ await reconcileDomain(domain, preferences[domain] ?? null, userId, generation);
+ } catch (error) {
+ console.error(`[preferences] hydration reconcile for ${domain} failed:`, error);
+ reMarkDirtyAndSurface(domain, null);
+ }
+ }
+ } catch (error) {
+ console.error('[preferences] hydration failed:', error);
+ raiseUnsavedForDomains(DOMAINS);
+ } finally {
+ setHydratingDomains(new Set());
+ }
+ }
+
+ /** Reconcile one domain against an observed server row (GET response, or
+ * the `current` envelope of a 409). Behavior by row kind:
+ * - absent: migrate (create-if-absent).
+ * - live doc: dirty fields re-applied on top of the server doc; the merged
+ * result is written back conditionally. No dirty fields: hydrate the
+ * server doc as-is.
+ * - tombstone: if pending edits are based on an older revision, they
+ * are DISCARDED and the reset adopted (defaults). Only edits made after
+ * adopting the tombstone survive.
+ * - corrupt: adopt defaults + enqueue a conditional repair PUT.
+ */
+ async function reconcileDomain(
+ domain: PreferenceDomain,
+ row: unknown,
+ userId: number,
+ generation: number,
+ ): Promise {
+ if (generation !== currentGeneration()) return;
+
+ if (row === null || row === undefined) {
+ // Absent: migrate local state (create-if-absent). Navigation defers
+ // until eligibility settles or the hook holds a valid document (the
+ // pump gate in the bus decides which).
+ queueMigrate(domain);
+ return;
+ }
+
+ if (!isRecord(row)) {
+ console.error(`[preferences] ${domain} row had an unexpected shape; skipping reconciliation`);
+ return;
+ }
+ const revision = typeof row.revision === 'number' && Number.isSafeInteger(row.revision) && row.revision >= 1
+ ? row.revision
+ : null;
+ if (revision === null) {
+ console.error(`[preferences] ${domain} row had no valid revision; skipping reconciliation`);
+ return;
+ }
+ adoptKnownRevision(domain, revision);
+
+ const schemaVersion = row.schemaVersion;
+ const corrupt = row.corrupt === true;
+
+ if (schemaVersion === 0 || corrupt) {
+ // Tombstone or corrupt row: adopt defaults locally and clear the cache
+ // keys for this domain so a reload paints defaults, not stale values.
+ // Any queued PUT based on an older revision is discarded first: the
+ // remote reset outranks a stale local edit, and the parked operation
+ // must never send the pre-reset values back to the server.
+ discardQueuedEdit(domain);
+ adoptDefaults(domain);
+ if (schemaVersion !== 0) {
+ // Corrupt row: enqueue a conditional repair PUT (tombstones are a
+ // deliberate reset; corrupt rows are damage to repair).
+ await repairCorruptRow(domain, revision, userId, generation);
+ }
+ return;
+ }
+
+ // Live doc: merge rule depends on pending dirty fields. The local
+ // document is captured BEFORE the server doc is applied, so the dirty
+ // values survive hydration; the merged result is then applied once.
+ // A clean load (no dirty fields) whose merged document equals the server
+ // document is a pure hydration: no write back, the server already holds
+ // exactly this state and a PUT would only burn a revision.
+ const serverDoc = row.data;
+ if (!isRecord(serverDoc)) return;
+ const dirty = dirtyRef.current[domain];
+ const dirtySnapshot = dirty.size === 0 ? null : new Set(dirty);
+ if (domain === 'appearance') {
+ const localDoc: Record = { ...buildAppearanceDocument() };
+ const merged = mergedDocument(serverDoc, localDoc, dirtySnapshot);
+ hydrateServerDocument(domain, merged);
+ dirtyRef.current[domain] = new Set();
+ if (dirtySnapshot === null && shallowEqual(merged, serverDoc)) return;
+ await conditionalPut(domain, merged, revision, userId, generation, dirtySnapshot);
+ return;
+ }
+ // Navigation: an unset provenance (never persisted) has no local edits to
+ // merge; treat as clean hydration of the server document.
+ const navDoc = buildNavigationDocument();
+ if (navDoc.status === 'valid') {
+ const validDoc = { mode: navDoc.mode, quickLinks: navDoc.quickLinks, labels: navDoc.labels, align: navDoc.align };
+ const merged = mergedDocument(serverDoc, validDoc, dirtySnapshot);
+ hydrateServerDocument(domain, merged);
+ dirtyRef.current[domain] = new Set();
+ if (dirtySnapshot === null && shallowEqual(merged, serverDoc)) return;
+ await conditionalPut(domain, merged, revision, userId, generation, dirtySnapshot);
+ return;
+ }
+ hydrateServerDocument(domain, serverDoc);
+ dirtyRef.current[domain] = new Set();
+ }
+
+ /** Field-wise equality for flat preference documents. Only safe because the
+ * clean path compares a shallow copy of the server document against itself,
+ * so array fields share references; do not reuse for independently parsed
+ * documents. */
+ function shallowEqual(a: Record, b: Record): boolean {
+ const aKeys = Object.keys(a);
+ if (aKeys.length !== Object.keys(b).length) return false;
+ return aKeys.every((key) => a[key] === b[key]);
+ }
+
+ /** Server-wins merge: untouched fields keep the server values, dirty fields
+ * re-apply the captured local values. A null dirty set is a clean
+ * hydration (the server doc as-is). */
+ function mergedDocument(
+ serverDoc: Record,
+ localDoc: Record,
+ dirtySnapshot: Set | null,
+ ): Record {
+ if (dirtySnapshot === null) return { ...serverDoc };
+ const merged: Record = { ...serverDoc };
+ for (const field of dirtySnapshot) {
+ if (field in localDoc) merged[field] = localDoc[field];
+ }
+ return merged;
+ }
+
+ /** A write outcome is unknown-but-pending until the PUT settles: if it
+ * fails before the server acknowledges the merge, the dirty fields stay
+ * dirty (a later reconciliation must re-apply them) and the failure
+ * surfaces as an unsaved episode instead of vanishing. */
+ function raiseUnsavedForDomains(domainsToMark: readonly PreferenceDomain[]): void {
+ for (const domain of domainsToMark) {
+ if (dirtyRef.current[domain].size > 0) setUnsavedEpisode(domain);
+ }
+ }
+
+ async function conditionalPut(
+ domain: PreferenceDomain,
+ document: Record,
+ expectedRevision: number,
+ userId: number,
+ generation: number,
+ dirtySnapshot: Set | null = null,
+ ): Promise {
+ const response = await apiFetch(`/user-preferences/${domain}`, {
+ method: 'PUT',
+ localOnly: true,
+ headers: { 'x-sencho-pref-user': String(userId) },
+ body: JSON.stringify({ expectedRevision, ...document }),
+ });
+ if (generation !== currentGeneration()) return;
+ if (response.ok) {
+ conflictCountRef.current[domain] = 0;
+ return;
+ }
+ if (response.status === 409) {
+ conflictCountRef.current[domain] += 1;
+ if (conflictCountRef.current[domain] >= 2) {
+ // Two consecutive conflicts: stop and surface; Retry restarts from a
+ // fresh GET rather than looping.
+ conflictCountRef.current[domain] = 0;
+ reMarkDirtyAndSurface(domain, dirtySnapshot);
+ return;
+ }
+ const parse = async (): Promise<{ current?: unknown } | null> => {
+ try {
+ return (await response.json()) as { current?: unknown };
+ } catch {
+ return null;
+ }
+ };
+ const payload = await parse();
+ if (payload !== null && isRecord(payload.current)) {
+ // The PUT failed, so the merged write never persisted: restore the
+ // dirty fields before re-reconciling against `current`. Without them
+ // the re-entry sees a clean document and silently hydrates the
+ // server state, losing the user's edit. No episode is raised here:
+ // either the re-PUT converges, or its own failure path surfaces.
+ restoreDirtyFields(domain, dirtySnapshot);
+ await reconcileDomain(domain, payload.current, userId, currentGeneration());
+ return;
+ }
+ reMarkDirtyAndSurface(domain, dirtySnapshot);
+ return;
+ }
+ console.error(`[preferences] conditional write for ${domain} failed with status ${response.status}`);
+ reMarkDirtyAndSurface(domain, dirtySnapshot);
+ }
+
+ /** Restore dirty state and raise the unsaved episode after a failed write,
+ * so the user's edits are retried on the next reconciliation instead of
+ * being silently dropped. */
+ function reMarkDirtyAndSurface(domain: PreferenceDomain, dirtySnapshot: Set | null): void {
+ restoreDirtyFields(domain, dirtySnapshot);
+ if (dirtyRef.current[domain].size > 0) setUnsavedEpisode(domain);
+ }
+
+ /** Re-mark fields as locally edited without surfacing a failure. */
+ function restoreDirtyFields(domain: PreferenceDomain, dirtySnapshot: Set | null): void {
+ if (dirtySnapshot !== null) {
+ for (const field of dirtySnapshot) dirtyRef.current[domain].add(field);
+ }
+ }
+
+ async function repairCorruptRow(
+ domain: PreferenceDomain,
+ revision: number,
+ userId: number,
+ generation: number,
+ ): Promise {
+ const doc = documentForDomain(domain);
+ if (doc === null) {
+ // Nothing local to repair with yet (navigation unsettled): leave the
+ // corrupt row; the next edit will conditionally overwrite it.
+ return;
+ }
+ await conditionalPut(domain, doc, revision, userId, generation);
+ }
+
+ function adoptDefaults(domain: PreferenceDomain): void {
+ setHydratingDomains(new Set(DOMAINS));
+ try {
+ if (domain === 'appearance') {
+ hydrateAppearanceDocument(defaultAppearanceDocument());
+ } else {
+ hydrateNavigationDefaults();
+ }
+ } finally {
+ setHydratingDomains(new Set());
+ }
+ }
+
+ function hydrateServerDocument(domain: PreferenceDomain, doc: Record): void {
+ setHydratingDomains(new Set(DOMAINS));
+ try {
+ if (domain === 'appearance') {
+ hydrateAppearanceDocument(doc);
+ } else {
+ hydrateNavigationDocument(doc);
+ }
+ } finally {
+ setHydratingDomains(new Set());
+ }
+ }
+
+ // The account the owner is currently synced for, read by the reconcile hook
+ // (declared before the effect that installs the hook so the closure sees a
+ // stable ref).
+ const currentUserIdRef = useRef(null);
+ useEffect(() => {
+ currentUserIdRef.current = userId;
+ }, [userId]);
+
+ // Conflict hook: the bus hands reconciliation to us (server base + dirty
+ // fields re-applied) instead of retrying blindly. reconcileDomain reads
+ // refs only, so a stable empty dependency list is correct here.
+ useEffect(() => {
+ setReconcileHook((domain) => {
+ const userIdNow = currentUserIdRef.current;
+ if (userIdNow === null) return;
+ void (async () => {
+ try {
+ const response = await apiFetch('/user-preferences', {
+ method: 'GET',
+ localOnly: true,
+ headers: { 'x-sencho-pref-user': String(userIdNow) },
+ });
+ if (!response.ok) {
+ console.error(`[preferences] reconcile request failed with status ${response.status}`);
+ // Dirty fields stay dirty; surface the episode so the failure is
+ // visible and Retry re-enters reconciliation (retryDomain with
+ // no parked operation delegates back to this hook).
+ reMarkDirtyAndSurface(domain, null);
+ return;
+ }
+ const payload = (await response.json()) as { preferences?: Record };
+ const row = isRecord(payload.preferences) ? payload.preferences[domain] : null;
+ await reconcileDomain(domain, row ?? null, userIdNow, currentGeneration());
+ } catch (error) {
+ console.error('[preferences] reconcile failed:', error);
+ reMarkDirtyAndSurface(domain, null);
+ }
+ })();
+ });
+ return () => setReconcileHook(null);
+ // reconcileDomain closes over refs only; the hook is installed once.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Identity transition: bump the generation so in-flight preference work is
+ // invalidated (the AuthContext already bumps; this covers direct logouts).
+ useEffect(() => {
+ if (appStatus === 'loading') return;
+ if (userId === null) {
+ bumpGeneration();
+ clearPreferenceCache();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [appStatus]);
+}
diff --git a/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts b/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts
new file mode 100644
index 00000000..73c63eae
--- /dev/null
+++ b/frontend/src/lib/preferences/__tests__/preferencesSync.test.ts
@@ -0,0 +1,478 @@
+/**
+ * Unit tests for the preference sync layer, driven at the transport boundary
+ * (apiFetch mocked) so assertions cover final local state and wire behavior.
+ *
+ * Covered scenarios:
+ * - Field-level merge: a delayed GET after a user edit merges dirty fields
+ * onto the server document (server wins untouched fields).
+ * - Local chronology: reset cancels pre-reset PUTs; a post-reset edit
+ * survives as a conditional PUT on the tombstone revision.
+ * - Remote-reset precedence: a pending edit based on an older revision is
+ * discarded when the GET reveals a newer tombstone.
+ * - Eligibility ownership: a stale producer's publication is rejected and
+ * its teardown cannot erase a newer publication.
+ * - Migration deferral: navigation migrate waits for settled eligibility, and
+ * the wire document carries only the four server-known fields.
+ * - Retries: a failed reset retries as DELETE (kind-preserving).
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const apiFetch = vi.fn();
+vi.mock('@/lib/api', () => ({
+ apiFetch: (path: string, opts?: unknown) => apiFetch(path, opts),
+}));
+
+import {
+ DOMAIN_FIELDS,
+ bumpGeneration,
+ clearEligibility,
+ currentGeneration,
+ getSettledEligibility,
+ notifyPreferenceWrite,
+ resetPreferenceSync,
+ setEligibilitySettled,
+ subscribeToPreferenceWrites,
+ type EligibilityOwnership,
+} from '../preferenceEvents';
+import {
+ adoptKnownRevision,
+ flushPendingWrites,
+ inspectQueue,
+ queueMigrate,
+ queueReset,
+ setCurrentSyncUser,
+ setHydratingDomains,
+ setReconcileHook,
+} from '../syncBus';
+import {
+ PREFERENCES_OWNER_KEY,
+ clearPreferenceCache,
+ hydrateAppearanceDocument,
+ hydrateNavigationDocument,
+} from '../preferencesDocuments';
+
+interface MockResponse {
+ ok: boolean;
+ status: number;
+ headers: { get: (name: string) => string | null };
+ json: () => Promise;
+ clone: () => MockResponse;
+}
+
+function jsonResponse(status: number, body: unknown): MockResponse {
+ const response: MockResponse = {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: () => null },
+ json: async () => body,
+ clone: () => response,
+ };
+ return response;
+}
+
+const APPEARANCE_DOC = {
+ theme: 'dim', accent: 'cyan', uiFont: 'Geist', monoFont: 'Geist Mono',
+ visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
+ density: 'comfortable', logChipColorMode: 'unified',
+ borderBoost: 0, glow: 0.16, contrast: 0, typeScale: 1,
+ reducedEffects: true, reducedMotion: true, readability: false,
+};
+
+const NAVIGATION_DOC = {
+ mode: 'compact', quickLinks: ['dashboard', 'fleet'], labels: true, align: 'left',
+};
+
+/** Capture every apiFetch call as { method, path, body }. */
+interface RecordedCall {
+ method: string;
+ path: string;
+ body: Record | undefined;
+}
+
+function recordedCalls(): RecordedCall[] {
+ return apiFetch.mock.calls.map(([path, opts]) => ({
+ method: (opts as RequestInit | undefined)?.method ?? 'GET',
+ path: path as string,
+ body: JSON.parse(((opts as RequestInit | undefined)?.body as string | undefined) ?? 'null'),
+ }));
+}
+
+describe('preference sync layer', () => {
+ beforeEach(() => {
+ apiFetch.mockReset();
+ localStorage.clear();
+ setReconcileHook(null);
+ setHydratingDomains(new Set());
+ setCurrentSyncUser(7);
+ resetPreferenceSync();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('a field-less notification marks the whole domain, a field list marks only those fields', () => {
+ const seen: Array<{ domain: string; fields: readonly string[] }> = [];
+ const stop = subscribeToPreferenceWrites((domain, fields) => seen.push({ domain, fields: [...fields] }));
+ notifyPreferenceWrite('appearance', ['theme']);
+ notifyPreferenceWrite('navigation', ['quickLinks']);
+ notifyPreferenceWrite('appearance'); // reset-style: whole domain
+ stop();
+ expect(seen).toEqual([
+ { domain: 'appearance', fields: ['theme'] },
+ { domain: 'navigation', fields: ['quickLinks'] },
+ { domain: 'appearance', fields: [...DOMAIN_FIELDS.appearance] },
+ ]);
+ });
+
+ it('a delayed GET after a density edit keeps the server theme and the local density', () => {
+ // Server state: OLED theme, comfortable density. Local edit: density only.
+ const serverDoc = { ...APPEARANCE_DOC, theme: 'oled', density: 'comfortable' };
+
+ // Hydration writes must not enqueue (bus guard), so simulate exactly the
+ // sync owner's sequence: adopt revision, hydrate the server doc under the
+ // hydrating guard, then a user density edit marks only that field dirty.
+ adoptKnownRevision('appearance', 4);
+ setHydratingDomains(new Set(['appearance']));
+ try {
+ hydrateAppearanceDocument(serverDoc);
+ } finally {
+ setHydratingDomains(new Set());
+ }
+ const cachedTheme = JSON.parse(localStorage.getItem('sencho.appearance.theme') as string);
+ expect(cachedTheme.theme).toBe('oled');
+
+ // The user changes density locally (no server round trip yet).
+ localStorage.setItem('sencho.appearance.density', 'compact');
+ notifyPreferenceWrite('appearance', ['density']);
+
+ // The sync owner's merge rule: server wins untouched fields, the dirty
+ // field re-applies on top. The theme key must still read oled.
+ expect(JSON.parse(localStorage.getItem('sencho.appearance.theme') as string)).toMatchObject({ theme: 'oled' });
+ expect(localStorage.getItem('sencho.appearance.density')).toBe('compact');
+ });
+
+ it('a queued reset cancels a pre-reset PUT; a post-reset edit survives as a conditional PUT on the tombstone revision', async () => {
+ // The reset's DELETE goes out as soon as queueReset runs (the pump starts
+ // inside enqueue), so its mock must be installed first. The DELETE
+ // succeeds against the adopted revision 2 and returns the tombstone's
+ // resulting revision 3.
+ apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
+ if (opts?.method === 'DELETE') {
+ expect(JSON.parse(opts.body as string)).toEqual({ expectedRevision: 2 });
+ return jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 3, updatedAt: 1 });
+ }
+ return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 3, updatedAt: 1, data: {} });
+ });
+
+ // The user edits density (PUT queued, debounced), then a revision is
+ // adopted from a GET, then the reset lands before the debounce fires.
+ notifyPreferenceWrite('appearance', ['density']);
+ adoptKnownRevision('appearance', 2);
+ queueReset('appearance');
+ expect(inspectQueue('appearance')).toMatchObject({ kind: 'reset', failed: null });
+
+ await vi.waitFor(() => {
+ expect(recordedCalls().some((c) => c.method === 'DELETE')).toBe(true);
+ });
+ // The reset runs exactly once: a post-reset edit must never re-execute
+ // the reset (a duplicate DELETE would 409 on its stale precondition).
+ expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(1);
+ // Let the DELETE's success bookkeeping (revision adoption) settle before
+ // the next edit, so the PUT targets the tombstone's revision.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ // An edit enqueued after the settled reset survives: a conditional PUT on
+ // the tombstone's resulting revision (3). The PUT document is rebuilt at
+ // send time from live state, so drive a real user setter.
+ const { applyThemeState } = await import('@/hooks/use-theme');
+ setHydratingDomains(new Set(['appearance']));
+ try {
+ applyThemeState({ theme: 'dim' });
+ } finally {
+ setHydratingDomains(new Set());
+ }
+ notifyPreferenceWrite('appearance', ['theme']);
+ await vi.waitFor(() => {
+ expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true);
+ });
+ const putCall = recordedCalls().find((c) => c.method === 'PUT');
+ expect(putCall?.body).toMatchObject({ expectedRevision: 3, theme: 'dim' });
+ });
+
+ it('an edit landing while a reset is in flight is staged behind it and PUTs conditionally on the tombstone revision', async () => {
+ // Hold the DELETE in flight so the edit arrives while the reset still
+ // owns the queue slot: the edit must be staged, not cancelled (it is a
+ // post-reset edit) and not sent (the tombstone does not exist yet).
+ let releaseDelete!: (r: MockResponse) => void;
+ const pendingDelete = new Promise((resolve) => { releaseDelete = resolve; });
+ apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
+ if (opts?.method === 'DELETE') return pendingDelete;
+ if (opts?.method === 'PUT') {
+ return jsonResponse(200, { domain: 'appearance', schemaVersion: 1, revision: 4, updatedAt: 1 });
+ }
+ // The staged PUT's baseline GET: the tombstone (revision 3) is the row.
+ return jsonResponse(200, {
+ preferences: { appearance: { schemaVersion: 0, revision: 3, updatedAt: 1 } },
+ });
+ });
+
+ adoptKnownRevision('appearance', 2);
+ queueReset('appearance');
+ // The edit lands while the DELETE is unresolved: staged behind the reset.
+ notifyPreferenceWrite('appearance', ['theme']);
+ expect(inspectQueue('appearance').kind).toBe('reset');
+ expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(false);
+
+ // The reset settles: exactly one DELETE, then the staged PUT runs.
+ releaseDelete(jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 3, updatedAt: 1 }));
+ await vi.waitFor(() => {
+ expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true);
+ });
+ expect(recordedCalls().filter((c) => c.method === 'DELETE')).toHaveLength(1);
+ const putCall = recordedCalls().find((c) => c.method === 'PUT');
+ // Conditional on the tombstone's resulting revision (3), never the
+ // pre-reset baseline (2): a stale precondition would 409 on the server.
+ expect(putCall?.body).toMatchObject({ expectedRevision: 3 });
+ expect(inspectQueue('appearance')).toEqual({ kind: null, failed: null, settling: false });
+ });
+
+ it('a failed reset retries as DELETE (kind-preserving), never as a PUT', async () => {
+ apiFetch.mockImplementation(async () => jsonResponse(503, { error: 'unavailable' }));
+ queueReset('appearance');
+ await flushPendingWrites();
+ await vi.waitFor(() => expect(inspectQueue('appearance').failed).toBe('reset'));
+
+ // Retry re-runs the failed operation by kind.
+ const { retryDomain } = await import('../syncBus');
+ apiFetch.mockImplementation(async () => jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 2, updatedAt: 1 }));
+ retryDomain('appearance');
+ await vi.waitFor(() => {
+ expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2);
+ });
+ });
+
+ it('a reset that 409s re-runs the DELETE once against the adopted revision instead of reverting to the server doc', async () => {
+ // First DELETE carries the stale baseline and 409s with the server's
+ // newer tombstone; the conflict re-queue must send a second DELETE
+ // conditional on the adopted revision, NOT hand the reset to
+ // reconciliation (which would re-hydrate the pre-reset document).
+ const conflictEnvelope = { schemaVersion: 0, revision: 5, updatedAt: 1 };
+ apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
+ if (opts?.method === 'DELETE') {
+ const body = JSON.parse(opts.body as string) as { expectedRevision?: number };
+ if (body.expectedRevision === 2) {
+ return jsonResponse(409, { error: 'CONFLICT', current: conflictEnvelope });
+ }
+ return jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 6, updatedAt: 1 });
+ }
+ return jsonResponse(200, { preferences: { appearance: conflictEnvelope } });
+ });
+
+ adoptKnownRevision('appearance', 2);
+ queueReset('appearance');
+ await vi.waitFor(() => {
+ expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2);
+ });
+ const secondDelete = recordedCalls().filter((c) => c.method === 'DELETE')[1];
+ expect(secondDelete.body).toEqual({ expectedRevision: 5 });
+ // The conflicted reset neither failed nor left a queue entry behind.
+ expect(inspectQueue('appearance')).toEqual({ kind: null, failed: null, settling: false });
+ });
+
+ it('a reset that 409s twice surfaces a failure instead of looping', async () => {
+ const conflictEnvelope = { schemaVersion: 0, revision: 5, updatedAt: 1 };
+ apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
+ if (opts?.method === 'DELETE') {
+ return jsonResponse(409, { error: 'CONFLICT', current: { ...conflictEnvelope, revision: conflictEnvelope.revision + 1 } });
+ }
+ return jsonResponse(200, { preferences: { appearance: conflictEnvelope } });
+ });
+
+ adoptKnownRevision('appearance', 2);
+ queueReset('appearance');
+ await vi.waitFor(() => {
+ expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2);
+ });
+ // Exactly two attempts: the second conflict is a failure the toast owns.
+ expect(recordedCalls().filter((c) => c.method === 'DELETE').length).toBe(2);
+ expect(inspectQueue('appearance').failed).toBe('reset');
+ });
+
+ it('a lost migration race against a tombstone winner hands the tombstone to reconciliation', async () => {
+ const reconciled: string[] = [];
+ setReconcileHook((domain) => reconciled.push(domain));
+ apiFetch.mockImplementation(async (path: string) => {
+ if (String(path).endsWith('/navigation/migrate')) {
+ // Another browser reset the domain first: migrate loses, the winner
+ // is the tombstone (schemaVersion 0).
+ return jsonResponse(200, { migrated: false, row: { schemaVersion: 0, revision: 4, updatedAt: 1 } });
+ }
+ return jsonResponse(200, { preferences: {} });
+ });
+
+ // Persist a pin list and settle eligibility: migration defers until the
+ // navigation document is valid, so the test must satisfy the same
+ // precondition the real app does (the seed effect writes the list first).
+ localStorage.setItem('sencho.appearance.topNavQuickLinks', JSON.stringify(['dashboard', 'fleet']));
+ setEligibilitySettled({ userId: 7, generation: currentGeneration() }, ['dashboard', 'fleet']);
+ queueMigrate('navigation');
+ await vi.waitFor(() => {
+ expect(reconciled).toContain('navigation');
+ });
+ // Clear the publication so later suites start from the parked state.
+ clearEligibility({ userId: 7, generation: currentGeneration() });
+ });
+
+ it('migration is deferred until navigation eligibility settles, then carries only server-known fields', async () => {
+ // No settled eligibility and no valid pin list: queueMigrate must not
+ // dispatch a request (the parked op waits for a settled publication).
+ setCurrentSyncUser(7);
+ localStorage.removeItem('sencho.appearance.topNavQuickLinks');
+ queueMigrate('navigation');
+ await flushPendingWrites();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(apiFetch).not.toHaveBeenCalled();
+ expect(inspectQueue('navigation').kind).toBe('migrate');
+
+ // Settle eligibility with a non-empty set, and persist a pin list so the
+ // document builder reports 'valid' provenance (never-seeded browsers defer
+ // indefinitely; the seed effect in the real app writes the list first).
+ apiFetch.mockImplementation(async (path: string) => {
+ if (String(path).endsWith('/navigation/migrate')) {
+ return jsonResponse(201, { migrated: true, row: { data: NAVIGATION_DOC, schemaVersion: 1, revision: 1, updatedAt: 1 } });
+ }
+ return jsonResponse(200, { preferences: {} });
+ });
+ localStorage.setItem('sencho.appearance.topNavQuickLinks', JSON.stringify(['dashboard', 'fleet']));
+ setEligibilitySettled({ userId: 7, generation: currentGeneration() }, ['dashboard', 'fleet']);
+ await flushPendingWrites();
+ await vi.waitFor(() => {
+ expect(recordedCalls().some((c) => c.path.endsWith('/navigation/migrate'))).toBe(true);
+ });
+ const migrateCall = recordedCalls().find((c) => c.path.endsWith('/navigation/migrate'));
+ expect(migrateCall?.body).toEqual({
+ mode: expect.any(String),
+ quickLinks: ['dashboard', 'fleet'],
+ labels: expect.any(Boolean),
+ align: expect.any(String),
+ });
+ expect(Object.keys(migrateCall?.body ?? {})).not.toContain('status');
+ });
+
+ it('an eligibility publication from a stale identity is rejected and its teardown is a no-op', () => {
+ // Start from the current identity so ownershipA is current at publish.
+ // The bus's synced account is 7 (the beforeEach), so account 1 must claim
+ // it before its publication is accepted.
+ setCurrentSyncUser(1);
+ const generation0 = currentGeneration();
+ const ownershipA: EligibilityOwnership = { userId: 1, generation: generation0 };
+ setEligibilitySettled(ownershipA, ['dashboard']);
+ expect(getSettledEligibility()?.eligibleIds).toEqual(['dashboard']);
+
+ // Identity transition: generation bumps. The old producer publishes late:
+ // the store keeps the stale row for teardown scoping but it reads as null,
+ // and the stale publish must not resurrect or replace anything.
+ bumpGeneration();
+ setEligibilitySettled(ownershipA, ['fleet']);
+ expect(getSettledEligibility()).toBeNull();
+
+ // The new account's producer publishes under the current identity.
+ setCurrentSyncUser(2);
+ const ownershipB: EligibilityOwnership = { userId: 2, generation: currentGeneration() };
+ setEligibilitySettled(ownershipB, ['dashboard', 'fleet']);
+ expect(getSettledEligibility()?.eligibleIds).toEqual(['dashboard', 'fleet']);
+
+ // The old producer's teardown (same ownership object) must not erase B's.
+ clearEligibility(ownershipA);
+ expect(getSettledEligibility()?.eligibleIds).toEqual(['dashboard', 'fleet']);
+ clearEligibility(ownershipB);
+ expect(getSettledEligibility()).toBeNull();
+ });
+
+ it('adoptKnownRevision feeds the next conditional write: a queued reset DELETE carries the adopted revision', async () => {
+ // A pending edit based on revision 2; the server tombstone is at 3. The
+ // DELETE must carry expectedRevision 3 (the adopted observable revision),
+ // not 2 (the pre-adoption baseline).
+ apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
+ if (opts?.method === 'DELETE') {
+ return jsonResponse(200, { domain: 'appearance', schemaVersion: 0, revision: 4, updatedAt: 1 });
+ }
+ return jsonResponse(200, { preferences: {} });
+ });
+ adoptKnownRevision('appearance', 2);
+ adoptKnownRevision('appearance', 3);
+ queueReset('appearance');
+ expect(inspectQueue('appearance').kind).toBe('reset');
+ await flushPendingWrites();
+ await vi.waitFor(() => {
+ expect(recordedCalls().some((c) => c.method === 'DELETE')).toBe(true);
+ });
+ const del = recordedCalls().find((c) => c.method === 'DELETE');
+ expect(del?.body).toEqual({ expectedRevision: 3 });
+ });
+
+ it('classic normalization: a legacy navigation document hydrates to Compact and never writes classic', () => {
+ hydrateNavigationDocument({ ...NAVIGATION_DOC, mode: 'classic' });
+ expect(localStorage.getItem('sencho.appearance.topNavMode')).toBe('compact');
+ });
+
+ it('cache ownership: the marker contract keys the cache to a numeric userId', () => {
+ localStorage.setItem('sencho.appearance.theme', JSON.stringify({ theme: 'oled' }));
+ localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId: 1, schema: 1 }));
+
+ // A different account claims the browser: the sync owner wipes the cache
+ // keys before hydration (clearPreferenceCache is the wipe primitive).
+ clearPreferenceCache();
+ expect(localStorage.getItem('sencho.appearance.theme')).toBeNull();
+ expect(localStorage.getItem(PREFERENCES_OWNER_KEY)).toBeNull();
+
+ // The claim writes a fresh marker for the new owner.
+ localStorage.setItem(PREFERENCES_OWNER_KEY, JSON.stringify({ userId: 7, schema: 1 }));
+ expect(JSON.parse(localStorage.getItem(PREFERENCES_OWNER_KEY) as string)).toEqual({ userId: 7, schema: 1 });
+ });
+
+ it('corrupt-row reconciliation adopts defaults and repairs conditionally on the corrupt revision', async () => {
+ // The sync owner sequence for a corrupt row: adopt the corrupt revision,
+ // hydrate defaults under the guard, then a conditional repair PUT.
+ adoptKnownRevision('appearance', 7);
+ setHydratingDomains(new Set(['appearance']));
+ try {
+ hydrateAppearanceDocument({ ...APPEARANCE_DOC });
+ } finally {
+ setHydratingDomains(new Set());
+ }
+ notifyPreferenceWrite('appearance');
+ await flushPendingWrites();
+ await vi.waitFor(() => expect(apiFetch).toHaveBeenCalled());
+ const put = recordedCalls()[0];
+ expect(put.method).toBe('PUT');
+ expect(put.path).toBe('/user-preferences/appearance');
+ expect(put.body).toMatchObject({ expectedRevision: 7, theme: 'dim' });
+ });
+
+ it('the pump drops a queued operation whose captured identity no longer matches', async () => {
+ // An operation captured under account 9 while the bus is synced to 9; the
+ // identity then changes (account switch, before any reset hook runs). The
+ // defense-in-depth guard must drop the queued operation without sending:
+ // the server guard is authoritative, but a stale client write never
+ // leaves the tab.
+ setCurrentSyncUser(9);
+ notifyPreferenceWrite('appearance', ['theme']);
+ setCurrentSyncUser(3);
+ await flushPendingWrites();
+ expect(recordedCalls().filter((c) => c.method === 'PUT')).toHaveLength(0);
+ expect(inspectQueue('appearance').kind).toBeNull();
+ });
+
+ it('the pump drops a queued operation whose generation is stale', async () => {
+ // The edit is enqueued under generation G; a logout-style generation bump
+ // (with the queue deliberately NOT reset) must stop the write from
+ // firing on the next pump.
+ notifyPreferenceWrite('appearance', ['theme']);
+ bumpGeneration();
+ await flushPendingWrites();
+ expect(recordedCalls().filter((c) => c.method === 'PUT')).toHaveLength(0);
+ expect(inspectQueue('appearance').kind).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/preferences/preferenceEvents.ts b/frontend/src/lib/preferences/preferenceEvents.ts
new file mode 100644
index 00000000..34ee10d5
--- /dev/null
+++ b/frontend/src/lib/preferences/preferenceEvents.ts
@@ -0,0 +1,212 @@
+/**
+ * Preference event bus. The React-free leaf of the preference sync layer:
+ * hooks, documents, and the sync bus all import this module, and it imports
+ * nothing from them (module graph stays a DAG).
+ *
+ * Hosts five concerns:
+ * 1. Write notification: user-facing setters call notifyPreferenceWrite() so
+ * the sync owner can queue a server write. Hydration-side writes deliberately
+ * do NOT notify (derived state, not user intent).
+ * 2. Identity generations: a monotonic counter bumped on every auth identity
+ * transition. Async preference work captures a generation and its result
+ * applies only if the generation is unchanged.
+ * 3. Eligibility readiness: a module-level store for the settled quick-link
+ * default eligibility, published by useViewNavigationState (which cannot be
+ * read from the always-mounted sync owner). Publications are bound to the
+ * ownership of the authorization snapshot that produced them so a delayed
+ * publish from a superseded producer cannot appear current.
+ * 4. The unsaved-episode failure surface consumed by the toast and the reset
+ * settle logic.
+ * 5. The queue-reset hook the sync bus registers at import time (identity
+ * transitions clear its queued/failed state).
+ */
+
+export type PreferenceDomain = 'appearance' | 'navigation';
+
+/** The dirty-field names a domain document carries. Setters attribute their
+ * writes to specific fields so a late GET merges server-wins for untouched
+ * fields (a stale cached theme must never overwrite the server's theme). */
+export type PreferenceField =
+ | 'theme' | 'accent' | 'uiFont' | 'monoFont' | 'visualStyle' | 'headingStyle'
+ | 'chartStyle' | 'density' | 'logChipColorMode' | 'borderBoost' | 'glow'
+ | 'contrast' | 'typeScale' | 'reducedEffects' | 'reducedMotion' | 'readability'
+ | 'mode' | 'quickLinks' | 'labels' | 'align';
+
+export const DOMAIN_FIELDS: Record = {
+ appearance: ['theme', 'accent', 'uiFont', 'monoFont', 'visualStyle', 'headingStyle',
+ 'chartStyle', 'density', 'logChipColorMode', 'borderBoost', 'glow', 'contrast',
+ 'typeScale', 'reducedEffects', 'reducedMotion', 'readability'],
+ navigation: ['mode', 'quickLinks', 'labels', 'align'],
+};
+
+// ── write notification ─────────────────────────────────────────────────────
+const writeListeners = new Set<(domain: PreferenceDomain, fields: readonly PreferenceField[]) => void>();
+
+/** Called by user-facing setters after a local write. `fields` names the
+ * document fields the write touched, so a late GET can merge server-wins for
+ * untouched fields (a stale cached theme must never overwrite the server's
+ * theme). Omitting `fields` marks the whole domain (a complete reset). Never
+ * call from hydration/seed paths: those are derived state, not user intent. */
+export function notifyPreferenceWrite(domain: PreferenceDomain, fields?: readonly PreferenceField[]): void {
+ const touched = fields ?? DOMAIN_FIELDS[domain];
+ for (const listener of writeListeners) listener(domain, touched);
+}
+
+export function subscribeToPreferenceWrites(
+ listener: (domain: PreferenceDomain, fields: readonly PreferenceField[]) => void,
+): () => void {
+ writeListeners.add(listener);
+ return () => {
+ writeListeners.delete(listener);
+ };
+}
+
+// ── identity generations ───────────────────────────────────────────────────
+let generation = 0;
+const generationListeners = new Set<() => void>();
+
+/** Monotonic identity generation. Captured by async work; results apply only
+ * when currentGeneration() still equals the captured value. */
+export function currentGeneration(): number {
+ return generation;
+}
+
+/** Bump on every identity transition (login, logout, account switch, 401). */
+export function bumpGeneration(): void {
+ generation += 1;
+ for (const listener of generationListeners) listener();
+}
+
+export function subscribeToGenerations(listener: () => void): () => void {
+ generationListeners.add(listener);
+ return () => {
+ generationListeners.delete(listener);
+ };
+}
+
+/** Clear every queued/failed state in the sync bus for the old identity. */
+export function resetPreferenceSync(): void {
+ resetQueuedOperations();
+ resetUnsaved();
+}
+
+// ── eligibility readiness (ownership-captured) ─────────────────────────────
+/** Ownership of an authorization snapshot: the account and identity generation
+ * under which a producer computed its value. Publications carry it so a
+ * superseded producer cannot publish into the new account. */
+export interface EligibilityOwnership {
+ userId: number | null;
+ generation: number;
+}
+
+export interface SettledEligibility {
+ ownership: EligibilityOwnership;
+ eligibleIds: readonly ActiveView[] | null;
+}
+
+let settledEligibility: SettledEligibility | null = null;
+
+function isCurrentOwnership(ownership: EligibilityOwnership): boolean {
+ if (generation !== ownership.generation) return false;
+ const userId = currentUserIdReader ? currentUserIdReader() : null;
+ return userId === ownership.userId;
+}
+
+import type { ActiveView } from '@/lib/router/routeTypes';
+
+/**
+ * Publish settled quick-link eligibility. Rejected unless the ownership still
+ * matches the current generation: a delayed publication from an old producer
+ * (account switched, logout, identity bump) must never appear current.
+ * Ownership is captured by the producer from its own authorization snapshot,
+ * never read from current state here. `null` means eligibility did not settle
+ * (permissions still loading); a settled list is `readonly ActiveView[]`.
+ */
+export function setEligibilitySettled(ownership: EligibilityOwnership, eligibleIds: readonly ActiveView[] | null): void {
+ if (!isCurrentOwnership(ownership)) return;
+ settledEligibility = { ownership, eligibleIds: eligibleIds === null ? null : [...eligibleIds] };
+}
+
+/** Clear the publication only if it still belongs to the given ownership, so
+ * an obsolete producer's teardown cannot erase a newer account's publication. */
+export function clearEligibility(ownership: EligibilityOwnership): void {
+ if (settledEligibility && isSameOwnership(settledEligibility.ownership, ownership)) {
+ settledEligibility = null;
+ }
+}
+
+/** The settled publication, visible only while its ownership is still current.
+ * A superseded account's publication stays stored (teardown scoping needs it)
+ * but reads as null, so no consumer can act on the previous account's
+ * eligibility after an identity transition. */
+export function getSettledEligibility(): SettledEligibility | null {
+ if (!settledEligibility || !isCurrentOwnership(settledEligibility.ownership)) return null;
+ return settledEligibility;
+}
+
+function isSameOwnership(a: EligibilityOwnership, b: EligibilityOwnership): boolean {
+ return a.userId === b.userId && a.generation === b.generation;
+}
+
+// ── sync bus failure surface ───────────────────────────────────────────────
+export interface UnsavedEpisode {
+ domain: PreferenceDomain;
+ episode: number;
+}
+
+const unsavedListeners = new Set<(episode: UnsavedEpisode | null) => void>();
+let unsaved: UnsavedEpisode | null = null;
+let episodeCounter = 0;
+
+/** The current unsaved episode, or null when everything is persisted. */
+export function getUnsavedEpisode(): UnsavedEpisode | null {
+ return unsaved;
+}
+
+export function subscribeToUnsaved(listener: (episode: UnsavedEpisode | null) => void): () => void {
+ unsavedListeners.add(listener);
+ return () => {
+ unsavedListeners.delete(listener);
+ };
+}
+
+function emitUnsaved(): void {
+ for (const listener of unsavedListeners) listener(unsaved);
+}
+
+function resetUnsaved(): void {
+ unsaved = null;
+ emitUnsaved();
+}
+
+// Bus-internal mutators. Kept here (not exported from syncBus) so the leaf
+// module stays dependency-free while the bus can still drive the shared state.
+export function setUnsavedEpisode(domain: PreferenceDomain): number {
+ episodeCounter += 1;
+ unsaved = { domain, episode: episodeCounter };
+ emitUnsaved();
+ return episodeCounter;
+}
+
+export function clearUnsavedEpisode(episode: number): void {
+ if (unsaved && unsaved.episode === episode) {
+ unsaved = null;
+ emitUnsaved();
+ }
+}
+
+// ── queued-operation reset hook (set by syncBus at import time) ────────────
+let resetQueuedOperations: () => void = () => {};
+
+export function registerQueueReset(reset: () => void): void {
+ resetQueuedOperations = reset;
+}
+
+/** The bus-installed reader of the currently synced account. syncBus sets it
+ * at import time so the ownership check can compare accounts without a
+ * circular import. */
+let currentUserIdReader: (() => number | null) | null = null;
+
+export function registerCurrentUserIdReader(reader: () => number | null): void {
+ currentUserIdReader = reader;
+}
diff --git a/frontend/src/lib/preferences/preferencesDocuments.ts b/frontend/src/lib/preferences/preferencesDocuments.ts
new file mode 100644
index 00000000..b187b32a
--- /dev/null
+++ b/frontend/src/lib/preferences/preferencesDocuments.ts
@@ -0,0 +1,256 @@
+/**
+ * Preference documents: the server-side shape of the two preference domains.
+ *
+ * The sync layer serializes local state into these documents for migration and
+ * writes, and hydrates server rows back into the local hooks. Sanitization is
+ * per-field against the live registries (the hooks' own guards), so a future
+ * enum removal degrades to the default value instead of corrupting state.
+ */
+
+import {
+ CALM_PRESET,
+ CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE,
+ currentThemeState, applyThemeState,
+ isMode, isAccent, isUiFont, isMonoFont,
+ isVisualStyle, isHeadingStyle, isChartStyle, isBool,
+ type ThemeState,
+} from '@/hooks/use-theme';
+import { currentDensityValue, applyDensityValue, isDensity, type Density } from '@/hooks/use-density';
+import {
+ TOP_NAV_MODE_KEY, parseTopNavMode, currentTopNavMode, applyTopNavMode, type TopNavMode,
+} from '@/hooks/use-top-nav-mode';
+import {
+ TOP_NAV_QUICK_LINKS_KEY, sanitizeQuickLinkIds,
+ currentQuickLinks, applyQuickLinks, currentQuickLinksProvenance,
+} from '@/hooks/use-top-nav-quick-links';
+import { TOP_NAV_LABELS_KEY, currentTopNavLabels, applyTopNavLabels } from '@/hooks/use-top-nav-labels';
+import { TOP_NAV_ALIGN_KEY, currentTopNavAlign, applyTopNavAlign, isTopNavAlignExport, type TopNavAlign } from '@/hooks/use-top-nav-align';
+import { currentLogChipColorMode, applyLogChipColorMode, isLogChipColorModeExport, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
+import { recommendedQuickLinkIds } from '@/lib/navigation/appNavRegistry';
+
+export interface AppearanceDocument {
+ theme: ThemeState['theme'];
+ accent: ThemeState['accent'];
+ uiFont: ThemeState['uiFont'];
+ monoFont: ThemeState['monoFont'];
+ visualStyle: ThemeState['visualStyle'];
+ headingStyle: ThemeState['headingStyle'];
+ chartStyle: ThemeState['chartStyle'];
+ density: Density;
+ logChipColorMode: LogChipColorMode;
+ borderBoost: number;
+ glow: number;
+ contrast: number;
+ typeScale: number;
+ reducedEffects: boolean;
+ reducedMotion: boolean;
+ readability: boolean;
+}
+
+/** What the raw theme cache key holds: everything except the two fields
+ * (`density`, `logChipColorMode`) stored under their own keys. */
+export type ThemeCacheDocument = Omit;
+
+/** Navigation document with unset provenance for the pin list. `unset` means
+ * the hook has never persisted a list (never-seeded or eligibility still
+ * settling); `valid` includes a deliberately empty list ([]). */
+export type NavigationDocument =
+ | { status: 'unset' }
+ | {
+ status: 'valid';
+ mode: TopNavMode;
+ quickLinks: string[];
+ labels: boolean;
+ align: TopNavAlign;
+ };
+
+function clampNumber(value: unknown, bounds: { min: number; max: number; default: number }): number {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return bounds.default;
+ return Math.min(bounds.max, Math.max(bounds.min, value));
+}
+
+/** Serialize the live appearance state (theme + density + log chips) into the
+ * server document shape. */
+export function buildAppearanceDocument(): AppearanceDocument {
+ const t = currentThemeState();
+ return {
+ theme: t.theme,
+ accent: t.accent,
+ uiFont: t.uiFont,
+ monoFont: t.monoFont,
+ visualStyle: t.visualStyle,
+ headingStyle: t.headingStyle,
+ chartStyle: t.chartStyle,
+ density: currentDensityValue(),
+ logChipColorMode: currentLogChipColorMode(),
+ borderBoost: t.borderBoost,
+ glow: t.glow,
+ contrast: t.contrast,
+ typeScale: t.typeScale,
+ reducedEffects: t.reducedEffects,
+ reducedMotion: t.reducedMotion,
+ readability: t.readability,
+ };
+}
+
+/**
+ * Serialize the live navigation state. Carries unset provenance: a hook that
+ * has never persisted a list produces `{ status: 'unset' }` and the sync layer
+ * defers migration until eligibility has settled (never saves a derived `[]`).
+ * Legacy 'classic' is normalized to Compact here by parseTopNavMode before any
+ * write, so the server never receives the retired value.
+ */
+export function buildNavigationDocument(): NavigationDocument {
+ const provenance = currentQuickLinksProvenance();
+ if (provenance.status === 'unset') return { status: 'unset' };
+ return {
+ status: 'valid',
+ mode: currentTopNavMode(),
+ quickLinks: currentQuickLinks(),
+ labels: currentTopNavLabels(),
+ align: currentTopNavAlign(),
+ };
+}
+
+// ── hydration (server document → local hooks + cache) ──────────────────────
+
+/** Per-field sanitize of an untrusted appearance document and apply through
+ * the hooks' internal write path (DOM + localStorage + subscribers). Invalid
+ * scalars keep the current local value; out-of-range numerics fall back to
+ * their registry default; a document missing the visual-style axes (older
+ * writer) fills them from Signature exactly like the local storage read does. */
+export function hydrateAppearanceDocument(raw: unknown): void {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
+ const p = raw as Record;
+ const current = currentThemeState();
+ // Visual-style axes: a document missing them (older writer) is a returning
+ // user, so fill from Signature exactly like the local storage read does.
+ const visualFallback = {
+ visualStyle: isVisualStyle(p.visualStyle) ? p.visualStyle : 'signature' as const,
+ headingStyle: isHeadingStyle(p.headingStyle) ? p.headingStyle : 'signature' as const,
+ chartStyle: isChartStyle(p.chartStyle) ? p.chartStyle : 'signature' as const,
+ reducedEffects: isBool(p.reducedEffects) ? p.reducedEffects : true,
+ reducedMotion: isBool(p.reducedMotion) ? p.reducedMotion : true,
+ readability: isBool(p.readability) ? p.readability : false,
+ };
+ applyThemeState({
+ theme: isMode(p.theme) ? p.theme : current.theme,
+ accent: isAccent(p.accent) ? p.accent : current.accent,
+ uiFont: isUiFont(p.uiFont) ? p.uiFont : current.uiFont,
+ monoFont: isMonoFont(p.monoFont) ? p.monoFont : current.monoFont,
+ borderBoost: clampNumber(p.borderBoost, BORDER_BOOST),
+ glow: clampNumber(p.glow, GLOW),
+ contrast: clampNumber(p.contrast, CONTRAST),
+ typeScale: clampNumber(p.typeScale, TYPE_SCALE),
+ ...visualFallback,
+ });
+ applyDensityValue(isDensity(p.density) ? p.density : 'comfortable');
+ applyLogChipColorMode(isLogChipColorModeExport(p.logChipColorMode) ? p.logChipColorMode : 'unified');
+}
+
+/** The documented calm-default appearance document, used for tombstone
+ * hydration (the server says "reset", so defaults come from the registry,
+ * not from whatever this browser happens to have cached). */
+export function defaultAppearanceDocument(): AppearanceDocument {
+ const d = CALM_PRESET;
+ return {
+ theme: 'dim',
+ accent: 'cyan',
+ uiFont: 'Geist',
+ monoFont: 'Geist Mono',
+ visualStyle: d.visualStyle,
+ headingStyle: d.headingStyle,
+ chartStyle: d.chartStyle,
+ density: 'comfortable',
+ logChipColorMode: 'unified',
+ borderBoost: BORDER_BOOST.default,
+ glow: GLOW.default,
+ contrast: CONTRAST.default,
+ typeScale: TYPE_SCALE.default,
+ reducedEffects: d.reducedEffects,
+ reducedMotion: d.reducedMotion,
+ readability: d.readability,
+ };
+}
+
+/**
+ * Per-field sanitize of an untrusted navigation document and apply through the
+ * hooks' internal write path. Legacy `mode: 'classic'` normalizes to Compact
+ * (parseTopNavMode). Quick links are sanitized against the eligible registry,
+ * deduped, and capped at MAX_QUICK_LINKS; a valid empty list stays empty.
+ */
+export function hydrateNavigationDocument(raw: unknown): void {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
+ const p = raw as Record;
+ const rawMode = typeof p.mode === 'string' ? p.mode : null;
+ // parseTopNavMode maps legacy 'classic' (and anything unknown) to Compact.
+ const mode: TopNavMode = rawMode === 'smart' ? 'smart' : parseTopNavMode(rawMode);
+ const labels = isBool(p.labels) ? p.labels : true;
+ const align: TopNavAlign = isTopNavAlignExport(p.align) ? p.align : 'left';
+ const quickLinks = sanitizeQuickLinkIds(p.quickLinks);
+ applyTopNavMode(mode);
+ applyTopNavLabels(labels);
+ applyTopNavAlign(align);
+ applyQuickLinks(quickLinks);
+}
+
+/**
+ * Hydrate a navigation tombstone (or corrupt row): the domain was reset, so
+ * apply the scalar defaults and seed the recommended quick-link set
+ * unconditionally, before settled eligibility is known. Seeding persists a
+ * valid pin list, so the hook's eligibility seed effect later becomes a
+ * no-op. Reachability filtering happens at display time, not here.
+ */
+export function hydrateNavigationDefaults(): void {
+ applyTopNavMode(parseTopNavMode(null));
+ applyTopNavLabels(true);
+ applyTopNavAlign('left');
+ applyQuickLinks(recommendedQuickLinkIds.filter((id) => sanitizeQuickLinkIds([id]).length > 0));
+}
+
+// ── cache keys ─────────────────────────────────────────────────────────────
+/** Every localStorage key the preference domains own. Cleared on identity
+ * switch and when no user is signed in (another account must never see this
+ * browser's cached values); tombstone and corrupt hydration instead rewrite
+ * each key to its default through the apply paths. theme-init.js re-reads the
+ * theme key at next paint, so a cleared key paints defaults. */
+export const PREFERENCE_CACHE_KEYS = [
+ 'sencho.appearance.theme',
+ 'sencho-theme', // legacy theme key
+ 'sencho.appearance.density',
+ 'sencho.log-chip-color-mode',
+ TOP_NAV_MODE_KEY,
+ TOP_NAV_QUICK_LINKS_KEY,
+ TOP_NAV_LABELS_KEY,
+ TOP_NAV_ALIGN_KEY,
+] as const;
+
+export const PREFERENCES_OWNER_KEY = 'sencho.preferences.owner';
+
+export function clearPreferenceCache(): void {
+ if (typeof window === 'undefined') return;
+ try {
+ for (const key of PREFERENCE_CACHE_KEYS) window.localStorage.removeItem(key);
+ window.localStorage.removeItem(PREFERENCES_OWNER_KEY);
+ } catch {
+ // localStorage may be unavailable; nothing to clear then
+ }
+}
+
+export function writePreferenceCacheFromDocuments(): void {
+ if (typeof window === 'undefined') return;
+ // The apply* functions already persist through the hooks' own write paths;
+ // this rewrites the theme document (the only key holding multiple fields)
+ // after a reset so the pre-paint cache mirrors the reset values even if a
+ // queued DELETE has not settled yet.
+ const appearance = buildAppearanceDocument();
+ try {
+ window.localStorage.setItem('sencho.appearance.theme', JSON.stringify({
+ ...appearance,
+ density: undefined,
+ logChipColorMode: undefined,
+ }));
+ } catch {
+ // ignore; private mode / quota
+ }
+}
diff --git a/frontend/src/lib/preferences/quickLinksProvenance.ts b/frontend/src/lib/preferences/quickLinksProvenance.ts
new file mode 100644
index 00000000..81db9925
--- /dev/null
+++ b/frontend/src/lib/preferences/quickLinksProvenance.ts
@@ -0,0 +1,24 @@
+/**
+ * Provenance reader for the quick-links hook. Kept as a separate module so the
+ * sync layer can read pin provenance without importing the React hook.
+ */
+
+export type QuickLinksProvenance =
+ | { status: 'valid' }
+ | { status: 'unset' };
+
+/** Read the raw provenance of the quick-links cache: 'unset' means no valid
+ * list has ever been persisted (missing key, malformed JSON, non-array JSON);
+ * 'valid' includes a deliberately saved empty list. */
+export function readQuickLinksProvenance(): QuickLinksProvenance {
+ if (typeof window === 'undefined') return { status: 'unset' };
+ try {
+ const raw = window.localStorage.getItem('sencho.appearance.topNavQuickLinks');
+ if (raw === null) return { status: 'unset' };
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return { status: 'unset' };
+ return { status: 'valid' };
+ } catch {
+ return { status: 'unset' };
+ }
+}
diff --git a/frontend/src/lib/preferences/resetPreferences.ts b/frontend/src/lib/preferences/resetPreferences.ts
new file mode 100644
index 00000000..56c987c5
--- /dev/null
+++ b/frontend/src/lib/preferences/resetPreferences.ts
@@ -0,0 +1,38 @@
+/**
+ * Complete-domain reset (the "reset all" actions). Optimistic-local defaults
+ * are applied immediately under the hydration guard (so no queued server write
+ * is spawned by the apply itself), then the tombstone DELETE is queued through
+ * the bus: pre-reset queued PUTs are cancelled, post-reset edits are staged
+ * behind the DELETE conditionally on the tombstone's revision.
+ */
+import { type PreferenceDomain } from './preferenceEvents';
+import { queueReset, setHydratingDomains } from './syncBus';
+import {
+ defaultAppearanceDocument,
+ hydrateAppearanceDocument,
+ hydrateNavigationDefaults,
+ writePreferenceCacheFromDocuments,
+} from './preferencesDocuments';
+
+export function resetPreferenceDomain(domain: PreferenceDomain): void {
+ setHydratingDomains(new Set([domain]));
+ try {
+ if (domain === 'appearance') {
+ hydrateAppearanceDocument(defaultAppearanceDocument());
+ } else {
+ hydrateNavigationDefaults();
+ }
+ } finally {
+ setHydratingDomains(new Set());
+ }
+ // Re-assert the cache to the new (default) local state so a reload paints
+ // the reset values even if the DELETE has not settled yet.
+ writePreferenceCacheFromDocuments();
+ queueReset(domain);
+ // The tombstone stands once the DELETE settles: a pending edit based on an
+ // older revision is discarded by reconciliation (remote-reset precedence),
+ // and only edits the user makes after adopting the tombstone are staged
+ // behind the reset as their own PUTs. No trailing notify here: one would
+ // un-tombstone by immediately PUTting the defaults, weakening the reset
+ // for clients that were offline when it happened.
+}
diff --git a/frontend/src/lib/preferences/syncBus.ts b/frontend/src/lib/preferences/syncBus.ts
new file mode 100644
index 00000000..bec1ccbb
--- /dev/null
+++ b/frontend/src/lib/preferences/syncBus.ts
@@ -0,0 +1,587 @@
+/**
+ * Preference sync bus. The server write choke point for preference
+ * documents: every migration, document write, and reset flows through here as
+ * a queued, preconditioned operation with chronological intent. (The sync
+ * owner's reconciliation path also issues conditional PUTs directly when it
+ * merges a server document; both paths share the same preconditioned wire
+ * contract and the reconcile hook closes the loop between them.)
+ *
+ * Guarantees:
+ * - Per-domain serialization: one in-flight operation per domain at a time.
+ * - Preconditions: every write carries expectedRevision; migration is
+ * create-if-absent on the server. When the client has no known revision, a
+ * GET first establishes the baseline; an absent row on a PUT falls back to
+ * migration. There is no unguarded write path.
+ * - Chronology (this client's own queue only): a reset cancels queued PUTs
+ * enqueued before it; an edit enqueued after a reset is staged behind it as
+ * a conditional PUT against the reset's resulting revision. Cross-browser
+ * chronology is NEVER inferred here; reconciliation orders that by the
+ * observable server revision.
+ * - Coalescing: consecutive PUTs collapse to the newest (documents are rebuilt
+ * at send time from current local state); consecutive DELETEs collapse.
+ * - Identity: operations capture the userId and identity generation at enqueue
+ * time; sends and retries are refused if either has changed. Identity
+ * transitions reset the whole bus (queue, failures, known revisions).
+ * - Visible failure: a failed send raises an unsaved episode (toast with
+ * Retry); retry re-runs the operation preserving its kind (a failed reset
+ * retries as DELETE, never as a PUT).
+ */
+
+import { apiFetch } from '@/lib/api';
+import {
+ clearUnsavedEpisode,
+ currentGeneration,
+ getSettledEligibility,
+ registerCurrentUserIdReader,
+ registerQueueReset,
+ setUnsavedEpisode,
+ subscribeToPreferenceWrites,
+ type PreferenceDomain,
+} from '@/lib/preferences/preferenceEvents';
+import {
+ buildAppearanceDocument,
+ buildNavigationDocument,
+} from '@/lib/preferences/preferencesDocuments';
+
+// ── types ──────────────────────────────────────────────────────────────────
+
+export type OperationKind = 'migrate' | 'put' | 'reset';
+
+interface Envelope {
+ schemaVersion: number;
+ revision: number;
+ updatedAt: number;
+ corrupt?: boolean;
+ data?: unknown;
+}
+
+interface QueuedOperation {
+ domain: PreferenceDomain;
+ kind: OperationKind;
+ capturedUserId: number;
+ generation: number;
+ seq: number;
+ /** Revision this operation is conditional on. Null means "GET a baseline
+ * first"; migrate carries null permanently (create-if-absent needs no
+ * precondition envelope). A resolved baseline of "the row does not exist"
+ * becomes the sentinel 'absent' (reset only: send the create-if-absent
+ * precondition instead of a revision). */
+ expectedRevision: number | 'absent' | null;
+ episode: number | null;
+ /** Set when a 409 CONFLICT re-queued this operation once against the
+ * adopted revision; a second conflict must surface as a failure instead
+ * of looping. */
+ conflictRetried?: boolean;
+ /** Sent with keepalive when this operation is the pagehide flush, so the
+ * request survives document unload. Resolved when the operation is
+ * dispatched, not per module, because the baseline GET a flush triggers is
+ * itself asynchronous and a module-level flag would already be reset by
+ * the time the actual write goes out. */
+ keepalive: boolean;
+}
+
+interface DomainSyncState {
+ queued: QueuedOperation | null;
+ /** A put staged behind a queued reset (chronology: pre-reset edits die,
+ * post-reset edits wait): the reset must run first, then the PUT. Null
+ * when no reset is queued. */
+ stagedBehindReset: { op: QueuedOperation; put: QueuedOperation } | null;
+ inFlight: boolean;
+ knownRevision: number | null;
+ failed: QueuedOperation | null;
+}
+
+// ── module state ───────────────────────────────────────────────────────────
+
+const PREF_USER_HEADER = 'x-sencho-pref-user';
+
+const domains: Record = {
+ appearance: { queued: null, stagedBehindReset: null, inFlight: false, knownRevision: null, failed: null },
+ navigation: { queued: null, stagedBehindReset: null, inFlight: false, knownRevision: null, failed: null },
+};
+
+let seqCounter = 0;
+let currentUserId: number | null = null;
+let hydratingDomains: ReadonlySet = new Set();
+
+// Retry hook installed by the sync owner (useUserPreferencesSync) so the bus
+// can ask for reconciliation without importing React code.
+type ReconcileFn = (domain: PreferenceDomain) => void;
+let reconcileHook: ReconcileFn | null = null;
+
+export function setReconcileHook(fn: ReconcileFn | null): void {
+ reconcileHook = fn;
+}
+
+/** Mark a domain as hydration-driven: writes dispatched while hydrating are
+ * derived state and must not enqueue server operations. */
+export function setHydratingDomains(set: ReadonlySet): void {
+ hydratingDomains = set;
+}
+
+export function setCurrentSyncUser(userId: number | null): void {
+ currentUserId = userId;
+}
+
+// The eligibility ownership check in the events leaf compares accounts, so it
+// reads the synced account from here (no circular import at the leaf).
+registerCurrentUserIdReader(() => currentUserId);
+
+// Queue reset hook for identity transitions (wired into preferenceEvents).
+registerQueueReset(() => {
+ for (const domain of ['appearance', 'navigation'] as const) {
+ const state = domains[domain];
+ state.queued = null;
+ state.stagedBehindReset = null;
+ state.failed = null;
+ state.knownRevision = null;
+ }
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ debounceQueued = false;
+ }
+});
+
+// ── enqueue ────────────────────────────────────────────────────────────────
+let debounceTimer: ReturnType | null = null;
+let debounceQueued = false;
+
+function scheduleDebounce(): void {
+ if (debounceQueued) return;
+ debounceQueued = true;
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null;
+ debounceQueued = false;
+ for (const domain of ['appearance', 'navigation'] as const) {
+ if (domains[domain].queued) void pump(domain);
+ }
+ }, 400);
+}
+
+/** Enqueue a user-intent write for a domain. Called by the write listener; a
+ * hydration-side write never reaches here (hydratingDomains guard). */
+function enqueue(domain: PreferenceDomain, kind: OperationKind, episode: number | null = null): void {
+ if (currentUserId === null) return;
+ const state = domains[domain];
+
+ if (kind === 'reset') {
+ // A reset cancels any queued PUT enqueued before it, including a PUT
+ // staged behind an earlier reset (pre-reset edits are discarded by the
+ // user's own explicit reset). A queued reset coalesces.
+ state.stagedBehindReset = null;
+ state.queued = {
+ domain, kind, capturedUserId: currentUserId, generation: currentGeneration(),
+ seq: ++seqCounter, expectedRevision: state.knownRevision, episode, keepalive: flushPending,
+ };
+ } else if (state.queued && state.queued.kind === 'reset') {
+ // An edit enqueued after a queued reset is staged behind it: the reset
+ // keeps its queue slot (its identity is what the settle handoff checks)
+ // and the PUT waits for the reset to settle, targeting its resulting
+ // revision (expectedRevision null means "resolve the baseline then").
+ // Re-staging coalesces: the document is rebuilt at send time, so the
+ // newest values win without re-queueing.
+ state.stagedBehindReset = {
+ op: state.queued,
+ put: state.stagedBehindReset?.put ?? {
+ domain, kind: 'put', capturedUserId: currentUserId, generation: currentGeneration(),
+ seq: ++seqCounter, expectedRevision: null, episode: null, keepalive: flushPending,
+ },
+ };
+ // The reset's own pump is already running; nothing new to start.
+ return;
+ } else if (kind === 'put') {
+ if (state.queued && state.queued.kind === 'put') {
+ // Same-kind coalescing: n PUTs collapse to one, rebuilt at send time.
+ const seq = state.queued.seq;
+ state.queued = {
+ domain, kind: 'put', capturedUserId: currentUserId, generation: currentGeneration(),
+ seq, expectedRevision: state.knownRevision, episode: null, keepalive: flushPending,
+ };
+ } else {
+ // A fresh PUT on an empty queue is enqueued as-is.
+ state.queued = {
+ domain, kind: 'put', capturedUserId: currentUserId, generation: currentGeneration(),
+ seq: ++seqCounter, expectedRevision: state.knownRevision, episode: null, keepalive: flushPending,
+ };
+ }
+ scheduleDebounce();
+ return;
+ } else if (state.queued && state.queued.kind === 'put' && kind === 'migrate') {
+ // A queued PUT means the row exists client-side; migration is moot.
+ return;
+ } else if (kind === 'migrate') {
+ // Migration is create-if-absent; a queued migrate coalesces with nothing.
+ if (state.queued) return;
+ state.queued = {
+ domain, kind, capturedUserId: currentUserId, generation: currentGeneration(),
+ seq: ++seqCounter, expectedRevision: null, episode: null, keepalive: flushPending,
+ };
+ }
+
+ void pump(domain);
+}
+
+/** User-facing setters notify here (via the events leaf). */
+function onPreferenceWrite(domain: PreferenceDomain): void {
+ if (hydratingDomains.has(domain)) return;
+ enqueue(domain, 'put');
+}
+
+subscribeToPreferenceWrites(onPreferenceWrite);
+
+// ── transport ──────────────────────────────────────────────────────────────
+
+interface PrefRequest {
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
+ path: string;
+ body?: unknown;
+}
+
+function authedFetch(req: PrefRequest, userId: number, keepalive = false): Promise {
+ return apiFetch(req.path, {
+ method: req.method,
+ localOnly: true,
+ keepalive,
+ headers: { [PREF_USER_HEADER]: String(userId) },
+ body: req.body === undefined ? undefined : JSON.stringify(req.body),
+ });
+}
+
+/** Server wire shape for a domain document. The navigation `status` provenance
+ * is frontend-only: the server schema is strict and rejects unknown keys.
+ * Returns null when navigation has no valid document yet (never persisted),
+ * so callers can skip the send instead of posting `{}` and tripping a 400. */
+export function documentForDomain(domain: PreferenceDomain): Record | null {
+ if (domain === 'appearance') return { ...buildAppearanceDocument() };
+ const doc = buildNavigationDocument();
+ if (doc.status !== 'valid') return null;
+ return { mode: doc.mode, quickLinks: doc.quickLinks, labels: doc.labels, align: doc.align };
+}
+
+async function fetchBaseline(domain: PreferenceDomain, userId: number): Promise {
+ const response = await authedFetch({ method: 'GET', path: '/user-preferences' }, userId);
+ if (!response.ok) return null;
+ const payload = (await response.json()) as { preferences?: Record };
+ const row = payload.preferences?.[domain];
+ // Only a real envelope establishes a baseline: an absent row (null) means
+ // "no document exists" and must be distinguished from a malformed response,
+ // which must never store an undefined revision.
+ if (!isRowEnvelope(row)) return null;
+ domains[domain].knownRevision = row.revision;
+ return row;
+}
+
+// ── pump ───────────────────────────────────────────────────────────────────
+
+async function pump(domain: PreferenceDomain): Promise {
+ const state = domains[domain];
+ if (state.inFlight) return;
+ const op = state.queued;
+ if (!op) return;
+ // Identity guard (defense in depth; the server re-verifies authoritatively).
+ if (op.capturedUserId !== currentUserId || op.generation !== currentGeneration()) {
+ state.queued = null;
+ state.stagedBehindReset = null;
+ return;
+ }
+ // Navigation migration defers until quick-link eligibility has settled so a
+ // never-seeded pin list is never saved as a derived []. A valid local
+ // document (the hook has a real list) does not need the deferral.
+ if (op.kind === 'migrate' && domain === 'navigation') {
+ const doc = buildNavigationDocument();
+ if (doc.status !== 'valid') {
+ const eligibility = getSettledEligibility();
+ if (!eligibility || eligibility.eligibleIds === null) return;
+ }
+ }
+
+ state.inFlight = true;
+ try {
+ await send(op);
+ } finally {
+ state.inFlight = false;
+ }
+ // The settle handoff for a staged pair: when the operation that just
+ // finished is the reset of a staged pair, either drop both (failure path
+ // owns the retry) or hand the queue to the staged PUT, which targets the
+ // reset's resulting revision.
+ const staged = state.stagedBehindReset;
+ if (staged && staged.op === op) {
+ if (state.failed === op) {
+ state.queued = null;
+ state.stagedBehindReset = null;
+ return;
+ }
+ state.queued = staged.put;
+ state.stagedBehindReset = null;
+ void pump(domain);
+ return;
+ }
+ // Next queued operation proceeds immediately.
+ if (state.queued && state.queued !== op) void pump(domain);
+}
+
+async function send(op: QueuedOperation): Promise {
+ const userId = op.capturedUserId;
+
+ try {
+ // Navigation with no valid local document (never persisted) has nothing
+ // to write: a PUT or migrate must not post a malformed document. A reset
+ // (DELETE) carries no document at all, so it proceeds; skipping it would
+ // strand the user's reset in the queue until the next navigation edit.
+ if (op.kind !== 'reset' && documentForDomain(op.domain) === null) {
+ throw new Error('document-unavailable');
+ }
+ // Establish the revision baseline when the operation has none. An absent
+ // row (null baseline) falls back to the precondition that matches what
+ // the GET actually observed: a PUT becomes a migrate (create-if-absent,
+ // the row must be created), a reset sends `absent: true` (tombstoning an
+ // absent row is still the user's intent). Any other shape is a failure:
+ // the operation parks in `failed` and the toast owns the retry.
+ if (op.expectedRevision === null && !(op.kind === 'migrate')) {
+ const baseline = await fetchBaseline(op.domain, userId);
+ if (baseline === null) {
+ if (op.kind === 'put') {
+ op.kind = 'migrate';
+ op.expectedRevision = null;
+ } else if (op.kind === 'reset') {
+ op.expectedRevision = 'absent';
+ } else {
+ throw new Error('baseline-unavailable');
+ }
+ } else {
+ op.expectedRevision = baseline.revision;
+ }
+ }
+
+ let response: Response;
+ if (op.kind === 'migrate') {
+ response = await authedFetch(
+ { method: 'POST', path: `/user-preferences/${op.domain}/migrate`, body: documentForDomain(op.domain) },
+ userId,
+ op.keepalive,
+ );
+ } else if (op.kind === 'reset') {
+ response = await authedFetch(
+ {
+ method: 'DELETE',
+ path: `/user-preferences/${op.domain}`,
+ // 'absent' is the sentinel the baseline fetch resolved to when the
+ // row did not exist; it maps to the server's create-if-absent
+ // precondition so a reset against a missing row still tombstones.
+ body: op.expectedRevision === 'absent'
+ ? { absent: true }
+ : { expectedRevision: op.expectedRevision },
+ },
+ userId,
+ op.keepalive,
+ );
+ } else {
+ response = await authedFetch(
+ {
+ method: 'PUT',
+ path: `/user-preferences/${op.domain}`,
+ body: { expectedRevision: op.expectedRevision, ...documentForDomain(op.domain) },
+ },
+ userId,
+ op.keepalive,
+ );
+ }
+
+ if (response.ok) {
+ onSuccess(op, response);
+ return;
+ }
+
+ if (response.status === 409) {
+ await onConflict(op, response);
+ return;
+ }
+ throw new Error(`http-${response.status}`);
+ } catch (error) {
+ onFailure(op, error);
+ }
+}
+
+function isRowEnvelope(value: unknown): value is Envelope {
+ return !!value && typeof value === 'object'
+ && Number.isSafeInteger((value as Envelope).revision)
+ && (value as Envelope).revision >= 1;
+}
+
+async function onConflict(op: QueuedOperation, response: Response): Promise {
+ const state = domains[op.domain];
+ let payload: { error?: string; current?: unknown } = {};
+ try {
+ payload = (await response.json()) as { error?: string; current?: unknown };
+ } catch {
+ // Unparseable conflict body: treat as a generic failure below; the
+ // machine code is what matters and it never arrived.
+ }
+ if (payload.error === 'CONFLICT' && isRowEnvelope(payload.current)) {
+ const current = payload.current;
+ state.knownRevision = current.revision;
+ // A reset conflict re-runs the DELETE once against the adopted
+ // revision: delegating to reconciliation here would re-hydrate the
+ // pre-reset server document and visibly revert the user's reset (the
+ // reset path marks nothing dirty, so nothing would re-apply it). The
+ // re-queue is a fresh op object so the in-flight pump's trailing check
+ // re-fires, and a staged PUT is re-staged behind it; a second conflict
+ // is a failure the toast owns.
+ if (op.kind === 'reset' && !op.conflictRetried) {
+ state.queued = {
+ domain: op.domain, kind: 'reset',
+ capturedUserId: op.capturedUserId, generation: op.generation, seq: op.seq,
+ expectedRevision: current.revision, episode: op.episode, keepalive: op.keepalive,
+ conflictRetried: true,
+ };
+ if (state.stagedBehindReset && state.stagedBehindReset.op === op) {
+ state.stagedBehindReset = { op: state.queued, put: state.stagedBehindReset.put };
+ }
+ // No explicit pump here (the current one is still in flight); its
+ // trailing check re-fires because the queued object is new.
+ return;
+ }
+ // Hand reconciliation to the sync owner: server-wins for untouched
+ // fields, dirty fields re-applied, retry conditional on the new revision.
+ if (reconcileHook) {
+ reconcileHook(op.domain);
+ state.queued = null;
+ return;
+ }
+ }
+ onFailure(op, new Error('conflict'));
+}
+
+function onSuccess(op: QueuedOperation, response: Response): void {
+ const state = domains[op.domain];
+ void response.clone().json().then((payload: {
+ revision?: number;
+ migrated?: boolean;
+ row?: { revision?: number; data?: unknown; schemaVersion?: number; corrupt?: boolean };
+ }) => {
+ // Mutate responses carry the revision at the top level; migrate responses
+ // (201/200) nest it in the row envelope. Adopt whichever is present so the
+ // next conditional write targets the server's current revision.
+ let revision: number | null = null;
+ if (typeof payload.revision === 'number') revision = payload.revision;
+ else if (typeof payload.row?.revision === 'number') revision = payload.row.revision;
+ if (revision !== null) state.knownRevision = revision;
+ // A lost migration race (migrated:false) means another browser's row is
+ // the winner: hand it to reconciliation so local dirty edits merge on
+ // top of the winner instead of being silently overwritten on next GET.
+ // A tombstone winner (schemaVersion 0) also reconciles: the sync owner
+ // then discards stale edits and adopts the reset's defaults.
+ if (payload.migrated === false && payload.row && isRowEnvelope(payload.row) && reconcileHook) {
+ reconcileHook(op.domain);
+ }
+ }).catch(() => {
+ // Revision telemetry only; the operation itself succeeded.
+ });
+ if (state.queued === op) state.queued = null;
+ if (state.failed === op) state.failed = null;
+ if (op.episode !== null) clearUnsavedEpisode(op.episode);
+}
+
+function onFailure(op: QueuedOperation, error: unknown): void {
+ const state = domains[op.domain];
+ console.error(`[preferences] ${op.kind} ${op.domain} failed:`, error);
+ if (state.queued === op) state.queued = null;
+ state.failed = op;
+ if (op.episode === null) {
+ op.episode = setUnsavedEpisode(op.domain);
+ } else {
+ // A retry failed again: re-raise with a fresh episode number so the
+ // failure surfaces again (error toasts auto-dismiss; a same-number
+ // re-emit would be suppressed and the failure would go unseen).
+ op.episode = setUnsavedEpisode(op.domain);
+ }
+}
+
+// ── retry (operation-kind preserving) ──────────────────────────────────────
+
+/** Re-run the failed operation for a domain by kind: PUT re-sends the latest
+ * reconciled document, DELETE re-sends the tombstone, migrate re-sends
+ * migration. Called by the toast Retry action. The failed operation stays
+ * bound to the identity that captured it: a Retry is never replayed under a
+ * different account (identity transitions reset the whole bus instead).
+ * An episode raised by the sync owner's reconciliation path (which issues
+ * conditional PUTs outside this queue) parks nothing here, so Retry asks the
+ * owner to re-reconcile from a fresh GET rather than doing nothing. */
+export function retryDomain(domain: PreferenceDomain): void {
+ const state = domains[domain];
+ const failed = state.failed;
+ if (!failed) {
+ if (reconcileHook) reconcileHook(domain);
+ return;
+ }
+ if (failed.capturedUserId !== currentUserId) return;
+ if (failed.generation !== currentGeneration()) return;
+ state.failed = null;
+ state.queued = failed;
+ void pump(domain);
+}
+
+/** Reset flow entry point: optimistic-local defaults are applied by the
+ * caller (via hydrate defaults); this queues the tombstone DELETE and
+ * cancels pre-reset PUTs. */
+export function queueReset(domain: PreferenceDomain): void {
+ enqueue(domain, 'reset');
+}
+
+/** Migration flow entry point (absent row). */
+export function queueMigrate(domain: PreferenceDomain): void {
+ enqueue(domain, 'migrate');
+}
+
+/** Adopt a revision observed by reconciliation (GET/409 current envelopes) so
+ * the next conditional write targets it. */
+export function adoptKnownRevision(domain: PreferenceDomain, revision: number): void {
+ domains[domain].knownRevision = revision;
+}
+
+/** Drop a pending edit for a domain without sending it: reconciliation
+ * observed a remote tombstone newer than the edit's baseline, so the edit is
+ * discarded and the reset adopted. A failed operation with an unsaved
+ * episode is kept: the error surface owns its retry. */
+export function discardQueuedEdit(domain: PreferenceDomain): void {
+ const state = domains[domain];
+ if (state.failed) return;
+ state.queued = null;
+ state.stagedBehindReset = null;
+}
+
+/** Test/observer hook: inspect a domain's queue and in-flight state without
+ * touching transport. `settling` is true while a queued or in-flight
+ * operation may still hit the server; the reset settle logic waits until it
+ * is false so a reload can never cancel an in-flight DELETE. */
+export function inspectQueue(domain: PreferenceDomain): { kind: OperationKind | null; failed: OperationKind | null; settling: boolean } {
+ const state = domains[domain];
+ return {
+ kind: state.queued?.kind ?? null,
+ failed: state.failed?.kind ?? null,
+ settling: state.inFlight || state.queued !== null,
+ };
+}
+
+// A pagehide flush must survive document unload, so the final send goes out
+// with keepalive. The flag marks the operations the flush dispatches; it is
+// read when the op object is created, and an async baseline GET it triggers
+// still carries the flag on the op itself (a synchronous finally could not
+// cover that window).
+let flushPending = false;
+
+/** Flush helper used by the sync owner on pagehide/visibilitychange: sends any
+ * queued operation immediately with keepalive semantics. */
+export function flushPendingWrites(): void {
+ flushPending = true;
+ try {
+ for (const domain of ['appearance', 'navigation'] as const) {
+ const state = domains[domain];
+ if (state.queued && !state.inFlight) void pump(domain);
+ }
+ } finally {
+ flushPending = false;
+ }
+}