diff --git a/src/arts/active-app/service-factories/frontend.ts b/src/arts/active-app/service-factories/frontend.ts index 1a17e7d..249e9dc 100644 --- a/src/arts/active-app/service-factories/frontend.ts +++ b/src/arts/active-app/service-factories/frontend.ts @@ -3,7 +3,13 @@ import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-fro import type { ActiveDom } from '$adom'; import type { ActiveLang } from '$lang'; import type { LocaleSource } from '$locale'; -import { prefsLanguageSource } from '$prefs'; +import { + prefsDensitySource, + prefsDirectionSource, + prefsLanguageSource, + prefsMotionSource, + prefsThemeSource +} from '$prefs'; import type { ActivePrefs } from '$prefs'; import type { AppServiceFactory } from '../services.ts'; @@ -34,6 +40,7 @@ import type { AppServiceFactory } from '../services.ts'; export function defineActiveFrontend( options: ActiveFrontendOptions = {} ): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'prefs', 'lang'], ActiveFrontend> { + const detachers: Array<() => void> = []; return { name: 'frontend', coreDependencies: [], @@ -55,13 +62,57 @@ export function defineActiveFrontend( }; } - return createActiveFrontend({ + const frontend = createActiveFrontend({ ...options, dom, localeSource }); + + // When prefs is in the schema, route theme / density / motion / + // direction through it. Each subscription is per-dimension (the + // capability sources only fire when their own field changes), so + // theme writes don't wake up the density listener and vice versa. + // + // `prefs.theme` (light|dark) maps to Frontend.MODE — Frontend's + // "theme" is a deeper UI variant name, "mode" is the light/dark + // scheme, and prefs's effective theme is exactly the latter. + // + // Initial values are applied before subscribing so the first + // paint reflects prefs without an extra commit. + if (prefsInstance !== undefined) { + const themeSrc = prefsThemeSource(prefsInstance); + const densitySrc = prefsDensitySource(prefsInstance); + const motionSrc = prefsMotionSource(prefsInstance); + const directionSrc = prefsDirectionSource(prefsInstance); + + frontend.setMode(themeSrc.get()); + frontend.setDensity(densitySrc.get()); + frontend.setReducedMotion(motionSrc.get() === 'reduce'); + frontend.setDir(directionSrc.get()); + + const offTheme = themeSrc.onChange?.((value) => frontend.setMode(value)); + const offDensity = densitySrc.onChange?.((value) => frontend.setDensity(value)); + const offMotion = motionSrc.onChange?.((value) => + frontend.setReducedMotion(value === 'reduce') + ); + const offDirection = directionSrc.onChange?.((value) => frontend.setDir(value)); + + if (offTheme !== undefined) detachers.push(offTheme); + if (offDensity !== undefined) detachers.push(offDensity); + if (offMotion !== undefined) detachers.push(offMotion); + if (offDirection !== undefined) detachers.push(offDirection); + } + + return frontend; }, dispose(instance) { + for (const off of detachers.splice(0)) { + try { + off(); + } catch { + // best-effort; teardown must not throw + } + } instance.dispose(); } }; diff --git a/src/arts/active-app/service-factories/index.ts b/src/arts/active-app/service-factories/index.ts index 5e5ad05..cb757f3 100644 --- a/src/arts/active-app/service-factories/index.ts +++ b/src/arts/active-app/service-factories/index.ts @@ -23,7 +23,12 @@ export { defineActiveFormat } from './format.ts'; export { defineActiveFrontend } from './frontend.ts'; export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts'; export { defineActivePerm } from './perm.ts'; -export { defineActivePrefs, type DefineActivePrefsOptions } from './prefs.ts'; +export { + defineActivePrefs, + defineActivePrefsWithStorage, + type DefineActivePrefsOptions, + type DefineActivePrefsWithStorageOptions +} from './prefs.ts'; export { defineActiveSession } from './session.ts'; export { defineActiveStorage } from './storage.ts'; export { defineEngineHttp } from './http.ts'; diff --git a/src/arts/active-app/service-factories/prefs.ts b/src/arts/active-app/service-factories/prefs.ts index 412ddfc..c9bd323 100644 --- a/src/arts/active-app/service-factories/prefs.ts +++ b/src/arts/active-app/service-factories/prefs.ts @@ -1,5 +1,12 @@ -import { createActivePrefs } from '$prefs'; -import type { ActivePrefs, EnginePrefsOptions } from '$prefs'; +import { + createActivePrefs, + createPrefsStorageBridge, + type ActivePrefs, + type EnginePrefsOptions, + type PrefsIntentStorage, + type PrefsStorageBridge, + type PrefsStorageOp +} from '$prefs'; import type { AppServiceFactory } from '../services.ts'; /** @@ -9,6 +16,15 @@ import type { AppServiceFactory } from '../services.ts'; */ export type DefineActivePrefsOptions = EnginePrefsOptions; +/** + * Options for `defineActivePrefsWithStorage`. Adds a `PrefsIntentStorage` + * port and an optional error reporter on top of the base prefs config. + */ +export interface DefineActivePrefsWithStorageOptions extends DefineActivePrefsOptions { + readonly storage: PrefsIntentStorage; + readonly onError?: (error: unknown, op: PrefsStorageOp) => void; +} + /** * `defineActivePrefs(options)` produces a service factory for the * `prefs` slot. Adds the runtime to the App schema as @@ -40,3 +56,40 @@ export function defineActivePrefs( } }; } + +/** + * `defineActivePrefsWithStorage(options)` is the storage-bundled variant + * of `defineActivePrefs`. Bootstraps the engine and immediately attaches + * a `PrefsStorageBridge` so persisted intent loads on hydrate and every + * subsequent commit is saved back. Bridge teardown runs before the + * engine disposes. + * + * Use this when the application has a `PrefsIntentStorage` ready to + * inject (typically a thin adapter over `arts/storage`). Apps that wire + * persistence by hand can stay on `defineActivePrefs(...)` and call + * `createPrefsStorageBridge(...)` themselves. + */ +export function defineActivePrefsWithStorage( + options: DefineActivePrefsWithStorageOptions +): AppServiceFactory<'prefs', readonly [], readonly [], ActivePrefs> { + let bridge: PrefsStorageBridge | undefined; + return { + name: 'prefs', + coreDependencies: [], + initMode: 'immediate', + create(): ActivePrefs { + const prefs = createActivePrefs(options); + bridge = createPrefsStorageBridge({ + engine: prefs, + storage: options.storage, + onError: options.onError + }); + return prefs; + }, + dispose(instance) { + bridge?.dispose(); + bridge = undefined; + instance.dispose(); + } + }; +} diff --git a/src/arts/active-app/test/prefs-consumer-wiring.test.ts b/src/arts/active-app/test/prefs-consumer-wiring.test.ts index 3b648ec..eab14a6 100644 --- a/src/arts/active-app/test/prefs-consumer-wiring.test.ts +++ b/src/arts/active-app/test/prefs-consumer-wiring.test.ts @@ -18,8 +18,11 @@ import { defineActiveFormat, defineActiveFrontend, defineActiveLang, - defineActivePrefs + defineActivePrefs, + defineActivePrefsWithStorage } from '../service-factories/index.ts'; +import type { PrefsIntent } from '$libs/prefs'; +import type { PrefsIntentStorage } from '$prefs'; import type { CoreServices } from '../services.ts'; function buildCore(): CoreServices { @@ -31,8 +34,8 @@ function buildCore(): CoreServices { } const CAPS: PrefsCapabilities = { - languages: ['es-ES', 'en-US'], - locales: ['es-ES', 'en-US'], + languages: ['es-ES', 'en-US', 'ar-EG'], + locales: ['es-ES', 'en-US', 'ar-EG'], currencies: ['EUR', 'USD'], unitSystems: ['metric', 'imperial'], themes: ['light', 'dark', 'system'], @@ -130,6 +133,84 @@ describe('prefs → consumer wiring', () => { builders.disposeAll(); }); + it('frontend mode/density/motion/dir track prefs end-to-end', () => { + const core = buildCore(); + const builders = buildServiceBuilders( + { + prefs: defineActivePrefs({ capabilities: CAPS }), + dom: defineActiveDom(), + frontend: defineActiveFrontend({ applyDom: false }) + }, + core + ); + const { prefs, frontend } = builders.proxies as { + prefs: { + setIntent: (k: 'theme' | 'density' | 'motion' | 'language', v: string) => unknown; + }; + frontend: { + getMode: () => string; + getDensity: () => string; + getReducedMotion: () => boolean; + getDir: () => string; + }; + }; + + // Initial values flow from prefs.defaults at construction time. + expect(frontend.getMode()).toBe('light'); + expect(frontend.getDensity()).toBe('comfortable'); + expect(frontend.getReducedMotion()).toBe(false); + expect(frontend.getDir()).toBe('ltr'); + + prefs.setIntent('theme', 'dark'); + expect(frontend.getMode()).toBe('dark'); + + prefs.setIntent('density', 'compact'); + expect(frontend.getDensity()).toBe('compact'); + + prefs.setIntent('motion', 'reduce'); + expect(frontend.getReducedMotion()).toBe(true); + + prefs.setIntent('language', 'ar-EG'); + expect(frontend.getDir()).toBe('rtl'); + + builders.disposeAll(); + }); + + it('defineActivePrefsWithStorage hydrates intent and persists writes', async () => { + const core = buildCore(); + const saved: PrefsIntent[] = []; + const storage: PrefsIntentStorage = { + load: () => ({ locale: 'en-US' }), + save: (intent) => { + saved.push(intent); + }, + clear: () => {} + }; + const builders = buildServiceBuilders( + { + prefs: defineActivePrefsWithStorage({ capabilities: CAPS, storage }) + }, + core + ); + const { prefs } = builders.proxies as { + prefs: { + effective: () => { locale: string }; + setIntent: (k: 'currency', v: string) => unknown; + }; + }; + // The bridge's `load()` always resolves via `await`, so hydrate + // applies on the next microtask even for synchronous storage. + // Yield once before asserting. + await Promise.resolve(); + expect(prefs.effective().locale).toBe('en-US'); + + prefs.setIntent('currency', 'USD'); + await new Promise((r) => setTimeout(r, 0)); + expect(saved).toEqual([{ locale: 'en-US', currency: 'USD' }]); + + builders.disposeAll(); + }); + it('format / frontend retain the lang fallback when prefs is not declared', () => { const core = buildCore(); const builders = buildServiceBuilders( diff --git a/src/arts/prefs/adapters/browser-environment.ts b/src/arts/prefs/adapters/browser-environment.ts index e61b9d1..b3b0427 100644 --- a/src/arts/prefs/adapters/browser-environment.ts +++ b/src/arts/prefs/adapters/browser-environment.ts @@ -1,4 +1,5 @@ import type { PrefsEnvironment } from '$libs/prefs'; +import type { EnginePrefs } from '../types.ts'; /** * Browser environment detector. Reads `navigator.languages`, @@ -124,3 +125,30 @@ export function watchBrowserEnvironment( } }; } + +/** + * Convenience wrapper for client-side bootstrap: applies the current + * browser environment to `engine` via `refreshEnvironment(...)` and + * starts a `matchMedia` watcher that pipes subsequent changes back + * through `patchEnvironment(...)`. + * + * Returns a detach function that stops the watcher. The engine's other + * environment fields (locales, timezone) survive the patches because + * the watcher only emits keys it owns (`colorScheme`, `reducedMotion`). + * + * Typical usage from a SvelteKit `onMount`: + * + * ```ts + * onMount(() => applyBrowserEnvironment(App.prefs)); + * ``` + */ +export function applyBrowserEnvironment( + engine: EnginePrefs, + overrides: BrowserEnvironmentOverrides = {} +): () => void { + engine.refreshEnvironment(detectBrowserEnvironment(overrides)); + return watchBrowserEnvironment( + (patch) => engine.patchEnvironment(patch), + { matchMedia: overrides.matchMedia } + ); +} diff --git a/src/arts/prefs/index.ts b/src/arts/prefs/index.ts index 9f31f73..6baf13e 100644 --- a/src/arts/prefs/index.ts +++ b/src/arts/prefs/index.ts @@ -39,6 +39,7 @@ export { } from './sources.ts'; export { + applyBrowserEnvironment, detectBrowserEnvironment, watchBrowserEnvironment } from './adapters/browser-environment.ts';