diff --git a/demos/dating/web/_lib/app.ts b/demos/dating/web/_lib/app.ts index 3b94bf2..feb6665 100644 --- a/demos/dating/web/_lib/app.ts +++ b/demos/dating/web/_lib/app.ts @@ -19,13 +19,12 @@ import { defineActiveFormat, defineActiveFrontend, defineActiveLang, - defineActivePrefs, defineActiveSession, defineActiveStorage, defineEngineHttp, defineEngineSium } from '$active-app/services'; -import type { PrefsCapabilities } from '$libs/prefs'; +import { standardPrefsDimensions } from '$prefs'; import type { DatingUser } from './types.ts'; import { createDatingApiClient, type DatingApiClient } from './api.ts'; @@ -57,25 +56,24 @@ export const NEXO_LANG_SCHEMA = { } } as const; -export const NEXO_PREFS_CAPABILITIES: PrefsCapabilities = { - languages: ['es', 'en'], - 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', - locale: 'es-ES', - currency: 'EUR', - timezone: 'Europe/Madrid', - unitSystem: 'metric', - theme: 'light', - density: 'comfortable', - motion: 'allow', - direction: 'ltr' - } +/** + * Nexo prefs schema. Composes the canonical built-in dimensions + * (`language`, `locale`, `currency`, `theme`, …) around the demo's + * catalogs. App-specific prefs would join the spread; today the demo + * doesn't have any beyond the standard set. + */ +export const NEXO_PREFS_SCHEMA = { + ...standardPrefsDimensions({ + languages: ['es', 'en'], + locales: ['es-ES', 'en-US'], + currencies: ['EUR', 'USD'], + defaults: { + language: 'es', + locale: 'es-ES', + currency: 'EUR', + timezone: 'Europe/Madrid' + } + }) }; export interface CreateDatingAppOptions { @@ -106,13 +104,17 @@ export type DatingApp = ReturnType; function composeApp(options: CreateDatingAppOptions) { return createActiveApp({ + // Prefs is core. Each schema key becomes a typed dimension on + // `App.prefs.` (`App.prefs.locale.set('en-US')` etc.). + // Persistence is a follow-up: when we wire `arts/storage` → + // `PrefsIntentStorage`, attach `storage` here. + prefs: { schema: NEXO_PREFS_SCHEMA }, services: { lang: defineActiveLang({ schema: NEXO_LANG_SCHEMA, defaultLocale: 'es', fallbackChain: ['en'] }), - prefs: defineActivePrefs({ capabilities: NEXO_PREFS_CAPABILITIES }), storage: defineActiveStorage({ namespace: 'nexo' }), frontend: defineActiveFrontend({ target: options.frontendTarget, diff --git a/scripts/check-aliases.mjs b/scripts/check-aliases.mjs index acbaf52..37c9f86 100644 --- a/scripts/check-aliases.mjs +++ b/scripts/check-aliases.mjs @@ -6,8 +6,9 @@ * the move from capitalized service slots (`App.Cache`) to lowercase * declarable services (`App.cache`). * - * Allowed capitalised names on `App.*` are the ecosystem core: - * `Logger`, `Bus`, `Timers`, `Orca`. Everything else is lowercase. + * Every member of `App.*` is lowercase, including the core + * (`logger` / `bus` / `timers` / `orca` / `prefs`). PascalCase on the + * App proxy is forbidden. * * Run: `node scripts/check-aliases.mjs` * Exit code: 0 when clean, 1 when any forbidden token is found. @@ -38,6 +39,11 @@ const STALE_ALIAS_PATTERNS = [ ]; const APP_FORBIDDEN_CAPITALS = [ + 'Logger', + 'Bus', + 'Timers', + 'Orca', + 'Prefs', 'Cache', 'Sess', 'Session', @@ -52,22 +58,34 @@ const APP_FORBIDDEN_CAPITALS = [ 'Permissions', 'Http', 'Dom', - 'Connections', - 'Prefs' + 'Connections' ]; const APP_PATTERN = new RegExp(`\\bApp\\.(${APP_FORBIDDEN_CAPITALS.join('|')})\\b`, 'g'); /** * Pre-`createActiveApp({ services })` API surfaces. The current runtime - * routes locale through `App.lang` / `App.prefs` and validation - * through `App.sium`; these old method handles no longer exist on the - * App proxy and call sites must be migrated. The audit explicitly - * flagged that the previous version of this guard missed them. + * routes locale through `App.lang` / `App.prefs` and validation through + * `App.sium`; these old method handles no longer exist on the App + * proxy and call sites must be migrated. + * + * The dimension API on `App.prefs..set(value)` supersedes + * `App.prefs.setIntent('', value)` for ergonomic call sites; the + * lower-level `setIntent` stays available for adapters but UI code + * should use the dimension surface. */ const OLD_APP_METHOD_PATTERNS = [ - { rx: /\bApp\.setLocale\b/g, hint: 'App.setLocale → App.lang.setLocale (or App.prefs.setIntent("language", ...))' }, - { rx: /\bApp\.getLocale\b/g, hint: 'App.getLocale → App.lang.getLocale (or App.prefs.effective().language)' }, - { rx: /\bApp\.createSiumEngine\b/g, hint: 'App.createSiumEngine → declare `sium: defineEngineSium()` in services and read App.sium' } + { + rx: /\bApp\.setLocale\b/g, + hint: 'App.setLocale → App.lang.setLocale (or App.prefs.locale.set(...))' + }, + { + rx: /\bApp\.getLocale\b/g, + hint: 'App.getLocale → App.lang.getLocale (or App.prefs.locale.get())' + }, + { + rx: /\bApp\.createSiumEngine\b/g, + hint: 'App.createSiumEngine → declare `sium: defineEngineSium()` in services and read App.sium' + } ]; const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', '.git', 'build', 'dist']); diff --git a/src/arts/active-app/README.md b/src/arts/active-app/README.md index b2ad33a..f6a8da6 100644 --- a/src/arts/active-app/README.md +++ b/src/arts/active-app/README.md @@ -161,7 +161,7 @@ error — the wrapping is cheap and uniform. ## Orchestration -`App.Orca` is always present and inert. Reactions are not pre-wired — apps +`App.orca` is always present and inert. Reactions are not pre-wired — apps register them explicitly through orca presets in `arts/active-app/presets/`. ```ts @@ -194,7 +194,7 @@ never on a sibling art. The Svelte-context helper `setBus` / `getBus` lives in `$bus`, not here. The bus is the semantic owner of the propagation pattern; App is just a -consumer that calls `setBus(App.Bus)` once near the layout root. +consumer that calls `setBus(App.bus)` once near the layout root. ```svelte @@ -202,7 +202,7 @@ consumer that calls `setBus(App.Bus)` once near the layout root. import { setBus } from '$bus'; import { App } from './app'; - setBus(App.Bus); + setBus(App.bus); ``` diff --git a/src/arts/active-app/active-app.svelte.ts b/src/arts/active-app/active-app.svelte.ts index 9e88fb1..b91187e 100644 --- a/src/arts/active-app/active-app.svelte.ts +++ b/src/arts/active-app/active-app.svelte.ts @@ -1,16 +1,30 @@ /** * `createActiveApp()` — composed runtime root. * - * Builds the four pieces of the core (Logger, Bus, Timers, Orca) and - * then defers everything else to the declarative service schema. The - * function itself is short on purpose — every art-specific knob has - * moved to its `defineActive*` / `defineEngine*` factory. + * Builds the five pieces of the core (`logger`, `bus`, `timers`, + * `orca`, `prefs`) and then defers everything else to the declarative + * service schema. The function itself is short on purpose — every + * art-specific knob has moved to its `defineActive*` / + * `defineEngine*` factory. + * + * Lowercase core surface — `App.logger` / `App.bus` / `App.timers` / + * `App.orca` / `App.prefs`. The previous PascalCase rule for core + * members existed for visual signalling and went against JS property + * convention. Removed. */ import type { EngineBus } from '$bus'; import { createSvelteEngineBus } from '$bus'; import { createEngineLogger } from '$logger/engine-logger'; import { createEngineOrca } from '$orca'; +import { + createActivePrefs, + createPrefsStorageBridge, + NEUTRAL_PREFS_SCHEMA, + type ActivePrefs, + type PrefsStorageBridge +} from '$prefs'; +import type { PrefsSchema } from '$libs/prefs'; import { createActiveTimers } from '$timer/active-timers.svelte'; import { APP_MODULE } from './consts.ts'; @@ -21,68 +35,74 @@ import type { ActiveApp, ActiveAppBusEvents, ActiveAppCore, - ActiveAppOptions + ActiveAppOptions, + ActiveAppPrefsOptions } from './types.ts'; -export function createActiveApp( - options: ActiveAppOptions = {} -): ActiveApp { +export function createActiveApp< + TSchema extends AppServiceSchema = AppServiceSchema, + TPrefsSchema extends PrefsSchema = PrefsSchema +>(options: ActiveAppOptions = {}): ActiveApp { // ── Core ──────────────────────────────────────────────────────────── - const Logger = createEngineLogger(options.logger); + const logger = createEngineLogger(options.logger); - const Timers = createActiveTimers({ + const timers = createActiveTimers({ ...options.timers, - logger: Logger + logger }); - const Bus = createSvelteEngineBus({ + const bus = createSvelteEngineBus({ ...options.bus, - logger: Logger, - clock: Timers.clock + logger, + clock: timers.clock }); - const Orca = createEngineOrca({ + const orca = createEngineOrca({ ...options.orca, - bus: Bus, - timers: Timers, - logger: Logger + bus, + timers, + logger }); + const { engine: prefs, bridge: prefsBridge } = buildPrefs(options.prefs); + // ── Services ──────────────────────────────────────────────────────── - const core = coreForBuilder(Logger, Bus, Timers, Orca); + const core = coreForBuilder(logger, bus, timers, orca, prefs); const serviceBuilders = options.services ? buildServiceBuilders(options.services as AppServiceSchema, core) : undefined; let disposed = false; - const baseApp: ActiveAppCore = { - Logger, - Bus, - Timers, - Orca, + const baseApp: ActiveAppCore = { + logger, + bus, + timers, + orca, + prefs: prefs as ActivePrefs, dispose() { if (disposed) return; disposed = true; // Announce dispose BEFORE tearing anything down so subscribers // can still reach the bus and any service they depend on. - publishAppDisposeStarting(Bus, { cause: APP_MODULE }); + publishAppDisposeStarting(bus, { cause: APP_MODULE }); // Schema-declared services first (reverse construction order // is handled by the builder). serviceBuilders?.disposeAll(); - // Core last, in reverse build order. - Orca.dispose(); - Bus.dispose(); - Timers.dispose(); - Logger.dispose(); + // Prefs bridge tears down before the engine so a late storage + // op can't race a disposed engine. + prefsBridge?.dispose(); + prefs.dispose(); + // Remaining core last, in reverse build order. + orca.dispose(); + bus.dispose(); + timers.dispose(); + logger.dispose(); } }; - // Compose the final App: core + schema services + status - // introspection. Services are exposed as own properties via - // `Object.defineProperty` so lazy getters are preserved. - const app = baseApp as ActiveApp; + const app = baseApp as ActiveApp; if (serviceBuilders !== undefined) { for (const name of Object.keys(serviceBuilders.proxies)) { @@ -112,6 +132,35 @@ export function createActiveApp( + options: ActiveAppPrefsOptions | undefined +): { + engine: ActivePrefs; + bridge: PrefsStorageBridge | undefined; +} { + const schema = + (options?.schema as S | undefined) ?? (NEUTRAL_PREFS_SCHEMA as unknown as S); + const engine = createActivePrefs({ + schema, + environment: options?.environment, + intent: options?.intent + }); + let bridge: PrefsStorageBridge | undefined; + if (options?.storage !== undefined) { + bridge = createPrefsStorageBridge({ + engine, + storage: options.storage, + onError: options.onStorageError + }); + } + return { engine, bridge }; +} + /** * Adapt the App-level core to the generic `CoreServices` contract that * service factories see. @@ -122,21 +171,19 @@ export function createActiveApp { +export interface CacheClearOnIdentityChangeApp extends Pick { readonly cache: Pick; } @@ -24,7 +24,7 @@ export interface CacheClearOnIdentityChangeApp extends Pick void { - return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { + return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, provides: [TOKEN_CLEARED], diff --git a/src/arts/active-app/presets/cache-clear-on-revoke.ts b/src/arts/active-app/presets/cache-clear-on-revoke.ts index 8d44443..397d8b9 100644 --- a/src/arts/active-app/presets/cache-clear-on-revoke.ts +++ b/src/arts/active-app/presets/cache-clear-on-revoke.ts @@ -13,7 +13,7 @@ const TOKEN_CLEARED = 'cache:cleared-on-revoke'; * declares a compatible cache, regardless of what other services it * has. */ -export interface CacheClearOnRevokeApp extends Pick { +export interface CacheClearOnRevokeApp extends Pick { readonly cache: Pick; } @@ -26,7 +26,7 @@ export interface CacheClearOnRevokeApp extends Pick { * Returns a detach function. Calling it unregisters the action. */ export function applyCacheClearOnRevoke(App: CacheClearOnRevokeApp): () => void { - return App.Orca.onEvent(SESSION_EVENT_REVOKED, { + return App.orca.onEvent(SESSION_EVENT_REVOKED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, provides: [TOKEN_CLEARED], diff --git a/src/arts/active-app/presets/connections-close-on-revoke.ts b/src/arts/active-app/presets/connections-close-on-revoke.ts index 6817b10..777f029 100644 --- a/src/arts/active-app/presets/connections-close-on-revoke.ts +++ b/src/arts/active-app/presets/connections-close-on-revoke.ts @@ -10,7 +10,7 @@ const CLOSE_REASON = 'session-revoked'; /** * Shape this preset requires from `App`. Only `closeAll()` is needed. */ -export interface ConnectionsCloseOnRevokeApp extends Pick { +export interface ConnectionsCloseOnRevokeApp extends Pick { readonly connections: Pick; } @@ -25,7 +25,7 @@ export interface ConnectionsCloseOnRevokeApp extends Pick export function applyConnectionsCloseOnRevoke( App: ConnectionsCloseOnRevokeApp ): () => void { - return App.Orca.onEvent(SESSION_EVENT_REVOKED, { + return App.orca.onEvent(SESSION_EVENT_REVOKED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, provides: [TOKEN_CLOSED], diff --git a/src/arts/active-app/presets/connections-reauth-on-identity-change.ts b/src/arts/active-app/presets/connections-reauth-on-identity-change.ts index d08f6b1..84d4e41 100644 --- a/src/arts/active-app/presets/connections-reauth-on-identity-change.ts +++ b/src/arts/active-app/presets/connections-reauth-on-identity-change.ts @@ -12,7 +12,7 @@ const TOKEN_REAUTHENTICATED = 'connections:reauthenticated-on-identity'; * inject any compatible adapter — no need to expose the full * `ActiveConnections` surface. */ -export interface ConnectionsReauthOnIdentityChangeApp extends Pick { +export interface ConnectionsReauthOnIdentityChangeApp extends Pick { readonly connections: Pick; } @@ -30,7 +30,7 @@ export interface ConnectionsReauthOnIdentityChangeApp extends Pick void { - return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { + return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, provides: [TOKEN_REAUTHENTICATED], diff --git a/src/arts/active-app/presets/index.ts b/src/arts/active-app/presets/index.ts index d0b812c..d38e4e2 100644 --- a/src/arts/active-app/presets/index.ts +++ b/src/arts/active-app/presets/index.ts @@ -1,6 +1,6 @@ /** * Orchestration presets for `arts/active-app`. Each `apply*` function - * registers one or more orca actions on `App.Orca` that react to + * registers one or more orca actions on `App.orca` that react to * canonical lifecycle events (`SESSION_EVENT_*`, future * `CONNECTION_EVENT_*`, etc.) and call the imperative API of the * affected service. diff --git a/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts b/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts index 8de0ea4..1397a8f 100644 --- a/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts +++ b/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts @@ -6,7 +6,7 @@ import type { ActiveAppCore } from '../types.ts'; const ACTION_ID = 'perm.invalidate-on-identity-change'; const TOKEN_INVALIDATED = 'perm:invalidated-on-identity'; -export interface PermInvalidateOnIdentityChangeApp extends Pick { +export interface PermInvalidateOnIdentityChangeApp extends Pick { readonly perm: Pick; } @@ -20,7 +20,7 @@ export interface PermInvalidateOnIdentityChangeApp extends Pick void { - return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { + return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, provides: [TOKEN_INVALIDATED], diff --git a/src/arts/active-app/presets/session-auto-refresh.ts b/src/arts/active-app/presets/session-auto-refresh.ts index 7d0885d..29dc0d5 100644 --- a/src/arts/active-app/presets/session-auto-refresh.ts +++ b/src/arts/active-app/presets/session-auto-refresh.ts @@ -9,12 +9,12 @@ import type { ActiveAppCore } from '../types.ts'; /** * Shape this preset requires from `App`. Only the `session` slot is * needed; the core's `Timers` is consumed automatically so that the - * refresh ticker runs through `App.Timers` (one clock for the whole + * refresh ticker runs through `App.timers` (one clock for the whole * ecosystem) instead of the host `setInterval` fallback baked into * `withAutoRefresh`. */ export interface SessionAutoRefreshApp - extends Pick { + extends Pick { readonly session: ActiveSession; } @@ -34,7 +34,7 @@ export function applySessionAutoRefresh App.Timers.clock.now()) + timers: options.timers ?? App.timers, + now: options.now ?? (() => App.timers.clock.now()) }); } diff --git a/src/arts/active-app/presets/standard.ts b/src/arts/active-app/presets/standard.ts index 31c3e57..a351727 100644 --- a/src/arts/active-app/presets/standard.ts +++ b/src/arts/active-app/presets/standard.ts @@ -15,7 +15,7 @@ import { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identi * pieces — so an App that only declares `cache` (without `perm`) gets * cache-related presets and nothing else. */ -export interface StandardOrcaApp extends Pick { +export interface StandardOrcaApp extends Pick { readonly cache?: Pick; readonly perm?: Pick; readonly connections?: Pick; diff --git a/src/arts/active-app/service-factories/cache.ts b/src/arts/active-app/service-factories/cache.ts index 3c7e495..597676b 100644 --- a/src/arts/active-app/service-factories/cache.ts +++ b/src/arts/active-app/service-factories/cache.ts @@ -10,7 +10,7 @@ import type { AppServiceFactory } from '../services.ts'; * outside via orca presets (e.g. `applyCacheClearOnIdentityChange` in * `arts/active-app/presets/`). The factory wires `logger` and * `clock` from the core — TTL evaluation and any other now-based - * math then flow through `App.Timers.clock`, the same time source + * math then flow through `App.timers.clock`, the same time source * the rest of the ecosystem uses. */ export function defineActiveCache( diff --git a/src/arts/active-app/service-factories/format.ts b/src/arts/active-app/service-factories/format.ts index b93b781..ca39c85 100644 --- a/src/arts/active-app/service-factories/format.ts +++ b/src/arts/active-app/service-factories/format.ts @@ -1,9 +1,6 @@ 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'; /** @@ -13,43 +10,40 @@ import type { AppServiceFactory } from '../services.ts'; * 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. + * 2. `App.prefs.locale` — the user's regional formatting locale, + * always present when the prefs schema declares a `locale` + * dimension (true for `standardPrefsDimensions(...)` callers). + * + * Apps that ship a custom prefs schema without a `locale` dimension + * must pass `options.localeSource` explicitly. */ export function defineActiveFormat( options: ActiveFormatOptions = {} -): AppServiceFactory<'format', readonly ['timers'], readonly ['prefs', 'lang'], ActiveFormat> { +): AppServiceFactory<'format', readonly ['timers', 'prefs'], readonly [], ActiveFormat> { return { name: 'format', - coreDependencies: ['timers'], - serviceDependencies: ['prefs', 'lang'], + coreDependencies: ['timers', 'prefs'], initMode: 'lazy', - create({ core, services }): ActiveFormat { - const prefsInstance = services.prefs as ActivePrefs | undefined; - const langInstance = services.lang as ActiveLang | undefined; - + create({ core }): ActiveFormat { 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) - }; + if (localeSource === undefined) { + const localeDim = (core.prefs as unknown as Record)['locale'] as + | { + get(): string; + onChange(handler: (value: string) => void): () => void; + } + | undefined; + if (localeDim !== undefined) { + localeSource = { + get: () => localeDim.get(), + onChange: (fn) => localeDim.onChange(fn) + }; + } } return createActiveFormat({ ...options, localeSource, - // Caller-provided clock takes precedence; otherwise route the - // App's authoritative clock so rate-expiration math is - // deterministic in tests and consistent across modules. clock: options.clock ?? core.timers.clock }); }, diff --git a/src/arts/active-app/service-factories/frontend.ts b/src/arts/active-app/service-factories/frontend.ts index 249e9dc..f17ca93 100644 --- a/src/arts/active-app/service-factories/frontend.ts +++ b/src/arts/active-app/service-factories/frontend.ts @@ -1,64 +1,64 @@ import { createActiveFrontend } from '$frontend/active-frontend.svelte'; import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-frontend.svelte'; import type { ActiveDom } from '$adom'; -import type { ActiveLang } from '$lang'; import type { LocaleSource } from '$locale'; -import { - prefsDensitySource, - prefsDirectionSource, - prefsLanguageSource, - prefsMotionSource, - prefsThemeSource -} from '$prefs'; -import type { ActivePrefs } from '$prefs'; import type { AppServiceFactory } from '../services.ts'; +/** + * Active dimension shape accessed off `core.prefs.`. Service + * factories stay defensive about which dimensions a given app declares + * — when a dimension is missing, the factory degrades gracefully. + */ +interface PrefsSlot { + get(): T; + onChange(handler: (value: T) => void): () => void; +} + +function readSlot(prefs: unknown, key: string): PrefsSlot | undefined { + const slot = (prefs as Record)[key]; + if ( + slot !== null && + typeof slot === 'object' && + typeof (slot as { get?: unknown }).get === 'function' && + typeof (slot as { onChange?: unknown }).onChange === 'function' + ) { + return slot as PrefsSlot; + } + return undefined; +} + /** * `defineActiveFrontend(options)` produces a service factory for the * `frontend` slot. * - * 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): + * Frontend integrates with `core.prefs` for theme / density / motion / + * direction (when those dimensions are declared in the prefs schema) + * and with `dom` when declared as a service. The locale source for + * `direction = auto` derivation comes from `core.prefs.language` — + * Frontend follows the writing system, which is a property of the + * *language*, not the regional formatting locale. * - * 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 - * live in `arts/active-app/integrations/frontend-storage.ts`. After the - * refactor, persistence is the application's concern: it can be - * implemented as an orca preset, as an integration helper, or built - * into a custom Frontend wrapper. The factory itself stays slim. + * Each integration is conditional on the dimension being present, so + * apps with custom prefs schemas don't break by omitting one. */ export function defineActiveFrontend( options: ActiveFrontendOptions = {} -): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'prefs', 'lang'], ActiveFrontend> { +): AppServiceFactory<'frontend', readonly ['prefs'], readonly ['dom'], ActiveFrontend> { const detachers: Array<() => void> = []; return { name: 'frontend', - coreDependencies: [], - serviceDependencies: ['dom', 'prefs', 'lang'], + coreDependencies: ['prefs'], + serviceDependencies: ['dom'], initMode: 'lazy', - create({ services }): ActiveFrontend { + create({ core, 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 languageSlot = readSlot(core.prefs, 'language'); let localeSource: LocaleSource | undefined = options.localeSource; - if (localeSource === undefined && prefsInstance !== undefined) { - localeSource = prefsLanguageSource(prefsInstance); - } - if (localeSource === undefined && langInstance !== undefined) { + if (localeSource === undefined && languageSlot !== undefined) { localeSource = { - get: () => langInstance.getLocale(), - onChange: (fn) => langInstance.onLocaleChange(fn) + get: () => languageSlot.get(), + onChange: (fn) => languageSlot.onChange(fn) }; } @@ -68,39 +68,38 @@ export function defineActiveFrontend( 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); + // Theme / density / motion / direction integrations are + // per-dimension — each only fires when its own value + // changes. `prefs.theme` (light|dark|system) 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. + const themeSlot = readSlot<'light' | 'dark'>(core.prefs, 'theme'); + if (themeSlot !== undefined) { + frontend.setMode(themeSlot.get()); + detachers.push(themeSlot.onChange((value) => frontend.setMode(value))); + } - frontend.setMode(themeSrc.get()); - frontend.setDensity(densitySrc.get()); - frontend.setReducedMotion(motionSrc.get() === 'reduce'); - frontend.setDir(directionSrc.get()); + const densitySlot = readSlot(core.prefs, 'density'); + if (densitySlot !== undefined) { + frontend.setDensity(densitySlot.get() as never); + detachers.push( + densitySlot.onChange((value) => frontend.setDensity(value as never)) + ); + } - 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 motionSlot = readSlot<'allow' | 'reduce'>(core.prefs, 'motion'); + if (motionSlot !== undefined) { + frontend.setReducedMotion(motionSlot.get() === 'reduce'); + detachers.push( + motionSlot.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); + const directionSlot = readSlot<'ltr' | 'rtl'>(core.prefs, 'direction'); + if (directionSlot !== undefined) { + frontend.setDir(directionSlot.get()); + detachers.push(directionSlot.onChange((value) => frontend.setDir(value))); } return frontend; diff --git a/src/arts/active-app/service-factories/index.ts b/src/arts/active-app/service-factories/index.ts index cb757f3..f02087d 100644 --- a/src/arts/active-app/service-factories/index.ts +++ b/src/arts/active-app/service-factories/index.ts @@ -23,12 +23,10 @@ export { defineActiveFormat } from './format.ts'; export { defineActiveFrontend } from './frontend.ts'; export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts'; export { defineActivePerm } from './perm.ts'; -export { - defineActivePrefs, - defineActivePrefsWithStorage, - type DefineActivePrefsOptions, - type DefineActivePrefsWithStorageOptions -} from './prefs.ts'; +// `prefs` is part of the core (see `arts/active-app/services.ts` → +// `CoreServices.prefs`). It does not have a service-factory because +// every App ALWAYS has it; configure it via `createActiveApp({ prefs: +// { ... } })`. 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 54d7917..76a45c6 100644 --- a/src/arts/active-app/service-factories/lang.ts +++ b/src/arts/active-app/service-factories/lang.ts @@ -1,8 +1,6 @@ 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'; /** @@ -21,26 +19,23 @@ 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)`. + * Lang follows `App.prefs.language` automatically — it reads the + * dimension's current value at construction time, then subscribes via + * `language.onChange(...)` to forward future changes through + * `lang.setLocale(...)`. Apps that compose their prefs schema without a + * `language` dimension still get a working `lang` (it falls back to the + * configured `defaultLocale`); the contract is "if you want lang to + * track user intent, declare a `language` dimension in prefs". */ export function defineActiveLang( options: DefineActiveLangOptions -): AppServiceFactory<'lang', readonly ['logger'], readonly ['prefs'], ActiveLang> { - let unsubscribePrefs: (() => void) | undefined; +): AppServiceFactory<'lang', readonly ['logger', 'prefs'], readonly [], ActiveLang> { + let unsubscribe: (() => void) | undefined; return { name: 'lang', - coreDependencies: ['logger'], - serviceDependencies: ['prefs'], + coreDependencies: ['logger', 'prefs'], initMode: 'lazy', - create({ core, services }): ActiveLang { + create({ core }): ActiveLang { const lang = createActiveLang( options.schema, options.defaultLocale ?? 'es', @@ -48,17 +43,12 @@ export function defineActiveLang( ); 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) => { + const languageDim = (core.prefs as unknown as Record)['language'] as + | { get(): string; onChange(handler: (value: string) => void): () => void } + | undefined; + if (languageDim !== undefined) { + lang.setLocale(languageDim.get() as SupportedLocale); + unsubscribe = languageDim.onChange((next) => { lang.setLocale(next as SupportedLocale); }); } @@ -66,8 +56,8 @@ export function defineActiveLang( return lang; }, dispose(instance) { - unsubscribePrefs?.(); - unsubscribePrefs = undefined; + unsubscribe?.(); + unsubscribe = undefined; instance.dispose(); } }; diff --git a/src/arts/active-app/service-factories/perm.ts b/src/arts/active-app/service-factories/perm.ts index 78d135d..03e2384 100644 --- a/src/arts/active-app/service-factories/perm.ts +++ b/src/arts/active-app/service-factories/perm.ts @@ -12,7 +12,7 @@ import type { AppServiceFactory } from '../services.ts'; * `applyPermInvalidateOnIdentityChange` to react to identity changes. * * The factory wires `logger` and `clock` from the core, so decision - * cache TTL math runs on `App.Timers.clock`. If `App` declares an `http` + * cache TTL math runs on `App.timers.clock`. If `App` declares an `http` * service, this factory also wires `App.http` as the perm client's * `http` transport so retry/timeout/auth hooks composed at the App * level apply uniformly. Apps that prefer their own transport can pass diff --git a/src/arts/active-app/service-factories/prefs.ts b/src/arts/active-app/service-factories/prefs.ts deleted file mode 100644 index c9bd323..0000000 --- a/src/arts/active-app/service-factories/prefs.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - createActivePrefs, - createPrefsStorageBridge, - type ActivePrefs, - type EnginePrefsOptions, - type PrefsIntentStorage, - type PrefsStorageBridge, - type PrefsStorageOp -} 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; - -/** - * 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 - * `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(); - } - }; -} - -/** - * `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/service-factories/storage.ts b/src/arts/active-app/service-factories/storage.ts index 976183d..e2b81b9 100644 --- a/src/arts/active-app/service-factories/storage.ts +++ b/src/arts/active-app/service-factories/storage.ts @@ -5,7 +5,7 @@ import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveStorage(options)` produces a service factory for the * `storage` slot. Wires `logger` and `clock` from the core so - * envelope TTL math runs through `App.Timers.clock` — the same + * envelope TTL math runs through `App.timers.clock` — the same * time source the rest of the ecosystem uses. */ export function defineActiveStorage( diff --git a/src/arts/active-app/services.ts b/src/arts/active-app/services.ts index 6c13355..1da1cad 100644 --- a/src/arts/active-app/services.ts +++ b/src/arts/active-app/services.ts @@ -4,8 +4,12 @@ * `aapp` is built on top of two layers: * * - **Core** — fixed runtime infrastructure that always exists: - * `logger`, `bus`, `timers` and `orca`. Configurable via the - * `ActiveAppOptions` root, never declared as a service. + * `logger`, `bus`, `timers`, `orca` and `prefs`. Configurable via + * the `ActiveAppOptions` root, never declared as a service. The + * core surface is exposed in lowercase on the App + * (`App.logger`, `App.bus`, `App.prefs`, …) — the same convention + * services use, because the asymmetric "PascalCase for core" rule + * was decorative and went against JS property convention. * * - **Services** — opt-in runtime pieces that the application declares * in `services: { … }`. If a service is not declared, it does not @@ -21,20 +25,29 @@ import type { EngineBus } from '$bus'; import type { EngineLogger } from '$logger'; import type { EngineOrca } from '$orca'; +import type { ActivePrefs } from '$prefs'; +import type { PrefsSchema } from '$libs/prefs'; import type { ActiveTimers } from '$timer'; // ── Core ──────────────────────────────────────────────────────────────── /** - * The four pieces of the core. Always built before any service. A factory + * The five pieces of the core. Always built before any service. A factory * may declare a subset of these as `coreDependencies`; the builder * supplies only the declared keys to `create()`. + * + * `prefs` is generic over the user-defined `PrefsSchema`. Service + * factories that declare `coreDependencies: ['prefs']` see + * `core.prefs` typed as `ActivePrefs` (open) — they read dimensions + * defensively or document their schema requirements (e.g. "requires + * a `language` dimension"). */ -export interface CoreServices { +export interface CoreServices { readonly logger: EngineLogger; readonly bus: EngineBus; readonly timers: ActiveTimers; readonly orca: EngineOrca; + readonly prefs: ActivePrefs; } export type CoreServiceKey = keyof CoreServices; diff --git a/src/arts/active-app/test/ecosystem-cross-actor-isolation.test.ts b/src/arts/active-app/test/ecosystem-cross-actor-isolation.test.ts index ef6fdb6..3dfb437 100644 --- a/src/arts/active-app/test/ecosystem-cross-actor-isolation.test.ts +++ b/src/arts/active-app/test/ecosystem-cross-actor-isolation.test.ts @@ -46,10 +46,10 @@ interface User { } interface FakeAppCore { - Logger: EngineLogger; - Bus: EngineBus>; - Timers: ActiveTimers; - Orca: EngineOrca; + logger: EngineLogger; + bus: EngineBus>; + timers: ActiveTimers; + orca: EngineOrca; cache: ActiveCache; perm: ActivePerms; connections: ActiveConnections; @@ -96,10 +96,10 @@ function buildApp(): FakeAppCore { }) as unknown as ActiveSession; return { - Logger, - Bus, - Timers, - Orca, + logger: Logger, + bus: Bus, + timers: Timers, + orca: Orca, cache, perm, connections, @@ -157,7 +157,7 @@ describe('ecosystem — cross-actor isolation', () => { // User A logs in. Drain orca reactions to the initial null → A // transition before we mutate the cache. await app.session.adopt(sessionFor({ id: 'user-A' })); - await flush(app.Orca); + await flush(app.orca); (app.perm as ActivePerms & { __seedActor: (id: string) => void }).__seedActor('user-A'); // Cache something private for A. @@ -173,7 +173,7 @@ describe('ecosystem — cross-actor isolation', () => { // SESSION_EVENT_IDENTITY_CHANGED (cache.clear, perm.invalidate, // connections.reauth) plus SESSION_EVENT_REVOKED (closeAll). await app.session.revoke(); - await flush(app.Orca); + await flush(app.orca); expect(permApi.__currentActor()).toBeNull(); expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined(); @@ -183,14 +183,14 @@ describe('ecosystem — cross-actor isolation', () => { // Even without re-asserting cleanup, the previous step proved the // invariant: nothing belonging to A survives into B's session. await app.session.adopt(sessionFor({ id: 'user-B' })); - await flush(app.Orca); + await flush(app.orca); expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined(); }); it('revoke clears cache and closes connections', async () => { await app.session.adopt(sessionFor({ id: 'user-A' })); - await flush(app.Orca); + await flush(app.orca); await app.cache.set(['user-A:doc'], { title: 'Privado' }, { scope: 'public' }); // Revoke the session. The session art emits `SESSION_EVENT_REVOKED` @@ -198,7 +198,7 @@ describe('ecosystem — cross-actor isolation', () => { // `SESSION_EVENT_IDENTITY_CHANGED` (user-A → null) which clears the // cache via the same identity-change reaction. await app.session.revoke(); - await flush(app.Orca); + await flush(app.orca); expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined(); expect(app.connections.closeAll).toHaveBeenCalled(); @@ -207,22 +207,22 @@ describe('ecosystem — cross-actor isolation', () => { it('reauthenticateAll fires only on identity-state transitions', async () => { // First adopt: none → identified → reauth fires once. await app.session.adopt(sessionFor({ id: 'user-A' })); - await flush(app.Orca); + await flush(app.orca); expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1); // adopt(user-B) on top of an active identified session does NOT // transition the identity state (still `identified`), so no // extra reauth — that is the framework's documented contract. await app.session.adopt(sessionFor({ id: 'user-B' })); - await flush(app.Orca); + await flush(app.orca); expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1); // Logout + re-login: identified → none → identified counts as two // transitions, so reauth fires twice more (3 total). await app.session.revoke(); - await flush(app.Orca); + await flush(app.orca); await app.session.adopt(sessionFor({ id: 'user-C' })); - await flush(app.Orca); + await flush(app.orca); expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(3); }); @@ -230,17 +230,17 @@ describe('ecosystem — cross-actor isolation', () => { // Initial adopt + revoke runs the reactions (cache cleared on // identity-state transition). await app.session.adopt(sessionFor({ id: 'user-A' })); - await flush(app.Orca); + await flush(app.orca); await app.cache.set(['user-A:doc'], { title: 'doc' }, { scope: 'public' }); await app.session.revoke(); - await flush(app.Orca); + await flush(app.orca); expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined(); // Detach. The next identity change should leave cache untouched. detachOrca(); await app.cache.set(['user-A:doc'], { title: 'doc-2' }, { scope: 'public' }); await app.session.adopt(sessionFor({ id: 'user-C' })); - await flush(app.Orca); + await flush(app.orca); expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toEqual({ title: 'doc-2' }); // Re-attach so the afterEach detacher matches what's wired. diff --git a/src/arts/active-app/test/ecosystem-orca.test.ts b/src/arts/active-app/test/ecosystem-orca.test.ts index fa7fbd4..15d1532 100644 --- a/src/arts/active-app/test/ecosystem-orca.test.ts +++ b/src/arts/active-app/test/ecosystem-orca.test.ts @@ -116,7 +116,7 @@ describe('ecosystem orca — user A → user B switch', () => { closeAll: connectionsClose } as unknown as ActiveConnections; - applyStandardOrca({ Orca: core.orca, cache, perm, connections }); + applyStandardOrca({ orca: core.orca, cache, perm, connections }); // User A logs in. No identity change yet (anon → A is the first // adoption); the preset only listens to IDENTITY_CHANGED, so we @@ -175,7 +175,7 @@ describe('ecosystem orca — user A → user B switch', () => { closeAll: connectionsClose } as unknown as ActiveConnections; - applyStandardOrca({ Orca: core.orca, cache, perm, connections }); + applyStandardOrca({ orca: core.orca, cache, perm, connections }); core.bus.publish(SESSION_EVENT_REVOKED, revokedB); await flush(); @@ -203,7 +203,7 @@ describe('ecosystem orca — user A → user B switch', () => { closeAll: vi.fn() } as unknown as ActiveConnections; - applyStandardOrca({ Orca: core.orca, cache, perm, connections }); + applyStandardOrca({ orca: core.orca, cache, perm, connections }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB); await flush(); @@ -230,7 +230,7 @@ describe('ecosystem orca — user A → user B switch', () => { closeAll: vi.fn() } as unknown as ActiveConnections; - const detach = applyStandardOrca({ Orca: core.orca, cache, perm, connections }); + const detach = applyStandardOrca({ orca: core.orca, cache, perm, connections }); detach(); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB); 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 eab14a6..fd1d2ee 100644 --- a/src/arts/active-app/test/prefs-consumer-wiring.test.ts +++ b/src/arts/active-app/test/prefs-consumer-wiring.test.ts @@ -1,230 +1,156 @@ /** * 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. + * because `prefs` is part of the core, those consumers always source + * their locale / language / theme from the prefs engine — the wiring + * is not conditional on a service declaration. + * + * The "App-as-a-whole" lifecycle (storage bridge attached at root, + * disposed before the engine) is covered by the `service-factories` + * suite plus `arts/prefs/test/storage-bridge.test.ts`. */ 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 { createActivePrefs, standardPrefsDimensions } from '$prefs'; import { createActiveTimers } from '$timer/active-timers.svelte'; import { buildServiceBuilders } from '../service-builder.ts'; import { defineActiveDom, defineActiveFormat, defineActiveFrontend, - defineActiveLang, - defineActivePrefs, - defineActivePrefsWithStorage + defineActiveLang } 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 { +const NEXO_SCHEMA = { + ...standardPrefsDimensions({ + languages: ['es-ES', 'en-US', 'ar-EG'], + locales: ['es-ES', 'en-US', 'ar-EG'], + currencies: ['EUR', 'USD'], + defaults: { + language: 'es-ES', + locale: 'es-ES', + currency: 'EUR', + timezone: 'Europe/Madrid' + } + }) +}; + +type Scene = { core: CoreServices; prefs: ReturnType }; + +function buildPrefs() { + return createActivePrefs({ schema: NEXO_SCHEMA }); +} + +function buildScene(): Scene { 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 prefs = buildPrefs(); + return { core: { logger, bus, timers, orca, prefs }, prefs }; } -const CAPS: PrefsCapabilities = { - languages: ['es-ES', 'en-US', 'ar-EG'], - locales: ['es-ES', 'en-US', 'ar-EG'], - 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 scene = buildScene(); + const core = scene.core; const builders = buildServiceBuilders( - { - prefs: defineActivePrefs({ capabilities: CAPS }), - lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }) - }, + { 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 }; + const { lang } = builders.proxies as { + 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'); + scene.prefs.language.set('en-US'); expect(lang.getLocale()).toBe('en-US'); expect(lang.t('hello')).toBe('Hello'); builders.disposeAll(); + core.prefs.dispose(); }); - 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 }; - }; + it('format follows prefs.locale', () => { + const scene = buildScene(); + const core = scene.core; + const builders = buildServiceBuilders({ format: defineActiveFormat() }, core); + const { format } = builders.proxies as { format: { getLocale(): string } }; expect(format.getLocale()).toBe('es-ES'); - prefs.setIntent('locale', 'en-US'); + scene.prefs.locale.set('en-US'); expect(format.getLocale()).toBe('en-US'); builders.disposeAll(); + core.prefs.dispose(); }); it('frontend follows prefs.language for direction derivation', () => { - const core = buildCore(); + const scene = buildScene(); + const core = scene.core; 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 }; - }; - + const { frontend } = builders.proxies as { frontend: { getLocale(): string } }; expect(frontend.getLocale()).toBe('es-ES'); - prefs.setIntent('language', 'en-US'); + scene.prefs.language.set('en-US'); expect(frontend.getLocale()).toBe('en-US'); builders.disposeAll(); + core.prefs.dispose(); }); it('frontend mode/density/motion/dir track prefs end-to-end', () => { - const core = buildCore(); + const scene = buildScene(); + const core = scene.core; 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; - }; + const { frontend } = builders.proxies as { frontend: { - getMode: () => string; - getDensity: () => string; - getReducedMotion: () => boolean; - getDir: () => string; + 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'); + scene.prefs.theme.set('dark'); expect(frontend.getMode()).toBe('dark'); - prefs.setIntent('density', 'compact'); + scene.prefs.density.set('compact'); expect(frontend.getDensity()).toBe('compact'); - prefs.setIntent('motion', 'reduce'); + scene.prefs.motion.set('reduce'); expect(frontend.getReducedMotion()).toBe(true); - prefs.setIntent('language', 'ar-EG'); + scene.prefs.language.set('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( - { - 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(); + core.prefs.dispose(); }); }); diff --git a/src/arts/active-app/test/presets.test.ts b/src/arts/active-app/test/presets.test.ts index 388bf49..3f9cd39 100644 --- a/src/arts/active-app/test/presets.test.ts +++ b/src/arts/active-app/test/presets.test.ts @@ -80,7 +80,7 @@ describe('applyCacheClearOnIdentityChange', () => { const clear = vi.fn(() => Promise.resolve()); const cache = { clear } as unknown as ActiveCache; - applyCacheClearOnIdentityChange({ Orca: core.orca, cache }); + applyCacheClearOnIdentityChange({ orca: core.orca, cache }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); await flush(); @@ -92,7 +92,7 @@ describe('applyCacheClearOnIdentityChange', () => { const clear = vi.fn(() => Promise.resolve()); const cache = { clear } as unknown as ActiveCache; - const detach = applyCacheClearOnIdentityChange({ Orca: core.orca, cache }); + const detach = applyCacheClearOnIdentityChange({ orca: core.orca, cache }); detach(); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); @@ -105,7 +105,7 @@ describe('applyCacheClearOnIdentityChange', () => { const error = new Error('clear failed'); const cache = { clear: vi.fn(() => Promise.reject(error)) } as unknown as ActiveCache; - applyCacheClearOnIdentityChange({ Orca: core.orca, cache }); + applyCacheClearOnIdentityChange({ orca: core.orca, cache }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); await flush(); @@ -131,7 +131,7 @@ describe('applyPermInvalidateOnIdentityChange', () => { const invalidate = vi.fn(); const perm = { invalidate } as unknown as ActivePerms; - applyPermInvalidateOnIdentityChange({ Orca: core.orca, perm }); + applyPermInvalidateOnIdentityChange({ orca: core.orca, perm }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); await flush(); @@ -154,7 +154,7 @@ describe('applyCacheClearOnRevoke', () => { const clear = vi.fn(() => Promise.resolve()); const cache = { clear } as unknown as ActiveCache; - applyCacheClearOnRevoke({ Orca: core.orca, cache }); + applyCacheClearOnRevoke({ orca: core.orca, cache }); const revokePayload = { ...samplePayload, @@ -181,7 +181,7 @@ describe('applyConnectionsReauthOnIdentityChange', () => { const reauthenticateAll = vi.fn(() => Promise.resolve([])); const connections = { reauthenticateAll } as unknown as ActiveConnections; - applyConnectionsReauthOnIdentityChange({ Orca: core.orca, connections }); + applyConnectionsReauthOnIdentityChange({ orca: core.orca, connections }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); await flush(); @@ -195,7 +195,7 @@ describe('applyConnectionsReauthOnIdentityChange', () => { reauthenticateAll: vi.fn(() => Promise.reject(error)) } as unknown as ActiveConnections; - applyConnectionsReauthOnIdentityChange({ Orca: core.orca, connections }); + applyConnectionsReauthOnIdentityChange({ orca: core.orca, connections }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); await flush(); @@ -221,7 +221,7 @@ describe('applyConnectionsCloseOnRevoke', () => { const closeAll = vi.fn(); const connections = { closeAll } as unknown as ActiveConnections; - applyConnectionsCloseOnRevoke({ Orca: core.orca, connections }); + applyConnectionsCloseOnRevoke({ orca: core.orca, connections }); const revokePayload = { ...samplePayload, @@ -251,7 +251,7 @@ describe('applyStandardOrca', () => { const cache = { clear: cacheClear } as unknown as ActiveCache; const perm = { invalidate: permInvalidate } as unknown as ActivePerms; - applyStandardOrca({ Orca: core.orca, cache, perm }); + applyStandardOrca({ orca: core.orca, cache, perm }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); await flush(); @@ -265,7 +265,7 @@ describe('applyStandardOrca', () => { const closeAll = vi.fn(); const connections = { reauthenticateAll, closeAll } as unknown as ActiveConnections; - applyStandardOrca({ Orca: core.orca, connections }); + applyStandardOrca({ orca: core.orca, connections }); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); await flush(); @@ -283,7 +283,7 @@ describe('applyStandardOrca', () => { const permInvalidate = vi.fn(); const perm = { invalidate: permInvalidate } as unknown as ActivePerms; - applyStandardOrca({ Orca: core.orca, perm }); + applyStandardOrca({ orca: core.orca, perm }); // No cache action registered -> no error from publish core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); @@ -298,7 +298,7 @@ describe('applyStandardOrca', () => { const cache = { clear: cacheClear } as unknown as ActiveCache; const perm = { invalidate: permInvalidate } as unknown as ActivePerms; - const detach = applyStandardOrca({ Orca: core.orca, cache, perm }); + const detach = applyStandardOrca({ orca: core.orca, cache, perm }); detach(); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); diff --git a/src/arts/active-app/test/schema-declarative.test.ts b/src/arts/active-app/test/schema-declarative.test.ts index 39c9219..a3fb9b2 100644 --- a/src/arts/active-app/test/schema-declarative.test.ts +++ b/src/arts/active-app/test/schema-declarative.test.ts @@ -51,7 +51,7 @@ describe('createActiveApp — declarative service schema', () => { App.dispose(); }); - it('exposes only the four core members alongside declared services', () => { + it('exposes only the five core members alongside declared services', () => { const App = createActiveApp({ logger: SILENT_LOGGER, services: { @@ -60,10 +60,11 @@ describe('createActiveApp — declarative service schema', () => { }); // Core: always present. - expect(App.Logger).toBeDefined(); - expect(App.Bus).toBeDefined(); - expect(App.Timers).toBeDefined(); - expect(App.Orca).toBeDefined(); + expect(App.logger).toBeDefined(); + expect(App.bus).toBeDefined(); + expect(App.timers).toBeDefined(); + expect(App.orca).toBeDefined(); + expect(App.prefs).toBeDefined(); // Schema-declared service exposed as lowercase property. expect(App.cache).toBeDefined(); diff --git a/src/arts/active-app/test/service-builder.test.ts b/src/arts/active-app/test/service-builder.test.ts index 5e4e796..87128b7 100644 --- a/src/arts/active-app/test/service-builder.test.ts +++ b/src/arts/active-app/test/service-builder.test.ts @@ -38,7 +38,9 @@ function mockCore(): CoreServices { // eslint-disable-next-line @typescript-eslint/no-explicit-any timers: {} as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - orca: {} as any + orca: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prefs: {} as any }; } diff --git a/src/arts/active-app/test/service-factories.test.ts b/src/arts/active-app/test/service-factories.test.ts index 77de328..06d59ab 100644 --- a/src/arts/active-app/test/service-factories.test.ts +++ b/src/arts/active-app/test/service-factories.test.ts @@ -12,28 +12,42 @@ 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'; +import { createActivePrefs, standardPrefsDimensions } from '$prefs'; import { createActiveTimers } from '$timer/active-timers.svelte'; import type { CoreServices } from '../services.ts'; +const NEUTRAL_SCHEMA = { + ...standardPrefsDimensions({ + languages: ['es', 'en'], + locales: ['es-ES', 'en-US'], + currencies: ['EUR', 'USD'], + defaults: { + language: 'es', + locale: 'es-ES', + currency: 'EUR', + timezone: 'Europe/Madrid' + } + }) +}; + 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 prefs = createActivePrefs({ schema: NEUTRAL_SCHEMA }); + return { logger, bus, timers, orca, prefs }; } describe('service-factories — integration', () => { - it('builds storage / format / dom / http / sium without core deps', () => { + it('builds storage / format / dom / http / sium with declared core deps', () => { const core = buildCore(); const builders = buildServiceBuilders( { @@ -80,11 +94,10 @@ describe('service-factories — integration', () => { builders.disposeAll(); }); - it('builds frontend after dom and lang in topological order', () => { + it('builds frontend after dom in topological order', () => { const core = buildCore(); const builders = buildServiceBuilders( { - lang: defineActiveLang({ schema: { greeting: { es: 'a', en: 'b' } } }), dom: defineActiveDom(), frontend: defineActiveFrontend({ applyDom: false }) }, @@ -93,7 +106,7 @@ describe('service-factories — integration', () => { const status = builders.statusMap(); // All lazy: nothing built yet. - expect(status).toEqual({ lang: 'absent', dom: 'absent', frontend: 'absent' }); + expect(status).toEqual({ dom: 'absent', frontend: 'absent' }); // Touching frontend pulls it (and its declared deps if reachable). const fe = (builders.proxies as { frontend: object }).frontend; @@ -101,41 +114,18 @@ describe('service-factories — integration', () => { builders.disposeAll(); }); - it('builds prefs as an immediate-init service exposing the rune surface', () => { + it('format reads its locale from core.prefs', () => { + // Prefs is core, so format gets its locale source through + // `core.prefs` without declaring a service dependency. 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 }) - }, + { format: defineActiveFormat() }, 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'); + const format = (builders.proxies as { format: { getLocale(): string } }).format; + expect(format.getLocale()).toBe('es-ES'); builders.disposeAll(); + core.prefs.dispose(); }); it('reports failed status when a factory throws on construct', () => { @@ -155,5 +145,6 @@ describe('service-factories — integration', () => { expect(() => (builders.proxies as { broken: unknown }).broken).toThrow(); expect(builders.statusMap()).toEqual({ broken: 'failed' }); builders.disposeAll(); + core.prefs.dispose(); }); }); diff --git a/src/arts/active-app/test/session-auto-refresh.test.ts b/src/arts/active-app/test/session-auto-refresh.test.ts index 6e9905c..0b83162 100644 --- a/src/arts/active-app/test/session-auto-refresh.test.ts +++ b/src/arts/active-app/test/session-auto-refresh.test.ts @@ -1,6 +1,6 @@ /** * Tests for the `applySessionAutoRefresh` preset. The preset's job is - * to wire `App.Timers` and `App.Timers.clock` into `withAutoRefresh` + * to wire `App.timers` and `App.timers.clock` into `withAutoRefresh` * so the refresh ticker runs through the App's single time source * instead of falling back to `setInterval` + `Date.now`. */ @@ -25,10 +25,10 @@ function alice(expiresAt: number): Session { } interface Core { - Logger: EngineLogger; - Bus: EngineBus>; - Timers: ActiveTimers; - Orca: EngineOrca; + logger: EngineLogger; + bus: EngineBus>; + timers: ActiveTimers; + orca: EngineOrca; dispose: () => void; } @@ -41,10 +41,10 @@ function buildCore(): Core { }); const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger }); return { - Logger, - Bus, - Timers, - Orca, + logger: Logger, + bus: Bus, + timers: Timers, + orca: Orca, dispose() { Orca.dispose(); Bus.dispose(); @@ -72,7 +72,7 @@ describe('applySessionAutoRefresh', () => { vi.useRealTimers(); }); - it('routes the refresh ticker through App.Timers (not setInterval)', async () => { + it('routes the refresh ticker through App.timers (not setInterval)', async () => { await session.adopt(alice(NOW + 60_000)); // 60s ahead, margin 90s const stop = applySessionAutoRefresh( { ...core, session }, diff --git a/src/arts/active-app/types.ts b/src/arts/active-app/types.ts index a873292..3905800 100644 --- a/src/arts/active-app/types.ts +++ b/src/arts/active-app/types.ts @@ -1,24 +1,33 @@ /** * Public types for `arts/active-app`. * - * `ActiveApp` is the composed surface seen by the application: + * `ActiveApp` is the composed surface seen by + * the application: * - * - `ActiveAppCore` — `Logger`, `Bus`, `Timers`, `Orca`, `dispose`. - * Always present, never declared as a service. + * - `ActiveAppCore` — `logger`, `bus`, `timers`, `orca`, `prefs` plus + * `dispose`. Always present. Lowercase, like every other JS + * property — the previous PascalCase rule existed for visual + * signalling that the property was core, not because of any + * technical constraint. * - `ResolveServiceInstances` — every entry the application - * declared in `services: { … }` is exposed as a lowercase property - * with the exact instance type returned by its factory. + * declared in `services: { … }` is exposed as a property with the + * exact instance type returned by its factory. * - `ActiveAppServicesIntrospection` — `services` map for devtools. - * - * The legacy uppercase surface (`App.lang`, `App.cache`, `App.frontend`, - * `App.format`, `App.dom`, `App.storage`, `App.http`) has been removed. - * Those pieces are now opt-in services that the application declares - * via `defineActive*` / `defineEngine*` factories. */ import type { EngineBus, EngineBusOptions } from '$bus'; import type { EngineLogger, LoggerOptions } from '$logger'; import type { EngineOrca, EngineOrcaOptions } from '$orca'; +import type { + ActivePrefs, + PrefsIntentStorage, + PrefsStorageOp +} from '$prefs'; +import type { + PrefsEnvironment, + PrefsIntentOf, + PrefsSchema +} from '$libs/prefs'; import type { ActiveTimers, EngineTimersOptions } from '$timer'; import type { AppEventMap } from './events.ts'; @@ -29,7 +38,7 @@ import type { } from './services.ts'; /** - * Bus event map seen by `App.Bus`. Includes App-owned events and any + * Bus event map seen by `App.bus`. Includes App-owned events and any * module-level event maps that App is intended to surface. * * Module event maps (e.g. `SessEventMap`, `ConnectionEventMap`) are @@ -41,6 +50,23 @@ export interface ActiveAppBusEvents extends AppEventMap {} // ── Options ──────────────────────────────────────────────────────────── +/** + * Options for the App-wide `prefs` engine. Generic over the schema so + * dimension keys flow through to `App.prefs.` autocomplete and + * to the `intent` / `storage` shapes. + * + * Apps that don't need persistence omit `storage`; apps that do supply + * a `PrefsIntentStorage` (typically a thin adapter over + * `arts/storage`) and an optional `onStorageError` reporter. + */ +export interface ActiveAppPrefsOptions { + readonly schema: S; + readonly environment?: PrefsEnvironment; + readonly intent?: PrefsIntentOf; + readonly storage?: PrefsIntentStorage; + readonly onStorageError?: (error: unknown, op: PrefsStorageOp) => void; +} + /** * Options for `createActiveApp()`. All sections are optional. * @@ -51,6 +77,11 @@ export interface ActiveAppBusEvents extends AppEventMap {} * `logger` (and `clock` for the bus) automatically. * - `orca` builds with engine defaults; App injects `bus`, `timers` * and `logger`. + * - `prefs` defaults to a neutral `standardPrefsDimensions` preset + * (single `en` / `en-US` / `USD` baseline) so apps that don't care + * about preferences still get a valid `App.prefs` instance. Apps + * that do care declare their full `schema` here; the dimension + * keys flow through to `App.prefs.`. * - `services` declares the opt-in service schema. If omitted, only * the core is built and `App.services` is `{}`. * @@ -58,60 +89,42 @@ export interface ActiveAppBusEvents extends AppEventMap {} * `frontend`, `dom`, `storage`, `http`, `cache`) now belongs in * `services` via the corresponding `defineActive*` factory. */ -export interface ActiveAppOptions { - /** - * Logger options for the App-wide engine logger. App passes the - * resulting instance to every service that declares `logger` as a - * core dependency. - */ +export interface ActiveAppOptions< + TSchema extends AppServiceSchema = AppServiceSchema, + TPrefsSchema extends PrefsSchema = PrefsSchema +> { logger?: LoggerOptions; - - /** - * Timer scheduler options. App injects `logger` automatically. - */ timers?: Omit; - - /** - * Cross-artifact event bus options. App injects `logger` and the - * shared timers `clock` automatically. - */ bus?: Omit; - - /** - * Orca options. App injects `bus`, `timers` and `logger` - * automatically. - */ orca?: Omit; - - /** - * Declarative service schema. Each entry is built by an - * `AppServiceFactory` from `arts/active-app/service-factories/`. - * Services are exposed as lowercase properties on the App - * (`App.cache`, `App.session`, …). - * - * Lazy services build on first access; `immediate` services build - * during `createActiveApp()`. - */ + prefs?: ActiveAppPrefsOptions; services?: TSchema; } // ── Surface ──────────────────────────────────────────────────────────── /** - * The fixed core surface, present on every App: `Logger`, `Bus`, - * `Timers`, `Orca` plus the lifecycle helper `dispose`. None of these - * are services — they are the substrate every service depends on. + * The fixed core surface, present on every App: `logger`, `bus`, + * `timers`, `orca`, `prefs` plus the lifecycle helper `dispose`. None + * of these are services — they are the substrate every service + * depends on. Lowercase, like all JS properties. */ -export interface ActiveAppCore { - readonly Logger: EngineLogger; - readonly Bus: EngineBus; - readonly Timers: ActiveTimers; +export interface ActiveAppCore { + readonly logger: EngineLogger; + readonly bus: EngineBus; + readonly timers: ActiveTimers; /** * Orchestration engine. Always present, inert until the application - * registers actions via `App.Orca.onEvent(...)` or applies presets + * registers actions via `App.orca.onEvent(...)` or applies presets * from `arts/active-app/presets/`. */ - readonly Orca: EngineOrca; + readonly orca: EngineOrca; + /** + * Reactive preference state, generic over the user-defined schema. + * Each schema key becomes a typed dimension at `App.prefs.` + * with `.get()` / `.set()` / `.clear()` / `.onChange()` verbs. + */ + readonly prefs: ActivePrefs; /** * Tear down every constructed service in reverse order, then the @@ -133,10 +146,15 @@ export interface ActiveAppServicesIntrospection { * Composed application surface. * * Type-safe access: - * - `App.Logger` / `App.Bus` / `App.Timers` / `App.Orca` always exist. + * - `App.logger` / `App.bus` / `App.timers` / `App.orca` / `App.prefs` + * always exist. * - `App.` exists IFF the service was declared in * `options.services`. Reading an undeclared name is a TypeScript * error. */ -export type ActiveApp = - ActiveAppCore & ResolveServiceInstances & ActiveAppServicesIntrospection; +export type ActiveApp< + TSchema extends AppServiceSchema = AppServiceSchema, + TPrefsSchema extends PrefsSchema = PrefsSchema +> = ActiveAppCore & + ResolveServiceInstances & + ActiveAppServicesIntrospection; diff --git a/src/arts/bus/README.md b/src/arts/bus/README.md index ddf81fe..5d5541a 100644 --- a/src/arts/bus/README.md +++ b/src/arts/bus/README.md @@ -16,7 +16,7 @@ policy, and observability hooks. It does **not** know about `sess`, business rule. Applications never instantiate one bus per module. `aapp` creates a -single `App.Bus` per render scope and injects it. Artifacts that need +single `App.bus` per render scope and injects it. Artifacts that need to publish or listen receive that bus, or the smaller `EventPublisher` interface, from the composition root. @@ -35,7 +35,7 @@ comes from each owner declaring constants, payload shapes, and typed `publishX` / `onX` helpers. The framework distinguishes two layers of events that share a single -`App.Bus` instance: +`App.bus` instance: - **Module events** (`SESSION_EVENT_*`, `AUTH_EVENT_*`, `CACHE_EVENT_*`, …) — internal facts emitted by the artifact that owns them. They can @@ -346,9 +346,9 @@ on revoke, connections.reauth on identity change) are not bus re-publications: they live as **orca actions** registered through the presets in [arts/active-app/presets/](../active-app/presets/). Modules publish their own typed events (`SESSION_EVENT_*` etc.) directly on -`App.Bus`; orca subscribes and runs the registered actions. +`App.bus`; orca subscribes and runs the registered actions. -## App.Bus is always-present per render scope +## App.bus is always-present per render scope `aapp` creates the bus automatically; it is not a factory and never optional. **The bus is per request on server, per root on client — @@ -402,7 +402,7 @@ flow. Never serialize the bus itself. ### Rule 2 — Inject by context, not by import -Inside Svelte components, `App.Bus` is consumed via context. The helper +Inside Svelte components, `App.bus` is consumed via context. The helper lives in `arts/bus/svelte/context.svelte.ts` and is re-exported from `$bus`: @@ -428,7 +428,7 @@ Root component sets it; descendants read it: ``` @@ -571,7 +571,7 @@ removed that machinery entirely. The model now is: ```txt -module emits SESSION_EVENT_IDENTITY_CHANGED on App.Bus +module emits SESSION_EVENT_IDENTITY_CHANGED on App.bus -> orca picks up the event (it subscribed lazily on first action registration) -> orca runs every action registered for that event @@ -615,7 +615,7 @@ re-publish, classify, or react. The "consumer rules" reduce to: the consumer registers an orca action; the bus stays dumb. The consequence for module owners writing a new art: publish your -`_EVENT_*` events directly on `App.Bus` and document them. If a +`_EVENT_*` events directly on `App.bus` and document them. If a later integration needs to react across modules, the integration ships as an orca preset, not as code inside your art. @@ -697,7 +697,7 @@ Two protections: - Perceptual signals (taxis sema). `SemanticEngine` is a different registry for a different purpose; do not unify. - Stor's internal entry-bus. Per-`EngineStorage` synchronization stays - inside `stor`; it is not `App.Bus`. + inside `stor`; it is not `App.bus`. ## Testing patterns diff --git a/src/arts/connection/DESIGN_CONN.md b/src/arts/connection/DESIGN_CONN.md index d3c03a6..330eca5 100644 --- a/src/arts/connection/DESIGN_CONN.md +++ b/src/arts/connection/DESIGN_CONN.md @@ -332,7 +332,7 @@ Required behavior: - After reconnect, auth re-runs if configured. - Reconnect delay must be computed through the shared timer/backoff primitives (`$libs/timers.computeBackoffDelay`) and scheduled through injected - `TimerScheduler`/`App.Timers`, not raw `setTimeout` inside the connection + `TimerScheduler`/`App.timers`, not raw `setTimeout` inside the connection engine. A native fallback is allowed only in the standalone engine path. ### 1.1.9 Heartbeat @@ -583,7 +583,7 @@ Connection state is runtime state. Do not persist connections in storage. ### 3.6 With `timr` -`arts/conn` should use `App.Timers` when built from `aapp`. Reconnect, +`arts/conn` should use `App.timers` when built from `aapp`. Reconnect, heartbeat and pending-ack timeouts must be keyed timers so app-level debug panels and `App.dispose()` can see and cancel them uniformly. diff --git a/src/arts/connection/README.md b/src/arts/connection/README.md index a18b762..1558d7a 100644 --- a/src/arts/connection/README.md +++ b/src/arts/connection/README.md @@ -78,8 +78,8 @@ const Connections = App.createActiveConnections(); App inyecta: -- `App.Logger`, como `Logger` común de `$libs/logger`. -- `App.Timers`, para reconexión, heartbeat y timeouts de ack. +- `App.logger`, como `Logger` común de `$libs/logger`. +- `App.timers`, para reconexión, heartbeat y timeouts de ack. ### Reacción a cambios de identidad @@ -355,11 +355,11 @@ The full flow with the orca preset (`applyStandardOrca` or `applyConnectionsCloseOnRevoke`) is: ```txt -session -> SESSION_EVENT_IDENTITY_CHANGED on App.Bus +session -> SESSION_EVENT_IDENTITY_CHANGED on App.bus orca -> connections-reauth-on-identity action runs -> App.connections.reauthenticateAll() -> each connection calls auth() with the new credential -session -> SESSION_EVENT_REVOKED on App.Bus +session -> SESSION_EVENT_REVOKED on App.bus orca -> connections-close-on-revoke action runs -> App.connections.closeAll('session-revoked') ``` diff --git a/src/arts/connection/types.ts b/src/arts/connection/types.ts index c41b17c..15d0f97 100644 --- a/src/arts/connection/types.ts +++ b/src/arts/connection/types.ts @@ -363,7 +363,7 @@ export interface EngineConnectionsOptions { * Timer scheduler driving heartbeats, ack timeouts, reconnect * backoff and reauthentication windows. Required: `arts/conn` does * not construct its own scheduler. Composition roots pass - * `App.Timers`; standalone callers pass `createEngineTimers()` (or + * `App.timers`; standalone callers pass `createEngineTimers()` (or * a fake clock-driven double in tests). */ readonly timers: TimerScheduler; diff --git a/src/arts/format/currency/types.ts b/src/arts/format/currency/types.ts index 5f9b370..31b0f5b 100644 --- a/src/arts/format/currency/types.ts +++ b/src/arts/format/currency/types.ts @@ -74,7 +74,7 @@ export interface ActiveCurrencyOptions extends Omit; diff --git a/src/arts/http/README.md b/src/arts/http/README.md index 387e032..1c6ff2e 100644 --- a/src/arts/http/README.md +++ b/src/arts/http/README.md @@ -184,7 +184,7 @@ const http = createEngineHttp({ afterResponse: [], beforeError: [] }, - logger: App.Logger // wired automatically when used via App.http + logger: App.logger // wired automatically when used via App.http }); ``` diff --git a/src/arts/http/types.ts b/src/arts/http/types.ts index b7b982c..650a7cf 100644 --- a/src/arts/http/types.ts +++ b/src/arts/http/types.ts @@ -248,7 +248,7 @@ export interface RetryConfig { * `Date.now` / `Math.random` / host `setTimeout` / `clearTimeout`; * tests inject deterministic doubles. App composition typically * passes `core.timers.clock.now` for `now()` and the host - * `setTimeout` for timer scheduling — `App.Timers` schedules tasks by + * `setTimeout` for timer scheduling — `App.timers` schedules tasks by * key, which is a different shape from raw `setTimeout`. */ export interface HttpTimerPort { @@ -290,7 +290,7 @@ export interface EngineHttpOptions { * Replacement for `setTimeout` used to schedule retry delays and * per-attempt / total timeouts. Defaults to `globalThis.setTimeout`. * Tests inject a fake-timer adapter; the App composition can route - * through `App.Timers` if needed. + * through `App.timers` if needed. */ setTimeout?: (handler: () => void, ms?: number) => unknown; /** Replacement for `clearTimeout` paired with `setTimeout` above. */ diff --git a/src/arts/logger/types.ts b/src/arts/logger/types.ts index b6ac4ad..1904c5f 100644 --- a/src/arts/logger/types.ts +++ b/src/arts/logger/types.ts @@ -245,7 +245,7 @@ export interface LoggerOptions { /** * Optional clock used for `failureThrottleMs` window math and for the * `Date` stamp on every `LogEntry`. Defaults to `Date.now`. The Logger - * is created BEFORE `App.Timers`, so this option exists for tests and + * is created BEFORE `App.timers`, so this option exists for tests and * runtimes that need a deterministic time source — not for App-level * wiring. */ diff --git a/src/arts/orca/README.md b/src/arts/orca/README.md index b0c9ddd..7abc001 100644 --- a/src/arts/orca/README.md +++ b/src/arts/orca/README.md @@ -833,9 +833,9 @@ al terminar el run o al hacer `dispose()`. ```ts const Orca = createEngineOrca({ - bus: App.Bus, - timers: App.Timers, - logger: App.Logger + bus: App.bus, + timers: App.timers, + logger: App.logger }); ``` @@ -848,7 +848,7 @@ orcaTimerKey(runId, stage, actionId, ORCA_TIMER_ACTION_TIMEOUT); Si `orca` crea timers sobre un scheduler inyectado, no es propietario del scheduler. `Orca.dispose()` cancela los timers registrados por `orca`, pero no -destruye `App.Timers`. +destruye `App.timers`. ## Transacciones @@ -1006,7 +1006,7 @@ el bundle base. No se usara dynamic import para el nucleo v0; la complejidad de un proxy async no compensa si el engine inerte es pequeno. `Bus` tambien debe ser un recurso siempre presente e inerte. Si una app tiene -`App.Orchestration`, debe tener `App.Bus`. +`App.Orchestration`, debe tener `App.bus`. Uso explicito: @@ -1241,8 +1241,8 @@ cuando uno falla; y comprueba que el detach de - `orca` no conoce módulos de negocio. - Los artefactos no consumen `orca`; solo publican eventos en `bus`. - La aplicación registra acciones en `orca`. -- `App.Orca` existe siempre, pero no ejecuta nada sin acciones. -- `App.Bus` debe existir si existe `App.Orca`. +- `App.orca` existe siempre, pero no ejecuta nada sin acciones. +- `App.bus` debe existir si existe `App.orca`. - Todas las strings públicas viven en constantes. - Los eventos son constantes, no strings inline. - Los tokens son constantes, no strings inline. diff --git a/src/arts/perm/README.md b/src/arts/perm/README.md index 1c108e7..492a79e 100644 --- a/src/arts/perm/README.md +++ b/src/arts/perm/README.md @@ -584,7 +584,7 @@ await App.perm.check({ action, resource, context }); ``` `defineActivePerm(...)` makes the App builder inject `App.http`, -`App.Logger` and `App.Bus`. The endpoint remains explicit because the +`App.logger` and `App.bus`. The endpoint remains explicit because the client is remote by design. Active client API (use `App.perm` once registered, or a freestanding @@ -684,7 +684,7 @@ applyPermInvalidateOnIdentityChange(App); ``` Tenant switches and "permissions refreshed" notifications are app-defined -events on `App.Bus`. Register a custom orca action that calls +events on `App.bus`. Register a custom orca action that calls `App.perm.invalidate()` (and any other affected services) when those events fire — there is no built-in preset for them yet. diff --git a/src/arts/prefs/README.md b/src/arts/prefs/README.md index 4356810..e15ee9c 100644 --- a/src/arts/prefs/README.md +++ b/src/arts/prefs/README.md @@ -1,10 +1,37 @@ # Prefs -`prefs` is the Active preference resolution module. - -It is intentionally isolated for now. It is not wired into `active-app`, and no -existing artifact should consume it until the contracts are implemented and the -surrounding modules are ready to receive narrow preference ports. +`prefs` is the Active preference resolution module. It is part of the +core (`App.prefs`) and is generic over a user-defined `PrefsSchema = +Record>`. + +> **Heads-up:** the sections below describe the original four-layer +> design (capabilities + environment + intent → effective). The current +> implementation is **schema-based**: each preference is a +> `PrefsDimension` that owns its own validator, environment-fed +> resolver and (optionally) sibling-derived value. Built-in dimensions +> (`localeDimension`, `themeDimension`, …) live in +> `arts/prefs/dimensions/*` and the `standardPrefsDimensions(catalog)` +> preset composes the canonical set. The active surface exposes one +> slot per schema key with `.get()` / `.set()` / `.clear()` / +> `.onChange()` verbs: +> +> ```ts +> createActiveApp({ +> prefs: { +> schema: { +> ...standardPrefsDimensions({ languages, locales, currencies }), +> sidebarCollapsed: booleanDimension({ default: false }) +> } +> } +> }); +> +> App.prefs.locale.get(); +> App.prefs.locale.set('es-ES'); +> App.prefs.sidebarCollapsed.set(true); +> ``` +> +> The historical text below is kept for archival reference until this +> README is rewritten in full. ## Core Rule @@ -533,7 +560,7 @@ Later, `active-app` can bridge this to `Bus`: ```ts Prefs.subscribe((event) => { - App.Bus.publish(PREFS_EVENT_CHANGED, event); + App.bus.publish(PREFS_EVENT_CHANGED, event); }); ``` diff --git a/src/arts/prefs/active-prefs.svelte.ts b/src/arts/prefs/active-prefs.svelte.ts index 8397b56..19c2c3e 100644 --- a/src/arts/prefs/active-prefs.svelte.ts +++ b/src/arts/prefs/active-prefs.svelte.ts @@ -1,12 +1,43 @@ import type { - PrefsCapabilities, - PrefsEffective, + PrefsChangeHandler, + PrefsDimension, + PrefsEffectiveOf, PrefsEnvironment, - PrefsIntent, - PrefsSnapshot + PrefsIntentOf, + PrefsSchema, + PrefsSnapshot, + PrefsUnsubscribe } from '$libs/prefs'; import { createEnginePrefs } from './engine-prefs.ts'; -import type { EnginePrefs, EnginePrefsOptions } from './types.ts'; +import { PREFS_KIND } from './consts.ts'; +import { PrefsReservedKeyError, reservedKeyErrorMessage } from './errors.ts'; +import type { EnginePrefsOptions } from './types.ts'; + +/** + * Per-dimension active surface. Every key in the schema becomes one of + * these on the parent `ActivePrefs`, addressable as + * `App.prefs.`. The verbs are uniform across every dimension: get + * the effective value, set / clear user intent, listen for changes. + */ +export interface ActivePrefsDimension { + /** Current effective value (intent → environment → default). */ + get(): TEffective; + /** + * Set explicit user intent for this dimension. Validates first; + * throws `PrefsIntentInvalidError` on rejection. + */ + set(value: TIntent): void; + /** Drop user intent. Falls back to environment / default. */ + clear(): void; + /** + * Subscribe to commits where this dimension's effective value + * changed. Returns an unsubscribe function. Best-effort: a handler + * that throws does not block its peers. + */ + onChange(handler: (value: TEffective) => void): PrefsUnsubscribe; + /** Optional capability catalog when the dimension exposes one. */ + catalog(): readonly TIntent[] | undefined; +} /** * Reactive view of `EnginePrefs.state`. Backed by `$state` cells inside @@ -17,65 +48,138 @@ import type { EnginePrefs, EnginePrefsOptions } from './types.ts'; * storage bridge in particular). With a sync-only engine they stay * `false` / `null`. */ -export interface ActivePrefsState { - readonly snapshot: PrefsSnapshot; - readonly effective: PrefsEffective; - readonly capabilities: PrefsCapabilities; +export interface ActivePrefsState { + readonly snapshot: PrefsSnapshot; + readonly effective: PrefsEffectiveOf; readonly environment: PrefsEnvironment; - readonly intent: Readonly; + readonly intent: PrefsIntentOf; + readonly version: number; readonly pending: boolean; readonly lastError: unknown; } /** - * Svelte rune adapter over `EnginePrefs`. Forwards every engine method - * verbatim and adds a reactive `state` block that templates can read - * without manual subscription. - * - * The contract is intentionally a superset of `EnginePrefs` — server - * code that imports just the engine remains free of `.svelte.ts` - * runtime, while UI code uses `ActivePrefs` and gets reactivity for - * free. + * Reserved members of `ActivePrefs`. A schema key that matches one + * of these would shadow the active surface — the constructor throws + * `PrefsReservedKeyError` so the misconfiguration fails fast. + */ +export const ACTIVE_PREFS_RESERVED_KEYS: readonly string[] = [ + 'kind', + 'schema', + 'state', + 'snapshot', + 'environment', + 'intent', + 'effective', + 'resetIntent', + 'refreshEnvironment', + 'patchEnvironment', + 'subscribe', + 'dispose' +]; + +const RESERVED_SET = new Set(ACTIVE_PREFS_RESERVED_KEYS); + +/** + * Reactive Svelte adapter over `EnginePrefs`. Adds the + * `ActivePrefsState` block and exposes one `ActivePrefsDimension` per + * schema key as a property — i.e. for `schema = { locale, theme }` the + * returned object has `.locale.get()` / `.locale.set(...)` / + * `.theme.get()` / etc., type-checked against each dimension's + * `TIntent` and `TEffective`. + */ +/** + * Top-level (non-dimension) surface. Always present regardless of the + * schema. Service factories see this shape via `core.prefs` and read + * specific dimensions defensively at runtime through their string key. */ -export interface ActivePrefs extends EnginePrefs { - readonly state: ActivePrefsState; +export interface ActivePrefsBase { + readonly kind: typeof PREFS_KIND; + readonly schema: S; + readonly state: ActivePrefsState; + + snapshot(): PrefsSnapshot; + environment(): PrefsEnvironment; + intent(): PrefsIntentOf; + effective(): PrefsEffectiveOf; + + /** + * Low-level mutator. The recommended public API is + * `App.prefs..set(value)` — this stays exposed only for + * adapters (storage bridge, devtools) that operate generically over + * dimension keys. + */ + setIntent(key: K, value: unknown): PrefsSnapshot; + /** Low-level mutator; prefer `App.prefs..clear()`. */ + clearIntent(key: K): PrefsSnapshot; + resetIntent(next?: PrefsIntentOf): PrefsSnapshot; + refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot; + patchEnvironment(patch: Partial): PrefsSnapshot; + subscribe(handler: PrefsChangeHandler): PrefsUnsubscribe; + dispose(): void; } -export function createActivePrefs(options: EnginePrefsOptions): ActivePrefs { +/** + * Dimension surface — one slot per schema key. Only meaningful when + * `S` is a concrete schema literal. With the open `PrefsSchema` + * (`Record`) the mapped type would collide + * with the index signature on the base, so we degrade to `unknown` + * for the open case (`X & unknown = X`). + * + * Service factories that need typed dimensions cast through their own + * schema generic; consumers that read `App.prefs.` get the typed + * surface because `App` flows the concrete `TPrefsSchema`. + */ +export type ActivePrefsDimensions = string extends keyof S + ? unknown + : { + readonly [K in keyof S]: S[K] extends PrefsDimension + ? ActivePrefsDimension + : never; + }; + +export type ActivePrefs = ActivePrefsBase & + ActivePrefsDimensions; + +export function createActivePrefs( + options: EnginePrefsOptions +): ActivePrefs { + for (const key of Object.keys(options.schema)) { + if (RESERVED_SET.has(key)) { + throw new PrefsReservedKeyError(key, reservedKeyErrorMessage(key)); + } + } + const engine = createEnginePrefs(options); - let snapshotCell = $state(engine.snapshot()); + let snapshotCell = $state>(engine.snapshot()); // `pending` / `lastError` are placeholders today (sync engine has // nothing async to track). Declared as `let` so async adapters // (storage bridge in particular) can flip them when wired in // without restructuring the rune layout. - let pendingCell = $state(false); - let lastErrorCell = $state(null); + const pendingCell = $state(false); + const lastErrorCell = $state(null); - // Mirror every commit into the reactive cell. Reading `snapshotCell` - // inside a `$derived` or template re-runs whenever a write produces - // a new snapshot; no-ops in the engine skip this notification, so - // we don't trigger spurious reactivity. const detachCommit = engine.subscribe((event) => { snapshotCell = event.next; }); - const state: ActivePrefsState = { + const state: ActivePrefsState = { get snapshot() { return snapshotCell; }, get effective() { return snapshotCell.effective; }, - get capabilities() { - return snapshotCell.capabilities; - }, get environment() { return snapshotCell.environment; }, get intent() { return snapshotCell.intent; }, + get version() { + return snapshotCell.version; + }, get pending() { return pendingCell; }, @@ -84,54 +188,59 @@ export function createActivePrefs(options: EnginePrefsOptions): ActivePrefs { } }; - const active: ActivePrefs = { - kind: engine.kind, - state, + const dimensionMembers: Record> = {}; + for (const key of Object.keys(options.schema)) { + dimensionMembers[key] = makeDimension(engine, key); + } - snapshot() { - return engine.snapshot(); - }, - capabilities() { - return engine.capabilities(); - }, - environment() { - return engine.environment(); - }, - intent() { - return engine.intent(); - }, - effective() { - return engine.effective(); - }, - - setIntent(key, value) { - return engine.setIntent(key, value); - }, - clearIntent(key) { - return engine.clearIntent(key); - }, - resetIntent(next) { - return engine.resetIntent(next); - }, - refreshEnvironment(next) { - return engine.refreshEnvironment(next); - }, - patchEnvironment(patch) { - return engine.patchEnvironment(patch); - }, - setCapabilities(next) { - return engine.setCapabilities(next); - }, - - subscribe(handler) { - return engine.subscribe(handler); - }, - - dispose() { + const base = { + kind: PREFS_KIND as typeof PREFS_KIND, + schema: options.schema, + state, + snapshot: () => engine.snapshot(), + environment: () => engine.environment(), + intent: () => engine.intent(), + effective: () => engine.effective(), + setIntent: (key: K, value: unknown) => engine.setIntent(key, value), + clearIntent: (key: K) => engine.clearIntent(key), + resetIntent: (next?: PrefsIntentOf) => engine.resetIntent(next), + refreshEnvironment: (next: PrefsEnvironment) => engine.refreshEnvironment(next), + patchEnvironment: (patch: Partial) => engine.patchEnvironment(patch), + subscribe: (handler: PrefsChangeHandler) => engine.subscribe(handler), + dispose: () => { detachCommit(); engine.dispose(); } }; - return active; + return Object.assign(base, dimensionMembers) as unknown as ActivePrefs; +} + +function makeDimension( + engine: ReturnType, + key: string +): ActivePrefsDimension { + return { + get() { + return (engine.snapshot().effective as Record)[key]; + }, + set(value: unknown) { + engine.setIntent(key, value); + }, + clear() { + engine.clearIntent(key); + }, + onChange(handler: (value: unknown) => void) { + return engine.subscribe((event) => { + const diff = event.effectiveDiff as Record; + if (Object.prototype.hasOwnProperty.call(diff, key)) { + handler(diff[key]); + } + }); + }, + catalog() { + const dim = engine.schema[key]; + return dim?.catalog?.(); + } + }; } diff --git a/src/arts/prefs/adapters/browser-environment.ts b/src/arts/prefs/adapters/browser-environment.ts index b3b0427..8432197 100644 --- a/src/arts/prefs/adapters/browser-environment.ts +++ b/src/arts/prefs/adapters/browser-environment.ts @@ -1,4 +1,4 @@ -import type { PrefsEnvironment } from '$libs/prefs'; +import type { PrefsEnvironment, PrefsSchema } from '$libs/prefs'; import type { EnginePrefs } from '../types.ts'; /** @@ -142,8 +142,8 @@ export function watchBrowserEnvironment( * onMount(() => applyBrowserEnvironment(App.prefs)); * ``` */ -export function applyBrowserEnvironment( - engine: EnginePrefs, +export function applyBrowserEnvironment( + engine: EnginePrefs, overrides: BrowserEnvironmentOverrides = {} ): () => void { engine.refreshEnvironment(detectBrowserEnvironment(overrides)); diff --git a/src/arts/prefs/adapters/storage-bridge.ts b/src/arts/prefs/adapters/storage-bridge.ts index ab59a0e..0c073ea 100644 --- a/src/arts/prefs/adapters/storage-bridge.ts +++ b/src/arts/prefs/adapters/storage-bridge.ts @@ -1,4 +1,4 @@ -import type { PrefsIntent } from '$libs/prefs'; +import type { PrefsIntentOf, PrefsSchema } from '$libs/prefs'; import type { EnginePrefs } from '../types.ts'; /** @@ -14,18 +14,21 @@ import type { EnginePrefs } from '../types.ts'; * * Methods may be sync or async. The bridge always awaits the result * before applying. + * + * Generic over the schema so persisted intent type-checks against the + * dimensions the engine knows about. */ -export interface PrefsIntentStorage { - load(): PrefsIntent | null | undefined | Promise; - save(intent: PrefsIntent): void | Promise; +export interface PrefsIntentStorage { + load(): PrefsIntentOf | null | undefined | Promise | null | undefined>; + save(intent: PrefsIntentOf): void | Promise; clear(): void | Promise; } export type PrefsStorageOp = 'load' | 'save' | 'clear'; -export interface PrefsStorageBridgeOptions { - readonly engine: EnginePrefs; - readonly storage: PrefsIntentStorage; +export interface PrefsStorageBridgeOptions { + readonly engine: EnginePrefs; + readonly storage: PrefsIntentStorage; /** * Called when any storage op throws. Storage failures must not * corrupt the in-memory engine — the bridge swallows the error and @@ -52,7 +55,7 @@ export interface PrefsStorageBridge { } /** - * Wire an `EnginePrefs` to a storage backend. On construction: + * Wire an `EnginePrefs` to a storage backend. On construction: * * 1. Subscribes to engine commits and persists `intent` (only intent — * never `environment`, never `effective`). @@ -69,8 +72,8 @@ export interface PrefsStorageBridge { * persist via `save`. An empty intent calls `clear()` instead of * `save({})` so storage backends can drop the entry. */ -export function createPrefsStorageBridge( - options: PrefsStorageBridgeOptions +export function createPrefsStorageBridge( + options: PrefsStorageBridgeOptions ): PrefsStorageBridge { const { engine, storage, onError, skipHydrate = false } = options; @@ -104,7 +107,8 @@ export function createPrefsStorageBridge( if (event.previous.intent === event.next.intent) return; const next = event.next.intent; - const isEmpty = Object.keys(next).every((k) => (next as Record)[k] === undefined); + const nextRecord = next as Record; + const isEmpty = Object.keys(nextRecord).every((k) => nextRecord[k] === undefined); const op = isEmpty ? 'clear' : 'save'; const run = async (): Promise => { @@ -116,10 +120,9 @@ export function createPrefsStorageBridge( } }; - // Serialize saves so two rapid commits don't race in a - // backend that doesn't internally guarantee order. The chain - // is best-effort — failures are reported and don't stall - // further saves. + // Serialize saves so two rapid commits don't race in a backend + // that doesn't internally guarantee order. The chain is best- + // effort — failures are reported and don't stall further saves. pendingSave = (pendingSave ?? Promise.resolve()).then(run, run); }); diff --git a/src/arts/prefs/consts.ts b/src/arts/prefs/consts.ts index 84ec67a..80d4102 100644 --- a/src/arts/prefs/consts.ts +++ b/src/arts/prefs/consts.ts @@ -28,4 +28,3 @@ export const PREFS_ENGINE_METHOD_CLEAR_INTENT = 'clearIntent'; export const PREFS_ENGINE_METHOD_RESET_INTENT = 'resetIntent'; export const PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT = 'refreshEnvironment'; export const PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT = 'patchEnvironment'; -export const PREFS_ENGINE_METHOD_SET_CAPABILITIES = 'setCapabilities'; diff --git a/src/arts/prefs/dimensions/currency.ts b/src/arts/prefs/dimensions/currency.ts new file mode 100644 index 0000000..f9ef789 --- /dev/null +++ b/src/arts/prefs/dimensions/currency.ts @@ -0,0 +1,41 @@ +import { currencyFromLocales, type Currency } from '$libs/currency'; +import type { PrefsDimension } from '$libs/prefs'; + +export interface CurrencyDimensionOptions { + readonly catalog: readonly Currency[]; + readonly default?: Currency; +} + +/** + * Built-in currency dimension. Validates against the catalog. Falls + * back to `env.currency` (when in catalog), then walks + * `env.locales[]` via `currencyFromLocales` to derive a regional + * default, then `default`. + */ +export function currencyDimension( + options: CurrencyDimensionOptions +): PrefsDimension { + const catalog = options.catalog; + const fallback = options.default ?? catalog[0]; + if (fallback === undefined) { + throw new TypeError('currencyDimension(): catalog must not be empty.'); + } + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'unsupported_currency' }; + if (!catalog.includes(value as Currency)) { + return { ok: false, reason: 'unsupported_currency' }; + } + return { ok: true, value: value as Currency }; + }, + resolve(intent, env) { + if (intent !== undefined) return intent; + if (env.currency !== undefined && catalog.includes(env.currency)) { + return env.currency; + } + return currencyFromLocales(env.locales ?? [], catalog, fallback); + }, + catalog: () => catalog + }; +} diff --git a/src/arts/prefs/dimensions/density.ts b/src/arts/prefs/dimensions/density.ts new file mode 100644 index 0000000..1f9a7ae --- /dev/null +++ b/src/arts/prefs/dimensions/density.ts @@ -0,0 +1,25 @@ +import { DENSITIES, type Density } from '$libs/density'; +import type { PrefsDimension } from '$libs/prefs'; + +export interface DensityDimensionOptions { + readonly catalog?: readonly Density[]; + readonly default?: Density; +} + +export function densityDimension( + options: DensityDimensionOptions = {} +): PrefsDimension { + const catalog = options.catalog ?? DENSITIES; + const fallback = options.default ?? 'comfortable'; + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'unsupported_density' }; + if (!catalog.includes(value as Density)) { + return { ok: false, reason: 'unsupported_density' }; + } + return { ok: true, value: value as Density }; + }, + catalog: () => catalog + }; +} diff --git a/src/arts/prefs/dimensions/direction.ts b/src/arts/prefs/dimensions/direction.ts new file mode 100644 index 0000000..c8bdf9e --- /dev/null +++ b/src/arts/prefs/dimensions/direction.ts @@ -0,0 +1,42 @@ +import { directionFromLanguage, DIRECTIONS, type Direction } from '$libs/direction'; +import type { Locale } from '$libs/locale'; +import type { PrefsDimension } from '$libs/prefs'; + +export interface DirectionDimensionOptions { + /** + * Schema key for the language dimension that drives derivation. + * Defaults to `'language'`. Apps that name their language slot + * differently (or that want direction tied to `locale` instead of + * `language`) override this. + */ + readonly languageKey?: string; + readonly default?: Direction; +} + +/** + * Built-in direction dimension. Derived from the resolved language + * (RTL languages → `'rtl'`, everything else → `'ltr'`). User intent is + * still allowed to override — the resolver checks intent first, then + * the derive hook, then the default. + */ +export function directionDimension( + options: DirectionDimensionOptions = {} +): PrefsDimension { + const langKey = options.languageKey ?? 'language'; + const fallback = options.default ?? 'ltr'; + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'unsupported_direction' }; + if (!DIRECTIONS.includes(value as Direction)) { + return { ok: false, reason: 'unsupported_direction' }; + } + return { ok: true, value: value as Direction }; + }, + derive(effective) { + const resolvedLanguage = effective[langKey]; + if (typeof resolvedLanguage !== 'string') return fallback; + return directionFromLanguage(resolvedLanguage as Locale); + } + }; +} diff --git a/src/arts/prefs/dimensions/index.ts b/src/arts/prefs/dimensions/index.ts new file mode 100644 index 0000000..3ecc162 --- /dev/null +++ b/src/arts/prefs/dimensions/index.ts @@ -0,0 +1,27 @@ +/** + * Built-in `PrefsDimension` catalog. Each dimension is its own factory + * — `localeDimension({...})`, `themeDimension({...})` — and the + * `standardPrefsDimensions(catalog)` preset composes the canonical set. + * + * Application-specific dimensions go alongside these, either by reusing + * the primitive factories (`booleanDimension`, `enumDimension`, …) or + * by writing a bespoke `PrefsDimension` from + * scratch. The engine treats every dimension uniformly. + */ + +export { localeDimension } from './locale.ts'; +export { languageDimension } from './language.ts'; +export { themeDimension } from './theme.ts'; +export { densityDimension } from './density.ts'; +export { motionDimension } from './motion.ts'; +export { timezoneDimension } from './timezone.ts'; +export { currencyDimension } from './currency.ts'; +export { unitSystemDimension } from './unit-system.ts'; +export { directionDimension } from './direction.ts'; + +export { + booleanDimension, + enumDimension, + numberDimension, + stringDimension +} from './primitive.ts'; diff --git a/src/arts/prefs/dimensions/language.ts b/src/arts/prefs/dimensions/language.ts new file mode 100644 index 0000000..3bdfd19 --- /dev/null +++ b/src/arts/prefs/dimensions/language.ts @@ -0,0 +1,47 @@ +import { matchLocale, type Locale } from '$libs/locale'; +import type { PrefsDimension } from '$libs/prefs'; + +export interface LanguageDimensionOptions { + /** + * BCP-47 tags Lang has translations for. Distinct from + * `localeDimension.catalog` — language is the i18n choice, locale is + * the regional formatting choice. + */ + readonly catalog: readonly Locale[]; + readonly default?: Locale; +} + +/** + * Built-in translation-language dimension. Mirrors `localeDimension` + * with a separate catalog so language and locale stay orthogonal. + */ +export function languageDimension( + options: LanguageDimensionOptions +): PrefsDimension { + const catalog = options.catalog; + const fallback = options.default ?? catalog[0]; + if (fallback === undefined) { + throw new TypeError('languageDimension(): catalog must not be empty.'); + } + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') { + return { ok: false, reason: 'unsupported_language' }; + } + if (!catalog.includes(value as Locale)) { + return { ok: false, reason: 'unsupported_language' }; + } + return { ok: true, value: value as Locale }; + }, + resolve(intent, env) { + if (intent !== undefined) return intent; + return matchLocale({ + candidates: env.locales ?? [], + available: catalog, + fallback + }); + }, + catalog: () => catalog + }; +} diff --git a/src/arts/prefs/dimensions/locale.ts b/src/arts/prefs/dimensions/locale.ts new file mode 100644 index 0000000..c526403 --- /dev/null +++ b/src/arts/prefs/dimensions/locale.ts @@ -0,0 +1,49 @@ +import { matchLocale, type Locale } from '$libs/locale'; +import type { PrefsDimension } from '$libs/prefs'; + +export interface LocaleDimensionOptions { + /** + * Allowed locales. Order is significant: `default` defaults to the + * first entry, and `matchLocale` walks the catalog in order. + */ + readonly catalog: readonly Locale[]; + /** + * Final fallback. Must be present in `catalog`. Defaults to + * `catalog[0]` when omitted. + */ + readonly default?: Locale; +} + +/** + * Built-in regional-formatting locale dimension. Validates against + * `catalog`, falls back to `matchLocale` on `environment.locales[]` + * when no intent is set. + */ +export function localeDimension(options: LocaleDimensionOptions): PrefsDimension { + const catalog = options.catalog; + const fallback = options.default ?? catalog[0]; + if (fallback === undefined) { + throw new TypeError('localeDimension(): catalog must not be empty.'); + } + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') { + return { ok: false, reason: 'unsupported_locale' }; + } + if (!catalog.includes(value as Locale)) { + return { ok: false, reason: 'unsupported_locale' }; + } + return { ok: true, value: value as Locale }; + }, + resolve(intent, env) { + if (intent !== undefined) return intent; + return matchLocale({ + candidates: env.locales ?? [], + available: catalog, + fallback + }); + }, + catalog: () => catalog + }; +} diff --git a/src/arts/prefs/dimensions/motion.ts b/src/arts/prefs/dimensions/motion.ts new file mode 100644 index 0000000..ab095f4 --- /dev/null +++ b/src/arts/prefs/dimensions/motion.ts @@ -0,0 +1,39 @@ +import { + resolveMotion, + MOTIONS_INTENT, + type MotionEffective, + type MotionIntent +} from '$libs/motion'; +import type { PrefsDimension } from '$libs/prefs'; + +export interface MotionDimensionOptions { + readonly intents?: readonly MotionIntent[]; + readonly default?: MotionEffective; +} + +/** + * Built-in motion dimension. Like theme, intent includes `'system'` + * but effective is narrower (`'allow' | 'reduce'`); the dimension's + * `resolve` folds intent + `env.reducedMotion` into the effective + * value. + */ +export function motionDimension( + options: MotionDimensionOptions = {} +): PrefsDimension { + const intents = options.intents ?? MOTIONS_INTENT; + const fallback = options.default ?? 'allow'; + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'unsupported_motion' }; + if (!intents.includes(value as MotionIntent)) { + return { ok: false, reason: 'unsupported_motion' }; + } + return { ok: true, value: value as MotionIntent }; + }, + resolve(intent, env) { + return resolveMotion(intent, env.reducedMotion, fallback); + }, + catalog: () => intents + }; +} diff --git a/src/arts/prefs/dimensions/primitive.ts b/src/arts/prefs/dimensions/primitive.ts new file mode 100644 index 0000000..c5fd1e6 --- /dev/null +++ b/src/arts/prefs/dimensions/primitive.ts @@ -0,0 +1,95 @@ +import type { PrefsDimension } from '$libs/prefs'; + +/** + * Boolean dimension — for on/off toggles like `sidebarCollapsed`, + * `betaFeaturesEnabled`. Validation rejects everything that isn't a + * literal boolean. + */ +export function booleanDimension(options: { + default: boolean; +}): PrefsDimension { + return { + defaultValue: options.default, + validate(value) { + if (typeof value !== 'boolean') return { ok: false, reason: 'invalid_boolean' }; + return { ok: true, value }; + } + }; +} + +/** + * Enum dimension over a fixed string union. Use `as const` on the + * `values` array so TypeScript infers a literal type. + * + * ```ts + * const dim = enumDimension(['all', 'mentions', 'none'] as const, { + * default: 'mentions' + * }); + * App.prefs.notificationLevel.set('mentions'); // typed + * ``` + */ +export function enumDimension( + values: readonly T[], + options: { default: T } +): PrefsDimension { + return { + defaultValue: options.default, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'invalid_enum' }; + if (!values.includes(value as T)) return { ok: false, reason: 'invalid_enum' }; + return { ok: true, value: value as T }; + }, + catalog: () => values + }; +} + +/** + * Free-form string dimension. Optional `pattern` validation. Useful + * for "username" or other strings without a closed catalog. + */ +export function stringDimension(options: { + default: string; + pattern?: RegExp; + maxLength?: number; +}): PrefsDimension { + const { default: fallback, pattern, maxLength } = options; + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'invalid_string' }; + if (maxLength !== undefined && value.length > maxLength) { + return { ok: false, reason: 'invalid_string' }; + } + if (pattern !== undefined && !pattern.test(value)) { + return { ok: false, reason: 'invalid_string' }; + } + return { ok: true, value }; + } + }; +} + +/** + * Number dimension. Optional `min` / `max` bounds and `integer` + * constraint. Rejects `NaN`, `Infinity` and non-number inputs. + */ +export function numberDimension(options: { + default: number; + min?: number; + max?: number; + integer?: boolean; +}): PrefsDimension { + const { default: fallback, min, max, integer } = options; + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'number') return { ok: false, reason: 'invalid_number' }; + if (!Number.isFinite(value)) return { ok: false, reason: 'invalid_number' }; + if (integer === true && !Number.isInteger(value)) { + return { ok: false, reason: 'invalid_number' }; + } + if (min !== undefined && value < min) return { ok: false, reason: 'invalid_number' }; + if (max !== undefined && value > max) return { ok: false, reason: 'invalid_number' }; + return { ok: true, value }; + } + }; +} diff --git a/src/arts/prefs/dimensions/theme.ts b/src/arts/prefs/dimensions/theme.ts new file mode 100644 index 0000000..30c3ac2 --- /dev/null +++ b/src/arts/prefs/dimensions/theme.ts @@ -0,0 +1,48 @@ +import type { PrefsDimension } from '$libs/prefs'; +import { + resolveTheme, + THEMES_INTENT, + type ThemeEffective, + type ThemeIntent +} from '$libs/theme'; + +export interface ThemeDimensionOptions { + /** + * Allowed intent values. Defaults to the full `THEMES_INTENT` + * (`'light' | 'dark' | 'system'`). Apps that don't want users to + * follow the OS pass `['light', 'dark']`. + */ + readonly intents?: readonly ThemeIntent[]; + /** + * Final fallback applied when neither intent nor `env.colorScheme` + * provide a value. Must be a `ThemeEffective`. + */ + readonly default?: ThemeEffective; +} + +/** + * Built-in theme dimension. `TIntent` includes `'system'`, `TEffective` + * does not — the dimension's `resolve` folds `'system'` (and the no- + * intent case) into a concrete `'light' | 'dark'` using + * `env.colorScheme`. + */ +export function themeDimension( + options: ThemeDimensionOptions = {} +): PrefsDimension { + const intents = options.intents ?? THEMES_INTENT; + const fallback = options.default ?? 'light'; + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'unsupported_theme' }; + if (!intents.includes(value as ThemeIntent)) { + return { ok: false, reason: 'unsupported_theme' }; + } + return { ok: true, value: value as ThemeIntent }; + }, + resolve(intent, env) { + return resolveTheme(intent, env.colorScheme, fallback); + }, + catalog: () => intents + }; +} diff --git a/src/arts/prefs/dimensions/timezone.ts b/src/arts/prefs/dimensions/timezone.ts new file mode 100644 index 0000000..cc1361d --- /dev/null +++ b/src/arts/prefs/dimensions/timezone.ts @@ -0,0 +1,58 @@ +import type { PrefsDimension } from '$libs/prefs'; +import type { Timezone } from '$libs/timezone'; + +export interface TimezoneDimensionOptions { + /** + * Optional allowlist of IANA zones. When omitted, any value that + * canonicalises through `Intl.DateTimeFormat` is accepted. + */ + readonly catalog?: readonly Timezone[]; + readonly default?: Timezone; +} + +/** + * Canonicalise a timezone via `Intl.DateTimeFormat`. Returns + * `undefined` when the value is not a recognisable IANA zone — the + * dimension treats that as `'invalid_timezone'`. + */ +function canonicalize(value: string): string | undefined { + try { + return new Intl.DateTimeFormat('en-US', { timeZone: value }) + .resolvedOptions() + .timeZone; + } catch { + return undefined; + } +} + +export function timezoneDimension( + options: TimezoneDimensionOptions = {} +): PrefsDimension { + const catalog = options.catalog; + const fallback = options.default ?? ('UTC' as Timezone); + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'invalid_timezone' }; + const canonical = canonicalize(value); + if (canonical === undefined) return { ok: false, reason: 'invalid_timezone' }; + if (catalog !== undefined && !catalog.includes(canonical as Timezone)) { + return { ok: false, reason: 'unsupported_timezone' }; + } + return { ok: true, value: canonical as Timezone }; + }, + resolve(intent, env) { + if (intent !== undefined) return intent; + if (env.timezone !== undefined) { + const canonical = canonicalize(env.timezone); + if (canonical !== undefined) { + if (catalog === undefined || catalog.includes(canonical as Timezone)) { + return canonical as Timezone; + } + } + } + return fallback; + }, + catalog: catalog === undefined ? undefined : () => catalog + }; +} diff --git a/src/arts/prefs/dimensions/unit-system.ts b/src/arts/prefs/dimensions/unit-system.ts new file mode 100644 index 0000000..5c88a1a --- /dev/null +++ b/src/arts/prefs/dimensions/unit-system.ts @@ -0,0 +1,32 @@ +import type { PrefsDimension } from '$libs/prefs'; +import { unitSystemFromLocales, UNIT_SYSTEMS, type UnitSystem } from '$libs/units'; + +export interface UnitSystemDimensionOptions { + readonly catalog?: readonly UnitSystem[]; + readonly default?: UnitSystem; +} + +export function unitSystemDimension( + options: UnitSystemDimensionOptions = {} +): PrefsDimension { + const catalog = options.catalog ?? UNIT_SYSTEMS; + const fallback = options.default ?? 'metric'; + return { + defaultValue: fallback, + validate(value) { + if (typeof value !== 'string') return { ok: false, reason: 'unsupported_unit_system' }; + if (!catalog.includes(value as UnitSystem)) { + return { ok: false, reason: 'unsupported_unit_system' }; + } + return { ok: true, value: value as UnitSystem }; + }, + resolve(intent, env) { + if (intent !== undefined) return intent; + if (env.unitSystem !== undefined && catalog.includes(env.unitSystem)) { + return env.unitSystem; + } + return unitSystemFromLocales(env.locales ?? [], catalog, fallback); + }, + catalog: () => catalog + }; +} diff --git a/src/arts/prefs/engine-prefs.ts b/src/arts/prefs/engine-prefs.ts index 9b11c39..6bc01c9 100644 --- a/src/arts/prefs/engine-prefs.ts +++ b/src/arts/prefs/engine-prefs.ts @@ -1,65 +1,61 @@ import { resolvePrefs, - validateIntentValue, - type PrefsCapabilities, + sanitizeIntent, type PrefsChangeCause, type PrefsChangeEvent, type PrefsChangeHandler, - type PrefsEffective, + type PrefsEffectiveOf, type PrefsEnvironment, - type PrefsIntent, + type PrefsIntentOf, + type PrefsSchema, type PrefsSnapshot, - type PrefsUnsubscribe, - type PrefsValidationFailure + type PrefsUnsubscribe } from '$libs/prefs'; import { PREFS_ENGINE_METHOD_CLEAR_INTENT, PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT, PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT, PREFS_ENGINE_METHOD_RESET_INTENT, - PREFS_ENGINE_METHOD_SET_CAPABILITIES, PREFS_ENGINE_METHOD_SET_INTENT, PREFS_KIND } from './consts.ts'; import { - PrefsCapabilitiesInvalidError, PrefsDisposedError, PrefsIntentInvalidError, - capabilitiesInvalidErrorMessage, + PrefsUnknownDimensionError, disposedErrorMessage, - intentInvalidErrorMessage + intentInvalidErrorMessage, + unknownDimensionErrorMessage } from './errors.ts'; import type { EnginePrefs, EnginePrefsOptions } from './types.ts'; /** - * Build a fresh `EnginePrefs`. Runes-free — safe to import from - * server-only modules. The Active wrapper layers reactive state on top. + * Build a fresh `EnginePrefs` for the given schema. Runes-free — + * safe to import from server-only modules. The Active wrapper layers + * reactive state and the per-dimension surface on top. * * Internally maintains: - * - Three immutable layer cells (`capabilities`, `environment`, `intent`) - * replaced by reference on every commit. - * - A frozen `PrefsSnapshot` cell that bundles the layers plus the - * resolved `effective` view; rebuilt only on commits so reads return - * structurally-shared references. + * - The frozen `schema` reference (immutable for the engine's lifetime). + * - Two layer cells (`environment`, `intent`) replaced by reference on + * every commit. + * - A frozen `PrefsSnapshot` cell with the resolved `effective` + * view; rebuilt only on commits so reads return structurally-shared + * references. * - A monotonic `version` counter bumped per commit. Stable across * no-op writes (see `commit()` short-circuit). - * - A listener set notified with `{previous, next, effectiveDiff, cause}` - * only when at least one layer changed by reference. - * - * Validation is the same `validateIntentValue` the resolver uses — any - * value that survives `setIntent` is exactly a value the resolver could - * have stored on its own. This is important for the persisted-intent - * model: round-tripping intent through storage never produces a - * diverged effective view. + * - A listener set notified with `{previous, next, effectiveDiff, + * cause}` only when at least one layer changed by reference. */ -export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs { - let capabilities = options.capabilities; +export function createEnginePrefs( + options: EnginePrefsOptions +): EnginePrefs { + const schema = options.schema; let environment: PrefsEnvironment = options.environment ?? {}; - let intent: PrefsIntent = options.intent ?? {}; + let intent: PrefsIntentOf = sanitiseInitialIntent(schema, options.intent); let version = 0; - let snapshot: PrefsSnapshot = buildSnapshot(capabilities, environment, intent, version); + let snapshot = buildSnapshot(schema, environment, intent, version); let disposed = false; - const listeners = new Set(); + const listeners = new Set>(); function ensureLive(method: string): void { if (disposed) { @@ -68,37 +64,27 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs { } function commit( - nextCapabilities: PrefsCapabilities, nextEnvironment: PrefsEnvironment, - nextIntent: PrefsIntent, + nextIntent: PrefsIntentOf, cause: PrefsChangeCause - ): PrefsSnapshot { - // No-op short-circuit: if every layer is the same reference, the - // resolved view is by definition unchanged. Skip the recompute, - // the version bump and the listener walk so callers can do - // idempotent writes (re-emitting the same `setIntent`, - // resubscribing a detector that fires on a no-op signal change) - // without spurious notifications. - if ( - nextCapabilities === capabilities && - nextEnvironment === environment && - nextIntent === intent - ) { + ): PrefsSnapshot { + // No-op short-circuit: if every mutable layer is the same + // reference, the resolved view is by definition unchanged. Skip + // the recompute, the version bump and the listener walk. + if (nextEnvironment === environment && nextIntent === intent) { return snapshot; } const previous = snapshot; - capabilities = nextCapabilities; environment = nextEnvironment; intent = nextIntent; version += 1; - snapshot = buildSnapshot(capabilities, environment, intent, version); + snapshot = buildSnapshot(schema, environment, intent, version); - const effectiveDiff = diffEffective(previous.effective, snapshot.effective); - const event: PrefsChangeEvent = { + const event: PrefsChangeEvent = { previous, next: snapshot, - effectiveDiff, + effectiveDiff: diffEffective(previous.effective, snapshot.effective), cause }; // Snapshot the listener set so a handler that mutates the engine @@ -106,19 +92,16 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs { for (const listener of [...listeners]) { listener(event); } - return snapshot; } - const engine: EnginePrefs = { + const engine: EnginePrefs = { kind: PREFS_KIND, + schema, snapshot() { return snapshot; }, - capabilities() { - return capabilities; - }, environment() { return environment; }, @@ -129,57 +112,58 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs { return snapshot.effective; }, - setIntent(key, value) { + setIntent(key: K, value: unknown): PrefsSnapshot { ensureLive(PREFS_ENGINE_METHOD_SET_INTENT); - const result = validateIntentValue(key, value, capabilities); + const stringKey = key as string; + const dim = schema[stringKey]; + if (dim === undefined) { + throw new PrefsUnknownDimensionError(stringKey, unknownDimensionErrorMessage(stringKey)); + } + const result = dim.validate(value); if (!result.ok) { throw new PrefsIntentInvalidError( - key, + stringKey, result.reason, - intentInvalidErrorMessage(key, result.reason) + intentInvalidErrorMessage(stringKey, result.reason) ); } - // Canonicalised value (timezone alias → IANA canonical) is - // what gets stored, not the raw input. - if (intent[key] === result.value) return snapshot; - const nextIntent: PrefsIntent = { ...intent, [key]: result.value }; - return commit(capabilities, environment, nextIntent, 'intent:set'); + const current = (intent as Record)[stringKey]; + if (current === result.value) return snapshot; + const next = { ...(intent as Record), [stringKey]: result.value }; + return commit(environment, next as PrefsIntentOf, 'intent:set'); }, - clearIntent(key) { + clearIntent(key: K): PrefsSnapshot { ensureLive(PREFS_ENGINE_METHOD_CLEAR_INTENT); - if (intent[key] === undefined) return snapshot; - const nextIntent: PrefsIntent = { ...intent }; - delete (nextIntent as { [P in keyof PrefsIntent]?: PrefsIntent[P] })[key]; - return commit(capabilities, environment, nextIntent, 'intent:clear'); + const stringKey = key as string; + if (schema[stringKey] === undefined) { + throw new PrefsUnknownDimensionError(stringKey, unknownDimensionErrorMessage(stringKey)); + } + if ((intent as Record)[stringKey] === undefined) return snapshot; + const next = { ...(intent as Record) }; + delete next[stringKey]; + return commit(environment, next as PrefsIntentOf, 'intent:clear'); }, - resetIntent(next) { + resetIntent(next?: PrefsIntentOf): PrefsSnapshot { ensureLive(PREFS_ENGINE_METHOD_RESET_INTENT); - const nextIntent: PrefsIntent = next ?? {}; - if (intentEqual(intent, nextIntent)) return snapshot; - return commit(capabilities, environment, nextIntent, 'intent:reset'); + const sanitised = sanitizeIntent(schema, (next ?? {}) as Record); + if (intentEqual(intent, sanitised)) return snapshot; + return commit(environment, sanitised as PrefsIntentOf, 'intent:reset'); }, - refreshEnvironment(next) { + refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot { ensureLive(PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT); if (environmentEqual(environment, next)) return snapshot; - return commit(capabilities, next, intent, 'environment:refresh'); + return commit(next, intent, 'environment:refresh'); }, - patchEnvironment(patch) { + patchEnvironment(patch: Partial): PrefsSnapshot { ensureLive(PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT); const merged: PrefsEnvironment = { ...environment, ...patch }; if (environmentEqual(environment, merged)) return snapshot; - return commit(capabilities, merged, intent, 'environment:refresh'); - }, - - setCapabilities(next) { - ensureLive(PREFS_ENGINE_METHOD_SET_CAPABILITIES); - validateDefaults(next); - if (next === capabilities) return snapshot; - return commit(next, environment, intent, 'capabilities:set'); + return commit(merged, intent, 'environment:refresh'); }, subscribe(handler) { @@ -205,80 +189,85 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs { // Helpers // ───────────────────────────────────────────────────────────────────── -function buildSnapshot( - capabilities: PrefsCapabilities, +function sanitiseInitialIntent( + schema: S, + intent: PrefsIntentOf | undefined +): PrefsIntentOf { + if (intent === undefined) return {} as PrefsIntentOf; + return sanitizeIntent(schema, intent as Record) as PrefsIntentOf; +} + +function buildSnapshot( + schema: S, environment: PrefsEnvironment, - intent: PrefsIntent, + intent: PrefsIntentOf, version: number -): PrefsSnapshot { - const effective = resolvePrefs({ capabilities, environment, intent }); +): PrefsSnapshot { + const effective = resolvePrefs({ schema, environment, intent }) as PrefsEffectiveOf; return Object.freeze({ - capabilities, environment, intent, - effective: Object.freeze(effective), + effective, version - }); + }) as PrefsSnapshot; } /** - * Shallow per-field diff over `PrefsEffective`. The result holds only - * keys whose value changed — `Object.keys(diff).length === 0` is the - * "effective unchanged" signal subscribers can branch on. + * Shallow per-key diff over `effective`. Returns only keys whose value + * changed — `Object.keys(diff).length === 0` is the "effective + * unchanged" signal subscribers can branch on. */ -function diffEffective( - previous: PrefsEffective, - next: PrefsEffective -): Partial { - const diff: { -readonly [K in keyof PrefsEffective]?: PrefsEffective[K] } = {}; - const keys = Object.keys(next) as Array; - for (const key of keys) { - if (previous[key] !== next[key]) { - (diff[key] as PrefsEffective[typeof key]) = next[key]; +function diffEffective( + previous: PrefsEffectiveOf, + next: PrefsEffectiveOf +): Partial> { + const diff: Record = {}; + const previousMap = previous as unknown as Record; + const nextMap = next as unknown as Record; + for (const key of Object.keys(nextMap)) { + if (previousMap[key] !== nextMap[key]) { + diff[key] = nextMap[key]; } } - return diff; + return diff as Partial>; } /** - * Sparse-map equality for `PrefsIntent`. Keys with `undefined` values - * are treated as absent so callers can write `{ locale: undefined }` - * without spuriously committing — but the canonical "clear" path is - * still `clearIntent(key)`. + * Sparse-map equality. Keys with `undefined` values are treated as + * absent so callers can write `{ locale: undefined }` without + * spuriously committing — the canonical "clear" path is still + * `clearIntent(key)`. */ -function intentEqual(a: PrefsIntent, b: PrefsIntent): boolean { - const aKeys = (Object.keys(a) as Array).filter( - (k) => a[k] !== undefined - ); - const bKeys = (Object.keys(b) as Array).filter( - (k) => b[k] !== undefined - ); +function intentEqual(a: object, b: object): boolean { + const aMap = a as Record; + const bMap = b as Record; + const aKeys = Object.keys(aMap).filter((k) => aMap[k] !== undefined); + const bKeys = Object.keys(bMap).filter((k) => bMap[k] !== undefined); if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { - if (a[key] !== b[key]) return false; + if (aMap[key] !== bMap[key]) return false; } return true; } function environmentEqual(a: PrefsEnvironment, b: PrefsEnvironment): boolean { if (a === b) return true; - const aKeys = Object.keys(a) as Array; - const bKeys = Object.keys(b) as Array; + const aMap = a as Record; + const bMap = b as Record; + const aKeys = Object.keys(aMap); + const bKeys = Object.keys(bMap); if (aKeys.length !== bKeys.length) return false; for (const key of aKeys) { if (key === 'locales') { if (!arrayEqual(a.locales, b.locales)) return false; - } else if (a[key] !== b[key]) { + } else if (aMap[key] !== bMap[key]) { return false; } } return true; } -function arrayEqual( - a: readonly T[] | undefined, - b: readonly T[] | undefined -): boolean { +function arrayEqual(a: readonly T[] | undefined, b: readonly T[] | undefined): boolean { if (a === b) return true; if (a === undefined || b === undefined) return false; if (a.length !== b.length) return false; @@ -287,45 +276,3 @@ function arrayEqual( } return true; } - -/** - * `capabilities.defaults` is the final fallback for every `effective` - * field. If a default is itself unsupported by the new sets, every - * resolution that lands on the fallback would produce an invalid view - * — surface the misconfiguration loudly instead of letting it propagate. - * - * Direction is intentionally not validated: it is derived from the - * resolved language by `resolvePrefs`, so any value passed in - * `defaults.direction` is overwritten anyway. - */ -function validateDefaults(capabilities: PrefsCapabilities): void { - const defaults = capabilities.defaults; - const checks: ReadonlyArray<{ - key: keyof PrefsIntent; - value: NonNullable; - }> = [ - { key: 'language', value: defaults.language }, - { key: 'locale', value: defaults.locale }, - { key: 'currency', value: defaults.currency }, - { key: 'unitSystem', value: defaults.unitSystem }, - { key: 'theme', value: defaults.theme }, - { key: 'density', value: defaults.density }, - { key: 'motion', value: defaults.motion }, - { key: 'timezone', value: defaults.timezone } - ]; - - for (const { key, value } of checks) { - const result = validateIntentValue(key, value, capabilities); - if (!result.ok) { - throwCapabilitiesInvalid(key, result.reason); - } - } -} - -function throwCapabilitiesInvalid(field: string, reason: PrefsValidationFailure): never { - throw new PrefsCapabilitiesInvalidError( - field, - reason, - capabilitiesInvalidErrorMessage(field, reason) - ); -} diff --git a/src/arts/prefs/errors.ts b/src/arts/prefs/errors.ts index 55039ab..17745df 100644 --- a/src/arts/prefs/errors.ts +++ b/src/arts/prefs/errors.ts @@ -6,7 +6,8 @@ * The engine surfaces lifecycle conditions via change events and * commit-returns; exceptions are reserved for programmer/data errors * where catching at the call site is the right pattern (disposed-engine - * misuse, capability-default mismatch, intent-validation failure). + * misuse, intent-validation failure, schema-key collision with a + * reserved member, write to a key not in the schema). */ import { @@ -24,15 +25,17 @@ import { PREFS_MODULE, type PrefsValidationFailure } from '$libs/prefs'; export const PREFS_ERR: ModuleSeed = moduleSeed(PREFS_MODULE); export const PREFS_ERR_DISPOSED: ErrCode = errCode(PREFS_ERR, 'disposed'); export const PREFS_ERR_INTENT_INVALID: ErrCode = errCode(PREFS_ERR, 'intent_invalid'); -export const PREFS_ERR_CAPABILITIES_INVALID: ErrCode = errCode(PREFS_ERR, 'capabilities_invalid'); +export const PREFS_ERR_RESERVED_KEY: ErrCode = errCode(PREFS_ERR, 'reserved_key'); +export const PREFS_ERR_UNKNOWN_DIMENSION: ErrCode = errCode(PREFS_ERR, 'unknown_dimension'); // ── Error message strings ────────────────────────────────────────────── export const PREFS_ERROR_PREFIX = `[${PREFS_MODULE}] `; export const PREFS_ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed prefs engine'; export const PREFS_ERROR_MSG_INTENT_INVALID_PREFIX = 'setIntent() rejected: '; -export const PREFS_ERROR_MSG_CAPABILITIES_INVALID_PREFIX = - 'setCapabilities() rejected: defaults do not validate — '; +export const PREFS_ERROR_MSG_RESERVED_KEY_PREFIX = + 'schema key collides with a reserved active-prefs member: '; +export const PREFS_ERROR_MSG_UNKNOWN_DIMENSION_PREFIX = 'no such dimension in schema: '; export function disposedErrorMessage(method: string): string { return `${PREFS_ERROR_PREFIX}${method}${PREFS_ERROR_MSG_DISPOSED_SUFFIX}`; @@ -45,19 +48,21 @@ export function intentInvalidErrorMessage( return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_INTENT_INVALID_PREFIX}${key} (${reason})`; } -export function capabilitiesInvalidErrorMessage( - field: string, - reason: PrefsValidationFailure -): string { - return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_CAPABILITIES_INVALID_PREFIX}defaults.${field} (${reason})`; +export function reservedKeyErrorMessage(key: string): string { + return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_RESERVED_KEY_PREFIX}${key}`; +} + +export function unknownDimensionErrorMessage(key: string): string { + return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_UNKNOWN_DIMENSION_PREFIX}${key}`; } // ── Error messages ───────────────────────────────────────────────────── export const PREFS_ERROR_MESSAGES: ErrorMessages = { [PREFS_ERR_DISPOSED]: `${PREFS_ERROR_PREFIX}operation called on a disposed prefs engine`, - [PREFS_ERR_INTENT_INVALID]: `${PREFS_ERROR_PREFIX}setIntent() received a value outside capabilities`, - [PREFS_ERR_CAPABILITIES_INVALID]: `${PREFS_ERROR_PREFIX}setCapabilities() received defaults that do not validate against the new capability sets` + [PREFS_ERR_INTENT_INVALID]: `${PREFS_ERROR_PREFIX}setIntent() received a value rejected by the dimension's validator`, + [PREFS_ERR_RESERVED_KEY]: `${PREFS_ERROR_PREFIX}schema declares a key that collides with a reserved active-prefs member`, + [PREFS_ERR_UNKNOWN_DIMENSION]: `${PREFS_ERROR_PREFIX}attempted to mutate a dimension that is not in the schema` }; // ── Error classes ────────────────────────────────────────────────────── @@ -73,19 +78,14 @@ export class PrefsDisposedError extends CodeError { } /** - * `setIntent()` was called with a value outside `capabilities`. Carries - * the structured `PrefsValidationFailure` reason from - * `validateIntentValue` so UI can branch on a stable code instead of - * parsing the message. + * `setIntent()` was called with a value rejected by the dimension's + * `validate` callback. Carries the dimension key plus the + * `PrefsValidationFailure` reason for UI branches. */ export class PrefsIntentInvalidError extends CodeError { - readonly key: keyof import('$libs/prefs').PrefsIntent; + readonly key: string; readonly reason: PrefsValidationFailure; - constructor( - key: keyof import('$libs/prefs').PrefsIntent, - reason: PrefsValidationFailure, - message: string - ) { + constructor(key: string, reason: PrefsValidationFailure, message: string) { super(PREFS_ERR_INTENT_INVALID, { message }); this.key = key; this.reason = reason; @@ -93,18 +93,29 @@ export class PrefsIntentInvalidError extends CodeError { } /** - * `setCapabilities()` was called with `defaults` that fail validation - * against the new capability sets — the engine cannot silently accept - * this because `capabilities.defaults` is the final fallback in every - * `effective` projection and must itself be reachable. + * The provided schema declares a key that would shadow a reserved + * active-prefs member (`state`, `dispose`, `subscribe`, `snapshot`, + * etc.). Detected at construction time so the application fails fast + * rather than producing a confusingly-shaped runtime. */ -export class PrefsCapabilitiesInvalidError extends CodeError { - readonly field: string; - readonly reason: PrefsValidationFailure; - constructor(field: string, reason: PrefsValidationFailure, message: string) { - super(PREFS_ERR_CAPABILITIES_INVALID, { message }); - this.field = field; - this.reason = reason; +export class PrefsReservedKeyError extends CodeError { + readonly key: string; + constructor(key: string, message: string) { + super(PREFS_ERR_RESERVED_KEY, { message }); + this.key = key; + } +} + +/** + * A `setIntent` / `clearIntent` write targeted a key that is not in the + * schema. The engine cannot guess a validator for it, so the call is + * rejected. + */ +export class PrefsUnknownDimensionError extends CodeError { + readonly key: string; + constructor(key: string, message: string) { + super(PREFS_ERR_UNKNOWN_DIMENSION, { message }); + this.key = key; } } @@ -118,8 +129,10 @@ export function isPrefsIntentInvalidError(value: unknown): value is PrefsIntentI return value instanceof PrefsIntentInvalidError; } -export function isPrefsCapabilitiesInvalidError( - value: unknown -): value is PrefsCapabilitiesInvalidError { - return value instanceof PrefsCapabilitiesInvalidError; +export function isPrefsReservedKeyError(value: unknown): value is PrefsReservedKeyError { + return value instanceof PrefsReservedKeyError; +} + +export function isPrefsUnknownDimensionError(value: unknown): value is PrefsUnknownDimensionError { + return value instanceof PrefsUnknownDimensionError; } diff --git a/src/arts/prefs/index.ts b/src/arts/prefs/index.ts index 6baf13e..e1a649c 100644 --- a/src/arts/prefs/index.ts +++ b/src/arts/prefs/index.ts @@ -1,42 +1,53 @@ -/** - * Public surface of `arts/prefs`. Re-exports the runtime engine - * (`createEnginePrefs`), the engine contract (`EnginePrefs`) and the - * artifact's error infrastructure. - * - * The Svelte rune adapter (`active-prefs.svelte.ts`) and the IO - * adapters (browser/server environment detectors, storage bridge) live - * in submodules and are imported through their own paths so that - * server-only callers can pull just the engine without dragging - * `.svelte.ts` files into the build. - */ +// Public surface of the prefs artifact. Named re-exports (not `export *`) +// so the bundler can prove which symbols are reached from a given import. +// +// The library lives in `$libs/prefs` (pure types + resolver). This +// artifact adds the runtime engine, the reactive Svelte adapter, the +// built-in dimension catalog, the standard preset and the IO adapters +// (browser/server environment detectors, storage bridge). export { PREFS_ENGINE_METHOD_CLEAR_INTENT, PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT, PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT, PREFS_ENGINE_METHOD_RESET_INTENT, - PREFS_ENGINE_METHOD_SET_CAPABILITIES, PREFS_ENGINE_METHOD_SET_INTENT, PREFS_KIND } from './consts.ts'; export { createEnginePrefs } from './engine-prefs.ts'; -export { createActivePrefs } from './active-prefs.svelte.ts'; +export { + createActivePrefs, + ACTIVE_PREFS_RESERVED_KEYS, + type ActivePrefs, + type ActivePrefsDimension, + type ActivePrefsState +} from './active-prefs.svelte.ts'; export type { EnginePrefs, EnginePrefsOptions } from './types.ts'; -export type { ActivePrefs, ActivePrefsState } from './active-prefs.svelte.ts'; export { - prefsCurrencySource, - prefsDensitySource, - prefsDirectionSource, - prefsLanguageSource, - prefsLocaleSource, - prefsMotionSource, - prefsThemeSource, - prefsTimezoneSource, - prefsUnitSystemSource -} from './sources.ts'; + booleanDimension, + currencyDimension, + densityDimension, + directionDimension, + enumDimension, + languageDimension, + localeDimension, + motionDimension, + numberDimension, + stringDimension, + themeDimension, + timezoneDimension, + unitSystemDimension +} from './dimensions/index.ts'; + +export { + NEUTRAL_PREFS_SCHEMA, + standardPrefsDimensions, + type StandardPrefsCatalog, + type StandardPrefsSchema +} from './standard.ts'; export { applyBrowserEnvironment, @@ -61,15 +72,18 @@ export type { export { PREFS_ERR, - PREFS_ERR_CAPABILITIES_INVALID, PREFS_ERR_DISPOSED, PREFS_ERR_INTENT_INVALID, + PREFS_ERR_RESERVED_KEY, + PREFS_ERR_UNKNOWN_DIMENSION, PREFS_ERROR_MESSAGES, PREFS_ERROR_PREFIX, - PrefsCapabilitiesInvalidError, PrefsDisposedError, PrefsIntentInvalidError, - isPrefsCapabilitiesInvalidError, + PrefsReservedKeyError, + PrefsUnknownDimensionError, isPrefsDisposedError, - isPrefsIntentInvalidError + isPrefsIntentInvalidError, + isPrefsReservedKeyError, + isPrefsUnknownDimensionError } from './errors.ts'; diff --git a/src/arts/prefs/sources.ts b/src/arts/prefs/sources.ts deleted file mode 100644 index 9f9cc6e..0000000 --- a/src/arts/prefs/sources.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Capability-source proxies. Each helper turns an `EnginePrefs` into a - * narrow `Source` for one effective field — the shape `lang`, - * `format`, `frontend` etc. consume. - * - * Why these live in `arts/prefs` instead of in each consumer: the - * consumers should NOT know `prefs` exists. They depend on the abstract - * `Source` port from `$libs/reactive` and accept any producer that - * satisfies it (a hand-written test stub, a Svelte `$state` wrapper, or - * one of these proxies). `arts/prefs` is the bridge — it knows about - * `prefs` and produces ports. - * - * `EnginePrefs` is enough — `subscribe()` and `effective()` are the only - * surfaces these proxies use, so the same helpers work with the runes - * adapter (`ActivePrefs`) too. - */ - -import type { CurrencySource } from '$libs/currency'; -import type { DensitySource } from '$libs/density'; -import type { DirectionSource } from '$libs/direction'; -import type { LocaleSource } from '$libs/locale'; -import type { MotionSource } from '$libs/motion'; -import type { PrefsEffective } from '$libs/prefs'; -import type { Source } from '$libs/reactive'; -import type { ThemeSource } from '$libs/theme'; -import type { TimezoneSource } from '$libs/timezone'; -import type { UnitSystemSource } from '$libs/units'; -import type { EnginePrefs } from './types.ts'; - -/** - * Generic builder. Reads `field` from `effective`, and forwards - * `onChange` only when that specific field appears in the change - * event's `effectiveDiff`. Other-field changes do not wake the - * consumer up — the proxy delivers per-dimension reactivity. - */ -function fieldSource( - prefs: EnginePrefs, - field: K -): Source { - return { - get: () => prefs.effective()[field], - onChange(fn) { - return prefs.subscribe((event) => { - if (field in event.effectiveDiff) { - fn(event.next.effective[field]); - } - }); - } - }; -} - -export function prefsLanguageSource(prefs: EnginePrefs): LocaleSource { - return fieldSource(prefs, 'language'); -} - -export function prefsLocaleSource(prefs: EnginePrefs): LocaleSource { - return fieldSource(prefs, 'locale'); -} - -export function prefsCurrencySource(prefs: EnginePrefs): CurrencySource { - return fieldSource(prefs, 'currency'); -} - -export function prefsTimezoneSource(prefs: EnginePrefs): TimezoneSource { - return fieldSource(prefs, 'timezone'); -} - -export function prefsUnitSystemSource(prefs: EnginePrefs): UnitSystemSource { - return fieldSource(prefs, 'unitSystem'); -} - -export function prefsThemeSource(prefs: EnginePrefs): ThemeSource { - return fieldSource(prefs, 'theme'); -} - -export function prefsDensitySource(prefs: EnginePrefs): DensitySource { - return fieldSource(prefs, 'density'); -} - -export function prefsMotionSource(prefs: EnginePrefs): MotionSource { - return fieldSource(prefs, 'motion'); -} - -export function prefsDirectionSource(prefs: EnginePrefs): DirectionSource { - return fieldSource(prefs, 'direction'); -} diff --git a/src/arts/prefs/standard.ts b/src/arts/prefs/standard.ts new file mode 100644 index 0000000..13496d9 --- /dev/null +++ b/src/arts/prefs/standard.ts @@ -0,0 +1,85 @@ +import type { Currency } from '$libs/currency'; +import type { Locale } from '$libs/locale'; +import type { Timezone } from '$libs/timezone'; + +import { currencyDimension } from './dimensions/currency.ts'; +import { densityDimension } from './dimensions/density.ts'; +import { directionDimension } from './dimensions/direction.ts'; +import { languageDimension } from './dimensions/language.ts'; +import { localeDimension } from './dimensions/locale.ts'; +import { motionDimension } from './dimensions/motion.ts'; +import { themeDimension } from './dimensions/theme.ts'; +import { timezoneDimension } from './dimensions/timezone.ts'; +import { unitSystemDimension } from './dimensions/unit-system.ts'; + +/** + * Catalog input for `standardPrefsDimensions`. Apps declare which + * languages, locales and currencies they support; the preset wires the + * canonical built-ins around them. + */ +export interface StandardPrefsCatalog { + readonly languages: readonly Locale[]; + readonly locales: readonly Locale[]; + readonly currencies: readonly Currency[]; + /** Optional IANA zone allowlist; omit for "any zone Intl can canonicalize". */ + readonly timezones?: readonly Timezone[]; + /** + * Optional per-dimension defaults. When omitted each dimension picks + * its own — usually the first entry in its catalog. + */ + readonly defaults?: { + readonly language?: Locale; + readonly locale?: Locale; + readonly currency?: Currency; + readonly timezone?: Timezone; + }; +} + +/** + * Compose the canonical built-in dimensions around the application's + * catalogs. Returns the schema fragment so apps can spread it into + * their full schema: + * + * ```ts + * const schema = { + * ...standardPrefsDimensions({ languages, locales, currencies }), + * // App-specific dimensions + * sidebarCollapsed: booleanDimension({ default: false }), + * notificationLevel: enumDimension(['all','mentions','none'] as const, { default: 'mentions' }) + * }; + * + * createActiveApp({ prefs: { schema } }); + * ``` + * + * Apps that don't want a particular built-in (say, a single-currency + * app skipping `currency`) drop the entry after spreading or compose a + * subset by hand instead of using this preset. + */ +export function standardPrefsDimensions(catalog: StandardPrefsCatalog) { + const defaults = catalog.defaults ?? {}; + return { + language: languageDimension({ catalog: catalog.languages, default: defaults.language }), + locale: localeDimension({ catalog: catalog.locales, default: defaults.locale }), + currency: currencyDimension({ catalog: catalog.currencies, default: defaults.currency }), + timezone: timezoneDimension({ catalog: catalog.timezones, default: defaults.timezone }), + unitSystem: unitSystemDimension(), + theme: themeDimension(), + density: densityDimension(), + motion: motionDimension(), + direction: directionDimension() + } as const; +} + +/** + * Neutral baseline used by `createActiveApp` when the caller omits + * `options.prefs`. Keeps the core surface populated even for apps that + * don't think about preferences. Apps that DO care override + * `options.prefs.schema`. + */ +export const NEUTRAL_PREFS_SCHEMA = standardPrefsDimensions({ + languages: ['en'], + locales: ['en-US'], + currencies: ['USD'] +}); + +export type StandardPrefsSchema = ReturnType; diff --git a/src/arts/prefs/test/active-prefs.svelte.test.ts b/src/arts/prefs/test/active-prefs.svelte.test.ts index 847c6b9..c2292a0 100644 --- a/src/arts/prefs/test/active-prefs.svelte.test.ts +++ b/src/arts/prefs/test/active-prefs.svelte.test.ts @@ -1,124 +1,91 @@ -/** - * ActivePrefs reactive surface tests. Runs in the browser project so - * `$state` cells fire effects. - */ +import { describe, expect, it, vi } from 'vitest'; +import { + booleanDimension, + enumDimension, + localeDimension, + themeDimension +} from '$prefs'; +import { createActivePrefs } from '../active-prefs.svelte.ts'; +import { PrefsReservedKeyError } from '../errors.ts'; -import { describe, expect, it } from 'vitest'; -import { flushSync } from 'svelte'; -import type { PrefsCapabilities, PrefsEffective } from '$libs/prefs'; -import { createActivePrefs } from '../active-prefs.svelte'; - -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 schema = { + locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }), + theme: themeDimension({ default: 'light' }), + sidebarCollapsed: booleanDimension({ default: false }), + notifications: enumDimension(['all', 'mentions', 'none'] as const, { default: 'mentions' }) }; -/** - * Run the body inside an `$effect.root` and tear it down after the - * body's returned promise resolves. Mirrors the helper in the session - * tests so reactivity tracks across `await` points. - */ -async function inRoot(body: () => Promise | void): Promise { - let cleanup!: () => void; - const ready = new Promise((resolve) => { - cleanup = $effect.root(() => { - Promise.resolve(body()).then(resolve); - }); - }); - await ready; - cleanup(); -} +describe('createActivePrefs — dimension-as-object surface', () => { + it('exposes one slot per schema key with get/set/clear/onChange', () => { + const prefs = createActivePrefs({ schema }); -describe('createActivePrefs', () => { - it('exposes the engine surface and reflects defaults', () => { - const Prefs = createActivePrefs({ capabilities: CAPS }); - expect(Prefs.kind).toBe('prefs'); - expect(Prefs.effective().locale).toBe('es-ES'); - expect(Prefs.state.effective.locale).toBe('es-ES'); - expect(Prefs.state.pending).toBe(false); - expect(Prefs.state.lastError).toBeNull(); - Prefs.dispose(); - }); + expect(prefs.locale.get()).toBe('es-ES'); + expect(prefs.theme.get()).toBe('light'); + expect(prefs.sidebarCollapsed.get()).toBe(false); + expect(prefs.notifications.get()).toBe('mentions'); + + prefs.locale.set('en-US'); + expect(prefs.locale.get()).toBe('en-US'); - it('state.effective updates inside an effect when intent changes', async () => { - const Prefs = createActivePrefs({ capabilities: CAPS }); - const observed: Array = []; + prefs.sidebarCollapsed.set(true); + expect(prefs.sidebarCollapsed.get()).toBe(true); - await inRoot(async () => { - $effect(() => { - observed.push(Prefs.state.effective.locale); - }); - flushSync(); - Prefs.setIntent('locale', 'en-US'); - flushSync(); - }); + prefs.locale.clear(); + expect(prefs.locale.get()).toBe('es-ES'); - expect(observed).toEqual(['es-ES', 'en-US']); - Prefs.dispose(); + prefs.dispose(); }); - it('state.snapshot version increments per commit', async () => { - const Prefs = createActivePrefs({ capabilities: CAPS }); - const versions: number[] = []; + it('onChange fires only when the dimension itself changes', () => { + const prefs = createActivePrefs({ schema }); + const localeChange = vi.fn(); + const themeChange = vi.fn(); + prefs.locale.onChange(localeChange); + prefs.theme.onChange(themeChange); - await inRoot(async () => { - $effect(() => { - versions.push(Prefs.state.snapshot.version); - }); - flushSync(); - Prefs.setIntent('theme', 'dark'); - flushSync(); - Prefs.setIntent('density', 'compact'); - flushSync(); - }); + prefs.locale.set('en-US'); + expect(localeChange).toHaveBeenCalledWith('en-US'); + expect(themeChange).not.toHaveBeenCalled(); - expect(versions).toEqual([0, 1, 2]); - Prefs.dispose(); + prefs.theme.set('dark'); + expect(themeChange).toHaveBeenCalledWith('dark'); + + prefs.dispose(); }); - it('no-op writes do not trigger reactive updates', async () => { - const Prefs = createActivePrefs({ - capabilities: CAPS, - intent: { theme: 'dark' } - }); - let runs = 0; + it('throws PrefsReservedKeyError when a schema key collides with a reserved member', () => { + expect(() => + createActivePrefs({ + schema: { + state: booleanDimension({ default: false }) + } + }) + ).toThrow(PrefsReservedKeyError); + }); - await inRoot(async () => { - $effect(() => { - // touch the snapshot so the effect tracks it - void Prefs.state.snapshot; - runs += 1; - }); - flushSync(); - Prefs.setIntent('theme', 'dark'); // no-op - flushSync(); - }); + it('catalog() returns the dimension catalog when one is declared', () => { + const prefs = createActivePrefs({ schema }); + expect(prefs.locale.catalog()).toEqual(['es-ES', 'en-US']); + expect(prefs.notifications.catalog()).toEqual(['all', 'mentions', 'none']); + expect(prefs.sidebarCollapsed.catalog()).toBeUndefined(); + prefs.dispose(); + }); - expect(runs).toBe(1); - Prefs.dispose(); + it('low-level setIntent / clearIntent stay available for adapters', () => { + const prefs = createActivePrefs({ schema }); + prefs.setIntent('locale', 'en-US'); + expect(prefs.locale.get()).toBe('en-US'); + prefs.clearIntent('locale'); + expect(prefs.locale.get()).toBe('es-ES'); + prefs.dispose(); }); - it('dispose detaches the engine subscription', () => { - const Prefs = createActivePrefs({ capabilities: CAPS }); - Prefs.dispose(); - // Engine mutations after dispose throw; the rune adapter's own - // state cell stays at the last commit. - expect(() => Prefs.setIntent('locale', 'en-US')).toThrow(); + it('state.snapshot reflects the latest commit', () => { + const prefs = createActivePrefs({ schema }); + const v0 = prefs.state.version; + prefs.theme.set('dark'); + expect(prefs.state.version).toBe(v0 + 1); + expect(prefs.state.effective.theme).toBe('dark'); + prefs.dispose(); }); }); diff --git a/src/arts/prefs/test/engine-prefs.test.ts b/src/arts/prefs/test/engine-prefs.test.ts index 311a6d4..bd6c653 100644 --- a/src/arts/prefs/test/engine-prefs.test.ts +++ b/src/arts/prefs/test/engine-prefs.test.ts @@ -1,366 +1,106 @@ import { describe, expect, it, vi } from 'vitest'; -import type { PrefsCapabilities, PrefsChangeEvent } from '$libs/prefs'; +import { booleanDimension, enumDimension, localeDimension, themeDimension } from '$prefs'; import { createEnginePrefs } from '../engine-prefs.ts'; -import { PREFS_KIND } from '../consts.ts'; import { - PrefsCapabilitiesInvalidError, PrefsDisposedError, PrefsIntentInvalidError, - isPrefsDisposedError, - isPrefsIntentInvalidError + PrefsUnknownDimensionError } from '../errors.ts'; -const CAPS: PrefsCapabilities = { - languages: ['es-ES', 'en-US', 'ar-EG'], - locales: ['es-ES', 'en-US', 'ar-EG'], - currencies: ['EUR', 'USD'], - unitSystems: ['metric', 'imperial'], - themes: ['light', 'dark', 'system'], - densities: ['compact', 'comfortable', 'spacious'], - motions: ['allow', 'reduce', 'system'], - timezones: ['Europe/Madrid', 'America/New_York'], - defaults: { - language: 'es-ES', - locale: 'es-ES', - currency: 'EUR', - timezone: 'Europe/Madrid', - unitSystem: 'metric', - theme: 'light', - density: 'comfortable', - motion: 'allow', - direction: 'ltr' - } +const baseSchema = { + locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }), + theme: themeDimension({ default: 'light' }), + flag: booleanDimension({ default: false }), + mode: enumDimension(['compact', 'roomy'] as const, { default: 'roomy' }) }; -describe('createEnginePrefs — construction', () => { - it('initial snapshot reflects defaults when env and intent are empty', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const snap = engine.snapshot(); - expect(snap.version).toBe(0); - expect(snap.effective).toEqual(CAPS.defaults); - expect(snap.intent).toEqual({}); - expect(engine.kind).toBe(PREFS_KIND); +describe('createEnginePrefs — schema-generic engine', () => { + it('exposes the schema and computes effective from defaults', () => { + const engine = createEnginePrefs({ schema: baseSchema }); + expect(engine.schema).toBe(baseSchema); + const eff = engine.effective(); + expect(eff.locale).toBe('es-ES'); + expect(eff.flag).toBe(false); + expect(eff.mode).toBe('roomy'); + engine.dispose(); }); - it('honours initial environment and intent', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - environment: { locales: ['en-US'] }, - intent: { theme: 'dark' } - }); - expect(engine.effective().language).toBe('en-US'); - expect(engine.effective().locale).toBe('en-US'); - expect(engine.effective().theme).toBe('dark'); - }); + it('setIntent commits and notifies subscribers with effectiveDiff', () => { + const engine = createEnginePrefs({ schema: baseSchema }); + const handler = vi.fn(); + engine.subscribe(handler); - it('returned snapshot, intent and effective are frozen', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const snap = engine.snapshot(); - expect(Object.isFrozen(snap)).toBe(true); - expect(Object.isFrozen(snap.effective)).toBe(true); + engine.setIntent('locale', 'en-US'); + expect(engine.effective().locale).toBe('en-US'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].effectiveDiff).toEqual({ locale: 'en-US' }); + engine.dispose(); }); -}); -describe('setIntent', () => { - it('writes a valid intent and bumps version', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const before = engine.snapshot(); - const after = engine.setIntent('locale', 'en-US'); - expect(after.version).toBe(before.version + 1); - expect(after.intent.locale).toBe('en-US'); - expect(after.effective.locale).toBe('en-US'); + it('setIntent on an unknown key throws PrefsUnknownDimensionError', () => { + const engine = createEnginePrefs({ schema: baseSchema }); + expect(() => engine.setIntent('rogue' as never, 'x')).toThrow(PrefsUnknownDimensionError); + engine.dispose(); }); - it('throws PrefsIntentInvalidError with the structured reason', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); + it('setIntent rejects invalid values with PrefsIntentInvalidError carrying the reason', () => { + const engine = createEnginePrefs({ schema: baseSchema }); try { engine.setIntent('locale', 'fr-FR'); - expect.fail('should have thrown'); + expect.fail('expected throw'); } catch (error) { - expect(isPrefsIntentInvalidError(error)).toBe(true); - if (error instanceof PrefsIntentInvalidError) { - expect(error.key).toBe('locale'); - expect(error.reason).toBe('unsupported_locale'); - } + expect(error).toBeInstanceOf(PrefsIntentInvalidError); + expect((error as PrefsIntentInvalidError).key).toBe('locale'); + expect((error as PrefsIntentInvalidError).reason).toBe('unsupported_locale'); } + engine.dispose(); }); - it('canonicalises timezone aliases on commit', () => { - const engine = createEnginePrefs({ capabilities: { ...CAPS, timezones: undefined } }); - const after = engine.setIntent('timezone', 'America/New_York'); - // Whatever the runtime canonicalises to is exactly what gets - // stored — exact equality with a target alias is runtime-dependent - // but the value MUST be equal between intent and effective. - expect(after.intent.timezone).toBe(after.effective.timezone); - }); - - it('is a no-op when the value matches the existing intent', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { theme: 'dark' } - }); - const before = engine.snapshot(); - const after = engine.setIntent('theme', 'dark'); - expect(after).toBe(before); - expect(after.version).toBe(before.version); - }); -}); - -describe('clearIntent', () => { - it('removes the key (not stored as undefined) and falls back', () => { + it('clearIntent drops the key and re-resolves through environment / defaults', () => { const engine = createEnginePrefs({ - capabilities: CAPS, + schema: baseSchema, environment: { locales: ['en-US'] }, intent: { locale: 'es-ES' } }); - const after = engine.clearIntent('locale'); - expect(after.intent).toEqual({}); - expect('locale' in after.intent).toBe(false); - expect(after.effective.locale).toBe('en-US'); - }); - - it('is a no-op when the key was already absent', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const before = engine.snapshot(); - const after = engine.clearIntent('locale'); - expect(after).toBe(before); - }); -}); - -describe('resetIntent', () => { - it('replaces the whole intent map atomically', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { locale: 'en-US', theme: 'dark' } - }); - const after = engine.resetIntent({ currency: 'USD' }); - expect(after.intent).toEqual({ currency: 'USD' }); - expect(after.effective.currency).toBe('USD'); - expect(after.effective.locale).toBe(CAPS.defaults.locale); - }); - - it('clears every choice when called with no argument', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { theme: 'dark' } - }); - const after = engine.resetIntent(); - expect(after.intent).toEqual({}); - }); - - it('is a no-op when the next intent equals the current', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { locale: 'en-US' } - }); - const before = engine.snapshot(); - const after = engine.resetIntent({ locale: 'en-US' }); - expect(after).toBe(before); - }); -}); - -describe('refreshEnvironment / patchEnvironment', () => { - it('refreshEnvironment replaces the env wholesale', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - environment: { locales: ['en-US'], colorScheme: 'dark' } - }); - const after = engine.refreshEnvironment({ locales: ['ar-EG'] }); - expect(after.environment.locales).toEqual(['ar-EG']); - expect(after.environment.colorScheme).toBeUndefined(); - expect(after.effective.language).toBe('ar-EG'); - }); - - it('patchEnvironment merges with existing env', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - environment: { locales: ['en-US'], colorScheme: 'light' } - }); - const after = engine.patchEnvironment({ colorScheme: 'dark' }); - expect(after.environment.locales).toEqual(['en-US']); - expect(after.environment.colorScheme).toBe('dark'); - }); - - it('patchEnvironment is a no-op when the patch matches existing values', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - environment: { colorScheme: 'dark' } - }); - const before = engine.snapshot(); - const after = engine.patchEnvironment({ colorScheme: 'dark' }); - expect(after).toBe(before); - }); - - it('environment may contain unsupported values; effective never does', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - environment: { locales: ['fr-FR'], currency: 'JPY' as 'JPY' } - }); - // env preserved verbatim for diagnostics - expect(engine.environment().locales).toEqual(['fr-FR']); - expect(engine.environment().currency).toBe('JPY'); - // effective falls through to defaults — no JPY, no fr-FR. - expect(engine.effective().currency).toBe(CAPS.defaults.currency); - expect(engine.effective().language).toBe(CAPS.defaults.language); - }); -}); - -describe('setCapabilities', () => { - it('throws PrefsCapabilitiesInvalidError when defaults do not validate', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const broken: PrefsCapabilities = { - ...CAPS, - currencies: ['USD'], // EUR no longer supported - defaults: { ...CAPS.defaults } // defaults.currency = 'EUR' → invalid - }; - expect(() => engine.setCapabilities(broken)).toThrow(PrefsCapabilitiesInvalidError); - }); - - it('preserves existing intent when capabilities shrink', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { locale: 'en-US' } - }); - // Keep en-US in defaults so the new caps validate, but drop - // it from the user-selectable locale list. - const shrunk: PrefsCapabilities = { - ...CAPS, - locales: ['es-ES'], - languages: ['es-ES'], - defaults: { ...CAPS.defaults } - }; - const after = engine.setCapabilities(shrunk); - // Intent kept verbatim — survives capability shrink. - expect(after.intent.locale).toBe('en-US'); - // But effective falls back since intent.locale is no longer - // in capabilities.locales. - expect(after.effective.locale).toBe('es-ES'); - }); - - it('drops invalid intent from effective immediately on commit', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { locale: 'en-US' } - }); + expect(engine.effective().locale).toBe('es-ES'); + engine.clearIntent('locale'); expect(engine.effective().locale).toBe('en-US'); - - // Drop en-US from locale capabilities — intent persists raw, - // but effective falls through to defaults since the intent no - // longer validates and no env hint is present. - const shrunk: PrefsCapabilities = { - ...CAPS, - locales: ['es-ES'], - languages: ['es-ES'] - }; - const after = engine.setCapabilities(shrunk); - expect(after.intent.locale).toBe('en-US'); - expect(after.effective.locale).toBe('es-ES'); - }); -}); - -describe('subscribe', () => { - it('notifies subscribers with previous/next/diff/cause on commit', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const listener = vi.fn<(event: PrefsChangeEvent) => void>(); - engine.subscribe(listener); - - const before = engine.snapshot(); - engine.setIntent('locale', 'en-US'); - - expect(listener).toHaveBeenCalledTimes(1); - const event = listener.mock.calls[0][0]; - expect(event.cause).toBe('intent:set'); - expect(event.previous).toBe(before); - expect(event.next.effective.locale).toBe('en-US'); - expect(event.effectiveDiff).toEqual({ - locale: 'en-US' - }); - }); - - it('does not notify on no-op writes', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { theme: 'dark' } - }); - const listener = vi.fn(); - engine.subscribe(listener); - engine.setIntent('theme', 'dark'); // same value - expect(listener).not.toHaveBeenCalled(); - }); - - it('returned unsubscribe stops further notifications', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const listener = vi.fn(); - const off = engine.subscribe(listener); - engine.setIntent('locale', 'en-US'); - off(); - engine.setIntent('locale', 'es-ES'); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('iteration is safe when a handler unsubscribes itself mid-dispatch', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const otherListener = vi.fn(); - let off: (() => void) | undefined; - const selfRemoving = vi.fn(() => off?.()); - off = engine.subscribe(selfRemoving); - engine.subscribe(otherListener); - - engine.setIntent('locale', 'en-US'); - expect(selfRemoving).toHaveBeenCalledTimes(1); - expect(otherListener).toHaveBeenCalledTimes(1); - - engine.setIntent('locale', 'es-ES'); - expect(selfRemoving).toHaveBeenCalledTimes(1); // didn't fire again - expect(otherListener).toHaveBeenCalledTimes(2); + engine.dispose(); }); -}); -describe('dispose', () => { - it('is idempotent', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - expect(() => { - engine.dispose(); - engine.dispose(); - }).not.toThrow(); + it('resetIntent sanitises through every dimension', () => { + const engine = createEnginePrefs({ schema: baseSchema }); + engine.resetIntent({ locale: 'en-US', mode: 'unsupported' as never }); + expect(engine.effective().locale).toBe('en-US'); + expect(engine.effective().mode).toBe('roomy'); // dropped, fell back + engine.dispose(); }); - it('clears listeners and rejects mutations', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const listener = vi.fn(); - engine.subscribe(listener); + it('refreshEnvironment publishes a commit', () => { + const engine = createEnginePrefs({ schema: baseSchema }); + engine.refreshEnvironment({ locales: ['en-US'] }); + expect(engine.effective().locale).toBe('en-US'); engine.dispose(); - - expect(() => engine.setIntent('locale', 'en-US')).toThrow(PrefsDisposedError); - try { - engine.clearIntent('locale'); - expect.fail('should have thrown'); - } catch (error) { - expect(isPrefsDisposedError(error)).toBe(true); - } - expect(listener).not.toHaveBeenCalled(); }); - it('subscribe after dispose returns a no-op unsubscribe', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); + it('mutators throw PrefsDisposedError after dispose()', () => { + const engine = createEnginePrefs({ schema: baseSchema }); engine.dispose(); - const off = engine.subscribe(vi.fn()); - expect(() => off()).not.toThrow(); + expect(() => engine.setIntent('locale', 'en-US')).toThrow(PrefsDisposedError); + expect(() => engine.clearIntent('locale')).toThrow(PrefsDisposedError); + expect(() => engine.resetIntent({})).toThrow(PrefsDisposedError); }); -}); - -describe('version', () => { - it('monotonically increments per commit and is stable across no-ops', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - expect(engine.snapshot().version).toBe(0); + it('snapshot.version increments per commit and stays stable across no-ops', () => { + const engine = createEnginePrefs({ schema: baseSchema }); + const v0 = engine.snapshot().version; engine.setIntent('locale', 'en-US'); - expect(engine.snapshot().version).toBe(1); - - engine.setIntent('locale', 'en-US'); // no-op - expect(engine.snapshot().version).toBe(1); - - engine.setIntent('theme', 'dark'); - expect(engine.snapshot().version).toBe(2); + const v1 = engine.snapshot().version; + // Setting the same value again is a no-op. + engine.setIntent('locale', 'en-US'); + const v2 = engine.snapshot().version; + expect(v1).toBe(v0 + 1); + expect(v2).toBe(v1); + engine.dispose(); }); }); diff --git a/src/arts/prefs/test/sources.test.ts b/src/arts/prefs/test/sources.test.ts deleted file mode 100644 index ea0740c..0000000 --- a/src/arts/prefs/test/sources.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { PrefsCapabilities } from '$libs/prefs'; -import { createEnginePrefs } from '../engine-prefs.ts'; -import { - prefsCurrencySource, - prefsDirectionSource, - prefsLanguageSource, - prefsLocaleSource, - prefsThemeSource, - prefsUnitSystemSource -} from '../sources.ts'; - -const CAPS: PrefsCapabilities = { - languages: ['es-ES', 'en-US', 'ar-EG'], - locales: ['es-ES', 'en-US', 'ar-EG'], - 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' - } -}; - -describe('capability proxies', () => { - it('language source reflects effective.language and notifies on change', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const source = prefsLanguageSource(engine); - expect(source.get()).toBe('es-ES'); - - const fn = vi.fn(); - const off = source.onChange?.(fn); - engine.setIntent('language', 'en-US'); - expect(fn).toHaveBeenCalledWith('en-US'); - expect(source.get()).toBe('en-US'); - off?.(); - }); - - it('locale source is independent of language source', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const language = prefsLanguageSource(engine); - const locale = prefsLocaleSource(engine); - const langFn = vi.fn(); - const localeFn = vi.fn(); - language.onChange?.(langFn); - locale.onChange?.(localeFn); - - // Move only locale; language stays at default. - engine.setIntent('locale', 'en-US'); - expect(localeFn).toHaveBeenCalledWith('en-US'); - expect(langFn).not.toHaveBeenCalled(); - }); - - it('currency source notifies only on currency change', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const source = prefsCurrencySource(engine); - const fn = vi.fn(); - source.onChange?.(fn); - - // Theme change must NOT fire the currency listener. - engine.setIntent('theme', 'dark'); - expect(fn).not.toHaveBeenCalled(); - - engine.setIntent('currency', 'USD'); - expect(fn).toHaveBeenCalledTimes(1); - expect(fn).toHaveBeenCalledWith('USD'); - }); - - it('theme source emits the effective theme (never the intent string)', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - environment: { colorScheme: 'dark' } - }); - const source = prefsThemeSource(engine); - // Pin the engine to 'light' so the upcoming `setIntent('theme', - // 'system')` actually changes the effective value (otherwise - // the engine's no-op short-circuit fires and no event is sent). - engine.setIntent('theme', 'light'); - - const fn = vi.fn(); - source.onChange?.(fn); - engine.setIntent('theme', 'system'); - expect(fn).toHaveBeenCalledWith('dark'); - }); - - it('direction source updates when language changes scripts', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const source = prefsDirectionSource(engine); - const fn = vi.fn(); - source.onChange?.(fn); - - engine.setIntent('language', 'ar-EG'); - expect(fn).toHaveBeenCalledWith('rtl'); - expect(source.get()).toBe('rtl'); - }); - - it('unit-system source reflects locale-derived projection', () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - environment: { locales: ['en-US'] } - }); - const source = prefsUnitSystemSource(engine); - expect(source.get()).toBe('imperial'); - }); - - it('returned unsubscribe stops further notifications', () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const source = prefsLocaleSource(engine); - const fn = vi.fn(); - const off = source.onChange?.(fn); - off?.(); - engine.setIntent('locale', 'en-US'); - expect(fn).not.toHaveBeenCalled(); - }); -}); diff --git a/src/arts/prefs/test/storage-bridge.test.ts b/src/arts/prefs/test/storage-bridge.test.ts index a541e66..7df97fb 100644 --- a/src/arts/prefs/test/storage-bridge.test.ts +++ b/src/arts/prefs/test/storage-bridge.test.ts @@ -1,239 +1,115 @@ import { describe, expect, it, vi } from 'vitest'; -import type { PrefsCapabilities, PrefsIntent } from '$libs/prefs'; +import { booleanDimension, localeDimension } from '$prefs'; import { createEnginePrefs } from '../engine-prefs.ts'; import { createPrefsStorageBridge, - type PrefsIntentStorage, - type PrefsStorageOp + type PrefsIntentStorage } from '../adapters/storage-bridge.ts'; -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 schema = { + locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }), + flag: booleanDimension({ default: false }) }; -interface MemoryStorage extends PrefsIntentStorage { - readonly saved: PrefsIntent[]; - readonly cleared: number; - current: PrefsIntent | null; -} - -function createMemoryStorage(initial: PrefsIntent | null = null): MemoryStorage { - const saved: PrefsIntent[] = []; - let cleared = 0; - let current = initial; - return { - get saved() { - return saved; - }, - get cleared() { - return cleared; - }, - get current() { - return current; - }, - set current(value: PrefsIntent | null) { - current = value; - }, - load() { - return current; - }, - save(intent) { - saved.push(intent); - current = intent; +type Schema = typeof schema; + +function inMemoryStorage(initial?: { locale?: 'es-ES' | 'en-US'; flag?: boolean }) { + const saves: Array> = []; + const clears: number[] = []; + let value = initial ? { ...initial } : null; + const storage: PrefsIntentStorage = { + load: () => value, + save: (intent) => { + saves.push({ ...(intent as Record) }); + value = { ...(intent as Record) } as typeof value; }, - clear() { - cleared += 1; - current = null; + clear: () => { + clears.push(clears.length + 1); + value = null; } }; + return { storage, saves, clears }; } -describe('createPrefsStorageBridge — hydrate', () => { - it('applies the loaded intent on hydrate without echoing back to storage', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const storage = createMemoryStorage({ locale: 'en-US' }); +describe('createPrefsStorageBridge', () => { + it('hydrates intent from storage on construction', async () => { + const engine = createEnginePrefs({ schema }); + const { storage } = inMemoryStorage({ locale: 'en-US' }); const bridge = createPrefsStorageBridge({ engine, storage }); await bridge.hydrated; - - expect(engine.intent()).toEqual({ locale: 'en-US' }); expect(engine.effective().locale).toBe('en-US'); - expect(storage.saved).toEqual([]); // no echo bridge.dispose(); + engine.dispose(); }); - it('does nothing when storage.load returns null', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const storage = createMemoryStorage(null); - const bridge = createPrefsStorageBridge({ engine, storage }); - await bridge.hydrated; - - expect(engine.intent()).toEqual({}); - bridge.dispose(); - }); - - it('skips hydrate when skipHydrate=true', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const storage = createMemoryStorage({ locale: 'en-US' }); - const loadSpy = vi.spyOn(storage, 'load'); - const bridge = createPrefsStorageBridge({ engine, storage, skipHydrate: true }); - await bridge.hydrated; - - expect(loadSpy).not.toHaveBeenCalled(); - expect(engine.intent()).toEqual({}); - bridge.dispose(); - }); - - it('local writes during hydrate win over the loaded value', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - // Async load: returns en-US after a microtask. - const storage: PrefsIntentStorage = { - async load() { - await Promise.resolve(); - return { locale: 'en-US' }; - }, - save: vi.fn(), - clear: vi.fn() - }; - const bridge = createPrefsStorageBridge({ engine, storage }); - // User write hits before load resolves. - engine.setIntent('locale', 'es-ES'); - await bridge.hydrated; - - expect(engine.intent().locale).toBe('es-ES'); - bridge.dispose(); - }); -}); - -describe('createPrefsStorageBridge — persistence', () => { - it('persists every commit that changes intent', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const storage = createMemoryStorage(); + it('persists subsequent commits via save()', async () => { + const engine = createEnginePrefs({ schema }); + const { storage, saves } = inMemoryStorage(); const bridge = createPrefsStorageBridge({ engine, storage }); await bridge.hydrated; engine.setIntent('locale', 'en-US'); - engine.setIntent('theme', 'dark'); await new Promise((r) => setTimeout(r, 0)); + expect(saves).toEqual([{ locale: 'en-US' }]); - expect(storage.saved).toEqual([ - { locale: 'en-US' }, - { locale: 'en-US', theme: 'dark' } - ]); - bridge.dispose(); - }); - - it('only persists `intent` — environment changes are ignored', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const storage = createMemoryStorage(); - const bridge = createPrefsStorageBridge({ engine, storage }); - await bridge.hydrated; - - engine.refreshEnvironment({ locales: ['en-US'] }); - engine.patchEnvironment({ colorScheme: 'dark' }); + engine.setIntent('flag', true); await new Promise((r) => setTimeout(r, 0)); + expect(saves).toHaveLength(2); + expect(saves[1]).toEqual({ locale: 'en-US', flag: true }); - expect(storage.saved).toEqual([]); bridge.dispose(); + engine.dispose(); }); - it('calls clear() instead of save({}) when intent becomes empty', async () => { - const engine = createEnginePrefs({ - capabilities: CAPS, - intent: { locale: 'en-US' } - }); - const storage = createMemoryStorage({ locale: 'en-US' }); - const bridge = createPrefsStorageBridge({ engine, storage, skipHydrate: true }); + it('clears storage when the intent map empties out', async () => { + const engine = createEnginePrefs({ schema, intent: { locale: 'en-US' } }); + const { storage, clears } = inMemoryStorage(); + const bridge = createPrefsStorageBridge({ engine, storage }); await bridge.hydrated; - engine.resetIntent(); + engine.clearIntent('locale'); await new Promise((r) => setTimeout(r, 0)); + expect(clears).toHaveLength(1); - expect(storage.cleared).toBe(1); - expect(storage.saved).toEqual([]); bridge.dispose(); + engine.dispose(); }); - it('reports save errors via onError without corrupting the engine', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const errors: Array<{ error: unknown; op: PrefsStorageOp }> = []; - const storage: PrefsIntentStorage = { - load: () => null, - save: () => { - throw new Error('disk full'); - }, + it('skips hydrate when the user wrote intent before load resolved', async () => { + const engine = createEnginePrefs({ schema }); + let resolveLoad: (v: { locale: 'en-US' } | null) => void = () => {}; + const storage: PrefsIntentStorage = { + load: () => new Promise((r) => { resolveLoad = r; }), + save: () => {}, clear: () => {} }; - const bridge = createPrefsStorageBridge({ - engine, - storage, - onError(error, op) { - errors.push({ error, op }); - } - }); + const bridge = createPrefsStorageBridge({ engine, storage }); + + engine.setIntent('locale', 'es-ES'); + resolveLoad({ locale: 'en-US' }); await bridge.hydrated; - engine.setIntent('locale', 'en-US'); - await new Promise((r) => setTimeout(r, 0)); + expect(engine.effective().locale).toBe('es-ES'); - expect(errors).toHaveLength(1); - expect(errors[0].op).toBe('save'); - expect(errors[0].error).toBeInstanceOf(Error); - // Engine state survived the failed save. - expect(engine.intent().locale).toBe('en-US'); bridge.dispose(); + engine.dispose(); }); it('reports load errors via onError', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const errors: PrefsStorageOp[] = []; - const storage: PrefsIntentStorage = { + const engine = createEnginePrefs({ schema }); + const onError = vi.fn(); + const storage: PrefsIntentStorage = { load: () => { - throw new Error('quota exceeded'); + throw new Error('boom'); }, save: () => {}, clear: () => {} }; - const bridge = createPrefsStorageBridge({ - engine, - storage, - onError(_error, op) { - errors.push(op); - } - }); + const bridge = createPrefsStorageBridge({ engine, storage, onError }); await bridge.hydrated; - - expect(errors).toEqual(['load']); - expect(engine.intent()).toEqual({}); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][1]).toBe('load'); bridge.dispose(); - }); - - it('dispose stops further persistence', async () => { - const engine = createEnginePrefs({ capabilities: CAPS }); - const storage = createMemoryStorage(); - const bridge = createPrefsStorageBridge({ engine, storage }); - await bridge.hydrated; - - bridge.dispose(); - engine.setIntent('locale', 'en-US'); - await new Promise((r) => setTimeout(r, 0)); - - expect(storage.saved).toEqual([]); + engine.dispose(); }); }); diff --git a/src/arts/prefs/types.ts b/src/arts/prefs/types.ts index 0e31408..e60d915 100644 --- a/src/arts/prefs/types.ts +++ b/src/arts/prefs/types.ts @@ -1,110 +1,89 @@ import type { - PrefsCapabilities, PrefsChangeHandler, - PrefsEffective, + PrefsEffectiveOf, PrefsEnvironment, - PrefsIntent, + PrefsIntentOf, + PrefsSchema, PrefsSnapshot, PrefsUnsubscribe } from '$libs/prefs'; import type { PREFS_KIND } from './consts.ts'; /** - * Construction options for `createEnginePrefs`. Only `capabilities` is + * Construction options for `createEnginePrefs`. Only `schema` is * required — `environment` and `intent` default to empty (which means - * "every effective field falls through to `capabilities.defaults`"). + * "every effective field falls through to its dimension's + * `defaultValue` or `fromEnvironment`"). * * The engine never reads from globals, so server-only callers are safe; * detection of `navigator`/`matchMedia`/storage lives in adapters. */ -export interface EnginePrefsOptions { - readonly capabilities: PrefsCapabilities; +export interface EnginePrefsOptions { + readonly schema: S; readonly environment?: PrefsEnvironment; - readonly intent?: PrefsIntent; + readonly intent?: PrefsIntentOf; } /** - * Runtime preference engine. Holds the four-layer state (capabilities, - * environment, intent, effective) and exposes a small mutator surface. + * Runtime preference engine, generic over the user-defined schema. + * Holds the layered state (schema, environment, intent, effective) and + * exposes a small mutator surface. * * The engine is reactive only via `subscribe(handler)`. The Svelte * adapter (`active-prefs.svelte.ts`) layers runes on top — the engine * itself is runes-free and importable from server-only modules. * - * Reads (`snapshot`, `capabilities`, `environment`, `intent`, - * `effective`) return frozen, structurally-shared values. Each commit - * publishes a new snapshot — consumers can use `snapshot.version` as a - * cheap optimistic equality key. + * Reads (`snapshot`, `environment`, `intent`, `effective`) return + * frozen, structurally-shared values. Each commit publishes a new + * snapshot — consumers can use `snapshot.version` as a cheap optimistic + * equality key. * - * Mutators return the post-commit `PrefsSnapshot` so callers can chain - * without an extra `engine.snapshot()` round trip: - * - * ```ts - * const after = engine.setIntent('locale', 'es-ES'); - * console.log(after.effective.locale); // 'es-ES' - * ``` + * Mutators return the post-commit `PrefsSnapshot` so callers can + * chain without an extra `engine.snapshot()` round trip. */ -export interface EnginePrefs { +export interface EnginePrefs { readonly kind: typeof PREFS_KIND; + readonly schema: S; - snapshot(): PrefsSnapshot; - capabilities(): PrefsCapabilities; + snapshot(): PrefsSnapshot; environment(): PrefsEnvironment; - intent(): Readonly; - effective(): PrefsEffective; + intent(): PrefsIntentOf; + effective(): PrefsEffectiveOf; /** - * Persist a single explicit user choice. The value is validated - * against `capabilities` synchronously and a `PrefsValidationError` - * is thrown on failure — write-time rejection is part of the - * contract so UI bugs surface immediately rather than silently - * dropping at resolution time. + * Persist a single explicit user choice for the dimension at `key`. + * Validates via the dimension's `validate` callback; throws + * `PrefsIntentInvalidError` on failure or + * `PrefsUnknownDimensionError` when `key` is not in the schema. */ - setIntent( - key: K, - value: NonNullable - ): PrefsSnapshot; + setIntent(key: K, value: unknown): PrefsSnapshot; /** * Drop the user's explicit choice for `key` so the resolver falls - * back to environment/defaults. Distinct from `setIntent(key, - * undefined)` (not allowed) to keep "did not write" and "wrote - * undefined" distinguishable. + * back to environment / dimension default. Distinct from + * `setIntent(key, undefined)` (not allowed). */ - clearIntent(key: K): PrefsSnapshot; + clearIntent(key: K): PrefsSnapshot; /** - * Replace the entire intent map. Pass `{}` (or omit `next`) to - * clear every explicit choice in one commit. + * Replace the entire intent map. Pass `{}` (or omit `next`) to clear + * every explicit choice in one commit. Values are sanitised through + * each dimension's `validate` callback before applying. */ - resetIntent(next?: PrefsIntent): PrefsSnapshot; + resetIntent(next?: PrefsIntentOf): PrefsSnapshot; /** * Replace the detected environment wholesale. Used by detector - * adapters when the underlying signals (`navigator.languages`, - * `prefers-color-scheme`, …) change in bulk. + * adapters when the underlying signals change in bulk. */ - refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot; + refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot; /** * Patch a subset of `environment`. Existing fields not present in - * `patch` survive — useful when one signal updates independently of - * the others (e.g. `matchMedia` color-scheme listener). - */ - patchEnvironment(patch: Partial): PrefsSnapshot; - - /** - * Replace `capabilities`. The new `defaults` are validated against - * the new sets — a `PrefsCapabilitiesInvalidError` is thrown if - * they would not pass `validateIntentValue`. - * - * Existing `intent` is NOT mutated. Entries that no longer validate - * are simply dropped from `effective` until capabilities allow them - * again or the app explicitly clears them. This matches the - * "persisted intent survives capability shrink" rule in the README. + * `patch` survive. */ - setCapabilities(next: PrefsCapabilities): PrefsSnapshot; + patchEnvironment(patch: Partial): PrefsSnapshot; - subscribe(handler: PrefsChangeHandler): PrefsUnsubscribe; + subscribe(handler: PrefsChangeHandler): PrefsUnsubscribe; dispose(): void; } diff --git a/src/arts/session/README.md b/src/arts/session/README.md index 28c6a09..a15fd4b 100644 --- a/src/arts/session/README.md +++ b/src/arts/session/README.md @@ -459,8 +459,8 @@ const Sess = createActiveSession({ storage: { adapter: localAdapter, key: 'aapp:session' }, onRefresh: async (current, ctx) => { ... }, onRevoke: async (current, ctx) => { ... }, - logger: App.Logger, - bus: App.Bus, + logger: App.logger, + bus: App.bus, broadcastChannel: 'my-app:sess' }); ``` @@ -489,7 +489,7 @@ const stop = withAutoRefresh(Sess, { marginMs: 90_000, jitterMs: 5_000, refreshOnVisible: true, - timers: App.Timers // optional when using aapp; omit for native interval + timers: App.timers // optional when using aapp; omit for native interval }); // ... later stop(); @@ -567,7 +567,7 @@ session `data`. Code that needs the full snapshot should use `createEngineSession({ bus })` publishes from the engine. `createActiveSession` publishes from the active wrapper after `$state` has been updated, so consumers -that react through `App.Bus` see the latest `Sess.current` in the same tick. +that react through `App.bus` see the latest `Sess.current` in the same tick. When registered through the App service schema: @@ -583,7 +583,7 @@ await App.session.adopt({ user, credential, ... }); `defineActiveSession(...)` makes the App builder inject `Logger` and `Bus` automatically. The session art publishes its own `SESSION_EVENT_*` events -on `App.Bus`; cross-module reactions live in orca presets at the App +on `App.bus`; cross-module reactions live in orca presets at the App level (`applyCacheClearOnIdentityChange`, `applyPermInvalidateOnIdentityChange`, etc.) — the standard set is wired by `applyStandardOrca(App)`. @@ -723,7 +723,7 @@ applyStandardOrca(App); // cross-module reactions on identity changes `defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`; `App.http` is reachable through closure capture inside the handlers. The -session art publishes `SESSION_EVENT_*` directly on `App.Bus`. +session art publishes `SESSION_EVENT_*` directly on `App.bus`. --- diff --git a/src/arts/session/consts.ts b/src/arts/session/consts.ts index c242069..f661470 100644 --- a/src/arts/session/consts.ts +++ b/src/arts/session/consts.ts @@ -31,7 +31,7 @@ export const SESSION_DEFAULT_AUTO_REFRESH_MARGIN_MS = 90_000; /** Default jitter applied to the auto-refresh margin (ms). */ export const SESSION_DEFAULT_AUTO_REFRESH_JITTER_MS = 5_000; -/** Default timer key used when auto-refresh is driven by App.Timers. */ +/** Default timer key used when auto-refresh is driven by App.timers. */ export const SESSION_AUTO_REFRESH_TIMER_KEY = 'session:auto-refresh'; /** Browser events / document states used by auto-refresh and broadcast. */ diff --git a/src/arts/sium/README.md b/src/arts/sium/README.md index b62d780..236e707 100644 --- a/src/arts/sium/README.md +++ b/src/arts/sium/README.md @@ -17,7 +17,7 @@ El patron estandar es una linea al inicio del modulo de la pagina: ```ts import { createEngineSium } from '$sium'; -const sium = createEngineSium({ lang: App.lang, logger: App.Logger }); +const sium = createEngineSium({ lang: App.lang, logger: App.logger }); ``` Asi cada formulario obtiene un engine afinado a sus necesidades (logger diff --git a/src/arts/storage/README.md b/src/arts/storage/README.md index 952e9f0..8025940 100644 --- a/src/arts/storage/README.md +++ b/src/arts/storage/README.md @@ -35,7 +35,7 @@ ship together: - Validation via Standard Schema v1 (Sium schemas reuse out of the box) - Per-entry overrides for adapter, namespace, raw, TTL — mix cookies for some keys, localStorage for others -- `onError` with rich context routed through `App.Logger` when used from +- `onError` with rich context routed through `App.logger` when used from `aapp` ## Architecture @@ -555,7 +555,7 @@ const Storage = createEngineStorage({ Operations: `read | write | remove | serialize | deserialize | migrate | validate`. Default handler: diagnostics-only. When used from `aapp`, the shared -`App.Logger` is injected, so failures land through `StorageDiagnostics` with +`App.logger` is injected, so failures land through `StorageDiagnostics` with the storage diagnostic context. ## Auto-serializers diff --git a/src/arts/storage/types.ts b/src/arts/storage/types.ts index 36764f0..0fee813 100644 --- a/src/arts/storage/types.ts +++ b/src/arts/storage/types.ts @@ -165,7 +165,7 @@ export interface EngineStorageOptions { /** * Injectable clock used by envelope TTL evaluation. Defaults to a * `Date.now`-backed clock; the App composition wires - * `core.timers.clock` so all time flows through `App.Timers`. Tests + * `core.timers.clock` so all time flows through `App.timers`. Tests * inject a fake clock for deterministic TTL boundaries. */ clock?: { now(): number }; diff --git a/src/arts/timer/DESIGN_TIMR.md b/src/arts/timer/DESIGN_TIMR.md index 4146077..b772b75 100644 --- a/src/arts/timer/DESIGN_TIMR.md +++ b/src/arts/timer/DESIGN_TIMR.md @@ -93,7 +93,7 @@ const App = createActiveApp({ timers: {} }); -App.Timers.schedule(...); +App.timers.schedule(...); ``` Incorrecto: @@ -1134,20 +1134,20 @@ export interface ActiveAppTimersOptions { } ``` -En la práctica `App.Timers` debería estar siempre presente. +En la práctica `App.timers` debería estar siempre presente. ```ts const App = createActiveApp({ timers: {} }); -App.Timers.schedule(...); +App.timers.schedule(...); ``` Si no se configuran timers: ```ts -App.Timers = createActiveTimers(); +App.timers = createActiveTimers(); ``` ### 15.2 Inyección a otros artifacts @@ -1161,7 +1161,7 @@ timers?: TimerScheduler; Cuando se crean desde App: ```ts -timers: App.Timers +timers: App.timers ``` No deben importar un scheduler global. @@ -1174,7 +1174,7 @@ No deben importar un scheduler global. ```ts const stop = withAutoRefresh(Sess, { - timers: App.Timers, + timers: App.timers, tickMs, marginMs, jitterMs @@ -1431,7 +1431,7 @@ const App = createActiveApp({ timers: {} }); -App.Timers.schedule('sess:auto-refresh', 30_000, () => { +App.timers.schedule('sess:auto-refresh', 30_000, () => { return Sess.refresh(); }); ``` @@ -1508,7 +1508,7 @@ No sabe nada de sesiones, conexiones, cache, usuarios, auth, UI o dominio. Los demás artifacts expresan sus necesidades temporales mediante keys scoped. ```txt -App.Timers +App.timers ├── sess:auto-refresh ├── conn:main:reconnect ├── conn:main:heartbeat diff --git a/src/arts/timer/README.md b/src/arts/timer/README.md index d11f42a..906303e 100644 --- a/src/arts/timer/README.md +++ b/src/arts/timer/README.md @@ -43,7 +43,7 @@ Timers.cancelAll('conn:main'); ## Why another one -- **Singleton-per-App, not module-global.** `App.Timers` is one instance +- **Singleton-per-App, not module-global.** `App.timers` is one instance per App/runtime — SSR-safe, test-isolated, multi-App-friendly, cleanup is deterministic (`App.dispose()` cascades to `Timers.dispose()`). @@ -330,7 +330,7 @@ scheduling during SSR should be intentional. If a request-scoped EngineTimers schedules anything, the request must `dispose()` it before completion — otherwise the timer leaks across requests. -There is no module-global singleton. `App.Timers` is owned by the +There is no module-global singleton. `App.timers` is owned by the `ActiveApp` instance and torn down by `App.dispose()`. --- @@ -383,7 +383,7 @@ touching any cancellation, replace, reschedule or dispose path. ```ts const stop = withAutoRefresh(Sess, { - timers: App.Timers, + timers: App.timers, tickMs: 30_000, marginMs: 90_000 }); @@ -466,7 +466,7 @@ export const Timers = createActiveTimers(); // ✓ one per App const App = createActiveApp({ /* ... */ }); -App.Timers.schedule(...); +App.timers.schedule(...); ``` ### Don't forget scoped cleanup diff --git a/src/libs/cache/adapters/memory.ts b/src/libs/cache/adapters/memory.ts index 8fb4c4e..03d5363 100644 --- a/src/libs/cache/adapters/memory.ts +++ b/src/libs/cache/adapters/memory.ts @@ -34,7 +34,7 @@ export type MemoryCacheAdapterOptions = { /** * Route the production warning through a `$libs/logger.Logger` * (`logger.warn(category, message)`) instead of writing to - * `console.warn`. The framework's hosts wire this from `App.Logger` + * `console.warn`. The framework's hosts wire this from `App.logger` * so the warning lands in the same transports as the rest of the * runtime's diagnostics. */ diff --git a/src/libs/prefs/consts.ts b/src/libs/prefs/consts.ts index 1b103c0..bddb66a 100644 --- a/src/libs/prefs/consts.ts +++ b/src/libs/prefs/consts.ts @@ -14,13 +14,12 @@ export const PREFS_CHANGE_CAUSES = [ 'intent:clear', 'intent:reset', 'environment:refresh', - 'capabilities:set', 'hydrate' ] as const; /** - * Internal version constant carried in every `PrefsSnapshot`. Bumping - * this is a hint to consumers that the snapshot shape changed in a - * non-additive way; they can branch on it during migration windows. + * Internal version constant carried in every `PrefsSnapshot`. Bumped + * to 2 with the schema-based redesign — the snapshot now references a + * caller-provided `PrefsSchema` instead of fixed `PrefsCapabilities`. */ -export const PREFS_SNAPSHOT_VERSION = 1; +export const PREFS_SNAPSHOT_VERSION = 2; diff --git a/src/libs/prefs/index.ts b/src/libs/prefs/index.ts index f7372cd..4a02f3d 100644 --- a/src/libs/prefs/index.ts +++ b/src/libs/prefs/index.ts @@ -1,13 +1,14 @@ /** - * Public surface of `libs/prefs`. Exposes the layered state model - * (`PrefsCapabilities`, `PrefsEnvironment`, `PrefsIntent`, - * `PrefsEffective`), snapshot/event types, the resolver input shape and - * validation result types. + * Public surface of `libs/prefs`. The library defines the schema-based + * preference model: each preference is a `PrefsDimension` that owns its own validator, environment-fed resolver + * and (optionally) sibling-derived value. The engine in `arts/prefs` + * is generic over a `PrefsSchema = Record` and + * exposes one slot per dimension. * - * Domain primitives (`Locale`, `Currency`, `ThemeIntent`, …) and their - * capability sources (`LocaleSource`, `CurrencySource`, …) live in their - * own libs (`$libs/locale`, `$libs/currency`, …) so consumers can depend - * on a single capability port without importing `prefs`. + * Built-in dimensions (`localeDimension`, `themeDimension`, …) live in + * `arts/prefs/dimensions/*` so the bundle can pick exactly which ones + * to ship. */ export { @@ -20,14 +21,15 @@ export { resolvePrefs } from './resolve-prefs.ts'; export { sanitizeIntent, validateIntentValue } from './validate-intent.ts'; export type { - PrefsCapabilities, PrefsChangeCause, PrefsChangeEvent, PrefsChangeHandler, - PrefsEffective, + PrefsDimension, + PrefsEffectiveOf, PrefsEnvironment, - PrefsIntent, + PrefsIntentOf, PrefsResolveInput, + PrefsSchema, PrefsSnapshot, PrefsUnsubscribe, PrefsValidationFailure, diff --git a/src/libs/prefs/resolve-prefs.ts b/src/libs/prefs/resolve-prefs.ts index bb6b089..15f0c7d 100644 --- a/src/libs/prefs/resolve-prefs.ts +++ b/src/libs/prefs/resolve-prefs.ts @@ -1,210 +1,88 @@ -import { currencyFromLocales } from '$libs/currency'; -import type { Currency } from '$libs/currency'; -import type { Density } from '$libs/density'; -import { directionFromLanguage } from '$libs/direction'; -import type { Direction } from '$libs/direction'; -import { matchLocale } from '$libs/locale'; -import type { Locale } from '$libs/locale'; -import { resolveMotion } from '$libs/motion'; -import { resolveTheme } from '$libs/theme'; -import type { Timezone } from '$libs/timezone'; -import { unitSystemFromLocales } from '$libs/units'; -import type { UnitSystem } from '$libs/units'; import type { - PrefsCapabilities, - PrefsEffective, + PrefsDimension, + PrefsEffectiveOf, PrefsEnvironment, - PrefsIntent, - PrefsResolveInput + PrefsResolveInput, + PrefsSchema } from './types.ts'; -import { validateIntentValue } from './validate-intent.ts'; /** - * Pure orchestrator: compute `effective` from `(capabilities, - * environment, intent)`. Never reads browser APIs, persists, emits - * events or touches Svelte state — given the same input it always - * returns the same output. + * Pure orchestrator: compute `effective` from `(schema, environment, + * intent)`. Never reads browser APIs, persists, emits events or touches + * Svelte state — given the same input it always returns the same + * output. * - * Per-field strategy: each effective field is an INDEPENDENT projection - * of `environment.locales[]` against its own capability catalog, with - * `intent` overriding the projection when it validates and a - * field-specific environment hint (e.g. `environment.currency`) winning - * over the locale-derived guess when present. + * The resolver iterates the schema generically. Each dimension owns its + * resolution logic in `dim.validate`, `dim.resolve` and (optionally) + * `dim.derive`: * - * The user's most-preferred locale wins per dimension, even when the - * overall locale fallback diverges: an `Accept-Language: es-MX, en-US` - * with `capabilities.locales = ['es-ES', 'en-US']` may resolve language - * to `es-ES` (closest match for `es-MX`) and currency to `USD` (no - * region match for `MX` in the catalog → walk continues to `en-US`). + * 1. **Pass 1 — non-derived dimensions.** For each dim without a + * `derive` hook: validate `intent[key]`; pass the validated value + * (or `undefined`) plus the environment to `dim.resolve(intent, + * env)`. When a dim has no `resolve`, fall back to + * `intent ?? dim.defaultValue`. * - * Domain-specific helpers live in their respective capability libs - * (`$libs/locale/match-locale`, `$libs/direction/from-language`, - * `$libs/currency/from-locale`, `$libs/units/from-locale`, - * `$libs/theme/resolve`, `$libs/motion/resolve`); this file is just - * composition over the prefs layered model. + * 2. **Pass 2 — derived dimensions.** For each dim with a `derive`: + * validate `intent[key]` (intent always wins). When intent is + * absent, call `dim.derive(effective, env)` with the resolved + * siblings from pass 1. * - * `direction` is the one derivation: it follows from the resolved - * `language` because direction is a property of the writing system, not - * the region. + * Built-in dimensions encapsulate the domain rules that used to live + * inline here (`matchLocale`, `currencyFromLocales`, theme/motion + * resolution) — keeping the resolver small lets app-defined dimensions + * compose without forking the framework. */ -export function resolvePrefs(input: PrefsResolveInput): PrefsEffective { - const { capabilities, environment, intent } = input; - const defaults = capabilities.defaults; - const envLocales = environment.locales ?? []; +export function resolvePrefs( + input: PrefsResolveInput +): PrefsEffectiveOf { + const { schema, environment, intent } = input; + const out: Record = {}; - const language = resolveFromLocales( - 'language', - capabilities, - intent.language, - envLocales, - capabilities.languages, - defaults.language - ); - const locale = resolveFromLocales( - 'locale', - capabilities, - intent.locale, - envLocales, - capabilities.locales, - defaults.locale - ); - const currency = resolveCurrency(capabilities, environment, intent, envLocales, defaults.currency); - const unitSystem = resolveUnitSystem(capabilities, environment, intent, envLocales, defaults.unitSystem); - const timezone = resolveTimezone(capabilities, environment, intent, defaults.timezone); - const density = resolveDensity(capabilities, intent, defaults.density); - const theme = resolveTheme( - validatedIntent('theme', intent.theme, capabilities), - environment.colorScheme, - defaults.theme - ); - const motion = resolveMotion( - validatedIntent('motion', intent.motion, capabilities), - environment.reducedMotion, - defaults.motion - ); - const direction: Direction = directionFromLanguage(language); - - return { - language, - locale, - currency, - timezone, - unitSystem, - theme, - density, - motion, - direction - }; -} - -// ───────────────────────────────────────────────────────────────────── -// Per-field resolvers -// ───────────────────────────────────────────────────────────────────── - -/** - * Shared shape for `language` and `locale`: validate intent, then walk - * `environment.locales[]` through `matchLocale` against the field's own - * capability catalog. Same input array, different available list — that - * is what makes language and locale orthogonal projections. - */ -function resolveFromLocales( - key: 'language' | 'locale', - capabilities: PrefsCapabilities, - intentValue: Locale | undefined, - envLocales: readonly Locale[], - available: readonly Locale[], - fallback: Locale -): Locale { - if (intentValue !== undefined) { - const r = validateIntentValue(key, intentValue, capabilities); - if (r.ok) return r.value; + for (const [key, dim] of Object.entries(schema)) { + if (dim.derive !== undefined) continue; + out[key] = resolveOne(dim, intent[key as keyof typeof intent], environment); } - return matchLocale({ candidates: envLocales, available, fallback }); -} -function resolveCurrency( - capabilities: PrefsCapabilities, - environment: PrefsEnvironment, - intent: PrefsIntent, - envLocales: readonly Locale[], - fallback: Currency -): Currency { - if (intent.currency !== undefined) { - const r = validateIntentValue('currency', intent.currency, capabilities); - if (r.ok) return r.value; - } - if (environment.currency !== undefined) { - const r = validateIntentValue('currency', environment.currency, capabilities); - if (r.ok) return r.value; + for (const [key, dim] of Object.entries(schema)) { + if (dim.derive === undefined) continue; + out[key] = resolveDerived(dim, intent[key as keyof typeof intent], environment, out); } - return currencyFromLocales(envLocales, capabilities.currencies, fallback); -} -function resolveUnitSystem( - capabilities: PrefsCapabilities, - environment: PrefsEnvironment, - intent: PrefsIntent, - envLocales: readonly Locale[], - fallback: UnitSystem -): UnitSystem { - if (intent.unitSystem !== undefined) { - const r = validateIntentValue('unitSystem', intent.unitSystem, capabilities); - if (r.ok) return r.value; - } - if (environment.unitSystem !== undefined) { - const r = validateIntentValue('unitSystem', environment.unitSystem, capabilities); - if (r.ok) return r.value; - } - return unitSystemFromLocales(envLocales, capabilities.unitSystems, fallback); + return Object.freeze(out) as PrefsEffectiveOf; } -function resolveTimezone( - capabilities: PrefsCapabilities, - environment: PrefsEnvironment, - intent: PrefsIntent, - fallback: Timezone -): Timezone { - if (intent.timezone !== undefined) { - const r = validateIntentValue('timezone', intent.timezone, capabilities); - if (r.ok) return r.value; - } - if (environment.timezone !== undefined) { - const r = validateIntentValue('timezone', environment.timezone, capabilities); - if (r.ok) return r.value; +function resolveOne( + dim: PrefsDimension, + candidateIntent: unknown, + env: PrefsEnvironment +): TEffective { + const validated = candidateIntent === undefined ? undefined : tryValidate(dim, candidateIntent); + if (dim.resolve !== undefined) { + return dim.resolve(validated, env); } - return fallback; + return (validated ?? dim.defaultValue) as TEffective; } -function resolveDensity( - capabilities: PrefsCapabilities, - intent: PrefsIntent, - fallback: Density -): Density { - if (intent.density !== undefined) { - const r = validateIntentValue('density', intent.density, capabilities); - if (r.ok) return r.value; +function resolveDerived( + dim: PrefsDimension, + candidateIntent: unknown, + env: PrefsEnvironment, + resolvedSiblings: Readonly> +): TEffective { + const validated = candidateIntent === undefined ? undefined : tryValidate(dim, candidateIntent); + if (validated !== undefined) { + return validated as unknown as TEffective; } - return fallback; + if (dim.derive !== undefined) { + return dim.derive(resolvedSiblings, env); + } + return dim.defaultValue; } -/** - * Pass-through filter that drops an intent value when it fails - * validation against `capabilities`. Used for `theme` and `motion`, - * whose dedicated `resolve*` helpers (`resolveTheme`, `resolveMotion`) - * accept `intent | undefined` directly — passing `undefined` triggers - * the helper's "fall back to system hint" branch, which is exactly what - * we want when the stored intent is no longer in capabilities. - */ -function validatedIntent( - key: K, - value: PrefsIntent[K], - capabilities: PrefsCapabilities -): PrefsIntent[K] { - if (value === undefined) return undefined; - const r = validateIntentValue( - key, - value as NonNullable, - capabilities - ); +function tryValidate( + dim: PrefsDimension, + value: unknown +): TIntent | undefined { + const r = dim.validate(value); return r.ok ? r.value : undefined; } diff --git a/src/libs/prefs/test/resolve-prefs.test.ts b/src/libs/prefs/test/resolve-prefs.test.ts index 53a20b0..c1e328c 100644 --- a/src/libs/prefs/test/resolve-prefs.test.ts +++ b/src/libs/prefs/test/resolve-prefs.test.ts @@ -1,230 +1,81 @@ import { describe, expect, it } from 'vitest'; +import { + booleanDimension, + directionDimension, + enumDimension, + languageDimension, + localeDimension, + themeDimension +} from '$prefs'; import { resolvePrefs } from '../resolve-prefs.ts'; -import type { PrefsCapabilities, PrefsEnvironment, PrefsIntent } from '../types.ts'; -const CAPS: PrefsCapabilities = { - languages: ['es-ES', 'en-US', 'ar-EG'], - locales: ['es-ES', 'en-US', 'ar-EG'], - 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 EMPTY_ENV: PrefsEnvironment = {}; - -describe('resolvePrefs', () => { - it('returns capabilities.defaults when intent and environment are empty', () => { - const effective = resolvePrefs({ - capabilities: CAPS, - environment: EMPTY_ENV, - intent: {} - }); - expect(effective).toEqual(CAPS.defaults); - }); - - it('intent wins over environment when both are valid', () => { - const effective = resolvePrefs({ - capabilities: CAPS, - environment: { locales: ['en-US'], currency: 'USD' }, - intent: { language: 'es-ES', locale: 'es-ES', currency: 'EUR' } - }); - expect(effective.language).toBe('es-ES'); - expect(effective.locale).toBe('es-ES'); - expect(effective.currency).toBe('EUR'); - }); - - it('environment.locales wins when intent is absent', () => { - const effective = resolvePrefs({ - capabilities: CAPS, - environment: { locales: ['en-US'], currency: 'USD' }, - intent: {} - }); - expect(effective.language).toBe('en-US'); - expect(effective.locale).toBe('en-US'); - expect(effective.currency).toBe('USD'); - }); - - it('falls through invalid intent to environment, then defaults', () => { - const intent: PrefsIntent = { - language: 'fr-FR', - locale: 'fr-FR', - currency: 'JPY' - }; - const env: PrefsEnvironment = { locales: ['en-US'], currency: 'USD' }; - const effective = resolvePrefs({ - capabilities: CAPS, - environment: env, - intent - }); - expect(effective.language).toBe('en-US'); - expect(effective.locale).toBe('en-US'); - expect(effective.currency).toBe('USD'); - }); - - it('routes environment.locales through the matcher (es-MX → es-ES)', () => { - const effective = resolvePrefs({ - capabilities: CAPS, - environment: { locales: ['es-MX', 'en-US'] }, - intent: {} - }); - expect(effective.language).toBe('es-ES'); - expect(effective.locale).toBe('es-ES'); - }); - - it('language and locale resolve INDEPENDENTLY against their own catalogs', () => { - // capabilities.languages = ['es-ES'] only — but capabilities.locales - // supports en-US too. Same env.locales[] must produce different - // effective fields. - const splitCaps: PrefsCapabilities = { - ...CAPS, - languages: ['es-ES'], - locales: ['es-ES', 'en-US'] - }; - const effective = resolvePrefs({ - capabilities: splitCaps, - environment: { locales: ['en-US', 'es-ES'] }, - intent: {} - }); - // language can't be en-US (not in languages catalog) → falls to es-ES - expect(effective.language).toBe('es-ES'); - // locale CAN be en-US (first valid candidate against locales catalog) - expect(effective.locale).toBe('en-US'); - }); - - it('currency derives from environment.locales when intent and env.currency are absent', () => { - // es-MX has no region match in EUR/USD catalog → walk continues - // to en-US → USD. - const effective = resolvePrefs({ - capabilities: CAPS, - environment: { locales: ['es-MX', 'en-US'] }, - intent: {} - }); - expect(effective.currency).toBe('USD'); - }); - - it('unitSystem derives from environment.locales when intent and env.unitSystem are absent', () => { - const effective = resolvePrefs({ - capabilities: CAPS, +describe('resolvePrefs — schema-generic resolver', () => { + const schema = { + language: languageDimension({ catalog: ['es', 'en'], default: 'es' }), + locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }), + theme: themeDimension({ default: 'light' }), + direction: directionDimension(), + flag: booleanDimension({ default: false }), + mode: enumDimension(['compact', 'roomy'] as const, { default: 'roomy' }) + }; + + it('returns dimension defaults when intent and environment are empty', () => { + const eff = resolvePrefs({ schema, environment: {}, intent: {} }); + expect(eff.language).toBe('es'); + expect(eff.locale).toBe('es-ES'); + expect(eff.theme).toBe('light'); + expect(eff.flag).toBe(false); + expect(eff.mode).toBe('roomy'); + }); + + it('intent overrides environment and defaults', () => { + const eff = resolvePrefs({ + schema, environment: { locales: ['en-US'] }, - intent: {} + intent: { locale: 'es-ES', flag: true } }); - expect(effective.unitSystem).toBe('imperial'); + expect(eff.locale).toBe('es-ES'); + expect(eff.flag).toBe(true); }); - it('environment.currency wins over locale-derived currency', () => { - // en-US would derive USD via locale projection, but env.currency=EUR - // is the explicit hint and wins. - const effective = resolvePrefs({ - capabilities: CAPS, - environment: { locales: ['en-US'], currency: 'EUR' }, + it('environment fills dimensions that have a fromEnvironment hook', () => { + const eff = resolvePrefs({ + schema, + environment: { locales: ['en-US'], colorScheme: 'dark' }, intent: {} }); - expect(effective.currency).toBe('EUR'); + expect(eff.locale).toBe('en-US'); + expect(eff.theme).toBe('dark'); }); - it('resolves theme=system to the environment colorScheme', () => { - const effectiveDark = resolvePrefs({ - capabilities: CAPS, - environment: { colorScheme: 'dark' }, - intent: { theme: 'system' } - }); - expect(effectiveDark.theme).toBe('dark'); - - const effectiveLight = resolvePrefs({ - capabilities: CAPS, - environment: { colorScheme: 'light' }, - intent: { theme: 'system' } - }); - expect(effectiveLight.theme).toBe('light'); - }); - - it('resolves theme=system to defaults when environment lacks colorScheme', () => { - const effective = resolvePrefs({ - capabilities: CAPS, + it('drops invalid intent silently (resolver never throws)', () => { + const eff = resolvePrefs({ + schema, environment: {}, - intent: { theme: 'system' } + intent: { mode: 'unsupported' as never } }); - expect(effective.theme).toBe(CAPS.defaults.theme); + expect(eff.mode).toBe('roomy'); }); - it('resolves motion=system through environment.reducedMotion', () => { - const reduce = resolvePrefs({ - capabilities: CAPS, - environment: { reducedMotion: true }, - intent: { motion: 'system' } - }); - expect(reduce.motion).toBe('reduce'); - - const allow = resolvePrefs({ - capabilities: CAPS, - environment: { reducedMotion: false }, - intent: { motion: 'system' } - }); - expect(allow.motion).toBe('allow'); - }); - - it('derives direction from the resolved language (RTL)', () => { - const effective = resolvePrefs({ - capabilities: CAPS, - environment: {}, - intent: { language: 'ar-EG', locale: 'ar-EG' } - }); - expect(effective.language).toBe('ar-EG'); - expect(effective.direction).toBe('rtl'); - }); - - it('direction follows language even when locale is LTR', () => { - // Pathological-but-legal: language ar-EG, locale en-US (the user - // wants Arabic UI but US-style number/date formatting). Direction - // must follow language. - const effective = resolvePrefs({ - capabilities: CAPS, + it('derived dimensions read sibling effective values in pass 2', () => { + const arabicSchema = { + ...schema, + language: languageDimension({ catalog: ['ar', 'en'], default: 'en' }) + }; + const eff = resolvePrefs({ + schema: arabicSchema, environment: {}, - intent: { language: 'ar-EG', locale: 'en-US' } + intent: { language: 'ar' } }); - expect(effective.language).toBe('ar-EG'); - expect(effective.locale).toBe('en-US'); - expect(effective.direction).toBe('rtl'); + expect(eff.direction).toBe('rtl'); }); - it('density has no environment hint and falls back to default', () => { - const effective = resolvePrefs({ - capabilities: CAPS, + it('derived dimensions accept user intent overrides over derive', () => { + const eff = resolvePrefs({ + schema, environment: {}, - intent: {} - }); - expect(effective.density).toBe(CAPS.defaults.density); - }); - - it('effective is total: every field present', () => { - const effective = resolvePrefs({ - capabilities: CAPS, - environment: EMPTY_ENV, - intent: {} + intent: { direction: 'rtl' } }); - expect(Object.keys(effective).sort()).toEqual([ - 'currency', - 'density', - 'direction', - 'language', - 'locale', - 'motion', - 'theme', - 'timezone', - 'unitSystem' - ]); + expect(eff.direction).toBe('rtl'); }); }); diff --git a/src/libs/prefs/test/validate-intent.test.ts b/src/libs/prefs/test/validate-intent.test.ts index e9ee5d7..917fec5 100644 --- a/src/libs/prefs/test/validate-intent.test.ts +++ b/src/libs/prefs/test/validate-intent.test.ts @@ -1,153 +1,53 @@ import { describe, expect, it } from 'vitest'; -import type { PrefsCapabilities } from '../types.ts'; +import { booleanDimension, enumDimension, localeDimension } from '$prefs'; import { sanitizeIntent, validateIntentValue } from '../validate-intent.ts'; -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'], - timezones: ['Europe/Madrid', 'America/New_York'], - defaults: { - language: 'es-ES', - locale: 'es-ES', - currency: 'EUR', - timezone: 'Europe/Madrid', - unitSystem: 'metric', - theme: 'light', - density: 'comfortable', - motion: 'allow', - direction: 'ltr' - } -}; +const localeDim = localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }); +const flagDim = booleanDimension({ default: false }); +const modeDim = enumDimension(['compact', 'roomy'] as const, { default: 'roomy' }); -describe('validateIntentValue', () => { - it('accepts a language that is in capabilities.languages', () => { - expect(validateIntentValue('language', 'en-US', CAPS)).toEqual({ - ok: true, - value: 'en-US' - }); - }); - - it('rejects a language that is NOT in capabilities.languages', () => { - expect(validateIntentValue('language', 'fr-FR', CAPS)).toEqual({ - ok: false, - reason: 'unsupported_language' - }); - }); - - it('language and locale validate against independent catalogs', () => { - // `capabilities.languages` does not have to equal - // `capabilities.locales` — a value in one list may legitimately - // be absent from the other. - const splitCaps: PrefsCapabilities = { - ...CAPS, - languages: ['es-ES'], - locales: ['es-ES', 'en-US'] - }; - expect(validateIntentValue('language', 'en-US', splitCaps)).toEqual({ - ok: false, - reason: 'unsupported_language' - }); - expect(validateIntentValue('locale', 'en-US', splitCaps)).toEqual({ - ok: true, - value: 'en-US' - }); - }); - - it('accepts a locale that is in capabilities', () => { - expect(validateIntentValue('locale', 'en-US', CAPS)).toEqual({ - ok: true, - value: 'en-US' - }); +describe('validateIntentValue — dispatches to dimension.validate', () => { + it('passes through ok results', () => { + expect(validateIntentValue(localeDim, 'es-ES')).toEqual({ ok: true, value: 'es-ES' }); }); - it('rejects a locale that is NOT in capabilities', () => { - expect(validateIntentValue('locale', 'fr-FR', CAPS)).toEqual({ + it('reports the dimension reason on failure', () => { + expect(validateIntentValue(localeDim, 'fr-FR')).toEqual({ ok: false, reason: 'unsupported_locale' }); }); - it('rejects a currency outside the catalog', () => { - expect(validateIntentValue('currency', 'JPY', CAPS)).toEqual({ - ok: false, - reason: 'unsupported_currency' - }); - }); - - it('accepts theme `system` when capabilities allow it', () => { - expect(validateIntentValue('theme', 'system', CAPS)).toEqual({ - ok: true, - value: 'system' - }); - }); - - it('rejects theme `system` when capabilities exclude it', () => { - const restrictedCaps: PrefsCapabilities = { ...CAPS, themes: ['light', 'dark'] }; - expect(validateIntentValue('theme', 'system', restrictedCaps)).toEqual({ + it('rejects type-mismatch values', () => { + expect(validateIntentValue(flagDim, 'no')).toEqual({ ok: false, - reason: 'unsupported_theme' + reason: 'invalid_boolean' }); }); +}); - it('returns the runtime canonical form of a valid timezone', () => { - // Whatever `Intl.DateTimeFormat(...).resolvedOptions().timeZone` - // produces on this runtime is, by definition, the canonical - // form. The validator returns that exact string so callers - // persist a stable identifier — exact equality with a target - // alias is runtime-dependent and not part of the contract. - const result = validateIntentValue('timezone', 'Europe/Madrid', CAPS); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('Europe/Madrid'); - }); - - it('rejects an unrecognizable timezone string with `invalid_timezone`', () => { - expect(validateIntentValue('timezone', 'Not/A/Zone', CAPS)).toEqual({ - ok: false, - reason: 'invalid_timezone' - }); - }); +describe('sanitizeIntent — schema-driven filter', () => { + const schema = { locale: localeDim, flag: flagDim, mode: modeDim }; - it('rejects a valid IANA timezone outside `capabilities.timezones`', () => { - expect(validateIntentValue('timezone', 'Asia/Tokyo', CAPS)).toEqual({ - ok: false, - reason: 'unsupported_timezone' + it('keeps valid entries and drops invalid ones', () => { + const out = sanitizeIntent(schema, { + locale: 'es-ES', + flag: true, + mode: 'unsupported' }); + expect(out).toEqual({ locale: 'es-ES', flag: true }); }); - it('accepts any valid IANA timezone when `capabilities.timezones` is omitted', () => { - const openCaps: PrefsCapabilities = { ...CAPS, timezones: undefined }; - expect(validateIntentValue('timezone', 'Asia/Tokyo', openCaps)).toEqual({ - ok: true, - value: 'Asia/Tokyo' + it('drops keys that are not in the schema', () => { + const out = sanitizeIntent(schema, { + locale: 'en-US', + rogue: 'x' }); - }); -}); - -describe('sanitizeIntent', () => { - it('drops fields whose value fails validation', () => { - const cleaned = sanitizeIntent( - { - locale: 'fr-FR', // not in caps - currency: 'EUR', // ok - theme: 'light' // ok - }, - CAPS - ); - expect(cleaned).toEqual({ currency: 'EUR', theme: 'light' }); - }); - - it('preserves the canonical form returned by the validator', () => { - const cleaned = sanitizeIntent({ timezone: 'Europe/Madrid' }, CAPS); - expect(cleaned).toEqual({ timezone: 'Europe/Madrid' }); + expect(out).toEqual({ locale: 'en-US' }); }); - it('returns an empty object when the input is fully invalid', () => { - const cleaned = sanitizeIntent({ locale: 'fr-FR', currency: 'JPY' }, CAPS); - expect(cleaned).toEqual({}); + it('skips undefined values without invoking the validator', () => { + const out = sanitizeIntent(schema, { locale: undefined }); + expect(out).toEqual({}); }); }); diff --git a/src/libs/prefs/types.ts b/src/libs/prefs/types.ts index 56c37c2..131820b 100644 --- a/src/libs/prefs/types.ts +++ b/src/libs/prefs/types.ts @@ -1,72 +1,20 @@ import type { Currency } from '$libs/currency'; -import type { Density } from '$libs/density'; -import type { Direction } from '$libs/direction'; import type { Locale } from '$libs/locale'; -import type { MotionEffective, MotionIntent } from '$libs/motion'; -import type { ThemeEffective, ThemeIntent } from '$libs/theme'; import type { Timezone } from '$libs/timezone'; import type { UnitSystem } from '$libs/units'; import type { PREFS_CHANGE_CAUSES } from './consts.ts'; // ───────────────────────────────────────────────────────────────────── -// Layered state +// Detector-fed environment // ───────────────────────────────────────────────────────────────────── // -// The four layers (`capabilities`, `environment`, `intent`, `effective`) -// plus `defaults` are the model `prefs` owns. Domain primitives -// (`Locale`, `Currency`, `Theme*`, …) live in their own libs so -// consumers can depend on a single capability port without importing -// `prefs`. +// `PrefsEnvironment` is the raw observed context (Accept-Language, +// `prefers-color-scheme`, system timezone, etc.). Adapters fill it; the +// engine never reads from globals. Each dimension's `resolve` / +// `fromEnvironment` decides which environment fields it cares about. +// New dimensions may add new environment fields by extending this type +// (declaration merging) — the engine treats it as opaque data. -/** - * The legal universe of user-selectable values. The application - * composes this from its own configuration and from peer artifacts' - * advertised catalogs (Lang's translation set, the currency catalog, - * etc.). `prefs` does NOT discover capabilities by importing other - * modules — composition lives one layer above. - * - * `languages` and `locales` are independent lists with different - * sources and intent: `languages` is the i18n catalog (what Lang has - * translations for), `locales` is the regional formatting catalog - * (what Format / Intl-driven layout supports). They may overlap in - * simple apps but the framework treats them as orthogonal. - */ -export interface PrefsCapabilities { - /** BCP-47 tags Lang has translations for. Driven by i18n. */ - readonly languages: readonly Locale[]; - /** BCP-47 tags the app supports for regional formatting. Driven by product/legal/ops. */ - readonly locales: readonly Locale[]; - readonly currencies: readonly Currency[]; - readonly unitSystems: readonly UnitSystem[]; - readonly themes: readonly ThemeIntent[]; - readonly densities: readonly Density[]; - readonly motions: readonly MotionIntent[]; - /** - * Optional allowlist of IANA time zones. When omitted, `prefs` - * accepts any value that canonicalizes through `Intl.DateTimeFormat`. - * When present, the canonicalized timezone must match an entry. - */ - readonly timezones?: readonly Timezone[]; - /** - * Final fallback for every effective field. Must be valid against - * the rest of `capabilities` — `prefs` validates this at - * `setCapabilities()` time. - */ - readonly defaults: PrefsEffective; -} - -/** - * Detected context — server header parsing, browser APIs, system - * settings. Different from `intent`: the user has not chosen these - * values, the runtime observed them. - * - * Fields are optional because detection is best-effort; SSR may know - * `locales` from `Accept-Language` but not have `prefers-color-scheme`. - * - * Values may fall outside `capabilities` (e.g. `Accept-Language: es-MX` - * when only `es-ES` is in `capabilities.locales`). The resolver bridges - * the gap; `environment` keeps the raw observation for diagnostics. - */ export interface PrefsEnvironment { readonly locales?: readonly Locale[]; readonly timezone?: Timezone; @@ -83,124 +31,146 @@ export interface PrefsEnvironment { readonly source?: 'server' | 'browser' | 'mixed' | 'test'; } +// ───────────────────────────────────────────────────────────────────── +// Validation +// ───────────────────────────────────────────────────────────────────── + /** - * What the user explicitly selected. Sparse: a missing field means - * "derive from environment + defaults", NOT "clear it". To clear an - * intent the engine exposes `clearIntent(key)` so callers cannot - * confuse "did not write" with "wrote undefined". + * Each dimension owns its own validation reasons. A free-form string + * keeps the codes per-dimension without forcing a closed union at the + * library layer — built-in dimensions still publish stable codes + * (`unsupported_locale`, `invalid_timezone`, …) and app-defined + * dimensions choose their own. */ -export interface PrefsIntent { - readonly language?: Locale; - readonly locale?: Locale; - readonly currency?: Currency; - readonly timezone?: Timezone; - readonly unitSystem?: UnitSystem; - readonly theme?: ThemeIntent; - readonly density?: Density; - readonly motion?: MotionIntent; +export type PrefsValidationFailure = string; + +export type PrefsValidationResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly reason: PrefsValidationFailure }; + +// ───────────────────────────────────────────────────────────────────── +// Dimension contract +// ───────────────────────────────────────────────────────────────────── +// +// A dimension is the unit of preference. The engine knows nothing about +// "locale" or "theme" specifically — it iterates the schema and asks +// each dimension how to validate, resolve and (optionally) derive its +// value. Built-in dimensions live in `arts/prefs/dimensions/*` and the +// standard preset (`standardPrefsDimensions`) composes them. +// +// `TIntent` and `TEffective` are usually the same. They split for +// dimensions whose intent vocabulary is wider than the resolved one +// (e.g. theme accepts `'system'` as intent but resolves to +// `'light' | 'dark'` based on `environment.colorScheme`). + +export interface PrefsDimension { + /** + * Final fallback when neither intent nor environment yields a value. + * Must be a value the dimension's resolver can return — there is no + * second-chance validation on the default. + */ + readonly defaultValue: TEffective; + + /** + * Validate a candidate intent value. Called on every `setIntent()` + * write and on every persisted-intent hydrate. Returning `ok: false` + * triggers `PrefsIntentInvalidError` at the engine boundary. + */ + readonly validate: (value: unknown) => PrefsValidationResult; + + /** + * Per-dimension resolver: take the validated intent (or `undefined` + * when no intent is set) plus the environment, return the effective + * value. Default behavior when omitted: `intent ?? defaultValue`. + * + * Theme uses `resolve` to fold `'system'` intent into a concrete + * `'light' | 'dark'` effective using `env.colorScheme`. Locale uses + * it to walk `env.locales[]` against the catalog when intent is + * unset. + */ + readonly resolve?: (intent: TIntent | undefined, env: PrefsEnvironment) => TEffective; + + /** + * Derived dimension: read sibling dimensions' effective values. + * Direction uses this — it follows the resolved language. Runs in a + * second pass after non-derived dimensions are resolved. Cycles are + * not supported. + */ + readonly derive?: ( + effective: Readonly>, + env: PrefsEnvironment + ) => TEffective; + + /** + * Optional capability catalog for introspection (devtools, settings + * UIs, audit reports). Returning `undefined` means "no enumerable + * catalog" — fine for free-form dimensions like `string`-typed + * preferences. + */ + readonly catalog?: () => readonly TIntent[]; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type PrefsSchema = Record>; + +// ───────────────────────────────────────────────────────────────────── +// Type derivations +// ───────────────────────────────────────────────────────────────────── + /** - * The resolved view of every preference, total and always valid against - * `capabilities`. `prefs` produces this; consumers (or the wiring layer - * that builds capability proxies on top of it) read from here. - * - * Each field is the result of an INDEPENDENT projection of - * `environment.locales[]` against its own capability catalog (with - * `intent` override). No field derives from another except `direction`, - * which derives from the resolved `language` (because direction is a - * property of the writing system, not of the region). - * - * - `language` (BCP-47) — what Lang reads for translations. - * - `locale` (BCP-47) — what Format / Intl reads for regional layout. - * - `theme` is `'light' | 'dark'` — `'system'` is an intent, not an - * effective value. - * - `motion` is `'allow' | 'reduce'` — same reason. - * - `direction` is derived from `language`; not independently - * selectable as intent. + * Resolved view of every dimension. Total: every schema key is present + * with the dimension's `TEffective`. `App.prefs..get()` returns + * the value of that key. */ -export interface PrefsEffective { - readonly language: Locale; - readonly locale: Locale; - readonly currency: Currency; - readonly timezone: Timezone; - readonly unitSystem: UnitSystem; - readonly theme: ThemeEffective; - readonly density: Density; - readonly motion: MotionEffective; - readonly direction: Direction; -} +export type PrefsEffectiveOf = { + readonly [K in keyof S]: S[K] extends PrefsDimension + ? TEffective + : never; +}; + +/** + * Sparse view of explicit user choices. A missing key means "fall back + * to environment / default", NOT "clear it". To clear an intent the + * engine exposes `clearIntent(key)`. + */ +export type PrefsIntentOf = { + readonly [K in keyof S]?: S[K] extends PrefsDimension + ? TIntent + : never; +}; // ───────────────────────────────────────────────────────────────────── // Snapshot / events // ───────────────────────────────────────────────────────────────────── -/** - * Serializable, immutable view of every layer at one point in time. - * Used for SSR payloads, devtools panels, change-event diffs, and - * `getSnapshot()` reads. Consumers must not mutate the returned tree. - */ -export interface PrefsSnapshot { - readonly capabilities: PrefsCapabilities; +export interface PrefsSnapshot { readonly environment: PrefsEnvironment; - readonly intent: PrefsIntent; - readonly effective: PrefsEffective; - /** - * Bumped on every committed write. Equivalent snapshots compare - * unequal across writes, so consumers can use it as an optimistic - * version key without doing a deep compare. - */ + readonly intent: PrefsIntentOf; + readonly effective: PrefsEffectiveOf; readonly version: number; } export type PrefsChangeCause = (typeof PREFS_CHANGE_CAUSES)[number]; -/** - * Payload delivered to every `subscribe()` listener. Carries the - * previous and next snapshots PLUS a pre-computed shallow diff over - * `effective` — by far the most common consumer concern. - */ -export interface PrefsChangeEvent { - readonly previous: PrefsSnapshot; - readonly next: PrefsSnapshot; +export interface PrefsChangeEvent { + readonly previous: PrefsSnapshot; + readonly next: PrefsSnapshot; /** Sparse map: `{ locale: 'es-ES' }` when only locale changed. */ - readonly effectiveDiff: Partial; + readonly effectiveDiff: Partial>; readonly cause: PrefsChangeCause; } -export type PrefsChangeHandler = (event: PrefsChangeEvent) => void; +export type PrefsChangeHandler = ( + event: PrefsChangeEvent +) => void; export type PrefsUnsubscribe = () => void; // ───────────────────────────────────────────────────────────────────── -// Resolver / engine I/O +// Resolver input // ───────────────────────────────────────────────────────────────────── -export interface PrefsResolveInput { - readonly capabilities: PrefsCapabilities; +export interface PrefsResolveInput { + readonly schema: S; readonly environment: PrefsEnvironment; - readonly intent: PrefsIntent; + readonly intent: PrefsIntentOf; } - -// ───────────────────────────────────────────────────────────────────── -// Validation -// ───────────────────────────────────────────────────────────────────── - -/** - * Reasons `validateIntentValue` rejects a write. Stable string codes so - * tests and downstream UIs can branch on them without depending on - * message wording. - */ -export type PrefsValidationFailure = - | 'unsupported_language' - | 'unsupported_locale' - | 'unsupported_currency' - | 'unsupported_timezone' - | 'unsupported_unit_system' - | 'unsupported_theme' - | 'unsupported_density' - | 'unsupported_motion' - | 'invalid_timezone'; - -export type PrefsValidationResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly reason: PrefsValidationFailure }; diff --git a/src/libs/prefs/validate-intent.ts b/src/libs/prefs/validate-intent.ts index 65b9b62..e109031 100644 --- a/src/libs/prefs/validate-intent.ts +++ b/src/libs/prefs/validate-intent.ts @@ -1,145 +1,38 @@ -import type { Currency } from '$libs/currency'; -import type { Density } from '$libs/density'; -import type { Locale } from '$libs/locale'; -import type { MotionIntent } from '$libs/motion'; -import type { ThemeIntent } from '$libs/theme'; -import type { Timezone } from '$libs/timezone'; -import type { UnitSystem } from '$libs/units'; -import type { - PrefsCapabilities, - PrefsIntent, - PrefsValidationResult -} from './types.ts'; +import type { PrefsDimension, PrefsSchema, PrefsValidationResult } from './types.ts'; /** - * Canonicalize an IANA timezone string. Returns `undefined` when the - * value is not a recognizable timezone — consumers treat that as - * `'invalid_timezone'`. - * - * Uses `Intl.DateTimeFormat(...).resolvedOptions().timeZone` because it - * applies the same canonicalization the rest of the platform uses - * (`'Asia/Calcutta'` → `'Asia/Kolkata'`, etc.). - */ -function canonicalizeTimezone(timezone: string): string | undefined { - try { - return new Intl.DateTimeFormat('en-US', { timeZone: timezone }) - .resolvedOptions() - .timeZone; - } catch { - return undefined; - } -} - -/** - * Validate a single (key, value) pair against `capabilities`. Returns - * `{ ok: true, value }` (with `value` possibly canonicalized — only - * `timezone` does this today) or `{ ok: false, reason }` with a stable - * machine-readable reason code from `PrefsValidationFailure`. - * - * The function is the single source of truth for intent validation. - * Both the engine (`setIntent`, `resetIntent`) and the resolver call it, - * so an environment value that "passes" is exactly the same set of - * values an explicit intent could have stored. + * Per-dimension validation. The `validate` callback inside each dimension + * is the single source of truth for what counts as a legal intent value + * — this helper just dispatches and shapes the result type. Used by the + * engine on `setIntent` and by `sanitizeIntent` during hydrate. */ -export function validateIntentValue( - key: K, - value: NonNullable, - capabilities: PrefsCapabilities -): PrefsValidationResult> { - switch (key) { - case 'language': { - const language = value as Locale; - if (capabilities.languages.includes(language)) { - return { ok: true, value: language as NonNullable }; - } - return { ok: false, reason: 'unsupported_language' }; - } - case 'locale': { - const locale = value as Locale; - if (capabilities.locales.includes(locale)) { - return { ok: true, value: locale as NonNullable }; - } - return { ok: false, reason: 'unsupported_locale' }; - } - case 'currency': { - const currency = value as Currency; - if (capabilities.currencies.includes(currency)) { - return { ok: true, value: currency as NonNullable }; - } - return { ok: false, reason: 'unsupported_currency' }; - } - case 'unitSystem': { - const unitSystem = value as UnitSystem; - if (capabilities.unitSystems.includes(unitSystem)) { - return { ok: true, value: unitSystem as NonNullable }; - } - return { ok: false, reason: 'unsupported_unit_system' }; - } - case 'theme': { - const theme = value as ThemeIntent; - if (capabilities.themes.includes(theme)) { - return { ok: true, value: theme as NonNullable }; - } - return { ok: false, reason: 'unsupported_theme' }; - } - case 'density': { - const density = value as Density; - if (capabilities.densities.includes(density)) { - return { ok: true, value: density as NonNullable }; - } - return { ok: false, reason: 'unsupported_density' }; - } - case 'motion': { - const motion = value as MotionIntent; - if (capabilities.motions.includes(motion)) { - return { ok: true, value: motion as NonNullable }; - } - return { ok: false, reason: 'unsupported_motion' }; - } - case 'timezone': { - const canonical = canonicalizeTimezone(value as string); - if (canonical === undefined) { - return { ok: false, reason: 'invalid_timezone' }; - } - if ( - capabilities.timezones !== undefined && - !capabilities.timezones.includes(canonical as Timezone) - ) { - return { ok: false, reason: 'unsupported_timezone' }; - } - // Return the canonicalized form so callers can persist a - // stable identifier even when the input was an alias. - return { ok: true, value: canonical as NonNullable }; - } - } +export function validateIntentValue( + dim: PrefsDimension, + value: unknown +): PrefsValidationResult { + return dim.validate(value); } /** - * Sanitize a sparse `PrefsIntent` map by dropping every field whose - * value fails validation. Used by `resolvePrefs` and at hydrate time so - * a stale persisted intent (e.g. the app shrank `capabilities.locales`) - * does not poison the effective view. + * Filter a raw intent map (typically loaded from storage) through the + * schema: drop unknown keys and entries that fail their dimension's + * validator, keep the canonicalised values produced by `validate`. * - * Note: rejected entries are dropped, NOT replaced with environment or - * defaults — the resolver does that downstream. Keeps responsibilities - * separated: validation says yes/no; resolution decides the substitute. + * Storage hydrate must use this helper rather than passing the raw map + * to `engine.resetIntent()` directly — the engine assumes intent values + * have already validated against the active schema. */ -export function sanitizeIntent( - intent: PrefsIntent, - capabilities: PrefsCapabilities -): PrefsIntent { - const out: { -readonly [K in keyof PrefsIntent]?: PrefsIntent[K] } = {}; - - for (const key of Object.keys(intent) as Array) { - const raw = intent[key]; - if (raw === undefined) continue; - const result = validateIntentValue( - key, - raw as NonNullable, - capabilities - ); - if (result.ok) (out as Record)[key] = result.value; +export function sanitizeIntent( + schema: S, + intent: Readonly> +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(intent)) { + if (value === undefined) continue; + const dim = schema[key]; + if (dim === undefined) continue; + const result = dim.validate(value); + if (result.ok) out[key] = result.value; } - return out; } diff --git a/src/svrs/perm/README.md b/src/svrs/perm/README.md index 5c6d9a1..3ab5de1 100644 --- a/src/svrs/perm/README.md +++ b/src/svrs/perm/README.md @@ -17,7 +17,7 @@ export const Perms = createEnginePerms({ policies, providers, compilers, - logger: App.Logger + logger: App.logger }); ``` diff --git a/src/web/routes/active/+page.svelte b/src/web/routes/active/+page.svelte index a594dcf..b338a0b 100644 --- a/src/web/routes/active/+page.svelte +++ b/src/web/routes/active/+page.svelte @@ -4,16 +4,30 @@ import PageNav from './_components/PageNav.svelte'; const composition = `import { createActiveApp } from '$active-app'; +import { + defineActiveLang, + defineActiveFrontend, + defineActiveFormat +} from '$active-app/services'; const App = createActiveApp({ - lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] }, logger: { level: LogLevel.INFO, transports: [consoleTransport()] }, - frontend: { theme: 'base' } + prefs: { + capabilities, + environment, + intent: { language: 'es', locale: 'es-MX', theme: 'system' } + }, + services: { + lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }), + frontend: defineActiveFrontend({}), + format: defineActiveFormat({}) + } }); App.lang.t('common.ok'); App.format.currency.format(99.5); -App.lang.setLocale('es-MX');`; +App.prefs.locale.set('es-MX'); +App.bus.publish('app.ready', {});`; const sections = [ { @@ -37,8 +51,8 @@ App.lang.setLocale('es-MX');`; title: 'App', alias: '$active-app', href: '/active/docs/aapp', - description: 'Composes Lang, Logger, Format, Frontend, Dom, Storage, Http, Timers and Cache.', - factories: ['createActiveApp'] + description: 'Builds the fixed core Logger, Bus, Timers, Orca and Prefs, then resolves typed services.', + factories: ['createActiveApp', 'services'] } ] }, @@ -106,6 +120,18 @@ App.lang.setLocale('es-MX');`; } ] }, + { + title: 'Preferences & Environment', + items: [ + { + title: 'Prefs', + alias: '$prefs', + href: '/active/docs/prefs', + description: 'Cross-cutting user intent, environment defaults and effective runtime preferences.', + factories: ['createEnginePrefs', 'createActivePrefs'] + } + ] + }, { title: 'I18n & Format', items: [ @@ -159,6 +185,13 @@ App.lang.setLocale('es-MX');`; { title: 'Infrastructure', items: [ + { + title: 'Bus', + alias: '$bus', + href: '/active/docs/buss', + description: 'Typed event bus used by App core and cross-module notifications.', + factories: ['createSvelteEngineBus'] + }, { title: 'Logger', alias: '$logger', @@ -173,6 +206,13 @@ App.lang.setLocale('es-MX');`; description: 'Deterministic timer scheduler: clock injection, intervals, snapshots.', factories: ['createActiveTimers', 'createEngineTimers'] }, + { + title: 'Orca', + alias: '$orca', + href: '/active/docs/orca', + description: 'Cross-module orchestration: staged actions, queue policies, fan-in gates and App presets.', + factories: ['createEngineOrca', 'App.orca'] + }, { title: 'Connections', alias: '$connection', @@ -195,13 +235,13 @@ App.lang.setLocale('es-MX');`;
- Active framework — v0.0.1 + Active framework — 1.0 candidate

