Bloque L4 — active-app: defineActivePrefs + consumer wiring

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) <noreply@anthropic.com>
master
dev 5 months ago
parent 620a471850
commit 1f8015f2fe

@ -2,36 +2,47 @@ import { createActiveFormat } from '$format/active-formats.svelte';
import type { ActiveFormat, ActiveFormatOptions } from '$format/active-formats.svelte'; import type { ActiveFormat, ActiveFormatOptions } from '$format/active-formats.svelte';
import type { ActiveLang } from '$lang'; import type { ActiveLang } from '$lang';
import type { LocaleSource } from '$locale'; import type { LocaleSource } from '$locale';
import { prefsLocaleSource } from '$prefs';
import type { ActivePrefs } from '$prefs';
import type { AppServiceFactory } from '../services.ts'; import type { AppServiceFactory } from '../services.ts';
/** /**
* `defineActiveFormat(options)` produces a service factory for the * `defineActiveFormat(options)` produces a service factory for the
* `format` slot. * `format` slot.
* *
* Format runs entirely from a `LocaleSource`. If the application * Format runs entirely from a `LocaleSource`. Resolution priority:
* declares `lang` in its schema, this factory wires Format to the *
* `ActiveLang` instance automatically. If not, the application must * 1. `options.localeSource` (explicit override — escape hatch).
* pass its own `localeSource` via `options`, otherwise Format falls * 2. `App.prefs.state.effective.locale` when `prefs` is in the schema.
* back to its default locale. * 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( export function defineActiveFormat(
options: ActiveFormatOptions = {} options: ActiveFormatOptions = {}
): AppServiceFactory<'format', readonly ['timers'], readonly ['lang'], ActiveFormat> { ): AppServiceFactory<'format', readonly ['timers'], readonly ['prefs', 'lang'], ActiveFormat> {
return { return {
name: 'format', name: 'format',
coreDependencies: ['timers'], coreDependencies: ['timers'],
serviceDependencies: ['lang'], serviceDependencies: ['prefs', 'lang'],
initMode: 'lazy', initMode: 'lazy',
create({ core, services }): ActiveFormat { create({ core, services }): ActiveFormat {
const prefsInstance = services.prefs as ActivePrefs | undefined;
const langInstance = services.lang as ActiveLang | undefined; const langInstance = services.lang as ActiveLang | undefined;
const localeSource: LocaleSource | undefined =
options.localeSource ?? let localeSource: LocaleSource | undefined = options.localeSource;
(langInstance if (localeSource === undefined && prefsInstance !== undefined) {
? { localeSource = prefsLocaleSource(prefsInstance);
getLocale: () => langInstance.getLocale(), }
onLocaleChange: (fn) => langInstance.onLocaleChange(fn) if (localeSource === undefined && langInstance !== undefined) {
localeSource = {
get: () => langInstance.getLocale(),
onChange: (fn) => langInstance.onLocaleChange(fn)
};
} }
: undefined);
return createActiveFormat({ return createActiveFormat({
...options, ...options,

@ -3,16 +3,26 @@ import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-fro
import type { ActiveDom } from '$adom'; import type { ActiveDom } from '$adom';
import type { ActiveLang } from '$lang'; import type { ActiveLang } from '$lang';
import type { LocaleSource } from '$locale'; import type { LocaleSource } from '$locale';
import { prefsLanguageSource } from '$prefs';
import type { ActivePrefs } from '$prefs';
import type { AppServiceFactory } from '../services.ts'; import type { AppServiceFactory } from '../services.ts';
/** /**
* `defineActiveFrontend(options)` produces a service factory for the * `defineActiveFrontend(options)` produces a service factory for the
* `frontend` slot. * `frontend` slot.
* *
* Frontend integrates with `dom` and `lang` automatically when those * Frontend integrates with `dom`, `prefs` and `lang` automatically when
* services are declared in the schema. Explicit `dom` / `localeSource` * those services are declared in the schema. Resolution priority for
* passed in `options` take precedence — that is the escape hatch for * the locale source (which Frontend uses for `direction = auto`
* tests and non-standard wiring. * 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 * Note: `frontend` previously consumed `App.storage` to persist user
* preferences (theme/mode/density). That persistence layer used to * preferences (theme/mode/density). That persistence layer used to
@ -23,23 +33,27 @@ import type { AppServiceFactory } from '../services.ts';
*/ */
export function defineActiveFrontend( export function defineActiveFrontend(
options: ActiveFrontendOptions = {} options: ActiveFrontendOptions = {}
): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'lang'], ActiveFrontend> { ): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'prefs', 'lang'], ActiveFrontend> {
return { return {
name: 'frontend', name: 'frontend',
coreDependencies: [], coreDependencies: [],
serviceDependencies: ['dom', 'lang'], serviceDependencies: ['dom', 'prefs', 'lang'],
initMode: 'lazy', initMode: 'lazy',
create({ services }): ActiveFrontend { create({ services }): ActiveFrontend {
const dom = options.dom ?? (services.dom as ActiveDom | undefined); const dom = options.dom ?? (services.dom as ActiveDom | undefined);
const prefsInstance = services.prefs as ActivePrefs | undefined;
const langInstance = services.lang as ActiveLang | undefined; const langInstance = services.lang as ActiveLang | undefined;
const localeSource: LocaleSource | undefined =
options.localeSource ?? let localeSource: LocaleSource | undefined = options.localeSource;
(langInstance if (localeSource === undefined && prefsInstance !== undefined) {
? { localeSource = prefsLanguageSource(prefsInstance);
getLocale: () => langInstance.getLocale(), }
onLocaleChange: (fn) => langInstance.onLocaleChange(fn) if (localeSource === undefined && langInstance !== undefined) {
localeSource = {
get: () => langInstance.getLocale(),
onChange: (fn) => langInstance.onLocaleChange(fn)
};
} }
: undefined);
return createActiveFrontend({ return createActiveFrontend({
...options, ...options,

@ -23,6 +23,7 @@ export { defineActiveFormat } from './format.ts';
export { defineActiveFrontend } from './frontend.ts'; export { defineActiveFrontend } from './frontend.ts';
export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts'; export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts';
export { defineActivePerm } from './perm.ts'; export { defineActivePerm } from './perm.ts';
export { defineActivePrefs, type DefineActivePrefsOptions } from './prefs.ts';
export { defineActiveSession } from './session.ts'; export { defineActiveSession } from './session.ts';
export { defineActiveStorage } from './storage.ts'; export { defineActiveStorage } from './storage.ts';
export { defineEngineHttp } from './http.ts'; export { defineEngineHttp } from './http.ts';

@ -1,6 +1,8 @@
import { createActiveLang } from '$lang/active-lang.svelte'; import { createActiveLang } from '$lang/active-lang.svelte';
import type { ActiveLang } from '$lang'; import type { ActiveLang } from '$lang';
import type { LangNode, SupportedLocale } from '$libs/lang'; import type { LangNode, SupportedLocale } from '$libs/lang';
import { prefsLanguageSource } from '$prefs';
import type { ActivePrefs } from '$prefs';
import type { AppServiceFactory } from '../services.ts'; import type { AppServiceFactory } from '../services.ts';
/** /**
@ -19,28 +21,53 @@ export interface DefineActiveLangOptions<S extends LangNode> {
* a service factory for the `lang` slot. The schema generic flows * 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. * 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 * Lang receives no core deps directly — it manages its own logger
* internally via `setLogger(core.logger)` if needed. To keep the schema * internally via `setLogger(core.logger)`.
* contract clean, we leave that wiring to the application or to a
* later helper.
*/ */
export function defineActiveLang<S extends LangNode>( export function defineActiveLang<S extends LangNode>(
options: DefineActiveLangOptions<S> options: DefineActiveLangOptions<S>
): AppServiceFactory<'lang', readonly ['logger'], readonly [], ActiveLang<S>> { ): AppServiceFactory<'lang', readonly ['logger'], readonly ['prefs'], ActiveLang<S>> {
let unsubscribePrefs: (() => void) | undefined;
return { return {
name: 'lang', name: 'lang',
coreDependencies: ['logger'], coreDependencies: ['logger'],
serviceDependencies: ['prefs'],
initMode: 'lazy', initMode: 'lazy',
create({ core }): ActiveLang<S> { create({ core, services }): ActiveLang<S> {
const lang = createActiveLang<S>( const lang = createActiveLang<S>(
options.schema, options.schema,
options.defaultLocale ?? 'es', options.defaultLocale ?? 'es',
options.fallbackChain ? [...options.fallbackChain] : undefined options.fallbackChain ? [...options.fallbackChain] : undefined
); );
lang.setLogger(core.logger); 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; return lang;
}, },
dispose(instance) { dispose(instance) {
unsubscribePrefs?.();
unsubscribePrefs = undefined;
instance.dispose(); instance.dispose();
} }
}; };

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

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

@ -12,10 +12,12 @@ import {
defineActiveFormat, defineActiveFormat,
defineActiveFrontend, defineActiveFrontend,
defineActiveLang, defineActiveLang,
defineActivePrefs,
defineActiveStorage, defineActiveStorage,
defineEngineHttp, defineEngineHttp,
defineEngineSium defineEngineSium
} from '../service-factories/index.ts'; } from '../service-factories/index.ts';
import type { PrefsCapabilities } from '$libs/prefs';
import { createSvelteEngineBus } from '$bus'; import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger'; import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca'; import { createEngineOrca } from '$orca';
@ -99,6 +101,43 @@ describe('service-factories — integration', () => {
builders.disposeAll(); 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', () => { it('reports failed status when a factory throws on construct', () => {
const core = buildCore(); const core = buildCore();
const builders = buildServiceBuilders( const builders = buildServiceBuilders(

@ -36,6 +36,7 @@ const config = {
$logger: 'src/arts/logger', $logger: 'src/arts/logger',
$orca: 'src/arts/orca', $orca: 'src/arts/orca',
$perm: 'src/arts/perm', $perm: 'src/arts/perm',
$prefs: 'src/arts/prefs',
$session: 'src/arts/session', $session: 'src/arts/session',
$sium: 'src/arts/sium', $sium: 'src/arts/sium',
$storage: 'src/arts/storage', $storage: 'src/arts/storage',

Loading…
Cancel
Save

Powered by TurnKey Linux.