From 1f8015f2fe74b0d1e3af50c76c8f7212e5c92487 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 5 May 2026 13:18:52 +0200 Subject: [PATCH] =?UTF-8?q?Bloque=20L4=20=E2=80=94=20active-app:=20defineA?= =?UTF-8?q?ctivePrefs=20+=20consumer=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces `App.prefs` and routes lang/format/frontend through the prefs engine when it's declared in the schema. Apps that don't opt into prefs keep the existing lang-driven locale chain untouched. - `defineActivePrefs(options)` — service factory with `initMode: 'immediate'` (consumer factories ask for `prefs` synchronously at construction time; deferring would make the dependency graph order-sensitive). No core dependencies — the engine is pure data. - `defineActiveLang` — when `prefs` is in the schema, subscribes to `prefs.language` and drives `lang.setLocale()` for both the initial value and changes; the subscription is detached on `dispose()`. - `defineActiveFormat` — locale-source resolution is now `options.localeSource` → `prefs.locale` → `lang.locale` → Format default. `prefs.locale` (regional formatting) wins over `prefs.language` (i18n) when both are present. - `defineActiveFrontend` — same precedence chain but uses `prefs.language` (NOT `prefs.locale`) because Frontend's `dir = auto` follows the writing system, which is a property of the language. - `$prefs` alias added to `svelte.config.js`. Sium needs no factory change — it consumes `lang` for translation strings, so the prefs-driven language flows through transitively. Tests: 1 service-factory integration + 4 consumer-wiring integration scenarios covering lang.t() flip, format.getLocale flip, frontend.getLocale flip, and the prefs-less fallback. Full suite: 1674 / 1674 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../active-app/service-factories/format.ts | 41 +++-- .../active-app/service-factories/frontend.ts | 42 +++-- .../active-app/service-factories/index.ts | 1 + src/arts/active-app/service-factories/lang.ts | 37 ++++- .../active-app/service-factories/prefs.ts | 42 +++++ .../test/prefs-consumer-wiring.test.ts | 149 ++++++++++++++++++ .../active-app/test/service-factories.test.ts | 39 +++++ svelte.config.js | 1 + 8 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 src/arts/active-app/service-factories/prefs.ts create mode 100644 src/arts/active-app/test/prefs-consumer-wiring.test.ts diff --git a/src/arts/active-app/service-factories/format.ts b/src/arts/active-app/service-factories/format.ts index 9c12130..b93b781 100644 --- a/src/arts/active-app/service-factories/format.ts +++ b/src/arts/active-app/service-factories/format.ts @@ -2,36 +2,47 @@ import { createActiveFormat } from '$format/active-formats.svelte'; import type { ActiveFormat, ActiveFormatOptions } from '$format/active-formats.svelte'; import type { ActiveLang } from '$lang'; import type { LocaleSource } from '$locale'; +import { prefsLocaleSource } from '$prefs'; +import type { ActivePrefs } from '$prefs'; import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveFormat(options)` produces a service factory for the * `format` slot. * - * Format runs entirely from a `LocaleSource`. If the application - * declares `lang` in its schema, this factory wires Format to the - * `ActiveLang` instance automatically. If not, the application must - * pass its own `localeSource` via `options`, otherwise Format falls - * back to its default locale. + * Format runs entirely from a `LocaleSource`. Resolution priority: + * + * 1. `options.localeSource` (explicit override — escape hatch). + * 2. `App.prefs.state.effective.locale` when `prefs` is in the schema. + * This is the user's regional-formatting locale, distinct from + * `App.lang`'s translation locale. + * 3. `App.lang.getLocale()` when `lang` is in the schema and `prefs` + * is not. Preserves the historical wiring for apps that don't yet + * adopt `prefs`. + * 4. Format's own default — when nothing else is available. */ export function defineActiveFormat( options: ActiveFormatOptions = {} -): AppServiceFactory<'format', readonly ['timers'], readonly ['lang'], ActiveFormat> { +): AppServiceFactory<'format', readonly ['timers'], readonly ['prefs', 'lang'], ActiveFormat> { return { name: 'format', coreDependencies: ['timers'], - serviceDependencies: ['lang'], + serviceDependencies: ['prefs', 'lang'], initMode: 'lazy', create({ core, services }): ActiveFormat { + const prefsInstance = services.prefs as ActivePrefs | undefined; const langInstance = services.lang as ActiveLang | undefined; - const localeSource: LocaleSource | undefined = - options.localeSource ?? - (langInstance - ? { - getLocale: () => langInstance.getLocale(), - onLocaleChange: (fn) => langInstance.onLocaleChange(fn) - } - : undefined); + + let localeSource: LocaleSource | undefined = options.localeSource; + if (localeSource === undefined && prefsInstance !== undefined) { + localeSource = prefsLocaleSource(prefsInstance); + } + if (localeSource === undefined && langInstance !== undefined) { + localeSource = { + get: () => langInstance.getLocale(), + onChange: (fn) => langInstance.onLocaleChange(fn) + }; + } return createActiveFormat({ ...options, diff --git a/src/arts/active-app/service-factories/frontend.ts b/src/arts/active-app/service-factories/frontend.ts index 4d7f7dc..1a17e7d 100644 --- a/src/arts/active-app/service-factories/frontend.ts +++ b/src/arts/active-app/service-factories/frontend.ts @@ -3,16 +3,26 @@ 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 type { ActivePrefs } from '$prefs'; import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveFrontend(options)` produces a service factory for the * `frontend` slot. * - * Frontend integrates with `dom` and `lang` automatically when those - * services are declared in the schema. Explicit `dom` / `localeSource` - * passed in `options` take precedence — that is the escape hatch for - * tests and non-standard wiring. + * Frontend integrates with `dom`, `prefs` and `lang` automatically when + * those services are declared in the schema. Resolution priority for + * the locale source (which Frontend uses for `direction = auto` + * derivation): + * + * 1. `options.localeSource` — explicit override. + * 2. `App.prefs.state.effective.language` — Frontend's `dir = auto` + * follows the writing system, which is a property of the + * *language*, not the regional formatting locale. So we pipe + * `prefs.language` (NOT `prefs.locale`) here. + * 3. `App.lang.getLocale()` — historical fallback when prefs isn't + * declared. * * Note: `frontend` previously consumed `App.storage` to persist user * preferences (theme/mode/density). That persistence layer used to @@ -23,23 +33,27 @@ import type { AppServiceFactory } from '../services.ts'; */ export function defineActiveFrontend( options: ActiveFrontendOptions = {} -): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'lang'], ActiveFrontend> { +): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'prefs', 'lang'], ActiveFrontend> { return { name: 'frontend', coreDependencies: [], - serviceDependencies: ['dom', 'lang'], + serviceDependencies: ['dom', 'prefs', 'lang'], initMode: 'lazy', create({ services }): ActiveFrontend { const dom = options.dom ?? (services.dom as ActiveDom | undefined); + const prefsInstance = services.prefs as ActivePrefs | undefined; const langInstance = services.lang as ActiveLang | undefined; - const localeSource: LocaleSource | undefined = - options.localeSource ?? - (langInstance - ? { - getLocale: () => langInstance.getLocale(), - onLocaleChange: (fn) => langInstance.onLocaleChange(fn) - } - : undefined); + + let localeSource: LocaleSource | undefined = options.localeSource; + if (localeSource === undefined && prefsInstance !== undefined) { + localeSource = prefsLanguageSource(prefsInstance); + } + if (localeSource === undefined && langInstance !== undefined) { + localeSource = { + get: () => langInstance.getLocale(), + onChange: (fn) => langInstance.onLocaleChange(fn) + }; + } return createActiveFrontend({ ...options, diff --git a/src/arts/active-app/service-factories/index.ts b/src/arts/active-app/service-factories/index.ts index ffcd0f8..5e5ad05 100644 --- a/src/arts/active-app/service-factories/index.ts +++ b/src/arts/active-app/service-factories/index.ts @@ -23,6 +23,7 @@ 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 { defineActiveSession } from './session.ts'; export { defineActiveStorage } from './storage.ts'; export { defineEngineHttp } from './http.ts'; diff --git a/src/arts/active-app/service-factories/lang.ts b/src/arts/active-app/service-factories/lang.ts index 6430c96..54d7917 100644 --- a/src/arts/active-app/service-factories/lang.ts +++ b/src/arts/active-app/service-factories/lang.ts @@ -1,6 +1,8 @@ import { createActiveLang } from '$lang/active-lang.svelte'; import type { ActiveLang } from '$lang'; import type { LangNode, SupportedLocale } from '$libs/lang'; +import { prefsLanguageSource } from '$prefs'; +import type { ActivePrefs } from '$prefs'; import type { AppServiceFactory } from '../services.ts'; /** @@ -19,28 +21,53 @@ export interface DefineActiveLangOptions { * a service factory for the `lang` slot. The schema generic flows * through to `App.lang` so `t('a.b.c')` keeps end-to-end type safety. * + * When the schema declares `prefs`, this factory drives `lang`'s active + * locale from `prefs.state.effective.language`: the initial value is + * applied at construction time, and changes are forwarded via a + * subscription that the factory tears down on `dispose()`. Apps can + * still call `lang.setLocale(...)` directly — but the next prefs + * change wins, since prefs is the authoritative source. + * * Lang receives no core deps directly — it manages its own logger - * internally via `setLogger(core.logger)` if needed. To keep the schema - * contract clean, we leave that wiring to the application or to a - * later helper. + * internally via `setLogger(core.logger)`. */ export function defineActiveLang( options: DefineActiveLangOptions -): AppServiceFactory<'lang', readonly ['logger'], readonly [], ActiveLang> { +): AppServiceFactory<'lang', readonly ['logger'], readonly ['prefs'], ActiveLang> { + let unsubscribePrefs: (() => void) | undefined; return { name: 'lang', coreDependencies: ['logger'], + serviceDependencies: ['prefs'], initMode: 'lazy', - create({ core }): ActiveLang { + create({ core, services }): ActiveLang { const lang = createActiveLang( options.schema, options.defaultLocale ?? 'es', options.fallbackChain ? [...options.fallbackChain] : undefined ); lang.setLogger(core.logger); + + const prefsInstance = services.prefs as ActivePrefs | undefined; + if (prefsInstance !== undefined) { + const source = prefsLanguageSource(prefsInstance); + // `SupportedLocale` is a literal union derived from the schema's + // locale keys. The cast trusts the app to keep + // `capabilities.languages` aligned with the schema — there is + // no runtime way to validate that without a separate registry, + // and Lang's own `t()` already falls through gracefully on + // unknown locales. + lang.setLocale(source.get() as SupportedLocale); + unsubscribePrefs = source.onChange?.((next) => { + lang.setLocale(next as SupportedLocale); + }); + } + return lang; }, dispose(instance) { + unsubscribePrefs?.(); + unsubscribePrefs = undefined; instance.dispose(); } }; diff --git a/src/arts/active-app/service-factories/prefs.ts b/src/arts/active-app/service-factories/prefs.ts new file mode 100644 index 0000000..412ddfc --- /dev/null +++ b/src/arts/active-app/service-factories/prefs.ts @@ -0,0 +1,42 @@ +import { createActivePrefs } from '$prefs'; +import type { ActivePrefs, EnginePrefsOptions } from '$prefs'; +import type { AppServiceFactory } from '../services.ts'; + +/** + * Options for `defineActivePrefs`. A thin alias of `EnginePrefsOptions` + * — exported here so callers can `import { type DefineActivePrefsOptions } + * from '$active-app'` without reaching into `$prefs`. + */ +export type DefineActivePrefsOptions = EnginePrefsOptions; + +/** + * `defineActivePrefs(options)` produces a service factory for the + * `prefs` slot. Adds the runtime to the App schema as + * `App.prefs: ActivePrefs`, where downstream service factories + * (`defineActiveLang`, `defineActiveFormat`, `defineActiveFrontend`) + * read capability sources via the helpers in `$prefs/sources`. + * + * No core dependencies: the engine is pure data — capabilities, + * environment and intent are passed in by the caller. Environment + * detection lives in `$prefs/adapters/*` and is invoked by the + * application's bootstrap code, not by this factory. + * + * `initMode: 'immediate'` because consumer factories ask for `prefs` + * via `serviceDependencies` at construction time; deferring the build + * would make the dependency graph order-sensitive. + */ +export function defineActivePrefs( + options: DefineActivePrefsOptions +): AppServiceFactory<'prefs', readonly [], readonly [], ActivePrefs> { + return { + name: 'prefs', + coreDependencies: [], + initMode: 'immediate', + create(): ActivePrefs { + return createActivePrefs(options); + }, + dispose(instance) { + 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 new file mode 100644 index 0000000..3b648ec --- /dev/null +++ b/src/arts/active-app/test/prefs-consumer-wiring.test.ts @@ -0,0 +1,149 @@ +/** + * Verifies the cross-cutting wiring done by the consumer factories + * (`defineActiveLang`, `defineActiveFormat`, `defineActiveFrontend`): + * when `prefs` is declared in the schema, those consumers must source + * their locale/language from the prefs engine instead of from each + * other or from their own defaults. + */ + +import { describe, expect, it } from 'vitest'; +import type { PrefsCapabilities } from '$libs/prefs'; +import { createSvelteEngineBus } from '$bus'; +import { createEngineLogger } from '$logger/engine-logger'; +import { createEngineOrca } from '$orca'; +import { createActiveTimers } from '$timer/active-timers.svelte'; +import { buildServiceBuilders } from '../service-builder.ts'; +import { + defineActiveDom, + defineActiveFormat, + defineActiveFrontend, + defineActiveLang, + defineActivePrefs +} from '../service-factories/index.ts'; +import type { CoreServices } from '../services.ts'; + +function buildCore(): CoreServices { + const logger = createEngineLogger({}); + const timers = createActiveTimers({ logger }); + const bus = createSvelteEngineBus({ logger, clock: timers.clock }); + const orca = createEngineOrca({ bus, timers, logger }); + return { logger, bus, timers, orca }; +} + +const CAPS: PrefsCapabilities = { + languages: ['es-ES', 'en-US'], + locales: ['es-ES', 'en-US'], + currencies: ['EUR', 'USD'], + unitSystems: ['metric', 'imperial'], + themes: ['light', 'dark', 'system'], + densities: ['compact', 'comfortable', 'spacious'], + motions: ['allow', 'reduce', 'system'], + defaults: { + language: 'es-ES', + locale: 'es-ES', + currency: 'EUR', + timezone: 'Europe/Madrid', + unitSystem: 'metric', + theme: 'light', + density: 'comfortable', + motion: 'allow', + direction: 'ltr' + } +}; + +const LANG_SCHEMA = { + hello: { 'es-ES': 'Hola', 'en-US': 'Hello' } +}; + +describe('prefs → consumer wiring', () => { + it('lang.setLocale fires when prefs.language changes', () => { + const core = buildCore(); + const builders = buildServiceBuilders( + { + prefs: defineActivePrefs({ capabilities: CAPS }), + lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }) + }, + core + ); + const { prefs, lang } = builders.proxies as { + prefs: { setIntent: (k: 'language', v: string) => unknown }; + lang: { getLocale: () => string; t: (k: 'hello') => string }; + }; + + // Initial language flows from prefs (defaults.language = 'es-ES'). + expect(lang.getLocale()).toBe('es-ES'); + expect(lang.t('hello')).toBe('Hola'); + + // Switching prefs.language triggers lang.setLocale via the + // factory's subscription. + prefs.setIntent('language', 'en-US'); + expect(lang.getLocale()).toBe('en-US'); + expect(lang.t('hello')).toBe('Hello'); + + builders.disposeAll(); + }); + + it('format follows prefs.locale instead of lang when both are declared', () => { + const core = buildCore(); + const builders = buildServiceBuilders( + { + prefs: defineActivePrefs({ capabilities: CAPS }), + lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }), + format: defineActiveFormat() + }, + core + ); + const { prefs, format } = builders.proxies as { + prefs: { setIntent: (k: 'locale', v: string) => unknown }; + format: { getLocale: () => string }; + }; + + expect(format.getLocale()).toBe('es-ES'); + + prefs.setIntent('locale', 'en-US'); + expect(format.getLocale()).toBe('en-US'); + + builders.disposeAll(); + }); + + it('frontend follows prefs.language for direction derivation', () => { + const core = buildCore(); + const builders = buildServiceBuilders( + { + prefs: defineActivePrefs({ capabilities: CAPS }), + dom: defineActiveDom(), + lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }), + frontend: defineActiveFrontend({ applyDom: false }) + }, + core + ); + const { prefs, frontend } = builders.proxies as { + prefs: { setIntent: (k: 'language', v: string) => unknown }; + frontend: { getLocale: () => string }; + }; + + expect(frontend.getLocale()).toBe('es-ES'); + + prefs.setIntent('language', 'en-US'); + expect(frontend.getLocale()).toBe('en-US'); + + builders.disposeAll(); + }); + + it('format / frontend retain the lang fallback when prefs is not declared', () => { + const core = buildCore(); + const builders = buildServiceBuilders( + { + lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'en-US' }), + format: defineActiveFormat() + }, + core + ); + const { format } = builders.proxies as { + format: { getLocale: () => string }; + }; + // No prefs in schema → format falls back to lang's locale. + expect(format.getLocale()).toBe('en-US'); + builders.disposeAll(); + }); +}); diff --git a/src/arts/active-app/test/service-factories.test.ts b/src/arts/active-app/test/service-factories.test.ts index 59a9bd0..77de328 100644 --- a/src/arts/active-app/test/service-factories.test.ts +++ b/src/arts/active-app/test/service-factories.test.ts @@ -12,10 +12,12 @@ import { defineActiveFormat, defineActiveFrontend, defineActiveLang, + defineActivePrefs, defineActiveStorage, defineEngineHttp, defineEngineSium } from '../service-factories/index.ts'; +import type { PrefsCapabilities } from '$libs/prefs'; import { createSvelteEngineBus } from '$bus'; import { createEngineLogger } from '$logger/engine-logger'; import { createEngineOrca } from '$orca'; @@ -99,6 +101,43 @@ describe('service-factories — integration', () => { builders.disposeAll(); }); + it('builds prefs as an immediate-init service exposing the rune surface', () => { + const core = buildCore(); + const caps: PrefsCapabilities = { + languages: ['es-ES', 'en-US'], + locales: ['es-ES', 'en-US'], + currencies: ['EUR', 'USD'], + unitSystems: ['metric', 'imperial'], + themes: ['light', 'dark', 'system'], + densities: ['compact', 'comfortable', 'spacious'], + motions: ['allow', 'reduce', 'system'], + defaults: { + language: 'es-ES', + locale: 'es-ES', + currency: 'EUR', + timezone: 'Europe/Madrid', + unitSystem: 'metric', + theme: 'light', + density: 'comfortable', + motion: 'allow', + direction: 'ltr' + } + }; + const builders = buildServiceBuilders( + { + prefs: defineActivePrefs({ capabilities: caps }) + }, + core + ); + // `immediate` init: the slot is built before any access. + expect(builders.statusMap().prefs).toBe('present'); + + const prefs = (builders.proxies as { prefs: { kind: string; effective(): { locale: string } } }).prefs; + expect(prefs.kind).toBe('prefs'); + expect(prefs.effective().locale).toBe('es-ES'); + builders.disposeAll(); + }); + it('reports failed status when a factory throws on construct', () => { const core = buildCore(); const builders = buildServiceBuilders( diff --git a/svelte.config.js b/svelte.config.js index bb6032d..1a04b85 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -36,6 +36,7 @@ const config = { $logger: 'src/arts/logger', $orca: 'src/arts/orca', $perm: 'src/arts/perm', + $prefs: 'src/arts/prefs', $session: 'src/arts/session', $sium: 'src/arts/sium', $storage: 'src/arts/storage',