Bloque L5 — prefs closure: frontend deep-integration + storage helper + browser auto-detect

Closes the remaining wiring gaps so an app can adopt prefs end-to-end
without hand-rolling subscriptions.

- `defineActiveFrontend` extended: when prefs is in the schema, subscribes
  to `prefs.theme/density/motion/direction` and drives Frontend's
  `setMode/setDensity/setReducedMotion/setDir` per-dimension. Initial
  values are applied before subscribing so the first paint reflects
  prefs without an extra commit. `prefs.theme` (light|dark) maps to
  Frontend.MODE — Frontend's "theme" is a deeper UI variant name; "mode"
  is the light/dark scheme, which is exactly the prefs effective theme.
- `defineActivePrefsWithStorage(options)` — bundles `createActivePrefs`
  + `createPrefsStorageBridge` into a single service factory. Bridge
  teardown runs before engine dispose. Use this when a
  `PrefsIntentStorage` port is ready; manual wiring via
  `defineActivePrefs(...)` + `createPrefsStorageBridge(...)` still works
  for apps that need finer control.
- `applyBrowserEnvironment(engine, overrides?)` — convenience wrapper
  that calls `detectBrowserEnvironment` + `watchBrowserEnvironment` and
  pipes both into the engine. Returns the watcher detach function. Drop
  it inside a SvelteKit `onMount` and the rest is automatic.

Tests: 2 new integration scenarios in prefs-consumer-wiring covering
the frontend per-dimension wiring (theme/density/motion/direction) and
the storage-bundled factory (synchronous-storage hydrate + persist).

Full suite: 1676 / 1676 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 1f8015f2fe
commit 331cc72674

@ -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();
}
};

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

@ -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();
}
};
}

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

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

@ -39,6 +39,7 @@ export {
} from './sources.ts';
export {
applyBrowserEnvironment,
detectBrowserEnvironment,
watchBrowserEnvironment
} from './adapters/browser-environment.ts';

Loading…
Cancel
Save

Powered by TurnKey Linux.