From 7fb071d22ed0ac7e3e54919ca74c7074e9b2268a Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 5 May 2026 03:04:14 +0200 Subject: [PATCH] =?UTF-8?q?Bloque=20I1+I2=20=E2=80=94=20frontend=20snapsho?= =?UTF-8?q?t()=20+=20persistFrontendPreferences=20preset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I1 — `ActiveFrontend.snapshot(): FrontendSnapshot` returns every observable preference resolved at call time (`{ locale, dir, theme, mode, reducedMotion, reducedSound, density }`). Computed fresh on each call from the live state — useful for logger context, persistence, devtools, snapshot diffing. I2 — `applyPersistFrontendPreferences(App, options?)` preset round- trips the frontend preferences through `App.storage`. Replays a persisted snapshot at attach time, writes back on every preference change, optionally filters which keys to persist. The detacher cleanly stops persisting and disposes the storage entry — idempotent. Cross-tab sync rides on the storage adapter's `onChange` (the `storage` event for `localAdapter`, `BroadcastChannel` for `broadcastAdapter`); the preset guards against re-entrant writes when its own change triggers an external echo. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/arts/active-app/presets/index.ts | 5 + .../presets/persist-frontend-preferences.ts | 148 ++++++++++++++++++ .../test/persist-frontend-preferences.test.ts | 108 +++++++++++++ src/arts/frontend/active-frontend.svelte.ts | 65 +++++++- src/arts/frontend/index.ts | 1 + .../frontend/test/active-frontend.test.ts | 34 ++++ 6 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/arts/active-app/presets/persist-frontend-preferences.ts create mode 100644 src/arts/active-app/test/persist-frontend-preferences.test.ts diff --git a/src/arts/active-app/presets/index.ts b/src/arts/active-app/presets/index.ts index d0b812c..3dff052 100644 --- a/src/arts/active-app/presets/index.ts +++ b/src/arts/active-app/presets/index.ts @@ -36,6 +36,11 @@ export { applyPermInvalidateOnIdentityChange, type PermInvalidateOnIdentityChangeApp } from './perm-invalidate-on-identity-change.ts'; +export { + applyPersistFrontendPreferences, + type PersistFrontendPreferencesApp, + type PersistFrontendPreferencesOptions +} from './persist-frontend-preferences.ts'; export { applySessionAutoRefresh, type SessionAutoRefreshApp diff --git a/src/arts/active-app/presets/persist-frontend-preferences.ts b/src/arts/active-app/presets/persist-frontend-preferences.ts new file mode 100644 index 0000000..0ab7f6a --- /dev/null +++ b/src/arts/active-app/presets/persist-frontend-preferences.ts @@ -0,0 +1,148 @@ +import { + FRONTEND_PREFERENCE_KEYS, + applyFrontendPreferenceSnapshot, + readFrontendPreference, + type ActiveFrontend, + type FrontendPreferenceKey, + type FrontendPreferenceSnapshot +} from '$frontend'; +import type { ActiveStorage, ActiveStorageEntry } from '$storage/types'; +import type { ActiveAppCore } from '../types.ts'; + +/** + * Shape required by `applyPersistFrontendPreferences`. The preset + * touches only the `frontend` and `storage` slots — apps that compose + * different services pass a narrower `Pick<>` type if they want. + */ +export interface PersistFrontendPreferencesApp extends ActiveAppCore { + readonly frontend: ActiveFrontend; + readonly storage: ActiveStorage; +} + +export interface PersistFrontendPreferencesOptions { + /** + * Storage entry key. Defaults to `frontend:preferences`. + * + * The default is a `:`-namespaced key so it groups with other + * framework state under a single inspectable bucket without + * conflicting with app-owned keys. + */ + readonly key?: string; + /** + * Subset of preferences to persist. Defaults to every preference + * (`theme`, `mode`, `density`, `dir`, `reducedMotion`, + * `reducedSound`). Useful when an app wants to persist only theme + * and mode but always re-derive density from device class. + */ + readonly keys?: readonly FrontendPreferenceKey[]; +} + +const DEFAULT_PREFERENCES_KEY = 'frontend:preferences'; + +/** + * Wires `App.frontend` to `App.storage` so user preferences survive + * reloads. Reads any persisted snapshot at attach time and replays it + * onto the live frontend; subscribes to `onPreferenceChange` so every + * subsequent setter writes back through the storage entry. + * + * Returns a detach function that stops persisting and disposes the + * storage entry created internally. Idempotent. + * + * @example + * const detach = applyPersistFrontendPreferences(App); + * // ...later, on app teardown: + * detach(); + * + * @example + * // Persist only theme and mode; density stays in-memory. + * applyPersistFrontendPreferences(App, { keys: ['theme', 'mode'] }); + */ +export function applyPersistFrontendPreferences( + App: PersistFrontendPreferencesApp, + options: PersistFrontendPreferencesOptions = {} +): () => void { + const key = options.key ?? DEFAULT_PREFERENCES_KEY; + const keys = options.keys ?? FRONTEND_PREFERENCE_KEYS; + + const entry: ActiveStorageEntry = App.storage.entry( + key, + {} + ); + + // Replay the persisted snapshot onto the live frontend. We copy each + // known key explicitly so unknown fields stuck in storage from a + // previous schema don't bleed back into the runtime. + const persisted = entry.get(); + if (persisted !== null && typeof persisted === 'object') { + const replayable: FrontendPreferenceSnapshot = {}; + for (const k of keys) { + const value = (persisted as FrontendPreferenceSnapshot)[k]; + if (value !== undefined) { + (replayable as Record)[k] = value; + } + } + applySnapshotToFrontend(App.frontend, replayable); + } + + let writing = false; + const detachListener = App.frontend.onPreferenceChange(() => { + // The storage entry's onChange listener also calls + // `applySnapshotToFrontend` (cross-tab path); guard against the + // reentrant write that would re-enter this listener. + if (writing) return; + writing = true; + try { + const next: FrontendPreferenceSnapshot = {}; + for (const k of keys) { + (next as Record)[k] = readFrontendPreference(App.frontend, k); + } + entry.set(next); + } finally { + writing = false; + } + }); + + const detachStorage = entry.onChange((nextSnapshot: FrontendPreferenceSnapshot) => { + // Re-apply external changes (other tab) onto the live frontend. + // Skip when WE are the source of the write. + if (writing) return; + if (nextSnapshot === null || typeof nextSnapshot !== 'object') return; + const replayable: FrontendPreferenceSnapshot = {}; + for (const k of keys) { + const value = nextSnapshot[k]; + if (value !== undefined) { + (replayable as Record)[k] = value; + } + } + writing = true; + try { + applySnapshotToFrontend(App.frontend, replayable); + } finally { + writing = false; + } + }); + + let detached = false; + return () => { + if (detached) return; + detached = true; + detachListener(); + detachStorage(); + entry.dispose(); + }; +} + +function applySnapshotToFrontend( + frontend: ActiveFrontend, + snapshot: FrontendPreferenceSnapshot +): void { + // Bridge from declarative `FrontendPreferenceSnapshot` to imperative + // frontend setters so `setMode('auto')` clears the override etc. + const merged = applyFrontendPreferenceSnapshot({}, snapshot); + if (merged.theme !== undefined) frontend.setTheme(merged.theme); + if (merged.mode !== undefined) frontend.setMode(merged.mode); + if (merged.density !== undefined) frontend.setDensity(merged.density); + if (merged.dir !== undefined) frontend.setDir(merged.dir); + if (merged.reducedMotion !== undefined) frontend.setReducedMotion(merged.reducedMotion); + if (merged.reducedSound !== undefined) frontend.setReducedSound(merged.reducedSound); +} diff --git a/src/arts/active-app/test/persist-frontend-preferences.test.ts b/src/arts/active-app/test/persist-frontend-preferences.test.ts new file mode 100644 index 0000000..878b929 --- /dev/null +++ b/src/arts/active-app/test/persist-frontend-preferences.test.ts @@ -0,0 +1,108 @@ +/** + * Bloque I2 — `applyPersistFrontendPreferences` round-trips frontend + * preferences through `App.storage`. + * + * Wires `createActiveStorage` (memory adapter) + `createActiveFrontend` + * (no DOM) and confirms the preset: + * - replays a persisted snapshot onto the live frontend at attach time + * - writes back to storage on every preference change + * - honors the `keys` filter so unselected preferences are not persisted + * - cleanly stops persisting when the detacher fires + */ + +import { describe, expect, it } from 'vitest'; +import { createActiveFrontend } from '$frontend'; +import { createActiveStorage, createMemoryAdapter } from '$storage'; +import { applyPersistFrontendPreferences } from '../presets/persist-frontend-preferences.ts'; + +function buildHarness(initialEntries: Record = {}) { + const storage = createActiveStorage({ adapter: createMemoryAdapter(initialEntries) }); + const frontend = createActiveFrontend({ applyDom: false }); + return { + storage, + frontend, + dispose() { + frontend.dispose(); + storage.dispose(); + } + }; +} + +describe('applyPersistFrontendPreferences', () => { + it('writes every preference change back to the storage entry', () => { + const { storage, frontend, dispose } = buildHarness(); + const detach = applyPersistFrontendPreferences({ frontend, storage }); + + frontend.setTheme('dark'); + frontend.setDensity('compact'); + + const entry = storage.entry('frontend:preferences', {}); + const persisted = entry.get(); + expect(persisted).toMatchObject({ theme: 'dark', density: 'compact' }); + entry.dispose(); + + detach(); + dispose(); + }); + + it('replays a persisted snapshot onto the live frontend at attach time', () => { + // Prime the adapter with a snapshot the way storage.set would write + // it: a JSON envelope wrapping the value. + const initial = JSON.stringify({ + v: 1, + d: JSON.stringify({ theme: 'noir', mode: 'dark', density: 'compact' }) + }); + const { storage, frontend, dispose } = buildHarness({ + 'frontend:preferences': initial + }); + + const detach = applyPersistFrontendPreferences({ frontend, storage }); + + expect(frontend.getTheme()).toBe('noir'); + expect(frontend.getMode()).toBe('dark'); + expect(frontend.getDensity()).toBe('compact'); + + detach(); + dispose(); + }); + + it('respects the `keys` filter and only persists selected preferences', () => { + const { storage, frontend, dispose } = buildHarness(); + const detach = applyPersistFrontendPreferences( + { frontend, storage }, + { keys: ['theme'] } + ); + + frontend.setTheme('dark'); + frontend.setDensity('compact'); + + const entry = storage.entry('frontend:preferences', {}); + const persisted = entry.get() as Record; + expect(persisted.theme).toBe('dark'); + expect(persisted.density).toBeUndefined(); + entry.dispose(); + + detach(); + dispose(); + }); + + it('detacher stops persisting changes after it fires', () => { + const { storage, frontend, dispose } = buildHarness(); + const detach = applyPersistFrontendPreferences({ frontend, storage }); + + frontend.setTheme('dark'); + const entry = storage.entry('frontend:preferences', {}); + const beforeDetach = (entry.get() as { theme?: string }).theme; + expect(beforeDetach).toBe('dark'); + + detach(); + frontend.setTheme('light'); + + // Storage entry was not updated by the detached preset. + const afterDetach = (entry.get() as { theme?: string }).theme; + expect(afterDetach).toBe('dark'); + entry.dispose(); + + dispose(); + }); +}); diff --git a/src/arts/frontend/active-frontend.svelte.ts b/src/arts/frontend/active-frontend.svelte.ts index a92f7a7..b1d8227 100644 --- a/src/arts/frontend/active-frontend.svelte.ts +++ b/src/arts/frontend/active-frontend.svelte.ts @@ -1,9 +1,42 @@ import type { LocaleSource } from '$locale'; import { applyChange, type DomApplier } from '$libs/dom'; -import { DEFAULT_DENSITY, DEFAULT_MODE, DEFAULT_THEME, FRONTEND_ATTRS } from './consts'; +import { isDev } from '$libs/env'; +import { + DEFAULT_DENSITY, + DEFAULT_MODE, + DEFAULT_THEME, + FRONTEND_ATTRS, + VALID_FRONTEND_DENSITIES, + VALID_FRONTEND_DIRS, + VALID_FRONTEND_MODES +} from './consts'; import { resolveDir } from './locale-defaults'; import type { Direction } from './locale-defaults'; +/** + * In DEV, throw when a non-typed caller passes a value outside the + * closed string-union for `setMode`/`setDensity`/`setDir`. Typed + * callers get a compile-time error long before this; the runtime + * guard catches formless inputs (HTML form selects, untyped IPC, + * untyped JS imports) so the bug surfaces at the call site instead + * of bleeding into a `data-*` attribute the CSS doesn't recognise. + * + * In PROD the guard is a no-op — TypeScript already covered the + * happy path and we don't want to throw on an unfamiliar value at + * runtime in a customer's session. + */ +function assertValidFrontendValue( + field: string, + value: string, + allowed: T +): void { + if (!isDev) return; + if ((allowed as readonly string[]).includes(value)) return; + throw new TypeError( + `[frontend] ${field} expects one of ${allowed.join('|')}, received ${JSON.stringify(value)}` + ); +} + export type FrontendMode = 'light' | 'dark' | 'auto'; export type ResolvedFrontendMode = Exclude; export type FrontendDensity = 'compact' | 'normal' | 'comfortable'; @@ -41,6 +74,21 @@ export interface ActiveFrontendOptions { density?: FrontendDensity; } +/** + * Single read of every observable preference, ready for serialization + * (logger context, persistence, devtools, snapshot diffing). Computed + * fresh on each call from the live state. + */ +export interface FrontendSnapshot { + readonly locale: string; + readonly dir: Direction; + readonly theme: string; + readonly mode: ResolvedFrontendMode; + readonly reducedMotion: boolean; + readonly reducedSound: boolean; + readonly density: FrontendDensity; +} + export interface ActiveFrontend { getLocale: () => string; setLocale: (locale: string) => void; @@ -62,6 +110,8 @@ export interface ActiveFrontend { setReducedSound: (reduced: boolean) => void; getDensity: () => FrontendDensity; setDensity: (density: FrontendDensity) => void; + /** Read every observable preference at once. */ + snapshot: () => FrontendSnapshot; onPreferenceChange: (fn: () => void) => () => void; /** * `true` once `dispose()` has been called. After dispose every @@ -188,6 +238,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getDir, setDir: (dir) => { if (disposed) return; + assertValidFrontendValue('setDir', dir, VALID_FRONTEND_DIRS); dirOverride = dir === 'auto' ? null : dir; notify(); }, @@ -210,6 +261,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getMode, setMode: (mode) => { if (disposed) return; + assertValidFrontendValue('setMode', mode, VALID_FRONTEND_MODES); modeOverride = mode === 'auto' ? null : mode; notify(); }, @@ -247,10 +299,21 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getDensity: () => density, setDensity: (nextDensity) => { if (disposed) return; + assertValidFrontendValue('setDensity', nextDensity, VALID_FRONTEND_DENSITIES); density = nextDensity; notify(); }, + snapshot: () => ({ + locale: getLocale(), + dir: getDir(), + theme, + mode: getMode(), + reducedMotion: getReducedMotion(), + reducedSound, + density + }), + onPreferenceChange: (fn) => { if (disposed) return () => {}; listeners.add(fn); diff --git a/src/arts/frontend/index.ts b/src/arts/frontend/index.ts index ac91609..a1f0ef1 100644 --- a/src/arts/frontend/index.ts +++ b/src/arts/frontend/index.ts @@ -11,6 +11,7 @@ export type { FrontendLocaleSource, FrontendTarget, FrontendDom, + FrontendSnapshot, ActiveFrontendOptions, ActiveFrontend } from './active-frontend.svelte'; diff --git a/src/arts/frontend/test/active-frontend.test.ts b/src/arts/frontend/test/active-frontend.test.ts index e01d4e1..58a2558 100644 --- a/src/arts/frontend/test/active-frontend.test.ts +++ b/src/arts/frontend/test/active-frontend.test.ts @@ -3,6 +3,40 @@ import type { StructuralChange } from '$libs/dom'; import { FRONTEND_ATTRS, createActiveFrontend } from '..'; describe('createActiveFrontend()', () => { + it('snapshot() returns every observable preference resolved at call time', () => { + const frontend = createActiveFrontend({ + applyDom: false, + locale: 'fr-FR', + theme: 'dark', + mode: 'dark', + reducedMotion: true, + reducedSound: true, + density: 'compact' + }); + + expect(frontend.snapshot()).toEqual({ + locale: 'fr-FR', + dir: 'ltr', + theme: 'dark', + mode: 'dark', + reducedMotion: true, + reducedSound: true, + density: 'compact' + }); + + frontend.setLocale('ar-EG'); + frontend.setTheme('light'); + frontend.setDensity('comfortable'); + expect(frontend.snapshot()).toMatchObject({ + locale: 'ar-EG', + dir: 'rtl', + theme: 'light', + density: 'comfortable' + }); + + frontend.dispose(); + }); + it('keeps auto and explicit values consistent across locale changes', () => { const applied: StructuralChange[] = []; const target = {} as HTMLElement;