diff --git a/src/arts/active-app/presets/index.ts b/src/arts/active-app/presets/index.ts index 3dff052..d0b812c 100644 --- a/src/arts/active-app/presets/index.ts +++ b/src/arts/active-app/presets/index.ts @@ -36,11 +36,6 @@ 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 deleted file mode 100644 index 0ab7f6a..0000000 --- a/src/arts/active-app/presets/persist-frontend-preferences.ts +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index 878b929..0000000 --- a/src/arts/active-app/test/persist-frontend-preferences.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 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 b1d8227..a92f7a7 100644 --- a/src/arts/frontend/active-frontend.svelte.ts +++ b/src/arts/frontend/active-frontend.svelte.ts @@ -1,42 +1,9 @@ import type { LocaleSource } from '$locale'; import { applyChange, type DomApplier } from '$libs/dom'; -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 { DEFAULT_DENSITY, DEFAULT_MODE, DEFAULT_THEME, FRONTEND_ATTRS } 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'; @@ -74,21 +41,6 @@ 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; @@ -110,8 +62,6 @@ 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 @@ -238,7 +188,6 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getDir, setDir: (dir) => { if (disposed) return; - assertValidFrontendValue('setDir', dir, VALID_FRONTEND_DIRS); dirOverride = dir === 'auto' ? null : dir; notify(); }, @@ -261,7 +210,6 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ getMode, setMode: (mode) => { if (disposed) return; - assertValidFrontendValue('setMode', mode, VALID_FRONTEND_MODES); modeOverride = mode === 'auto' ? null : mode; notify(); }, @@ -299,21 +247,10 @@ 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 a1f0ef1..ac91609 100644 --- a/src/arts/frontend/index.ts +++ b/src/arts/frontend/index.ts @@ -11,7 +11,6 @@ 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 58a2558..e01d4e1 100644 --- a/src/arts/frontend/test/active-frontend.test.ts +++ b/src/arts/frontend/test/active-frontend.test.ts @@ -3,40 +3,6 @@ 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;