The runtime that wires your SvelteKit app together.

- A composable set of fifteen runtime artifacts — - i18n, logger, sessions, auth, permissions, cache, storage, HTTP, timers, formatting, and a reactive - frontend layer — built around two factories and one contract. + A composable set of runtime artifacts — core App infrastructure, + i18n, preferences, logger, sessions, auth, permissions, cache, storage, HTTP, timers, + formatting, orchestration, realtime connections and a reactive frontend layer.

@@ -222,7 +262,7 @@ App.lang.setLocale('es-MX');`;
Artifacts
-
15
+
18
Bundle
@@ -243,9 +283,9 @@ App.lang.setLocale('es-MX');`;

Quick look

- Every app starts from $active-app, which wires every artifact and exposes them on - a single App object. Identity, permissions, cache and connections are factories - built from the same App. + Every app starts from $active-app. It always builds the core + Logger, Bus, Timers and Orca, then + declares feature modules as typed services on the same App object.

@@ -294,7 +334,7 @@ App.lang.setLocale('es-MX');`; />

This makes every artifact testable, composable and disposable in the same way — and lets - the aapp composer wire them blindly. + the $active-app composer wire them blindly.

diff --git a/src/web/routes/active/_components/StubBody.svelte b/src/web/routes/active/_components/StubBody.svelte index 42a30a1..fabc8b0 100644 --- a/src/web/routes/active/_components/StubBody.svelte +++ b/src/web/routes/active/_components/StubBody.svelte @@ -23,7 +23,7 @@

Outline

-

The full page will follow the same shape as aapp and lang:

+

The full page will follow the same shape as $active-app and lang:

  1. Overview — what the artifact is for and what it explicitly does not do.
  2. Quick start — minimum useful example.
  3. diff --git a/src/web/routes/active/_components/Topbar.svelte b/src/web/routes/active/_components/Topbar.svelte index 54474e5..c8be1b2 100644 --- a/src/web/routes/active/_components/Topbar.svelte +++ b/src/web/routes/active/_components/Topbar.svelte @@ -7,7 +7,7 @@ active - 0.0.1 + 1.0