From 7adf93ca5741986d3c5c2bd9aa6550044fe5949f Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 6 May 2026 02:57:33 +0200 Subject: [PATCH] Prefs as schema-based core + lowercase App.* surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structural changes that were overdue and got bundled because they touched the same set of files. ## Prefs is now a schema, not a fixed shape Previously every preference had to be declared in a closed `PrefsCapabilities` interface (`languages`, `locales`, `currencies`, `themes`, `densities`, `motions`, `timezones`, `unitSystems`). Adding a new pref required forking `$libs/prefs` — bad framework design. The redesign replaces the fixed shape with a schema: PrefsSchema = Record> Each dimension owns its own validator (`validate`), environment-fed resolver (`resolve`) and optional sibling-derived value (`derive`). The engine is generic over the schema and iterates it; it knows nothing about "locale" or "theme" specifically. Built-in dimensions live in `arts/prefs/dimensions/*` (locale, language, theme, density, motion, timezone, currency, unit-system, direction, plus boolean / enum / string / number primitives). The `standardPrefsDimensions(catalog)` preset composes the canonical set; apps spread it and add their own: const schema = { ...standardPrefsDimensions({ languages, locales, currencies }), sidebarCollapsed: booleanDimension({ default: false }), notificationLevel: enumDimension( ['all', 'mentions', 'none'] as const, { default: 'mentions' } ) }; Active surface exposes one slot per schema key with uniform verbs: App.prefs.locale.get() App.prefs.locale.set('es-ES') App.prefs.locale.clear() App.prefs.locale.onChange((v) => …) App.prefs.sidebarCollapsed.set(true) `setIntent('locale', value)` stays available as a low-level pass- through (storage bridge consumes it generically) but UI code uses the dimension surface. ## Lowercase core surface `App.Logger`, `App.Bus`, `App.Timers`, `App.Orca`, `App.Prefs` are gone. The "PascalCase for core, lowercase for services" rule was visual signalling against JS convention, no technical benefit, and created an asymmetry on the same object. All core members are now lowercase, matching services: App.logger App.bus App.timers App.orca App.prefs `createPrefsStorageBridge` keeps its old responsibilities; sources helpers (`prefsLocaleSource`, …) are gone — the dimension API replaces them. ## What changed - `$libs/prefs`: fully generic schema-based types + resolver. Old fixed `PrefsCapabilities` / `PrefsIntent` / `PrefsEffective` removed; replaced by `PrefsDimension`, `PrefsSchema`, `PrefsEffectiveOf`, `PrefsIntentOf`. - `arts/prefs`: engine + active wrapper rewritten to schema. Per- dimension active surface auto-built from schema keys. Sources file deleted (replaced by dimension surface). New `arts/prefs/dimensions/*` and `arts/prefs/standard.ts`. Storage bridge made schema-generic. - `arts/active-app`: lowercase `CoreServices` / `ActiveAppCore`, `prefs?: ActiveAppPrefsOptions` root option carrying the schema. `defineActivePrefs` deleted (prefs is core, not service). `lang` / `format` / `frontend` factories migrated to read `core.prefs.` directly via defensive `readSlot()` helpers (each dimension is optional from the factory's POV; if the app's schema omits one, the integration degrades gracefully). - Presets, demos, web routes, README docstrings, `check-aliases.mjs` guards, marketing snippets all migrated. - Tests: `engine-prefs`, `active-prefs`, `storage-bridge`, `resolve-prefs`, `validate-intent`, `prefs-consumer-wiring`, `service-factories` rewritten for the schema-based API. `sources.test.ts` deleted (sources file is gone). ## Verification - `npm run check`: 0 errors, 0 warnings (1527 files). - `npm test`: 1645 tests across 139 files, all green. - `node scripts/check-aliases.mjs`: clean (lowercase enforced for every member of `App.*`, including `Logger`/`Bus`/`Timers`/`Orca`/ `Prefs` which now flag as forbidden capitals). Co-Authored-By: Claude Opus 4.7 (1M context) --- demos/dating/web/_lib/app.ts | 46 +- scripts/check-aliases.mjs | 40 +- src/arts/active-app/README.md | 6 +- src/arts/active-app/active-app.svelte.ts | 133 ++++-- src/arts/active-app/events.ts | 2 +- src/arts/active-app/index.ts | 5 +- .../presets/cache-clear-on-identity-change.ts | 4 +- .../presets/cache-clear-on-revoke.ts | 4 +- .../presets/connections-close-on-revoke.ts | 4 +- .../connections-reauth-on-identity-change.ts | 4 +- src/arts/active-app/presets/index.ts | 2 +- .../perm-invalidate-on-identity-change.ts | 4 +- .../presets/session-auto-refresh.ts | 8 +- src/arts/active-app/presets/standard.ts | 2 +- .../active-app/service-factories/cache.ts | 2 +- .../active-app/service-factories/format.ts | 50 +-- .../active-app/service-factories/frontend.ts | 135 +++--- .../active-app/service-factories/index.ts | 10 +- src/arts/active-app/service-factories/lang.ts | 48 +-- src/arts/active-app/service-factories/perm.ts | 2 +- .../active-app/service-factories/prefs.ts | 95 ----- .../active-app/service-factories/storage.ts | 2 +- src/arts/active-app/services.ts | 21 +- .../ecosystem-cross-actor-isolation.test.ts | 40 +- .../active-app/test/ecosystem-orca.test.ts | 8 +- .../test/prefs-consumer-wiring.test.ts | 200 +++------ src/arts/active-app/test/presets.test.ts | 24 +- .../test/schema-declarative.test.ts | 11 +- .../active-app/test/service-builder.test.ts | 4 +- .../active-app/test/service-factories.test.ts | 65 ++- .../test/session-auto-refresh.test.ts | 20 +- src/arts/active-app/types.ts | 124 +++--- src/arts/bus/README.md | 18 +- src/arts/connection/DESIGN_CONN.md | 4 +- src/arts/connection/README.md | 8 +- src/arts/connection/types.ts | 2 +- src/arts/format/currency/types.ts | 2 +- src/arts/http/README.md | 2 +- src/arts/http/types.ts | 4 +- src/arts/logger/types.ts | 2 +- src/arts/orca/README.md | 14 +- src/arts/perm/README.md | 4 +- src/arts/prefs/README.md | 39 +- src/arts/prefs/active-prefs.svelte.ts | 261 ++++++++---- .../prefs/adapters/browser-environment.ts | 6 +- src/arts/prefs/adapters/storage-bridge.ts | 33 +- src/arts/prefs/consts.ts | 1 - src/arts/prefs/dimensions/currency.ts | 41 ++ src/arts/prefs/dimensions/density.ts | 25 ++ src/arts/prefs/dimensions/direction.ts | 42 ++ src/arts/prefs/dimensions/index.ts | 27 ++ src/arts/prefs/dimensions/language.ts | 47 +++ src/arts/prefs/dimensions/locale.ts | 49 +++ src/arts/prefs/dimensions/motion.ts | 39 ++ src/arts/prefs/dimensions/primitive.ts | 95 +++++ src/arts/prefs/dimensions/theme.ts | 48 +++ src/arts/prefs/dimensions/timezone.ts | 58 +++ src/arts/prefs/dimensions/unit-system.ts | 32 ++ src/arts/prefs/engine-prefs.ts | 277 +++++-------- src/arts/prefs/errors.ts | 85 ++-- src/arts/prefs/index.ts | 70 ++-- src/arts/prefs/sources.ts | 86 ---- src/arts/prefs/standard.ts | 85 ++++ .../prefs/test/active-prefs.svelte.test.ts | 177 ++++---- src/arts/prefs/test/engine-prefs.test.ts | 392 +++--------------- src/arts/prefs/test/sources.test.ts | 124 ------ src/arts/prefs/test/storage-bridge.test.ts | 244 +++-------- src/arts/prefs/types.ts | 101 ++--- src/arts/session/README.md | 12 +- src/arts/session/consts.ts | 2 +- src/arts/sium/README.md | 2 +- src/arts/storage/README.md | 4 +- src/arts/storage/types.ts | 2 +- src/arts/timer/DESIGN_TIMR.md | 16 +- src/arts/timer/README.md | 8 +- src/libs/cache/adapters/memory.ts | 2 +- src/libs/prefs/consts.ts | 9 +- src/libs/prefs/index.ts | 24 +- src/libs/prefs/resolve-prefs.ts | 248 +++-------- src/libs/prefs/test/resolve-prefs.test.ts | 265 +++--------- src/libs/prefs/test/validate-intent.test.ts | 158 ++----- src/libs/prefs/types.ts | 274 ++++++------ src/libs/prefs/validate-intent.ts | 161 ++----- src/svrs/perm/README.md | 2 +- src/web/routes/active/+page.svelte | 68 ++- .../routes/active/_components/StubBody.svelte | 2 +- .../routes/active/_components/Topbar.svelte | 2 +- src/web/routes/active/_data/artifact-docs.ts | 253 +++++------ src/web/routes/active/_data/nav.ts | 7 +- src/web/routes/active/docs/aapp/+page.svelte | 330 ++++++++------- src/web/routes/active/docs/lang/+page.svelte | 92 ++-- src/web/routes/active/docs/orca/+page.svelte | 155 +++++++ src/web/routes/active/docs/perm/+page.svelte | 14 +- src/web/routes/active/docs/prefs/+page.svelte | 135 ++++++ src/web/routes/active/docs/svrs/+page.svelte | 18 +- .../active/get-started/ai-agents/+page.svelte | 16 +- .../get-started/composition/+page.svelte | 270 ++++++------ .../active/get-started/ecosystem/+page.svelte | 79 ++-- .../get-started/installation/+page.svelte | 76 ++-- .../get-started/versioning/+page.svelte | 91 ++-- src/web/routes/active/security/+page.svelte | 35 +- src/web/routes/demo/+layout.svelte | 2 +- .../routes/demo/_lib/components/BusLog.svelte | 6 +- .../demo/_lib/components/TimerWidget.svelte | 4 +- src/web/routes/test/aapp/+page.svelte | 12 +- src/web/routes/test/ecosystem/+page.svelte | 12 +- src/web/routes/test/http/+page.svelte | 4 +- src/web/routes/test/stor/+page.svelte | 2 +- 108 files changed, 3216 insertions(+), 3327 deletions(-) delete mode 100644 src/arts/active-app/service-factories/prefs.ts create mode 100644 src/arts/prefs/dimensions/currency.ts create mode 100644 src/arts/prefs/dimensions/density.ts create mode 100644 src/arts/prefs/dimensions/direction.ts create mode 100644 src/arts/prefs/dimensions/index.ts create mode 100644 src/arts/prefs/dimensions/language.ts create mode 100644 src/arts/prefs/dimensions/locale.ts create mode 100644 src/arts/prefs/dimensions/motion.ts create mode 100644 src/arts/prefs/dimensions/primitive.ts create mode 100644 src/arts/prefs/dimensions/theme.ts create mode 100644 src/arts/prefs/dimensions/timezone.ts create mode 100644 src/arts/prefs/dimensions/unit-system.ts delete mode 100644 src/arts/prefs/sources.ts create mode 100644 src/arts/prefs/standard.ts delete mode 100644 src/arts/prefs/test/sources.test.ts create mode 100644 src/web/routes/active/docs/orca/+page.svelte create mode 100644 src/web/routes/active/docs/prefs/+page.svelte 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