Bloque I1+I2 — frontend snapshot() + persistFrontendPreferences preset

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) <noreply@anthropic.com>
master
dev 5 months ago
parent 3ec92acdc3
commit 7fb071d22e

@ -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

@ -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<FrontendPreferenceSnapshot> = 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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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);
}

@ -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<string, string> = {}) {
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<string, unknown>;
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();
});
});

@ -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<T extends readonly string[]>(
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<FrontendMode, 'auto'>;
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);

@ -11,6 +11,7 @@ export type {
FrontendLocaleSource,
FrontendTarget,
FrontendDom,
FrontendSnapshot,
ActiveFrontendOptions,
ActiveFrontend
} from './active-frontend.svelte';

@ -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;

Loading…
Cancel
Save

Powered by TurnKey Linux.