Prefs as schema-based core + lowercase App.* surface

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<string, PrefsDimension<TIntent, TEffective>>

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<S>`, `PrefsIntentOf<S>`.
- `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<S>` root option carrying the
  schema. `defineActivePrefs` deleted (prefs is core, not service).
  `lang` / `format` / `frontend` factories migrated to read
  `core.prefs.<dim>` 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) <noreply@anthropic.com>
master
dev 5 months ago
parent 3ca1945dd9
commit 7adf93ca57

@ -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<typeof composeApp>;
function composeApp(options: CreateDatingAppOptions) {
return createActiveApp({
// Prefs is core. Each schema key becomes a typed dimension on
// `App.prefs.<key>` (`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,

@ -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.<dim>.set(value)` supersedes
* `App.prefs.setIntent('<dim>', 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']);

@ -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
<!-- app/+layout.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);
</script>
```

@ -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<TSchema extends AppServiceSchema = AppServiceSchema>(
options: ActiveAppOptions<TSchema> = {}
): ActiveApp<TSchema> {
export function createActiveApp<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
>(options: ActiveAppOptions<TSchema, TPrefsSchema> = {}): ActiveApp<TSchema, TPrefsSchema> {
// ── 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<ActiveAppBusEvents>({
const bus = createSvelteEngineBus<ActiveAppBusEvents>({
...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<TPrefsSchema> = {
logger,
bus,
timers,
orca,
prefs: prefs as ActivePrefs<TPrefsSchema>,
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<TSchema>;
const app = baseApp as ActiveApp<TSchema, TPrefsSchema>;
if (serviceBuilders !== undefined) {
for (const name of Object.keys(serviceBuilders.proxies)) {
@ -112,6 +132,35 @@ export function createActiveApp<TSchema extends AppServiceSchema = AppServiceSch
return app;
}
/**
* Build the prefs engine and its optional storage bridge. When the
* caller omits `options.prefs` entirely, fall back to
* `NEUTRAL_PREFS_SCHEMA` so the core slot is always populated.
*/
function buildPrefs<S extends PrefsSchema>(
options: ActiveAppPrefsOptions<S> | undefined
): {
engine: ActivePrefs<S>;
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<TSchema extends AppServiceSchema = AppServiceSch
* - Services that need to publish App-owned events do so through the
* dedicated typed publishers (`publishAppDisposeStarting`, …), not
* via raw `bus.publish(type, payload)` against an arbitrary string.
*
* Keeping this in a named helper means the rationale stays attached to
* the cast instead of trailing as an inline comment that future edits
* might lose.
*/
function coreForBuilder(
logger: ActiveAppCore['Logger'],
bus: ActiveAppCore['Bus'],
timers: ActiveAppCore['Timers'],
orca: ActiveAppCore['Orca']
logger: ActiveAppCore['logger'],
bus: ActiveAppCore['bus'],
timers: ActiveAppCore['timers'],
orca: ActiveAppCore['orca'],
prefs: ActiveAppCore['prefs']
): CoreServices {
return {
logger,
bus: bus as unknown as EngineBus,
timers,
orca
orca,
prefs
};
}

@ -6,7 +6,7 @@
* republication of module-level events (session, connection, etc.) —
* those republications have been removed. Apps that need to react to
* facts owned by other modules subscribe to those modules' events
* directly via `App.Bus.on(SESSION_EVENT_*, …)` or, more commonly,
* directly via `App.bus.on(SESSION_EVENT_*, …)` or, more commonly,
* register an orca action via a preset.
*/

@ -9,7 +9,7 @@
* factories for the declarative service schema. Loaded only by
* apps that declare services.
* - `$active-app/presets` — orchestration presets registered on
* `App.Orca`. Loaded only by apps that opt into the standard
* `App.orca`. Loaded only by apps that opt into the standard
* reactions.
*
* Splitting the entry points lets the bundler tree-shake each layer
@ -18,7 +18,7 @@
*
* The Svelte-context bridge (`setBus` / `getBus`) lives in `$bus`, not
* here. It is owned by the bus, which is the semantic origin of the
* pattern; App is a consumer that just calls `setBus(App.Bus)` once at
* pattern; App is a consumer that just calls `setBus(App.bus)` once at
* the layout root.
*/
@ -54,6 +54,7 @@ export type {
ActiveAppBusEvents,
ActiveAppCore,
ActiveAppOptions,
ActiveAppPrefsOptions,
ActiveAppServicesIntrospection
} from './types.ts';

@ -6,7 +6,7 @@ import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'cache.clear-on-identity-change';
const TOKEN_CLEARED = 'cache:cleared-on-identity';
export interface CacheClearOnIdentityChangeApp extends Pick<ActiveAppCore, 'Orca'> {
export interface CacheClearOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache: Pick<ActiveCache, 'clear'>;
}
@ -24,7 +24,7 @@ export interface CacheClearOnIdentityChangeApp extends Pick<ActiveAppCore, 'Orca
export function applyCacheClearOnIdentityChange(
App: CacheClearOnIdentityChangeApp
): () => 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],

@ -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<ActiveAppCore, 'Orca'> {
export interface CacheClearOnRevokeApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache: Pick<ActiveCache, 'clear'>;
}
@ -26,7 +26,7 @@ export interface CacheClearOnRevokeApp extends Pick<ActiveAppCore, 'Orca'> {
* 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],

@ -10,7 +10,7 @@ const CLOSE_REASON = 'session-revoked';
/**
* Shape this preset requires from `App`. Only `closeAll()` is needed.
*/
export interface ConnectionsCloseOnRevokeApp extends Pick<ActiveAppCore, 'Orca'> {
export interface ConnectionsCloseOnRevokeApp extends Pick<ActiveAppCore, 'orca'> {
readonly connections: Pick<ActiveConnections, 'closeAll'>;
}
@ -25,7 +25,7 @@ export interface ConnectionsCloseOnRevokeApp extends Pick<ActiveAppCore, 'Orca'>
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],

@ -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<ActiveAppCore, 'Orca'> {
export interface ConnectionsReauthOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly connections: Pick<ActiveConnections, 'reauthenticateAll'>;
}
@ -30,7 +30,7 @@ export interface ConnectionsReauthOnIdentityChangeApp extends Pick<ActiveAppCore
export function applyConnectionsReauthOnIdentityChange(
App: ConnectionsReauthOnIdentityChangeApp
): () => 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],

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

@ -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<ActiveAppCore, 'Orca'> {
export interface PermInvalidateOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly perm: Pick<ActivePerms, 'invalidate'>;
}
@ -20,7 +20,7 @@ export interface PermInvalidateOnIdentityChangeApp extends Pick<ActiveAppCore, '
export function applyPermInvalidateOnIdentityChange(
App: PermInvalidateOnIdentityChangeApp
): () => 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],

@ -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<TUser, TCredential = undefined, TData = undefined>
extends Pick<ActiveAppCore, 'Timers'> {
extends Pick<ActiveAppCore, 'timers'> {
readonly session: ActiveSession<TUser, TCredential, TData>;
}
@ -34,7 +34,7 @@ export function applySessionAutoRefresh<TUser, TCredential = undefined, TData =
): AutoRefreshCleanup {
return withAutoRefresh(App.session, {
...options,
timers: options.timers ?? App.Timers,
now: options.now ?? (() => App.Timers.clock.now())
timers: options.timers ?? App.timers,
now: options.now ?? (() => App.timers.clock.now())
});
}

@ -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<ActiveAppCore, 'Orca'> {
export interface StandardOrcaApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache?: Pick<ActiveCache, 'clear'>;
readonly perm?: Pick<ActivePerms, 'invalidate'>;
readonly connections?: Pick<ActiveConnections, 'reauthenticateAll' | 'closeAll'>;

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

@ -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<string, unknown>)['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
});
},

@ -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.<key>`. Service
* factories stay defensive about which dimensions a given app declares
* — when a dimension is missing, the factory degrades gracefully.
*/
interface PrefsSlot<T> {
get(): T;
onChange(handler: (value: T) => void): () => void;
}
function readSlot<T>(prefs: unknown, key: string): PrefsSlot<T> | undefined {
const slot = (prefs as Record<string, unknown>)[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<T>;
}
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<string>(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<string>(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;

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

@ -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<S extends LangNode> {
* 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<S extends LangNode>(
options: DefineActiveLangOptions<S>
): AppServiceFactory<'lang', readonly ['logger'], readonly ['prefs'], ActiveLang<S>> {
let unsubscribePrefs: (() => void) | undefined;
): AppServiceFactory<'lang', readonly ['logger', 'prefs'], readonly [], ActiveLang<S>> {
let unsubscribe: (() => void) | undefined;
return {
name: 'lang',
coreDependencies: ['logger'],
serviceDependencies: ['prefs'],
coreDependencies: ['logger', 'prefs'],
initMode: 'lazy',
create({ core, services }): ActiveLang<S> {
create({ core }): ActiveLang<S> {
const lang = createActiveLang<S>(
options.schema,
options.defaultLocale ?? 'es',
@ -48,17 +43,12 @@ export function defineActiveLang<S extends LangNode>(
);
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<string, unknown>)['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<S extends LangNode>(
return lang;
},
dispose(instance) {
unsubscribePrefs?.();
unsubscribePrefs = undefined;
unsubscribe?.();
unsubscribe = undefined;
instance.dispose();
}
};

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

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

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

@ -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<S extends PrefsSchema = PrefsSchema> {
readonly logger: EngineLogger;
readonly bus: EngineBus;
readonly timers: ActiveTimers;
readonly orca: EngineOrca;
readonly prefs: ActivePrefs<S>;
}
export type CoreServiceKey = keyof CoreServices;

@ -46,10 +46,10 @@ interface User {
}
interface FakeAppCore {
Logger: EngineLogger;
Bus: EngineBus<Record<string, unknown>>;
Timers: ActiveTimers;
Orca: EngineOrca;
logger: EngineLogger;
bus: EngineBus<Record<string, unknown>>;
timers: ActiveTimers;
orca: EngineOrca;
cache: ActiveCache;
perm: ActivePerms;
connections: ActiveConnections;
@ -96,10 +96,10 @@ function buildApp(): FakeAppCore {
}) as unknown as ActiveSession<User>;
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.

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

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

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

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

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

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

@ -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<User> {
}
interface Core {
Logger: EngineLogger;
Bus: EngineBus<Record<string, unknown>>;
Timers: ActiveTimers;
Orca: EngineOrca;
logger: EngineLogger;
bus: EngineBus<Record<string, unknown>>;
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 },

@ -1,24 +1,33 @@
/**
* Public types for `arts/active-app`.
*
* `ActiveApp<TSchema>` is the composed surface seen by the application:
* `ActiveApp<TSchema, TPrefsSchema>` 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<TSchema>` — 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.<key>` autocomplete and
* to the `intent` / `storage` shapes.
*
* Apps that don't need persistence omit `storage`; apps that do supply
* a `PrefsIntentStorage<TSchema>` (typically a thin adapter over
* `arts/storage`) and an optional `onStorageError` reporter.
*/
export interface ActiveAppPrefsOptions<S extends PrefsSchema> {
readonly schema: S;
readonly environment?: PrefsEnvironment;
readonly intent?: PrefsIntentOf<S>;
readonly storage?: PrefsIntentStorage<S>;
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.<key>`.
* - `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<TSchema extends AppServiceSchema = AppServiceSchema> {
/**
* 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<EngineTimersOptions, 'logger'>;
/**
* Cross-artifact event bus options. App injects `logger` and the
* shared timers `clock` automatically.
*/
bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
/**
* Orca options. App injects `bus`, `timers` and `logger`
* automatically.
*/
orca?: Omit<EngineOrcaOptions, 'bus' | 'timers' | 'logger'>;
/**
* 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<TPrefsSchema>;
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<ActiveAppBusEvents>;
readonly Timers: ActiveTimers;
export interface ActiveAppCore<TPrefsSchema extends PrefsSchema = PrefsSchema> {
readonly logger: EngineLogger;
readonly bus: EngineBus<ActiveAppBusEvents>;
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.<key>`
* with `.get()` / `.set()` / `.clear()` / `.onChange()` verbs.
*/
readonly prefs: ActivePrefs<TPrefsSchema>;
/**
* 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.<serviceName>` exists IFF the service was declared in
* `options.services`. Reading an undeclared name is a TypeScript
* error.
*/
export type ActiveApp<TSchema extends AppServiceSchema = AppServiceSchema> =
ActiveAppCore & ResolveServiceInstances<TSchema> & ActiveAppServicesIntrospection;
export type ActiveApp<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
> = ActiveAppCore<TPrefsSchema> &
ResolveServiceInstances<TSchema> &
ActiveAppServicesIntrospection;

@ -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:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setBus } from '$bus';
setBus(App.Bus);
setBus(App.bus);
</script>
```
@ -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
`<MODULE>_EVENT_*` events directly on `App.Bus` and document them. If a
`<MODULE>_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

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

@ -78,8 +78,8 @@ const Connections = App.createActiveConnections<AppConnections>();
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')
```

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

@ -74,7 +74,7 @@ export interface ActiveCurrencyOptions extends Omit<EngineCurrencyOptions, 'loca
/**
* Shorthand for `rates: createRates({ ...ratesOptions, now })`. When set,
* the active wrapper builds the rates provider for you and threads the
* `clock` injected from `App.Timers` (when wired through
* `clock` injected from `App.timers` (when wired through
* `defineActiveFormat`). Ignored when `rates` is also supplied.
*/
ratesOptions?: Omit<RatesOptions, 'now'>;

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

@ -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. */

@ -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.
*/

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

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

@ -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<string, PrefsDimension<TIntent, TEffective>>`.
> **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);
});
```

@ -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<S>`, addressable as
* `App.prefs.<key>`. The verbs are uniform across every dimension: get
* the effective value, set / clear user intent, listen for changes.
*/
export interface ActivePrefsDimension<TIntent, TEffective = TIntent> {
/** 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<S extends PrefsSchema> {
readonly snapshot: PrefsSnapshot<S>;
readonly effective: PrefsEffectiveOf<S>;
readonly environment: PrefsEnvironment;
readonly intent: Readonly<PrefsIntent>;
readonly intent: PrefsIntentOf<S>;
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<S>`. 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<S>`. 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<S extends PrefsSchema = PrefsSchema> {
readonly kind: typeof PREFS_KIND;
readonly schema: S;
readonly state: ActivePrefsState<S>;
snapshot(): PrefsSnapshot<S>;
environment(): PrefsEnvironment;
intent(): PrefsIntentOf<S>;
effective(): PrefsEffectiveOf<S>;
/**
* Low-level mutator. The recommended public API is
* `App.prefs.<dim>.set(value)` — this stays exposed only for
* adapters (storage bridge, devtools) that operate generically over
* dimension keys.
*/
setIntent<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S>;
/** Low-level mutator; prefer `App.prefs.<dim>.clear()`. */
clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S>;
resetIntent(next?: PrefsIntentOf<S>): PrefsSnapshot<S>;
refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot<S>;
patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot<S>;
subscribe(handler: PrefsChangeHandler<S>): 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<string, PrefsDimension>`) 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.<key>` get the typed
* surface because `App` flows the concrete `TPrefsSchema`.
*/
export type ActivePrefsDimensions<S extends PrefsSchema> = string extends keyof S
? unknown
: {
readonly [K in keyof S]: S[K] extends PrefsDimension<infer TIntent, infer TEffective>
? ActivePrefsDimension<TIntent, TEffective>
: never;
};
export type ActivePrefs<S extends PrefsSchema = PrefsSchema> = ActivePrefsBase<S> &
ActivePrefsDimensions<S>;
export function createActivePrefs<S extends PrefsSchema>(
options: EnginePrefsOptions<S>
): ActivePrefs<S> {
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<PrefsSnapshot>(engine.snapshot());
let snapshotCell = $state<PrefsSnapshot<S>>(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<unknown>(null);
const pendingCell = $state(false);
const lastErrorCell = $state<unknown>(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<S> = {
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<string, ActivePrefsDimension<unknown, unknown>> = {};
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: <K extends keyof S>(key: K, value: unknown) => engine.setIntent(key, value),
clearIntent: <K extends keyof S>(key: K) => engine.clearIntent(key),
resetIntent: (next?: PrefsIntentOf<S>) => engine.resetIntent(next),
refreshEnvironment: (next: PrefsEnvironment) => engine.refreshEnvironment(next),
patchEnvironment: (patch: Partial<PrefsEnvironment>) => engine.patchEnvironment(patch),
subscribe: (handler: PrefsChangeHandler<S>) => engine.subscribe(handler),
dispose: () => {
detachCommit();
engine.dispose();
}
};
return active;
return Object.assign(base, dimensionMembers) as unknown as ActivePrefs<S>;
}
function makeDimension(
engine: ReturnType<typeof createEnginePrefs>,
key: string
): ActivePrefsDimension<unknown, unknown> {
return {
get() {
return (engine.snapshot().effective as Record<string, unknown>)[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<string, unknown>;
if (Object.prototype.hasOwnProperty.call(diff, key)) {
handler(diff[key]);
}
});
},
catalog() {
const dim = engine.schema[key];
return dim?.catalog?.();
}
};
}

@ -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<S extends PrefsSchema>(
engine: EnginePrefs<S>,
overrides: BrowserEnvironmentOverrides = {}
): () => void {
engine.refreshEnvironment(detectBrowserEnvironment(overrides));

@ -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<PrefsIntent | null | undefined>;
save(intent: PrefsIntent): void | Promise<void>;
export interface PrefsIntentStorage<S extends PrefsSchema = PrefsSchema> {
load(): PrefsIntentOf<S> | null | undefined | Promise<PrefsIntentOf<S> | null | undefined>;
save(intent: PrefsIntentOf<S>): void | Promise<void>;
clear(): void | Promise<void>;
}
export type PrefsStorageOp = 'load' | 'save' | 'clear';
export interface PrefsStorageBridgeOptions {
readonly engine: EnginePrefs;
readonly storage: PrefsIntentStorage;
export interface PrefsStorageBridgeOptions<S extends PrefsSchema = PrefsSchema> {
readonly engine: EnginePrefs<S>;
readonly storage: PrefsIntentStorage<S>;
/**
* 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<S>` 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<S extends PrefsSchema>(
options: PrefsStorageBridgeOptions<S>
): 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<string, unknown>)[k] === undefined);
const nextRecord = next as Record<string, unknown>;
const isEmpty = Object.keys(nextRecord).every((k) => nextRecord[k] === undefined);
const op = isEmpty ? 'clear' : 'save';
const run = async (): Promise<void> => {
@ -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);
});

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

@ -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<Currency> {
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
};
}

@ -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<Density> {
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
};
}

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

@ -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<TIntent, TEffective>` 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';

@ -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<Locale> {
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
};
}

@ -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<Locale> {
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
};
}

@ -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<MotionIntent, MotionEffective> {
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
};
}

@ -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<boolean> {
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<T extends string>(
values: readonly T[],
options: { default: T }
): PrefsDimension<T> {
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<string> {
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<number> {
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 };
}
};
}

@ -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<ThemeIntent, ThemeEffective> {
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
};
}

@ -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<Timezone> {
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
};
}

@ -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<UnitSystem> {
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
};
}

@ -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<S>` 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<S>` 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<S extends PrefsSchema>(
options: EnginePrefsOptions<S>
): EnginePrefs<S> {
const schema = options.schema;
let environment: PrefsEnvironment = options.environment ?? {};
let intent: PrefsIntent = options.intent ?? {};
let intent: PrefsIntentOf<S> = 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<PrefsChangeHandler>();
const listeners = new Set<PrefsChangeHandler<S>>();
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<S>,
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<S> {
// 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<S> = {
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<S> = {
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<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S> {
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<string, unknown>)[stringKey];
if (current === result.value) return snapshot;
const next = { ...(intent as Record<string, unknown>), [stringKey]: result.value };
return commit(environment, next as PrefsIntentOf<S>, 'intent:set');
},
clearIntent(key) {
clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S> {
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<string, unknown>)[stringKey] === undefined) return snapshot;
const next = { ...(intent as Record<string, unknown>) };
delete next[stringKey];
return commit(environment, next as PrefsIntentOf<S>, 'intent:clear');
},
resetIntent(next) {
resetIntent(next?: PrefsIntentOf<S>): PrefsSnapshot<S> {
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<string, unknown>);
if (intentEqual(intent, sanitised)) return snapshot;
return commit(environment, sanitised as PrefsIntentOf<S>, 'intent:reset');
},
refreshEnvironment(next) {
refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot<S> {
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<PrefsEnvironment>): PrefsSnapshot<S> {
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<S extends PrefsSchema>(
schema: S,
intent: PrefsIntentOf<S> | undefined
): PrefsIntentOf<S> {
if (intent === undefined) return {} as PrefsIntentOf<S>;
return sanitizeIntent(schema, intent as Record<string, unknown>) as PrefsIntentOf<S>;
}
function buildSnapshot<S extends PrefsSchema>(
schema: S,
environment: PrefsEnvironment,
intent: PrefsIntent,
intent: PrefsIntentOf<S>,
version: number
): PrefsSnapshot {
const effective = resolvePrefs({ capabilities, environment, intent });
): PrefsSnapshot<S> {
const effective = resolvePrefs({ schema, environment, intent }) as PrefsEffectiveOf<S>;
return Object.freeze({
capabilities,
environment,
intent,
effective: Object.freeze(effective),
effective,
version
});
}) as PrefsSnapshot<S>;
}
/**
* 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<PrefsEffective> {
const diff: { -readonly [K in keyof PrefsEffective]?: PrefsEffective[K] } = {};
const keys = Object.keys(next) as Array<keyof PrefsEffective>;
for (const key of keys) {
if (previous[key] !== next[key]) {
(diff[key] as PrefsEffective[typeof key]) = next[key];
function diffEffective<S extends PrefsSchema>(
previous: PrefsEffectiveOf<S>,
next: PrefsEffectiveOf<S>
): Partial<PrefsEffectiveOf<S>> {
const diff: Record<string, unknown> = {};
const previousMap = previous as unknown as Record<string, unknown>;
const nextMap = next as unknown as Record<string, unknown>;
for (const key of Object.keys(nextMap)) {
if (previousMap[key] !== nextMap[key]) {
diff[key] = nextMap[key];
}
}
return diff;
return diff as Partial<PrefsEffectiveOf<S>>;
}
/**
* 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<keyof PrefsIntent>).filter(
(k) => a[k] !== undefined
);
const bKeys = (Object.keys(b) as Array<keyof PrefsIntent>).filter(
(k) => b[k] !== undefined
);
function intentEqual(a: object, b: object): boolean {
const aMap = a as Record<string, unknown>;
const bMap = b as Record<string, unknown>;
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<keyof PrefsEnvironment>;
const bKeys = Object.keys(b) as Array<keyof PrefsEnvironment>;
const aMap = a as Record<string, unknown>;
const bMap = b as Record<string, unknown>;
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<T>(
a: readonly T[] | undefined,
b: readonly T[] | undefined
): boolean {
function arrayEqual<T>(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<T>(
}
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<PrefsIntent[keyof PrefsIntent]>;
}> = [
{ 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)
);
}

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

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

@ -1,86 +0,0 @@
/**
* Capability-source proxies. Each helper turns an `EnginePrefs` into a
* narrow `Source<T>` 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<T>` 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<K extends keyof PrefsEffective>(
prefs: EnginePrefs,
field: K
): Source<PrefsEffective[K]> {
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');
}

@ -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<typeof standardPrefsDimensions>;

@ -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> | void): Promise<void> {
let cleanup!: () => void;
const ready = new Promise<void>((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<PrefsEffective['locale']> = [];
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();
});
});

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

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

@ -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<Record<string, unknown>> = [];
const clears: number[] = [];
let value = initial ? { ...initial } : null;
const storage: PrefsIntentStorage<Schema> = {
load: () => value,
save: (intent) => {
saves.push({ ...(intent as Record<string, unknown>) });
value = { ...(intent as Record<string, unknown>) } 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<Schema> = {
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<Schema> = {
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();
});
});

@ -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<S extends PrefsSchema> {
readonly schema: S;
readonly environment?: PrefsEnvironment;
readonly intent?: PrefsIntent;
readonly intent?: PrefsIntentOf<S>;
}
/**
* 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<S>` so callers can
* chain without an extra `engine.snapshot()` round trip.
*/
export interface EnginePrefs {
export interface EnginePrefs<S extends PrefsSchema = PrefsSchema> {
readonly kind: typeof PREFS_KIND;
readonly schema: S;
snapshot(): PrefsSnapshot;
capabilities(): PrefsCapabilities;
snapshot(): PrefsSnapshot<S>;
environment(): PrefsEnvironment;
intent(): Readonly<PrefsIntent>;
effective(): PrefsEffective;
intent(): PrefsIntentOf<S>;
effective(): PrefsEffectiveOf<S>;
/**
* 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<K extends keyof PrefsIntent>(
key: K,
value: NonNullable<PrefsIntent[K]>
): PrefsSnapshot;
setIntent<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S>;
/**
* 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<K extends keyof PrefsIntent>(key: K): PrefsSnapshot;
clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S>;
/**
* 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<S>): PrefsSnapshot<S>;
/**
* 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<S>;
/**
* 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<PrefsEnvironment>): 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<PrefsEnvironment>): PrefsSnapshot<S>;
subscribe(handler: PrefsChangeHandler): PrefsUnsubscribe;
subscribe(handler: PrefsChangeHandler<S>): PrefsUnsubscribe;
dispose(): void;
}

@ -459,8 +459,8 @@ const Sess = createActiveSession<User, JwtCredential, CartData>({
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`.
---

@ -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. */

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

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

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

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

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

@ -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.
*/

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

@ -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<TIntent,
* TEffective>` that owns its own validator, environment-fed resolver
* and (optionally) sibling-derived value. The engine in `arts/prefs`
* is generic over a `PrefsSchema = Record<string, PrefsDimension>` 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,

@ -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<S extends PrefsSchema>(
input: PrefsResolveInput<S>
): PrefsEffectiveOf<S> {
const { schema, environment, intent } = input;
const out: Record<string, unknown> = {};
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<S>;
}
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<TIntent, TEffective>(
dim: PrefsDimension<TIntent, TEffective>,
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<TIntent, TEffective>(
dim: PrefsDimension<TIntent, TEffective>,
candidateIntent: unknown,
env: PrefsEnvironment,
resolvedSiblings: Readonly<Record<string, unknown>>
): 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<K extends keyof PrefsIntent>(
key: K,
value: PrefsIntent[K],
capabilities: PrefsCapabilities
): PrefsIntent[K] {
if (value === undefined) return undefined;
const r = validateIntentValue(
key,
value as NonNullable<PrefsIntent[K]>,
capabilities
);
function tryValidate<TIntent, TEffective>(
dim: PrefsDimension<TIntent, TEffective>,
value: unknown
): TIntent | undefined {
const r = dim.validate(value);
return r.ok ? r.value : undefined;
}

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

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

@ -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<T> =
| { 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<TIntent, TEffective = TIntent> {
/**
* 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<TIntent>;
/**
* 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<Record<string, unknown>>,
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<string, PrefsDimension<any, any>>;
// ─────────────────────────────────────────────────────────────────────
// 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.<key>.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<S extends PrefsSchema> = {
readonly [K in keyof S]: S[K] extends PrefsDimension<infer _TIntent, infer TEffective>
? 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<S extends PrefsSchema> = {
readonly [K in keyof S]?: S[K] extends PrefsDimension<infer TIntent, infer _TEffective>
? 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<S extends PrefsSchema = PrefsSchema> {
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<S>;
readonly effective: PrefsEffectiveOf<S>;
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<S extends PrefsSchema = PrefsSchema> {
readonly previous: PrefsSnapshot<S>;
readonly next: PrefsSnapshot<S>;
/** Sparse map: `{ locale: 'es-ES' }` when only locale changed. */
readonly effectiveDiff: Partial<PrefsEffective>;
readonly effectiveDiff: Partial<PrefsEffectiveOf<S>>;
readonly cause: PrefsChangeCause;
}
export type PrefsChangeHandler = (event: PrefsChangeEvent) => void;
export type PrefsChangeHandler<S extends PrefsSchema = PrefsSchema> = (
event: PrefsChangeEvent<S>
) => void;
export type PrefsUnsubscribe = () => void;
// ─────────────────────────────────────────────────────────────────────
// Resolver / engine I/O
// Resolver input
// ─────────────────────────────────────────────────────────────────────
export interface PrefsResolveInput {
readonly capabilities: PrefsCapabilities;
export interface PrefsResolveInput<S extends PrefsSchema> {
readonly schema: S;
readonly environment: PrefsEnvironment;
readonly intent: PrefsIntent;
readonly intent: PrefsIntentOf<S>;
}
// ─────────────────────────────────────────────────────────────────────
// 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<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly reason: PrefsValidationFailure };

@ -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<K extends keyof PrefsIntent>(
key: K,
value: NonNullable<PrefsIntent[K]>,
capabilities: PrefsCapabilities
): PrefsValidationResult<NonNullable<PrefsIntent[K]>> {
switch (key) {
case 'language': {
const language = value as Locale;
if (capabilities.languages.includes(language)) {
return { ok: true, value: language as NonNullable<PrefsIntent[K]> };
}
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<PrefsIntent[K]> };
}
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<PrefsIntent[K]> };
}
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<PrefsIntent[K]> };
}
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<PrefsIntent[K]> };
}
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<PrefsIntent[K]> };
}
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<PrefsIntent[K]> };
}
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<PrefsIntent[K]> };
}
}
export function validateIntentValue<TIntent, TEffective>(
dim: PrefsDimension<TIntent, TEffective>,
value: unknown
): PrefsValidationResult<TIntent> {
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<keyof PrefsIntent>) {
const raw = intent[key];
if (raw === undefined) continue;
const result = validateIntentValue(
key,
raw as NonNullable<PrefsIntent[typeof key]>,
capabilities
);
if (result.ok) (out as Record<string, unknown>)[key] = result.value;
export function sanitizeIntent<S extends PrefsSchema>(
schema: S,
intent: Readonly<Record<string, unknown>>
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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;
}

@ -17,7 +17,7 @@ export const Perms = createEnginePerms({
policies,
providers,
compilers,
logger: App.Logger
logger: App.logger
});
```

@ -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');`;
<div class="hero-inner">
<span class="eyebrow">
<span class="dot"></span>
Active framework — v0.0.1
Active framework — 1.0 candidate
</span>
<h1>The runtime that wires your <span class="hl">SvelteKit</span> app together.</h1>
<p class="lead">
A composable set of <strong>fifteen runtime artifacts</strong> —
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 <strong>runtime artifacts</strong> — core App infrastructure,
i18n, preferences, logger, sessions, auth, permissions, cache, storage, HTTP, timers,
formatting, orchestration, realtime connections and a reactive frontend layer.
</p>
<div class="cta">
<a class="btn primary" href="/active/get-started/installation">
@ -222,7 +262,7 @@ App.lang.setLocale('es-MX');`;
<dl class="hero-stats">
<div>
<dt>Artifacts</dt>
<dd>15</dd>
<dd>18</dd>
</div>
<div>
<dt>Bundle</dt>
@ -243,9 +283,9 @@ App.lang.setLocale('es-MX');`;
<section class="quick">
<h2>Quick look</h2>
<p>
Every app starts from <code>$active-app</code>, which wires every artifact and exposes them on
a single <code>App</code> object. Identity, permissions, cache and connections are factories
built from the same <code>App</code>.
Every app starts from <code>$active-app</code>. It always builds the core
<code>Logger</code>, <code>Bus</code>, <code>Timers</code> and <code>Orca</code>, then
declares feature modules as typed services on the same <code>App</code> object.
</p>
<CodeBlock code={composition} lang="ts" title="App composition" />
</section>
@ -294,7 +334,7 @@ App.lang.setLocale('es-MX');`;
/>
<p>
This makes every artifact testable, composable and disposable in the same way — and lets
the <code>aapp</code> composer wire them blindly.
the <code>$active-app</code> composer wire them blindly.
</p>
</section>

@ -23,7 +23,7 @@
</ul>
<h2>Outline</h2>
<p>The full page will follow the same shape as <a href="/active/docs/aapp">aapp</a> and <a href="/active/docs/lang">lang</a>:</p>
<p>The full page will follow the same shape as <a href="/active/docs/aapp">$active-app</a> and <a href="/active/docs/lang">lang</a>:</p>
<ol>
<li>Overview — what the artifact is for and what it explicitly does not do.</li>
<li>Quick start — minimum useful example.</li>

@ -7,7 +7,7 @@
<a class="brand" href="/active" aria-label="Active home">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">active</span>
<span class="brand-version" aria-label="version">0.0.1</span>
<span class="brand-version" aria-label="version">1.0</span>
</a>
<nav class="primary" aria-label="Primary">

@ -147,7 +147,7 @@ const artifactApis = {
notes: 'Auth never owns the session cookie directly.'
},
{
name: 'ports.logr / ports.timer / ports.crypto',
name: 'ports.logger / ports.timer / ports.crypto',
purpose: 'Logging, clock and crypto primitives.',
notes: 'No direct Date.now() or random string shortcuts in flows.'
},
@ -224,7 +224,7 @@ const artifactApis = {
{
name: 'logger',
purpose: 'Shared Logger contract.',
notes: 'App injects App.Logger into App.Bus.'
notes: 'App injects App.logger into App.bus.'
},
{
name: 'clock',
@ -259,7 +259,7 @@ const artifactApis = {
{
name: 'source',
purpose: 'Publisher identity.',
notes: 'Defaults to buss; app translators use their module source.'
notes: 'Defaults to bus; module publishers can provide their own source.'
},
{
name: 'correlationId / causationId',
@ -320,7 +320,7 @@ const artifactApis = {
{
name: 'onChange(listener)',
purpose: 'Subscribe to lifecycle changes.',
notes: 'Local lifecycle stream. App.Bus integration uses safe sess.* events when bus is injected.'
notes: 'Local lifecycle stream. App.bus integration uses safe session.* events when bus is injected.'
},
{ name: 'dispose()', purpose: 'Stop timers/listeners.', notes: 'Called by App.dispose().' }
]
@ -352,7 +352,7 @@ const artifactApis = {
{
name: 'bus',
purpose: 'Optional EventPublisher<SessEventMap>.',
notes: 'App injects App.Bus so sess.* events can be translated to public app.* events.'
notes: 'App injects App.bus so session.* events can be published directly; cross-module reactions are handled by Orca presets.'
},
{
name: 'broadcastChannel',
@ -687,7 +687,7 @@ const artifactApis = {
{
name: 'hooks',
purpose: 'Composable request/result middleware.',
notes: 'No direct coupling to sess/auth/perm.'
notes: 'No direct coupling to session/auth/perm.'
},
{ name: 'logger', purpose: 'Shared Logger contract.', notes: 'Injected by App.' }
]
@ -1210,7 +1210,7 @@ const artifactApis = {
{
name: 'computeBackoffDelay(options)',
purpose: 'Shared exponential/jitter delay.',
notes: 'Used by conn reconnect and other retry loops.'
notes: 'Used by connection reconnect and other retry loops.'
},
{
name: 'DEFAULT_BACKOFF_*',
@ -1387,12 +1387,12 @@ export const artifactDocs = {
overview: [
'Auth is split into a shared language package, a server-authoritative engine and an active Svelte client. The server engine owns identity proof, CSRF, password/recovery flows, OAuth/MFA primitives, device methods and security events. The active client currently exposes current, password, recovery, email verification, sign-out and device methods over HTTP.',
'Use Auth when the app needs to sign users in or out, load the current actor, request verification or reset flows, and bind that identity proof to session state. Do not use Auth to decide permissions or to store long-lived credentials in the browser.',
'In an App composition, Auth receives App.http for route calls, App.cache for invalidation and App.Logger for diagnostics.'
'In an App composition, Auth receives App.logger from the core and talks to server auth routes through the HTTP client supplied in its options. Cache invalidation is not wired inside Auth; it belongs to Orca presets.'
],
dynamics: [
'The server creates an EngineAuth with ports. Those ports are the real integration points: store persists auth records, actors maps credentials to actor refs, sess starts or ends sessions, cach clears identity-scoped data, and logr records security events.',
'The server creates an EngineAuth with ports. Those ports are the real integration points: store persists auth records, actors maps credentials to actor refs, session starts or ends sessions, cache clears identity-scoped data, and logger records security events.',
'The browser creates ActiveAuth only as a reflector. It loads /current, sends CSRF-protected commands to server routes, updates current after successful responses and emits local state changes for UI. A protected server action must never trust ActiveAuth state.',
'The normal request path is: server hook resolves current auth, page load serializes a safe AuthCurrentView, ActiveAuth hydrates that snapshot, user triggers sign-in/out, server mutates session, and app-event consumers react only when their own auto*On options opt in.'
'The normal request path is: server hook resolves current auth, page load serializes a safe AuthCurrentView, ActiveAuth hydrates that snapshot, user triggers sign-in/out, server mutates session, and any cross-module reactions run only when the app registers the corresponding Orca presets.'
],
commonMistakes: [
{
@ -1440,7 +1440,7 @@ await App.auth.signOut();`
name: 'createEngineAuth(options)',
purpose: 'Creates the server-side authority for auth flows.',
notes:
'Lives in $svrs/auth and receives store, actors, sess, cach, logger, crypto and hasher ports.'
'Lives in $svrs/auth and receives store, actors, session, cache, logger, crypto and hasher ports.'
},
{
name: 'createActiveAuth(options)',
@ -1468,7 +1468,7 @@ await App.auth.signOut();`
code: `// server
const Auth = createEngineAuth({
security,
ports: { store, actors, sess, cach, logger, timer, crypto, passwordHasher }
ports: { store, actors, sess, cache, logger, timer, crypto, passwordHasher }
});
export const GET = Auth.handlers.current;
@ -1526,9 +1526,9 @@ const App = createActiveApp({
store,
actors,
sess,
cach,
logger: App.Logger,
timer: App.Timers,
cache,
logger,
timer: App.timers,
crypto,
passwordHasher,
mailer
@ -1621,19 +1621,19 @@ const App = createActiveApp({
title: 'Bus',
alias: '$bus',
summary:
'Typed event engine used by App.Bus for cross-artifact facts. Pure contracts live in $libs/bus; the engine implementation lives in $bus.',
'Typed event engine used by App.bus for cross-artifact facts. Pure contracts live in $libs/bus; the engine implementation lives in $bus.',
factories: ['createEngineBus'],
dependsOn: ['$libs/logger (Logger interface, optional)'],
layer: 'EngineBus',
status: {
variant: 'tip',
title: 'Two-layer split',
body: 'Modules import interfaces, error classes, SILENT_BUS and helpers from $libs/bus. The composition root (aapp) and tests import the createEngineBus implementation from $bus. Modules must never import from $bus directly — that mirrors the Logger / SILENT_LOGGER pattern in $libs/logger.'
body: 'Modules import interfaces, error classes, SILENT_BUS and helpers from $libs/bus. The composition root ($active-app) and tests import the createEngineBus implementation from $bus. Modules must never import from $bus directly — that mirrors the Logger / SILENT_LOGGER pattern in $libs/logger.'
},
overview: [
'Bus is the low-level event engine. It owns typed subscriptions, envelopes, listener error handling, listener leak warnings and disposal.',
'The contract surface (EngineBus, EventPublisher, BusEnvelope, BusListener, error classes, SILENT_BUS, generic constants) lives in $libs/bus as pure types and helpers — no runtime state. The engine factory createEngineBus() lives in $bus and imports its types from $libs/bus.',
'The only event App owns is APP_EVENT_DISPOSE_STARTING (in arts/active-app/events.ts). Every other public event is owned by its module — SESSION_EVENT_IDENTITY_CHANGED, SESSION_EVENT_REVOKED, etc. Modules publish their own events directly on App.Bus.',
'The only event App owns is APP_EVENT_DISPOSE_STARTING (in arts/active-app/events.ts). Every other public event is owned by its module — SESSION_EVENT_IDENTITY_CHANGED, SESSION_EVENT_REVOKED, etc. Modules publish their own events directly on App.bus.',
'Cross-module reactions are explicit: cache.clear / perm.invalidate / connection.reauth on identity change live as orca actions registered through applyStandardOrca(App) or the cherry-picked apply* presets in $active-app/presets. The bus stays inert — it carries events; orca runs reactions.'
],
dynamics: [
@ -1646,7 +1646,7 @@ const App = createActiveApp({
{
name: 'importing from $bus in module code',
purpose: '$bus exposes the concrete engine; module code must depend only on the contract layer.',
notes: 'Import EngineBus, EventPublisher, BusSubscription, BusEnvelope, SILENT_BUS, etc. from $libs/bus. Only aapp and tests touch $bus.'
notes: 'Import EngineBus, EventPublisher, BusSubscription, BusEnvelope, SILENT_BUS, etc. from $libs/bus. Only $active-app and tests touch $bus.'
},
{
name: 'putting secrets in app events',
@ -1661,12 +1661,12 @@ const App = createActiveApp({
{
name: 'using bus for private in-module events',
purpose: 'It creates needless coupling and noise.',
notes: 'Keep private event emitters inside the artifact; use App.Bus for cross-artifact facts.'
notes: 'Keep private event emitters inside the artifact; use App.bus for cross-artifact facts.'
},
{
name: 'creating createEngineBus() inside modules',
purpose: 'It fragments the event graph and makes cross-artifact behavior invisible.',
notes: 'Use App.Bus in application code; modules should accept an injected bus/publisher.'
notes: 'Use App.bus in application code; modules should accept an injected bus/publisher.'
},
{
name: 'publishing inline event strings',
@ -1675,11 +1675,11 @@ const App = createActiveApp({
}
],
quickStart: {
title: 'Central App.Bus',
title: 'Central App.bus',
code: `import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
// Direct subscription — UI-style reactions
const sub = App.Bus.on(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
const sub = App.bus.on(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
console.log(event.payload.identity.to);
});
@ -1706,7 +1706,7 @@ sub.unsubscribe();`
notes: 'Importable only from $bus (the artifact). Used by App and tests; never inside an artifact module.'
},
{
name: 'App.Bus',
name: 'App.bus',
purpose: 'Application bus instance, always-present.',
notes: 'Created by createActiveApp with Logger and Timers.clock injected. Modules receive it through their factory options.'
}
@ -1718,7 +1718,7 @@ sub.unsubscribe();`
body: [
'libs/bus owns the pure contract: interfaces, generic constants, error classes, SILENT_BUS, helpers. Nothing here has runtime state. Module code (cache, session, perm, connection, …) imports types from $libs/bus exclusively — that is the rule.',
'arts/bus owns the engine implementation (createEngineBus, createSvelteEngineBus) and the Svelte-context bridge (setBus / getBus, exported from $bus). The composition root (active-app) and tests import the engine from $bus; nothing else does. The split mirrors the Logger / SILENT_LOGGER pattern in $libs/logger.',
'A module that needs to publish or subscribe accepts a bus through its factory options, typed against the interface from $libs/bus. The App service builder passes App.Bus when constructing services that declare bus as a coreDependency; otherwise the module falls back to SILENT_BUS so the publish path stays unconditional.'
'A module that needs to publish or subscribe accepts a bus through its factory options, typed against the interface from $libs/bus. The App service builder passes App.bus when constructing services that declare bus as a coreDependency; otherwise the module falls back to SILENT_BUS so the publish path stays unconditional.'
],
table: [
{
@ -1744,7 +1744,7 @@ sub.unsubscribe();`
],
code: {
title: 'Canonical module factory pattern',
code: `// arts/sess/types.ts
code: `// arts/session/types.ts
import type { EventPublisher } from '$libs/bus';
export interface EngineSessionOptions<TUser, TCredential, TData> {
@ -1753,7 +1753,7 @@ export interface EngineSessionOptions<TUser, TCredential, TData> {
// ...
}
// arts/sess/engine-session.ts
// arts/session/engine-session.ts
import { SILENT_BUS } from '$libs/bus';
export function createEngineSession(options: EngineSessionOptions) {
@ -1767,8 +1767,8 @@ export function createEngineSession(options: EngineSessionOptions) {
title: 'Naming Convention — Scoped event values',
body: [
'Every event constant value across the framework is scoped with the artifact prefix to disambiguate aggregated logs, devtools and any future cross-bus serialization. A bare "delete" or "hit" leaves observers guessing which module emitted it; "cache.delete" or "cache.hit" does not.',
'Format: <artifact>.<concept> with snake_case for compound terms inside a level (cach.stale_if_error) and dots for hierarchy (cach.refresh.start). Same convention auth has used since v0 with AUTH_EVENT_NAMES.',
'Method labels passed to ensureLive(method) follow the same rule: sess.adopt, cach.invalidate, auth.signOut. Bare names like "adopt" or "invalidate" never appear in error messages.'
'Format: <artifact>.<concept> with snake_case for compound terms inside a level (cache.stale_if_error) and dots for hierarchy (cache.refresh.start). Same convention auth has used since v0 with AUTH_EVENT_NAMES.',
'Method labels passed to ensureLive(method) follow the same rule: session.adopt, cache.invalidate, auth.signOut. Bare names like "adopt" or "invalidate" never appear in error messages.'
],
table: [
{
@ -1792,7 +1792,7 @@ export function createEngineSession(options: EngineSessionOptions) {
notes: '"session.changed", "session.identity.changed", "session.revoked", …'
},
{
name: 'EVENT_* (sess lifecycle)',
name: 'EVENT_* (session lifecycle)',
purpose: 'Discriminant values inside SessionChange payloads.',
notes: '"session.lifecycle.initial", "session.lifecycle.adopted", "session.lifecycle.revoked", …'
},
@ -1802,9 +1802,9 @@ export function createEngineSession(options: EngineSessionOptions) {
notes: '"auth.sign_in.succeeded", "auth.csrf.issued", "auth.password.changed", …'
},
{
name: 'APP_EVENT_*',
purpose: 'Public app contract events.',
notes: '"app.user.identity.changed", "app.tenant.switched", …'
name: 'APP_EVENT_DISPOSE_STARTING',
purpose: 'The App-owned lifecycle event.',
notes: 'Published once at the start of App.dispose(); other lifecycle events belong to their owning modules.'
},
{
name: 'PERM_METHOD_* / CACHE_METHOD_* / AUTH_METHOD_* / ENGINE_METHOD_*',
@ -1829,7 +1829,7 @@ export function createEngineSession(options: EngineSessionOptions) {
{
title: 'Centralization rule',
body: [
'There is one app-level bus per application: App.Bus, built by createActiveApp. Modules do not call createEngineBus() for their own private island. They accept an injected bus, EventPublisher or EventSubscriber from the composition root.',
'There is one app-level bus per application: App.bus, built by createActiveApp. Modules do not call createEngineBus() for their own private island. They accept an injected bus, EventPublisher or EventSubscriber from the composition root.',
'createEngineBus() remains public because App, isolated services and unit tests need to build a bus engine, but it is not the normal usage pattern inside framework modules.'
]
},
@ -1903,7 +1903,7 @@ const Chat = App.connections.createConnection('chat', {
// Example transition: login, SSR hydration or actor switch received from server.
App.session.adoptServer(nextSessionFromServer);
// 1. App.session updates state, then publishes SESSION_EVENT_IDENTITY_CHANGED on App.Bus.
// 1. App.session updates state, then publishes SESSION_EVENT_IDENTITY_CHANGED on App.bus.
// 2. Orca runs the registered actions in a single trace:
// - cache-clear-on-identity -> App.cache.clear()
// - perm-invalidate-on-identity -> App.perm.invalidate()
@ -1917,19 +1917,19 @@ App.session.adoptServer(nextSessionFromServer);
],
tests: [
{
name: 'src/arts/buss/test',
name: 'src/arts/bus/test',
purpose: 'Bus core behavior.',
notes: 'Publish, async publish, once, onAny, listener errors and disposal.'
},
{
name: 'src/arts/aapp/test/active-app.test.ts',
purpose: 'App translator behavior.',
notes: 'Session changes publish public identity events and dispose publishes starting event.'
name: 'src/arts/active-app/test/ecosystem-orca.test.ts',
purpose: 'App/Orca event behavior.',
notes: 'Session events are consumed by Orca presets rather than republished as App shadows.'
},
{
name: 'src/arts/aapp/test/ecosystem.integration.test.ts',
name: 'src/arts/active-app/test/presets.test.ts',
purpose: 'Cross-artifact reactions.',
notes: 'Cache, Perms and Connections react only when their consumer options opt in.'
notes: 'Cache, Perms and Connections react only when orca presets are registered.'
}
]
},
@ -1946,7 +1946,7 @@ App.session.adoptServer(nextSessionFromServer);
overview: [
'Session is not authentication. It does not verify passwords, OAuth callbacks or permissions. It keeps continuity once another layer has established identity.',
'The session shape has three slots: user, credential and data. User is identity, credential is how the client can refresh or authenticate to the server, and data is session-scoped application state such as tenantId or cartId.',
'The engine is deterministic and testable: refresh can be deduped, storage is pluggable, timers can be injected and lifecycle changes can emit safe sess.* events when a bus is injected.'
'The engine is deterministic and testable: refresh can be deduped, storage is pluggable, timers can be injected and lifecycle changes can emit safe session.* events when a bus is injected.'
],
dynamics: [
'Session starts from a trusted source: SSR data, an auth success response or a refresh callback. adoptServer() is for data already validated on the server; adopt() validates client-provided values through configured schemas.',
@ -2078,21 +2078,21 @@ App.session.adoptServer(data.session);`
{
title: 'Bus integration',
body: [
'The session art publishes its own SESSION_EVENT_* events on App.Bus directly. There are no longer any republished APP_EVENT_* shadows; the canonical events are the module ones.',
'The session art publishes its own SESSION_EVENT_* events on App.bus directly. There are no longer any republished APP_EVENT_* shadows; the canonical events are the module ones.',
'Cross-module reactions (cache.clear on revoke, perm.invalidate on identity change, connection reauth) live as orca actions registered through applyStandardOrca(App) or the cherry-picked apply* presets in $active-app/presets.'
]
}
],
tests: [
{
name: 'src/arts/sess/test',
name: 'src/arts/session/test',
purpose: 'Lifecycle and integrations.',
notes: 'Refresh, revoke, actor metadata, HTTP and auto-refresh.'
},
{
name: 'src/arts/aapp/test/ecosystem.integration.test.ts',
purpose: 'Cross-module bus bridge.',
notes: 'Session events become app identity events and opted-in consumers react.'
name: 'src/arts/active-app/test/ecosystem-orca.test.ts',
purpose: 'Cross-module orchestration.',
notes: 'Session events drive orca presets for cache, permissions and connections.'
},
{
name: '/test/sess',
@ -2388,7 +2388,7 @@ const Perms = createEnginePerms({
{
title: 'Creation and adapter wiring',
body: [
'App.cache is always present and defaults to an in-memory adapter. That is safe for first use, tests and local UI state, but production private data should configure scopeResolver and an intentional adapter strategy.',
'When cache is declared with defineActiveCache(), App.cache is an ActiveCache service. Omitting adapter uses the in-memory default, which is fine for first use, tests and local UI state; production private data should configure scopeResolver and an intentional adapter strategy.',
'Server-side cache roots should be created from $svrs/cache. Client active cache roots are UI helpers and should not become the source of truth for permission-sensitive data.'
],
code: {
@ -2402,7 +2402,7 @@ const Perms = createEnginePerms({
actorId: Sess.current?.user?.id,
permissionHash: Perms.snapshot().version
}),
logger: App.Logger
logger
});`
}
},
@ -2528,7 +2528,7 @@ applyStandardOrca(App);
notes: 'Keys, policies, query, mutation, invalidation and refresh.'
},
{
name: 'src/arts/cach/test',
name: 'src/arts/cache/test',
purpose: 'Active wrapper.',
notes: 'Reactive entries and operation state.'
},
@ -2595,7 +2595,7 @@ const Storage = createActiveStorage({
namespace: 'app'
});
// In an application root, App.storage is already an ActiveStorage root.
// In an application root, App.storage exists when storage is declared.
const draft = App.storage.entry('profile-draft', () => ({
name: '',
bio: ''
@ -2625,6 +2625,11 @@ const locale = App.storage.entry('locale', 'es', {
name: 'createActiveStorage(options)',
purpose: 'Root factory: creates a reactive ActiveStorage.',
notes: 'Wraps EngineStorage and adds reactive entries.'
},
{
name: 'defineActiveStorage(options)',
purpose: 'Service factory for App.storage.',
notes: 'Declared under createActiveApp({ services }).'
}
],
api: artifactApis.stor,
@ -2634,14 +2639,14 @@ const locale = App.storage.entry('locale', 'es', {
body: [
'Storage has one root and many entries. The root is created with createEngineStorage() or createActiveStorage(); entries are created through Storage.entry(key, defaults, options?).',
'Adapters are not roots. localAdapter, sessionAdapter, cookieAdapter and createMemoryAdapter() implement SyncStorageAdapter. They decide where strings are stored; the root decides namespaces, entry registry, diagnostics and lifecycle.',
'aapp creates App.storage internally with createActiveStorage(). Feature code should normally use App.storage.entry(...). Create your own root only in tests, SSR helpers or isolated subsystems.'
'Inside App, storage is an opt-in service declared with defineActiveStorage(). Feature code should use App.storage.entry(...) only when that service exists. Create your own root in tests, SSR helpers or isolated subsystems.'
],
code: {
title: 'Root vs adapter',
code: `const Storage = createActiveStorage({
adapter: localAdapter, // backend used by default
namespace: 'app', // root-level key prefix
logger: App.Logger
logger: App.logger
});
const theme = Storage.entry('theme', 'base');
@ -2728,22 +2733,22 @@ const locale = Storage.entry('locale', 'es', {
}
},
{
title: 'Frontend Persistence',
title: 'Preference Persistence',
body: [
'aapp uses Storage to persist Frontend preferences when frontend.persist is enabled. fend owns preference semantics; aapp only bridges them to storage entries.'
'Frontend no longer persists preferences directly. User intent belongs to prefs (now part of the core); persist it by passing a storage adapter into the createActiveApp({ prefs: { storage } }) option, or by calling createPrefsStorageBridge() yourself.'
]
}
],
tests: [
{
name: 'src/arts/stor/test',
name: 'src/arts/storage/test',
purpose: 'Entries, adapters and envelopes.',
notes: 'TTL, migrate, raw, validation, sync.'
},
{
name: 'src/arts/aapp/test/storage-integration.test.ts',
name: 'src/arts/active-app/test/service-factories.test.ts',
purpose: 'App integration.',
notes: 'Frontend persistence and adapter overrides.'
notes: 'defineActiveStorage wiring and service schema behavior.'
},
{
name: '/test/stor',
@ -2806,7 +2811,7 @@ const locale = Storage.entry('locale', 'es', {
if (response.ok) {
console.log(response.value);
} else {
App.Logger.warn('http', 'project request failed', { context: response });
App.logger.warn('http', 'project request failed', { context: response });
}`
},
factoryRows: [
@ -2822,8 +2827,8 @@ if (response.ok) {
},
{
name: 'App.http',
purpose: 'App-wired engine.',
notes: 'Injects App.Logger and configured fetch/baseUrl.'
purpose: 'Schema-declared HTTP engine via defineEngineHttp.',
notes: 'The App builder injects App.logger when the service is declared.'
}
],
api: artifactApis.http,
@ -2831,7 +2836,7 @@ if (response.ok) {
{
title: 'Creation and request scoping',
body: [
'App.http is the normal browser/client client. On the server, create a scoped child with event.fetch so SvelteKit cookies, internal routes and platform behavior are preserved.',
'When http is declared with defineEngineHttp(), App.http is the normal browser/client client. On the server, create a scoped child with event.fetch so SvelteKit cookies, internal routes and platform behavior are preserved.',
'Do not mutate a global Http instance with request-specific headers. Use with() to create a child client for one request, tenant or backend integration.'
],
code: {
@ -2891,7 +2896,7 @@ if (response.ok) {
notes: 'Retry, timeout, schemas, hooks and tagged errors.'
},
{
name: 'src/arts/sess/test/http-integration.test.ts',
name: 'src/arts/session/test',
purpose: '401 rescue.',
notes: 'Session refresh integration.'
},
@ -2921,7 +2926,7 @@ if (response.ok) {
layer: 'EngineFormat / ActiveFormat',
overview: [
'Format centralizes everything that depends on locale but is not text translation: numeric separators, currency, units and date/time conventions.',
'It deliberately does not depend on Lang. Both consume the same LocaleSource when composed through App, so changing App locale updates translations and formats from one source of truth.',
'It deliberately does not depend on Lang. When composed through App, Format prefers prefs.effective.locale; Lang prefers prefs.effective.language. Apps that do not declare prefs can still wire an explicit LocaleSource or fall back to Lang.',
'Each submodule can run as an engine or active wrapper. Auto values derive from locale until the user sets an explicit override.'
],
dynamics: [
@ -2953,7 +2958,7 @@ if (response.ok) {
],
quickStart: {
title: 'App formats',
code: `App.lang.setLocale('es-AR');
code: `App.prefs.locale.set('es-AR');
App.format.numbers.format(1234.5);
App.format.currency.getCurrency(); // ARS
@ -2982,12 +2987,13 @@ App.format.dates.getDateOrder();`
{
title: 'Creation and locale source',
body: [
'When Format is created through App, it receives a LocaleSource backed by App.lang. That is the intended wiring: one locale change updates translations, numbers, currency, units, dates and Frontend direction.',
'When Format is created through App, it receives its LocaleSource from core App.prefs. That keeps regional formatting locale separate from translation language while still using one preference snapshot.',
'Create standalone sub-engines only when a non-UI service needs one formatting domain. UI code should prefer App.format so auto/manual state stays consistent.'
],
code: {
title: 'App-owned locale propagation',
code: `App.lang.setLocale('es-AR');
code: `App.prefs.language.set('es');
App.prefs.locale.set('es-AR');
App.lang.t('common.ok');
App.format.currency.getCurrency(); // ARS
@ -3042,14 +3048,14 @@ Format.currency.getCurrency(); // USD, explicit user choice`
],
tests: [
{
name: 'src/arts/fmts/test',
name: 'src/arts/format/test',
purpose: 'Formatting engines.',
notes: 'Numbers, currency, units, dates and auto-state.'
},
{
name: 'src/arts/aapp/test/active-app.test.ts',
purpose: 'Locale propagation.',
notes: 'App locale updates Format.'
name: 'src/arts/active-app/test/prefs-consumer-wiring.test.ts',
purpose: 'Prefs propagation.',
notes: 'Core Prefs drives Format locale when the Format service is declared.'
},
{
name: '/test/fmts',
@ -3071,12 +3077,12 @@ Format.currency.getCurrency(); // USD, explicit user choice`
overview: [
'Frontend is not a component system. It owns global presentation preferences and writes stable attributes to the configured DOM target.',
'dir, mode and reducedMotion can be auto. theme, density and reducedSound are explicit preferences. The same auto/manual dynamic used by Format applies here.',
'When built through App, Frontend consumes App.lang as LocaleSource and App.dom as the DOM writer.'
'When built through App, Frontend consumes core App.prefs plus optional App.dom and App.lang services. Prefs drives mode, density, motion and direction; Lang remains a fallback locale source only when the preference snapshot has no effective locale.'
],
dynamics: [
'Frontend reads locale and environment preferences, resolves auto-capable values, and writes the result to DOM attributes through Dom.apply(). Components then style against those attributes instead of each component recalculating theme, direction or density.',
'The auto/manual dynamic matches Format. While dir/mode/reducedMotion are auto, locale or media-query changes can update them. Once the user sets a value explicitly, later auto sources stop overriding it until clearX() is called.',
'Persistence is not owned by Frontend. Frontend emits preference changes; aapp can bridge selected keys to Storage when frontend.persist is configured.'
'Persistence is not owned by Frontend. User intent belongs to prefs, and persistence is handled by the prefs storage bridge or application code.'
],
commonMistakes: [
{
@ -3104,11 +3110,11 @@ Format.currency.getCurrency(); // USD, explicit user choice`
title: 'Direction and theme',
code: `const Frontend = App.frontend;
App.lang.setLocale('ar');
App.prefs.language.set('ar');
Frontend.getDir(); // rtl while dir is auto
Frontend.setDir('ltr'); // manual override
App.lang.setLocale('ar-EG');
App.prefs.language.set('ar');
Frontend.getDir(); // ltr
Frontend.clearDir();
@ -3122,8 +3128,8 @@ Frontend.getDir(); // rtl`
},
{
name: 'App.frontend',
purpose: 'Always-present App root.',
notes: 'Wired to App.lang locale and App.dom.'
purpose: 'Schema-declared frontend service.',
notes: 'Wired to core App.prefs and optional App.dom/App.lang services when they exist.'
}
],
api: artifactApis.fend,
@ -3131,22 +3137,25 @@ Frontend.getDir(); // rtl`
{
title: 'Creation and app wiring',
body: [
'Frontend should normally be created by App. App injects Lang as the locale source, Dom as the writer and Storage when persistence is enabled.',
'Frontend should normally be declared as an App service. The factory reads Dom from the service schema and Prefs from the core (always present).',
'Create ActiveFrontend directly only for tests or embedded widgets that intentionally own their own DOM target.'
],
code: {
title: 'App-wired frontend',
code: `const App = createActiveApp({
lang: { schema, defaultLocale: 'es' },
frontend: {
theme: 'base',
mode: 'auto',
dir: 'auto',
persist: { keys: ['theme', 'mode', 'density'] }
prefs: { capabilities, environment },
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es' }),
dom: defineActiveDom(),
frontend: defineActiveFrontend({
theme: 'base',
mode: 'auto',
dir: 'auto'
})
}
});
App.lang.setLocale('ar');
App.prefs.language.set('ar');
App.frontend.getDir(); // rtl while dir remains auto`
}
},
@ -3168,15 +3177,19 @@ App.frontend.getDir(); // rtl while dir remains auto`
{
title: 'Persisting Preferences',
body: [
'aapp can persist Frontend preferences through Storage. fend decides how to read user intent; aapp only bridges preferences to storage entries.'
'Frontend no longer owns preference persistence. User intent belongs to prefs; persist it by passing a storage adapter into the core prefs option, or by wiring createPrefsStorageBridge() yourself.'
],
code: {
title: 'Persist preferences',
code: `const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'app' },
frontend: {
theme: 'base',
persist: { keys: ['theme', 'mode', 'density'] }
prefs: {
capabilities,
environment,
storage: prefsIntentStorage
},
services: {
storage: defineActiveStorage({ adapter: localAdapter, namespace: 'app' }),
frontend: defineActiveFrontend({ theme: 'base' })
}
});`
}
@ -3184,14 +3197,14 @@ App.frontend.getDir(); // rtl while dir remains auto`
],
tests: [
{
name: 'src/arts/fend/test',
name: 'src/arts/frontend/test',
purpose: 'ActiveFrontend behavior.',
notes: 'DOM attrs, auto/manual and OS preferences.'
},
{
name: 'src/arts/aapp/test/storage-integration.test.ts',
purpose: 'Persistence bridge.',
notes: 'Storage seeding and write-back.'
name: 'src/arts/active-app/test/prefs-consumer-wiring.test.ts',
purpose: 'Prefs bridge.',
notes: 'Core Prefs drives Frontend mode, density, motion and direction when the Frontend service is declared.'
},
{
name: '/test/fend',
@ -3244,7 +3257,7 @@ App.frontend.getDir(); // rtl while dir remains auto`
],
quickStart: {
title: 'Responsive value and attrs',
code: `const Dom = App.dom;
code: `const Dom = App.dom; // when dom is declared as an App service
const size = Dom.resolve({ base: 'compact', md: 'comfortable' });
@ -3264,8 +3277,8 @@ Dom.apply({
},
{
name: 'App.dom',
purpose: 'Always-present App root.',
notes: 'Shared by Frontend and consumers.'
purpose: 'Schema-declared DOM service.',
notes: 'Shared by Frontend and consumers when declared.'
}
],
api: artifactApis.adom,
@ -3273,7 +3286,7 @@ Dom.apply({
{
title: 'Creation and target ownership',
body: [
'App.dom is the shared DOM service for the application. It should own viewport tracking, responsive resolution and global writes that other artifacts depend on.',
'When dom is declared with defineActiveDom(), App.dom is the shared DOM service for the application. It should own viewport tracking, responsive resolution and global writes that other artifacts depend on.',
'When using createActiveDom() directly, decide the target boundary explicitly: document-level app shell, an embedded widget root, or a test DOM. Do not let unrelated feature modules write global attributes independently.'
],
code: {
@ -3330,7 +3343,7 @@ const layout = Dom.resolve({ base: 'stack', md: 'split' });`
notes: 'Viewport, attrs, scroll and responsive resolution.'
},
{
name: 'src/arts/fend/test',
name: 'src/arts/frontend/test',
purpose: 'Frontend integration.',
notes: 'Frontend applies attrs through Dom.'
},
@ -3352,9 +3365,9 @@ const layout = Dom.resolve({ base: 'stack', md: 'split' });`
dependsOn: ['$lang (optional)', '$logger (optional)', '$libs/days', '$libs/color'],
layer: 'EngineSium',
overview: [
'Sium is page-scoped by design. Forms live in pages and features, so App exposes createSiumEngine() instead of keeping a global validator alive for every route.',
'Sium is page/feature scoped by design. Forms live in pages and features, so use createEngineSium() directly or declare sium with defineEngineSium() only where the App needs a shared validator service.',
'The engine creates schemas, validates values, returns structured issues and carries metadata for UI generation. It supports Standard Schema interoperability.',
'When created from App, Sium receives App.lang and App.Logger. If Lang is not provided, its local resolver is only a fallback.'
'When declared through App, Sium receives App.logger from the core and App.lang when the lang service exists. If Lang is not provided, its local resolver is only a fallback.'
],
dynamics: [
'Create a Sium engine close to the form or feature that needs it. Define schemas once, then call validate() for submitted values or Standard Schema consumers such as HTTP body validation.',
@ -3477,7 +3490,7 @@ const result = await Sium.validate(ProfileSchema, formValue);`
notes: 'Validation, pipes, metadata and translations.'
},
{
name: 'src/arts/aapp/test/create-sium-engine.test.ts',
name: 'src/arts/active-app/test/service-factories.test.ts',
purpose: 'App injection.',
notes: 'Lang and Logger are wired into Sium.'
},
@ -3499,7 +3512,7 @@ const result = await Sium.validate(ProfileSchema, formValue);`
dependsOn: ['$libs/logger'],
layer: 'EngineLogger / Logger contract',
overview: [
'The minimal Logger interface lives in libs/logger and is what all modules receive. EngineLogger lives in arts/logr and extends that contract with transports, history, child loggers, timers and lifecycle.',
'The minimal Logger interface lives in libs/logger and is what all modules receive. EngineLogger lives in arts/logger and extends that contract with transports, history, child loggers, timers and lifecycle.',
'Diagnostics are a cataloged layer above Logger. They map internal framework events to normal logger calls without forcing every log to become an event.',
'Transports can be filtered per level, buffered, throttled on failure and adapted to Sentry, Datadog, Loki, Logtail or OpenTelemetry.'
],
@ -3554,7 +3567,7 @@ Logger.info('checkout', 'payment completed', {
{
name: 'createEngineLogger(options)',
purpose: 'Creates the full logger runtime.',
notes: 'Use directly or through App.Logger.'
notes: 'Use directly or through App.logger.'
},
{
name: '$libs/logger.createCatalogDiagnostics(options)',
@ -3580,7 +3593,7 @@ Logger.info('checkout', 'payment completed', {
code: `import type { Logger } from '$libs/logger';
export function createFeature(options: { logger?: Logger }) {
const logger = options.logger ?? App.Logger;
const logger = options.logger ?? App.logger;
logger.info('feature.started', { context: { source: 'profile' } });
}`
}
@ -3605,7 +3618,7 @@ export function createFeature(options: { logger?: Logger }) {
const diagnostics = createCatalogDiagnostics({
logger,
defaultCategory: 'conn',
defaultCategory: 'connection',
catalog: {
reconnect_exhausted: {
level: LogLevel.WARN,
@ -3615,7 +3628,7 @@ const diagnostics = createCatalogDiagnostics({
});
diagnostics.emit({
artifact: 'conn',
artifact: 'connection',
type: 'reconnect_exhausted',
meta: { attempts: 5 }
});`
@ -3624,7 +3637,7 @@ diagnostics.emit({
],
tests: [
{
name: 'src/arts/logr/test',
name: 'src/arts/logger/test',
purpose: 'Engine logger.',
notes: 'Levels, transports, failures, buffers and adapters.'
},
@ -3684,7 +3697,7 @@ diagnostics.emit({
],
quickStart: {
title: 'Schedule work',
code: `const Timers = App.Timers;
code: `const Timers = App.timers;
Timers.schedule('profile:refresh', 5_000, async () => {
await refreshProfile();
@ -3707,7 +3720,7 @@ Timers.interval('sync', 30_000, syncInBackground, {
notes: 'Exposes entries() snapshots through Svelte state.'
},
{
name: 'App.Timers',
name: 'App.timers',
purpose: 'Always-present App root.',
notes: 'Injected into Connections and available to consumers.'
}
@ -3717,7 +3730,7 @@ Timers.interval('sync', 30_000, syncInBackground, {
{
title: 'Creation and ownership',
body: [
'App.Timers is the shared scheduler for browser-side artifacts. Connections, auto-refresh and debug panels should use this root instead of creating their own timer islands.',
'App.timers is the shared scheduler for browser-side artifacts. Connections, auto-refresh and debug panels should use this root instead of creating their own timer islands.',
'Create EngineTimers directly for deterministic unit tests, workers or server utilities that need an injected clock and do not need Svelte state.'
],
code: {
@ -3727,8 +3740,8 @@ Timers.interval('sync', 30_000, syncInBackground, {
replace: true
});
Timers.interval('conn:heartbeat', 30_000, heartbeat, {
scope: 'conn',
Timers.interval('connection:heartbeat', 30_000, heartbeat, {
scope: 'connection',
awaitTask: false
});
@ -3766,7 +3779,7 @@ Timers.cancelScope('profile');`
notes: 'One-shots, intervals, cancellation, backoff and fake clocks.'
},
{
name: 'src/arts/conn/test',
name: 'src/arts/connection/test',
purpose: 'Consumer integration.',
notes: 'Reconnect, heartbeat and ACK timeouts.'
},
@ -3816,7 +3829,7 @@ Timers.cancelScope('profile');`
notes: 'Use the injected Logger and module diagnostics/constants.'
},
{
name: 'forgetting app-event reauth behavior',
name: 'forgetting Orca reauth behavior',
purpose: 'Connections can keep old identity after login/logout/refresh.',
notes: 'Wire the orca preset (applyStandardOrca or applyConnectionsReauthOnIdentityChange / applyConnectionsCloseOnRevoke) so identity changes flow through reauthenticateAll/closeAll. For standalone connections without orca, use the per-connection session option.'
}
@ -3945,14 +3958,14 @@ await Chat.connect();`
],
tests: [
{
name: 'src/arts/conn/test',
name: 'src/arts/connection/test',
purpose: 'Connection runtime.',
notes: 'States, channels, websocket transport and app-event reauth.'
notes: 'States, channels, websocket transport and reauth behavior.'
},
{
name: 'src/arts/aapp/test/ecosystem.integration.test.ts',
purpose: 'App integration.',
notes: 'Connections reauth/disconnect from public app identity events when opted in.'
name: 'src/arts/active-app/test/ecosystem-orca.test.ts',
purpose: 'App orchestration.',
notes: 'Connections reauthenticate or close through orca presets when session events fire.'
},
{
name: '/test/conn',

@ -41,7 +41,7 @@ export const nav: readonly NavSection[] = [
label: 'App',
alias: '$active-app',
href: '/active/docs/aapp',
description: 'App composition: wires every artifact together.'
description: 'App composition: fixed Logger/Bus/Timers/Orca/Prefs core plus typed service schema.'
}
]
},
@ -72,6 +72,10 @@ export const nav: readonly NavSection[] = [
{ label: 'Http', alias: '$http', href: '/active/docs/http' }
]
},
{
title: 'Preferences & Environment',
items: [{ label: 'Prefs', alias: '$prefs', href: '/active/docs/prefs' }]
},
{
title: 'I18n & Format',
items: [
@ -96,6 +100,7 @@ export const nav: readonly NavSection[] = [
{ label: 'Bus', alias: '$bus', href: '/active/docs/buss' },
{ label: 'Logger', alias: '$logger', href: '/active/docs/logr' },
{ label: 'Timers', alias: '$timer', href: '/active/docs/timr' },
{ label: 'Orca', alias: '$orca', href: '/active/docs/orca' },
{ label: 'Connections', alias: '$connection', href: '/active/docs/conn' }
]
}

@ -8,120 +8,147 @@
const composition = `import { createActiveApp } from '$active-app';
import {
defineActiveLang,
defineActiveFrontend
defineActiveFormat,
defineActiveFrontend,
defineActiveCache,
defineActiveSession,
defineActivePerm,
defineActiveConnections
} from '$active-app/services';
import { applyStandardOrca } from '$active-app/presets';
import { LogLevel, consoleTransport } from '$logger';
export const App = createActiveApp({
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
orca: { maxDepth: 24 },
prefs: { capabilities, environment, intent },
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
frontend: defineActiveFrontend({ theme: 'base' })
format: defineActiveFormat(),
frontend: defineActiveFrontend({ theme: 'base' }),
cache: defineActiveCache(),
session: defineActiveSession({ schemas, storage, onRefresh, onRevoke }),
perm: defineActivePerm({ endpoint: '/api/perm' }),
connections: defineActiveConnections()
}
});`;
});
applyStandardOrca(App);`;
const access = `App.lang.t('common.ok');
const access = `App.logger.info('checkout.paid', { orderId });
App.prefs.locale.set('es-MX');
App.lang.t('common.ok');
App.format.currency.format(99.5);
App.Logger.info('checkout.paid', { orderId });
App.lang.setLocale('es-MX'); // notifies Format and Frontend via the locale source`;
App.frontend.setMode('dark');
App.cache.clear();`;
const factories = `import {
defineActiveLang,
defineActiveFormat,
defineActiveFrontend,
defineActiveStorage,
defineEngineHttp,
defineEngineSium,
defineActiveCache,
defineActiveSession,
defineActiveAuth,
defineActivePerm,
defineActiveConnections
} from '$active-app/services';
const factories = `// Every service is declared in the schema; the App builder injects core deps.
const App = createActiveApp({
prefs: { capabilities, environment },
services: {
sium: defineEngineSium({}), // validation engine
connections: defineActiveConnections({}), // realtime registry
auth: defineActiveAuth({ initial: data.auth }),
storage: defineActiveStorage(),
http: defineEngineHttp({ baseUrl: '/api' }),
lang: defineActiveLang({ schema }),
format: defineActiveFormat(),
frontend: defineActiveFrontend(),
sium: defineEngineSium({}),
cache: defineActiveCache(),
session: defineActiveSession({ schemas, storage, onRefresh, onRevoke }),
auth: defineActiveAuth({ endpoint: '/api/auth' }),
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({
schemas: { user: userSchema },
storage: { adapter: localAdapter, key: 'session' },
onRefresh,
onRevoke
})
connections: defineActiveConnections()
}
});
});`;
// Reactions to identity changes / revoke live in orca presets.
applyStandardOrca(App);`;
const eventFlow = `App.session publishes SESSION_EVENT_IDENTITY_CHANGED on App.bus
App.orca receives the event through registered presets
-> applyCacheClearOnIdentityChange calls App.cache.clear()
-> applyPermInvalidateOnIdentityChange calls App.perm.invalidate()
-> applyConnectionsReauthOnIdentityChange calls App.connections.reauthenticateAll()
const eventFlow = `session publishes SESSION_EVENT_IDENTITY_CHANGED on App.Bus
orca runs every action registered for that event:
- applyCacheClearOnIdentityChange -> App.cache.clear()
- applyPermInvalidateOnIdentityChange -> App.perm.invalidate()
connections track identity through their own ConnectionSessionSource (built from App.session)`;
No preset registered, no destructive reaction runs.`;
const disposal = `import { onDestroy } from 'svelte';
onDestroy(() => App.dispose());`;
const commonMistakes = [
{
name: 'Putting domain logic in App',
why: 'App becomes a god object and artifacts lose clear ownership.',
fix: 'Keep App as composition root; domain behavior stays inside each artifact.'
name: 'Treating App as a domain service',
why: 'The composition root becomes a mixed business object and module ownership disappears.',
fix: 'Keep business behavior inside the artifact that owns it; App only wires runtime pieces.'
},
{
name: 'Creating multiple singleton roots',
why: 'Session, Auth or Perms state can diverge inside one app.',
fix: 'Use the App factory once and read App.session/App.auth/App.perm afterwards.'
name: 'Expecting undeclared services to exist',
why: 'Only core members are always present. Services are exposed only when declared in the schema.',
fix: 'Declare each required module under services and let TypeScript enforce the App shape.'
},
{
name: 'Importing $active-app on the server as authority',
why: '$active-app is browser/client composition. Server code needs server engines and request context.',
fix: 'Use $svrs/auth, $svrs/perm and $svrs/cache from server files.'
name: 'Bypassing prefs for user intent',
why: 'Lang, Format and Frontend can disagree about language, locale, theme or direction.',
fix: 'Use App.prefs.<dim>.set(value) (e.g. App.prefs.locale.set("es-ES")); downstream services follow the effective snapshot.'
},
{
name: 'Bypassing App locale propagation',
why: 'Lang, Format and Frontend can disagree about locale/dir/currency.',
fix: 'Call App.lang.setLocale() so every locale consumer updates from one source.'
name: 'Putting reactions inside Bus listeners by hand',
why: 'Lifecycle behavior becomes invisible and hard to test.',
fix: 'Register orca presets in $active-app/presets or add explicit App.orca actions.'
},
{
name: 'Disposing providers before consumers',
why: 'Connections/Auth/Perms can call into already-disposed shared roots.',
fix: 'Keep the App.dispose() consumer-to-provider order.'
name: 'Importing $active-app as server authority',
why: 'App is client/runtime composition; server trust belongs to engines and $svrs.',
fix: 'Use $svrs/auth, $svrs/perm, $svrs/cache and pure Engine factories from server files.'
}
] as const;
const aiAgentRows = [
{
step: 'Composition surface',
where: 'src/arts/aapp/types.ts and src/arts/aapp/active-app.svelte.ts',
rule: 'Update options, public getters and factory wiring together. App composes artifacts; it must not absorb their domain logic.'
where: 'src/arts/active-app/types.ts and src/arts/active-app/active-app.svelte.ts',
rule: 'Update root options, core getters and service-schema typing together.'
},
{
step: 'Always-present roots',
step: 'Fixed core',
where: 'createActiveApp()',
rule: 'Do not make root members nullable. Use structurally-compatible fallbacks for Logger, Lang, Format, Frontend, Dom, Storage, Http, Timers, Bus and Cache.'
rule: 'Logger, Bus, Timers, Orca and Prefs are always present and never declared as services.'
},
{
step: 'Scoped factories',
where: 'createSiumEngine(), createActiveSession(), createActiveConnections(), createActiveAuth(), createActivePerms()',
rule: 'Preserve each factory lifetime and keep app-event reactions opt-in through the consumer options.'
step: 'Service factories',
where: 'src/arts/active-app/service-factories/*.ts',
rule: 'Each factory adapts one artifact to App; artifacts must not import App.'
},
{
step: 'Bus and orchestration',
where: 'App.Bus and src/arts/aapp/integrations/*-translator.ts',
rule: 'App may translate module events into public app events; destructive reactions belong to Cache, Perms or Connections.'
step: 'Orchestration',
where: 'src/arts/active-app/presets/*.ts',
rule: 'Cross-module reactions belong to Orca presets, not hidden auto-subscribers inside Cache, Perm or Connections.'
},
{
step: 'Server boundary',
where: 'src/svrs/*',
rule: '$active-app is the client composition root. Server authority belongs in $svrs and shared contracts belong in $libs.'
},
{
step: 'Disposal',
where: 'App.dispose()',
rule: 'Dispose consumers before providers and keep disposal idempotent.'
},
{
step: 'Tests',
where: 'src/arts/aapp/test and /test/ecosystem',
rule: 'Any wiring change needs composition and cross-module verification.'
where: 'src/arts/active-app/test',
rule: 'Composition, service ordering, prefs consumer wiring and orca presets need integration coverage.'
}
] as const;
</script>
<svelte:head>
<title>App ($active-app) — Active</title>
<title>App ($active-app) - Active</title>
</svelte:head>
<article class="article">
@ -129,135 +156,117 @@ onDestroy(() => App.dispose());`;
section="Composition"
title="App"
alias="$active-app"
summary="App composition: wires Logger, Lang, Format, Frontend, Dom, Storage, Http, Timers and Cache; exposes factories for Sium, Session, Connections, Auth and Perms."
summary="Client composition root: fixed Logger, Bus, Timers, Orca and Prefs core plus a typed opt-in service schema."
factories={['createActiveApp']}
dependsOn={['every artifact above']}
dependsOn={['$logger', '$bus', '$timer', '$orca', '$prefs', '$active-app/services']}
layer="ActiveApp"
/>
<h2>Overview</h2>
<p>
<code>$active-app</code> is the single composition root. It owns nine always-present
artifacts, exposes them through stable getters, and provides factories for the
five feature-scoped artifacts. Every member of <code>App</code> is present whether
or not the corresponding option was passed: missing configurations get a
structurally-identical fallback (mono-locale lang, console logger, default-locale
formats), so call sites stay uniform.
<code>$active-app</code> is the runtime composition root. It always builds five core
members - <code>App.logger</code>, <code>App.bus</code>, <code>App.timers</code> and
<code>App.orca</code>, plus <code>App.prefs</code> - and then exposes only the services declared by the application.
The old model where Lang, Format, Frontend, Dom, Storage, Http, Cache and Prefs were
always-present roots is gone.
</p>
<h2>Mental model</h2>
<p>
<code>aapp</code> is wiring, not business logic. It creates the always-present roots,
connects shared sources such as locale, logger, storage and timers, and exposes scoped
factories for artifacts that should only exist when a feature/page needs them. If a
behavior belongs to validation, cache, auth, permissions, realtime or formatting, it
belongs in that artifact; <code>aapp</code> only decides how the pieces are assembled.
</p>
<p>
The most important dynamic is propagation. <code>App.lang.setLocale()</code> updates Lang,
then Format and Frontend react from the same LocaleSource. <code>App.Bus</code> carries
typed events from each module — sessions publish <code>SESSION_EVENT_*</code>, etc. — but
the bus itself is inert. Cross-module reactions live as orca actions registered by the
application through <code>applyStandardOrca(App)</code> or the cherry-picked
<code>apply*</code> presets in <code>$active-app/presets</code>. Disposal runs in the
opposite direction: consumers first, providers last.
App is wiring, not business logic. It creates the core, adapts artifacts through service
factories, computes the service dependency order, exposes typed getters, and owns teardown.
Cross-module behavior is explicit: modules publish events on <code>App.bus</code>, while
<code>App.orca</code> runs the registered reactions.
</p>
<Callout variant="info" title="The schema is the App contract">
<p>
If a service is not declared in <code>services</code>, it is not part of the App type.
This keeps feature surfaces honest: a checkout app can declare cache and session, while
a static marketing page can keep only the fixed core.
</p>
</Callout>
<h2>Quick start</h2>
<CodeBlock code={composition} lang="ts" title="src/lib/app.ts" />
<p>Then read state directly:</p>
<p>Then read or mutate declared services directly:</p>
<CodeBlock code={access} lang="ts" />
<h2>Core surface (always present)</h2>
<p>
The four pieces of the core are always built and never declared as services. Configure
them via <code>{`createActiveApp({ logger, bus, timers, orca })`}</code>.
</p>
<h2>Fixed core</h2>
<table>
<thead>
<tr>
<th>Member</th>
<th>Type</th>
<th>Purpose</th>
<th>Configured from</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr><td><code>App.Logger</code></td><td><code>EngineLogger</code></td><td>Structured logger with transports.</td></tr>
<tr><td><code>App.Bus</code></td><td><code>EngineBus</code></td><td>Typed event bus carrying module events. Modules publish directly; consumers subscribe directly.</td></tr>
<tr><td><code>App.Timers</code></td><td><code>ActiveTimers</code></td><td>Deterministic timer scheduler.</td></tr>
<tr><td><code>App.Orca</code></td><td><code>EngineOrca</code></td><td>Orchestration engine. Inert until presets register actions.</td></tr>
<tr><td><code>App.logger</code></td><td><code>logger</code></td><td>Engine logger. Injected into core-aware service factories.</td></tr>
<tr><td><code>App.bus</code></td><td><code>bus</code></td><td>Typed Svelte-safe event bus. App injects logger and clock.</td></tr>
<tr><td><code>App.timers</code></td><td><code>timers</code></td><td>Active timer scheduler. Used by services and Orca.</td></tr>
<tr><td><code>App.orca</code></td><td><code>orca</code></td><td>Orchestration engine. App injects bus, timers and logger.</td></tr>
<tr><td><code>App.prefs</code></td><td><code>prefs</code></td><td>Preference engine. Always present; configured from root <code>prefs</code> options or neutral defaults.</td></tr>
</tbody>
</table>
<h2>Schema services (opt-in)</h2>
<h2>Service schema</h2>
<p>
Everything else is declared in <code>services: {`{ ... }`}</code>. The builder validates names,
computes topological order, builds <code>immediate</code> services eagerly and exposes
<code>lazy</code> services behind getters. Each service becomes a typed lowercase property
on <code>App</code> — accessing one that wasn't declared is a TypeScript error.
Services are adapted by <code>$active-app/services</code>. Factories declare the core
dependencies they consume, the services they can read, and whether they build lazily or
immediately. The builder validates names, detects cycles, builds dependencies first and
disposes constructed services in reverse construction order.
</p>
<CodeBlock code={factories} lang="ts" />
<table>
<thead>
<tr>
<th>Factory (from <code>$active-app/services</code>)</th>
<th>Slot</th>
<th>Notes</th>
<th>Factory</th>
<th>Dependency behavior</th>
</tr>
</thead>
<tbody>
<tr><td><code>defineActiveLang(options)</code></td><td><code>App.lang</code></td><td>Schema is required; the builder injects <code>logger</code> from the core.</td></tr>
<tr><td><code>defineActiveStorage(options)</code></td><td><code>App.storage</code></td><td>Memory adapter by default.</td></tr>
<tr><td><code>defineActiveDom(props)</code></td><td><code>App.dom</code></td><td>Inert on the server.</td></tr>
<tr><td><code>defineActiveFormat(options)</code></td><td><code>App.format</code></td><td>Wires <code>localeSource</code> from <code>App.lang</code> automatically when both are declared.</td></tr>
<tr><td><code>defineActiveFrontend(options)</code></td><td><code>App.frontend</code></td><td>Wires <code>dom</code> and <code>lang</code> automatically.</td></tr>
<tr><td><code>defineActiveCache(options)</code></td><td><code>App.cache</code></td><td>Passive runtime — invalidation is driven by orca presets.</td></tr>
<tr><td><code>defineActiveSession&lt;TUser, TCredential?, TData?&gt;(options)</code></td><td><code>App.session</code></td><td>Publishes <code>SESSION_EVENT_*</code> on the bus.</td></tr>
<tr><td><code>defineActivePerm(options)</code></td><td><code>App.perm</code></td><td>Auto-invalidation is OFF; use the orca preset.</td></tr>
<tr><td><code>defineActiveAuth(options)</code></td><td><code>App.auth</code></td><td>Requires HTTP routes to the server authority (<code>$svrs/auth</code>).</td></tr>
<tr><td><code>defineActiveConnections(options)</code></td><td><code>App.connections</code></td><td>Identity tracking via <code>ConnectionSessionSource</code>.</td></tr>
<tr><td><code>defineEngineHttp(options)</code></td><td><code>App.http</code></td><td>Engine only — no Active wrapper.</td></tr>
<tr><td><code>defineEngineSium(options)</code></td><td><code>App.sium</code></td><td>Wires <code>lang</code> automatically when declared.</td></tr>
<tr><td><code>lang</code></td><td><code>defineActiveLang()</code></td><td>Consumes <code>logger</code> and <code>prefs</code> from the core; follows <code>App.prefs.language.get()</code>.</td></tr>
<tr><td><code>format</code></td><td><code>defineActiveFormat()</code></td><td>Consumes <code>timers</code> and <code>prefs</code> from the core; resolves locale from explicit source or prefs.</td></tr>
<tr><td><code>frontend</code></td><td><code>defineActiveFrontend()</code></td><td>Consumes <code>prefs</code> from the core and optionally <code>dom</code>; prefs drives mode, density, motion and direction.</td></tr>
<tr><td><code>dom</code></td><td><code>defineActiveDom()</code></td><td>DOM integration, isolated as a service.</td></tr>
<tr><td><code>storage</code></td><td><code>defineActiveStorage()</code></td><td>Storage runtime, typically used by session and prefs persistence bridges.</td></tr>
<tr><td><code>http</code></td><td><code>defineEngineHttp()</code></td><td>Pure HTTP engine adapted into the schema.</td></tr>
<tr><td><code>sium</code></td><td><code>defineEngineSium()</code></td><td>Validation engine; uses lang when present.</td></tr>
<tr><td><code>cache</code></td><td><code>defineActiveCache()</code></td><td>Cache runtime. Identity clears are Orca presets.</td></tr>
<tr><td><code>session</code></td><td><code>defineActiveSession()</code></td><td>Publishes typed session lifecycle events on App.bus.</td></tr>
<tr><td><code>auth</code></td><td><code>defineActiveAuth()</code></td><td>Client auth reflector for server-backed flows.</td></tr>
<tr><td><code>perm</code></td><td><code>defineActivePerm()</code></td><td>Permission reflector. Identity invalidation is an Orca preset.</td></tr>
<tr><td><code>connections</code></td><td><code>defineActiveConnections()</code></td><td>Realtime connection registry. Identity reauth and revoke close are Orca presets.</td></tr>
</tbody>
</table>
<h2>Declaring services</h2>
<CodeBlock code={factories} lang="ts" />
<Callout variant="info" title="Single-instance by construction">
<p>
A duplicate slot in <code>services: {`{ ... }`}</code> is a JavaScript object-literal
error. There are no runtime "already created" exceptions because the schema makes
double-declaration impossible at the type level.
</p>
</Callout>
<h2>Locale propagation</h2>
<h2>Prefs propagation</h2>
<p>
<code>App.lang.setLocale(locale)</code> updates Lang's internal locale, which then notifies
<code>App.format</code> and <code>App.frontend</code> through a shared <code>localeSource</code>
bridge that the factories wire automatically. Consumers reading <code>App.lang.getLocale()</code>,
<code>App.format.currency.format(…)</code> or <code>App.frontend.dir</code> all agree on the
same BCP 47 tag without any extra wiring.
<code>App.prefs</code> is always present and is the core source for user intent.
<code>lang</code> follows <code>App.prefs.language.get()</code>,
<code>format</code> follows <code>App.prefs.locale.get()</code>, and
<code>frontend</code> follows theme, density, motion and direction. Direct service
overrides still work where the service exposes them, but the next Prefs update becomes
authoritative again.
</p>
<h2>Event bus and orca</h2>
<h2>Bus and Orca</h2>
<p>
<code>App.Bus</code> is always present. Modules publish their own typed events on it
(<code>SESSION_EVENT_IDENTITY_CHANGED</code>, <code>SESSION_EVENT_REVOKED</code>, etc.) —
the bus stays inert. Cross-module reactions live as <em>orca actions</em> registered by
the application through presets in <code>$active-app/presets</code>.
<code>App.bus</code> is the event transport. <code>App.orca</code> is the policy runner.
This separation matters: a module can publish a lifecycle event without silently clearing
cache, invalidating permissions or reconnecting sockets. Those reactions exist only when
the application registers presets from <code>$active-app/presets</code>.
</p>
<CodeBlock code={eventFlow} lang="txt" title="Identity event flow" />
<p>
<code>applyStandardOrca(App)</code> is the convenience aggregator: it registers every
standard preset whose required services are declared on <code>App</code>. Apps that want
a tailored set cherry-pick individual <code>apply*</code> functions instead.
</p>
<table>
<thead>
<tr>
<th>Preset</th>
<th>Triggered by</th>
<th>Event</th>
<th>Effect</th>
</tr>
</thead>
@ -265,44 +274,29 @@ onDestroy(() => App.dispose());`;
<tr><td><code>applyCacheClearOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.cache.clear()</code></td></tr>
<tr><td><code>applyCacheClearOnRevoke</code></td><td><code>SESSION_EVENT_REVOKED</code></td><td><code>App.cache.clear()</code></td></tr>
<tr><td><code>applyPermInvalidateOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.perm.invalidate()</code></td></tr>
<tr><td><code>applyConnectionsReauthOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.connections.reauthenticateAll()</code></td></tr>
<tr><td><code>applyConnectionsCloseOnRevoke</code></td><td><code>SESSION_EVENT_REVOKED</code></td><td><code>App.connections.closeAll()</code></td></tr>
<tr><td><code>applySessionAutoRefresh</code></td><td>Timer action</td><td>Schedules session refresh through <code>App.orca</code>.</td></tr>
</tbody>
</table>
<Callout variant="warn" title="Public payloads only">
<p>
Bus payloads are observable framework contracts. Do not put tokens, passwords,
authorization headers, refresh secrets or sensitive hashes in them. Use actor ids,
tenant ids, causes and correlation ids.
Bus payloads are observable contracts. Do not put tokens, passwords, authorization
headers, refresh secrets or sensitive hashes in them. Use actor ids, tenant ids,
causes and correlation ids.
</p>
</Callout>
<h2>Disposal</h2>
<p>
<code>App.dispose()</code> tears every artifact down in a <em>consumers → providers</em>
order: <code>Auth</code> and <code>Perms</code> first, then <code>Connections</code>,
then <code>Sess</code>, then <code>Cache</code>, <code>Timers</code>, <code>Frontend</code>,
<code>Dom</code>, <code>Format</code>, <code>Storage</code>, <code>Lang</code>, and
finally <code>Logger</code>. Subsequent calls are no-ops.
<code>App.dispose()</code> publishes the dispose-starting event, disposes all constructed
services in reverse construction order, tears down the prefs storage bridge, then disposes
<code>Prefs</code>, <code>Orca</code>, <code>Bus</code>, <code>Timers</code> and
<code>Logger</code>. Subsequent calls are no-ops.
</p>
<CodeBlock code={disposal} lang="ts" />
<h2>Limits</h2>
<ul>
<li>
<strong>No destructive defaults.</strong> Publishing an identity event does not clear
cache, invalidate permission decisions or reconnect sockets unless the consumer
<code>auto*On</code> option opts in.
</li>
<li>
<strong>Only identity and dispose have built-in sources today.</strong> Tenant,
permission-refresh, connectivity and cache-invalidation events are public typed
contracts that app code can publish explicitly until dedicated translators exist.
</li>
<li>
<code>aapp</code> is browser-first. Server entry points should consume the
<code>Engine*</code> factories directly from each artifact, not <code>$active-app</code>.
</li>
</ul>
<h2>Common mistakes</h2>
<table>
<thead>
@ -325,13 +319,13 @@ onDestroy(() => App.dispose());`;
<h2>Testing</h2>
<p>
<code>$active-app/testing</code> exposes a deterministic clock and synchronous transports for
integration tests. Suites under <code>src/arts/aapp/test/</code> verify composition,
factory enforcement, and disposal order.
Suites under <code>src/arts/active-app/test</code> cover schema construction, service
ordering, prefs consumer wiring, orca presets, session auto refresh and cross-actor
isolation.
</p>
<AiAgentsBox
intro="Before editing $active-app, verify the composition graph and the lifecycle of every injected artifact."
intro="Before editing $active-app, verify the fixed core, the service schema and the Orca preset boundary."
rows={aiAgentRows}
/>

@ -25,7 +25,7 @@ export const schema = {
App.lang.t('common.ok'); // 'Aceptar' (when locale = 'es')
App.lang.t('cart.items', { count: 3 }); // '3 artículos'
App.lang.setLocale('en');
App.prefs.language.set('en'); // core prefs drives lang
App.lang.t('common.ok'); // 'OK'`;
const mentalModel = `// EngineLang: pure resolver. Locale is passed explicitly.
@ -37,15 +37,18 @@ const Lang = createActiveLang(schema, 'es', ['en']);
Lang.t('common.ok'); // uses current locale
Lang.setLocale('en-GB'); // re-renders consumers that read t(), ts() or getLocale()
// App.lang: App-wired ActiveLang or mono-lang fallback.
App.lang.setLocale('ar');
// App.lang: service declared through $active-app/services.
App.prefs.language.set('ar');
App.lang.t('home.title');`;
const fallback = `createActiveApp({
lang: {
schema,
defaultLocale: 'es',
fallbackChain: ['en'] // missing keys in 'es' fall back to 'en'
prefs: { capabilities, environment },
services: {
lang: defineActiveLang({
schema,
defaultLocale: 'es',
fallbackChain: ['en'] // missing keys in 'es' fall back to 'en'
})
}
});`;
@ -73,13 +76,17 @@ export type AppLangSchema = typeof schema;
// src/lib/app.ts
import { createActiveApp } from '$active-app';
import { defineActiveLang } from '$active-app/services';
import { schema } from '$lib/i18n/schema';
export const App = createActiveApp<AppLangSchema>({
lang: {
schema,
defaultLocale: 'es',
fallbackChain: ['en']
export const App = createActiveApp({
prefs: {
capabilities,
environment,
intent: { language: 'es' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] })
}
});`;
@ -189,7 +196,7 @@ CheckoutLang.t('checkout.title'); // typed path on the child
CheckoutLang.t('checkout.items', { count: 3 });
// The child follows the parent locale until disposed.
App.lang.setLocale('en');
App.prefs.language.set('en');
CheckoutLang.t('checkout.title'); // 'Checkout'
CheckoutLang.dispose();`;
@ -237,16 +244,18 @@ export function createCheckoutText(lang = App.lang) {
// Reads track automatically inside templates and $derived.
<\/script>
<button onclick={() => App.lang.setLocale('en')}>EN</button>
<button onclick={() => App.lang.setLocale('es')}>ES</button>
<button onclick={() => App.prefs.language.set('en')}>EN</button>
<button onclick={() => App.prefs.language.set('es')}>ES</button>
<h1>{App.lang.t('home.title')}</h1>
<p>Locale: {App.lang.getLocale()}</p>`;
const monoLang = `// No 'lang' option → mono-lang fallback. Keys pass through verbatim.
const App = createActiveApp();
const monoLang = `// Explicit mono-lang fallback. Keys pass through verbatim.
import { createActiveMonoLang } from '$lang/mono-lang.svelte';
const Lang = createActiveMonoLang();
App.lang.t('home.title'); // 'home.title'`;
Lang.t('home.title'); // 'home.title'`;
const commonMistakes = [
{
@ -293,6 +302,11 @@ App.lang.t('home.title'); // 'home.title'`;
name: 'Expecting mono-lang to validate keys',
why: 'Mono-lang has no schema, so it cannot type-check or warn about missing translation paths in the same way.',
fix: 'Use a real schema for applications that depend on i18n correctness.'
},
{
name: 'Expecting App.lang without declaring lang',
why: 'The current App only exposes services that are declared in the service schema.',
fix: 'Add lang: defineActiveLang({ schema }) under services; configure core prefs with createActiveApp({ prefs }) when language intent needs app-specific capabilities.'
}
] as const;
@ -341,11 +355,12 @@ App.lang.t('home.title'); // 'home.title'`;
<h2>Overview</h2>
<p>
<code>lang</code> is one of the two zero-dependency roots of the framework
(<code>logr</code> being the other). It exposes a typed <code>t()</code> function
over a tree of translations, resolves <strong>BCP 47</strong> tags through a fallback
chain, supports <strong>CLDR plural rules</strong> per locale, and lets translation
strings reference each other through a small <code>#?key|fallback</code> syntax.
<code>lang</code> is the translation runtime. It exposes a typed <code>t()</code>
function over a tree of translations, resolves <strong>BCP 47</strong> tags through a
fallback chain, supports <strong>CLDR plural rules</strong> per locale, and lets
translation strings reference each other through a small <code>#?key|fallback</code>
syntax. Inside <code>$active-app</code>, it is an opt-in service declared with
<code>defineActiveLang()</code>.
</p>
<h2>Mental model</h2>
@ -354,8 +369,10 @@ App.lang.t('home.title'); // 'home.title'`;
call receives the locale explicitly. <code>ActiveLang</code> wraps that engine with a
Svelte <code>$state</code> locale, so reads inside templates, <code>$derived</code> and
<code>$effect</code> update when <code>setLocale()</code> changes the current locale.
<code>App.lang</code> is the application instance wired by <code>aapp</code>; if no schema
is configured, it is a mono-lang fallback that returns paths as readable strings.
<code>App.lang</code> is the application instance wired by <code>$active-app/services</code>.
The factory follows core <code>App.prefs.language.get()</code>; direct
<code>setLocale()</code> calls still work, but the next <code>App.prefs</code> change
becomes authoritative again.
</p>
<CodeBlock code={mentalModel} lang="ts" title="Engine vs Active vs App" />
@ -368,11 +385,11 @@ App.lang.t('home.title'); // 'home.title'`;
<h2>Creation and injection</h2>
<p>
The normal application path is to inject the initial schema into <code>createActiveApp()</code>.
That creates <code>App.lang</code>, wires it to <code>App.lang.setLocale()</code>, and lets
Format and Frontend react to the same locale source. If you are outside App, create the
pure engine with <code>createEngineLang()</code> or the Svelte wrapper with
<code>createActiveLang()</code>.
The normal application path is to declare <code>lang</code> in
<code>createActiveApp()</code> with <code>defineActiveLang()</code>. User language intent
flows through core <code>App.prefs</code> into
<code>App.lang</code>. If you are outside App, create the pure engine with
<code>createEngineLang()</code> or the Svelte wrapper with <code>createActiveLang()</code>.
</p>
<CodeBlock code={appInjection} lang="ts" title="Initial schema through App" />
<CodeBlock code={directCreation} lang="ts" title="Direct creation" />
@ -562,17 +579,18 @@ App.lang.t('cart.items', { count: 0 }); // 'لا توجد عناصر' (ar)`}
<h2>Mono-lang fallback</h2>
<p>
When <code>aapp</code> is created without a <code>lang</code> option, it provides
<code>createActiveMonoLang()</code> as a passthrough. <code>App.lang.t(key)</code>
returns the key string, so call sites stay uniform whether or not i18n is configured.
<code>createActiveMonoLang()</code> is still available as an explicit passthrough runtime
for apps or tests that want readable keys without a translation schema. App no longer
creates it implicitly: if <code>lang</code> is not declared in <code>services</code>,
<code>App.lang</code> is not part of the typed App surface.
</p>
<CodeBlock code={monoLang} lang="ts" />
<Callout variant="warn" title="Mono-lang is type-loose">
<p>
<code>createActiveApp()</code> casts the mono-lang to <code>ActiveLang&lt;S&gt;</code>
when no schema is given. Don't rely on auto-completion for translation keys when you
use mono-lang — there is no schema to derive types from.
Do not rely on auto-completion for translation keys when you use mono-lang - there is
no schema to derive types from. For typed apps, declare <code>defineActiveLang()</code>
with a real schema.
</p>
</Callout>
@ -585,7 +603,7 @@ App.lang.t('cart.items', { count: 0 }); // 'لا توجد عناصر' (ar)`}
<h2>Limits</h2>
<ul>
<li>No runtime parsing of ICU MessageFormat. Use plural objects + refs.</li>
<li>No runtime parser for free-form MessageFormat 2 patterns yet. For 1.0, model text with plural objects + refs and keep any MF2 parser as a dedicated adapter above the core resolver.</li>
<li>No async loading by default — translations are part of the bundle. For lazy locales, build a custom <code>EngineLang</code> with on-demand <code>setSchema()</code>.</li>
<li>HTML inside translations is not sanitised. Render with <code>{`{@html …}`}</code> only when you trust the source.</li>
</ul>

@ -0,0 +1,155 @@
<script lang="ts">
import CodeBlock from '../../_components/CodeBlock.svelte';
import ModuleHeader from '../../_components/ModuleHeader.svelte';
import PageNav from '../../_components/PageNav.svelte';
import Callout from '../../_components/Callout.svelte';
const quickStart = `import { createActiveApp } from '$active-app';
import { applyStandardOrca } from '$active-app/presets';
import {
defineActiveCache,
defineActiveConnections,
defineActivePerm,
defineActiveSession
} from '$active-app/services';
const App = createActiveApp({
services: {
cache: defineActiveCache({}),
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ onRefresh, onRevoke }),
connections: defineActiveConnections({})
}
});
applyStandardOrca(App);
App.session.adoptServer(nextSessionFromServer);
// session publishes session.identity.changed on App.bus;
// App.orca runs the presets registered by applyStandardOrca(App).`;
const customAction = `import {
ORCA_QUEUE_REPLACE_QUEUED,
ORCA_STAGE_POST,
orcaSuccess
} from '$orca';
App.orca.configureEvent('dating.match.created', {
queuePolicy: ORCA_QUEUE_REPLACE_QUEUED
});
App.orca.onEvent<{ userId: string }>('dating.match.created', {
id: 'dating.match.invalidate-feed',
stage: ORCA_STAGE_POST,
action: async (payload) => {
await App.cache.invalidate({
tags: [
{ type: 'dating:discover' },
{ type: 'dating:profile', id: payload.userId }
]
});
return orcaSuccess();
}
});`;
</script>
<svelte:head>
<title>Orca ($orca) - Active</title>
</svelte:head>
<article class="article">
<ModuleHeader
section="Infrastructure"
title="Orca"
alias="$orca"
summary="Orchestration engine for cross-module reactions, staged actions, queue policies, fan-in gates, validation and traceable runs."
factories={['createEngineOrca', 'createActiveOrca', 'App.orca']}
dependsOn={['$bus', '$timer', '$logger']}
layer="EngineOrca / ActiveOrca"
/>
<Callout variant="info" title="Core App service">
<p>
<code>Orca</code> is no longer optional documentation glue. <code>createActiveApp()</code>
builds <code>App.orca</code> as part of the fixed core alongside <code>Logger</code>,
<code>Bus</code>, <code>Timers</code> and <code>Prefs</code>. It stays inert until
actions or presets are registered.
</p>
</Callout>
<h2>Overview</h2>
<p>
Orca coordinates work that crosses module boundaries. Cache invalidation after identity
changes, permission refresh, connection reauth and feature-specific workflows should not
live inside those modules as hard-coded side effects. They are registered as Orca actions.
</p>
<p>
The engine listens through the shared Bus, schedules with Timers and reports through Logger.
A run is traceable: events produce staged actions, queue decisions, skipped gates, errors and
final run status.
</p>
<h2>Quick start</h2>
<CodeBlock code={quickStart} lang="ts" title="Standard App orchestration" />
<h2>Core concepts</h2>
<table>
<thead>
<tr><th>Concept</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>event</code></td><td>Trigger entering Orca.</td><td>Usually published through <code>App.bus</code>; derived events are emitted inside actions with <code>ctx.emit()</code>.</td></tr>
<tr><td><code>action</code></td><td>Unit of work for an event.</td><td>Has id, stage, guards, action function and error policy.</td></tr>
<tr><td><code>stage</code></td><td>Execution lane.</td><td>guard, pre, main, post, cleanup and finally keep ordering explicit.</td></tr>
<tr><td><code>queue</code></td><td>Concurrency policy.</td><td>Configured per event with <code>configureEvent()</code>: fifo, replace-queued, drop-latest and parallel.</td></tr>
<tr><td><code>fan-in</code></td><td>Wait for several tokens/events.</td><td>Useful for flows that need multiple prerequisites.</td></tr>
<tr><td><code>trace</code></td><td>Run identity and diagnostics.</td><td>Used by devtools, logs and tests.</td></tr>
</tbody>
</table>
<h2>Custom workflow</h2>
<CodeBlock code={customAction} lang="ts" title="Feature action" />
<h2>Standard presets</h2>
<p>
The presets under <code>$active-app/presets</code> are the canonical place for App-level
reactions. They wire identity/session events to cache clear, permission invalidation and
connection reauth/close without baking those reactions into the individual modules.
</p>
<table>
<thead>
<tr><th>Preset</th><th>Effect</th><th>Use when</th></tr>
</thead>
<tbody>
<tr><td><code>applyStandardOrca(App)</code></td><td>Registers the standard identity reactions.</td><td>Most client apps.</td></tr>
<tr><td><code>applyCacheClearOnIdentityChange(App)</code></td><td>Clears private cache after actor change.</td><td>Cache exists without full preset.</td></tr>
<tr><td><code>applyPermInvalidateOnIdentityChange(App)</code></td><td>Invalidates permission snapshots.</td><td>Permission decisions depend on actor state.</td></tr>
<tr><td><code>applyConnectionsReauthOnIdentityChange(App)</code></td><td>Reauthenticates connection registry.</td><td>Realtime channels depend on session identity.</td></tr>
<tr><td><code>applyConnectionsCloseOnRevoke(App)</code></td><td>Closes connections on revoke/logout.</td><td>Credentials must not survive session revocation.</td></tr>
</tbody>
</table>
<h2>Boundaries</h2>
<ul>
<li>Orca coordinates side effects; it does not own domain state.</li>
<li>Authorization decisions still belong to <code>$perm</code> / <code>$svrs/perm</code>.</li>
<li>Cache data still belongs to <code>$cache</code>.</li>
<li>Realtime transport still belongs to <code>$connection</code>.</li>
<li>Orca should make cross-module reactions visible, cancelable and testable.</li>
</ul>
<h2>Testing</h2>
<table>
<thead>
<tr><th>Target</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>src/arts/orca/test/engine-orca.test.ts</code></td><td>Core runtime.</td><td>Stages, queues, gates, fan-in, validation, reentry and error policies.</td></tr>
<tr><td><code>src/arts/orca/test/active-orca.svelte.test.ts</code></td><td>Reactive wrapper.</td><td>Active state and lifecycle.</td></tr>
<tr><td><code>src/arts/active-app/presets/*</code></td><td>App integration.</td><td>Identity reactions registered through Orca.</td></tr>
</tbody>
</table>
<PageNav />
</article>

@ -192,7 +192,7 @@ const Perms = createEnginePerms({
policies,
providers: createPermProviders(db),
compilers: [createSqlCompiler({ relation: compileRelationForSql })],
logger: App.Logger
logger: App.logger
});`;
const databaseProviders = `function createPermProviders(db): PermProviders {
@ -331,7 +331,7 @@ return rows;`;
}
},
compilers: [createSqlCompiler()],
logger: App.Logger
logger: App.logger
});`;
const serverCheck = `const actor = await resolveActorFromServerSession(event);
@ -579,7 +579,7 @@ for (const obligation of decision.obligations ?? []) {
<p>
Authorization is a server decision with a client mirror. The policy language in
<code>$libs/perm</code> defines what can be said, <code>$svrs/perm</code> evaluates it against
trusted actor/resource/context data, and <code>$arts/perm</code> only mirrors decisions for UI
trusted actor/resource/context data, and <code>$perm</code> only mirrors decisions for UI
responsiveness. If a route, action, WebSocket channel or job touches protected data, the server
engine must decide again even if the button was hidden by
<code>&lt;Can /&gt;</code>.
@ -989,16 +989,16 @@ for (const obligation of decision.obligations ?? []) {
<ul>
<li><code>auth</code> proves identity; <code>perm</code> decides access.</li>
<li>
<code>sess</code> is where the actor comes from; never trust an actor sent by the browser.
<code>$session</code> is where the actor comes from; never trust an actor sent by the browser.
</li>
<li><code>http</code> is the active client's transport when composed through App.</li>
<li>
<code>cach</code> and permission snapshots must be invalidated after actor or permission changes.
<code>$cache</code> and permission snapshots must be invalidated after actor or permission changes.
</li>
<li>
<code>conn</code> servers should call the engine before channel joins or privileged messages.
<code>$connection</code> servers should call the engine before channel joins or privileged messages.
</li>
<li><code>logr</code> receives decision, deny and indeterminate diagnostics when injected.</li>
<li><code>$logger</code> receives decision, deny and indeterminate diagnostics when injected.</li>
</ul>
<h2>Common mistakes</h2>

@ -0,0 +1,135 @@
<script lang="ts">
import CodeBlock from '../../_components/CodeBlock.svelte';
import ModuleHeader from '../../_components/ModuleHeader.svelte';
import PageNav from '../../_components/PageNav.svelte';
const quickStart = `import { createActiveApp } from '$active-app';
import { defineActiveFrontend, defineActiveFormat } from '$active-app/services';
const App = createActiveApp({
prefs: {
capabilities,
environment,
intent: {
language: 'es',
locale: 'es-ES',
theme: 'system',
density: 'normal',
timezone: 'Europe/Madrid'
}
},
services: {
frontend: defineActiveFrontend({}),
format: defineActiveFormat({})
}
});
App.prefs.theme.set('dark');
App.prefs.density.set('compact');
const locale = App.prefs.locale.get();`;
const sources = `import {
prefsLocaleSource,
prefsDensitySource,
prefsThemeSource,
prefsDirectionSource
} from '$prefs';
const localeSource = prefsLocaleSource(App.prefs);
const densitySource = prefsDensitySource(App.prefs);`;
</script>
<svelte:head>
<title>Prefs ($prefs) - Active</title>
</svelte:head>
<article class="article">
<ModuleHeader
section="Preferences & Environment"
title="Prefs"
alias="$prefs"
summary="Core preference engine for user intent, environment defaults, effective values and bridges consumed by frontend, format and lang."
factories={['createEnginePrefs', 'createActivePrefs', 'createPrefsStorageBridge']}
dependsOn={['$storage bridge (optional)']}
layer="Core (App.prefs) / EnginePrefs / ActivePrefs"
/>
<h2>Overview</h2>
<p>
Prefs is part of the <code>$active-app</code> core. Every App has <code>App.prefs</code>,
even when the application does not declare any services. The engine separates what the user
wants from what the environment provides: it receives intent, capabilities and environment;
then resolves an effective preference snapshot for locale, language, timezone, theme,
density, motion, direction, currency and unit system.
</p>
<p>
Frontend, Format and Lang should read from Prefs through source helpers instead of each
module inventing its own settings model. Storage persistence is handled through the storage
bridge, not by frontend.
</p>
<h2>Quick start</h2>
<CodeBlock code={quickStart} lang="ts" title="App core prefs" />
<h2>Source helpers</h2>
<p>
The source helpers expose narrow contracts so other modules can subscribe to only the
preference they need.
</p>
<CodeBlock code={sources} lang="ts" title="Bridge into other modules" />
<table>
<thead>
<tr><th>Helper</th><th>Feeds</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td><code>prefsLocaleSource()</code></td><td>Format</td><td>Regional BCP 47 locale for numbers, dates and currency.</td></tr>
<tr><td><code>prefsLanguageSource()</code></td><td>Lang / Frontend</td><td>Translation language and writing-system direction source.</td></tr>
<tr><td><code>prefsDirectionSource()</code></td><td>Frontend / Dom</td><td>LTR/RTL direction.</td></tr>
<tr><td><code>prefsThemeSource()</code></td><td>Frontend</td><td>Theme intent/effective value.</td></tr>
<tr><td><code>prefsDensitySource()</code></td><td>Frontend / UI</td><td>Density preference.</td></tr>
<tr><td><code>prefsTimezoneSource()</code></td><td>Format</td><td>Timezone for date/time display.</td></tr>
<tr><td><code>prefsCurrencySource()</code></td><td>Format</td><td>Currency preference.</td></tr>
<tr><td><code>prefsUnitSystemSource()</code></td><td>Format</td><td>Metric/imperial domain defaults.</td></tr>
</tbody>
</table>
<h2>Runtime API</h2>
<table>
<thead>
<tr><th>Member</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>state.snapshot</code></td><td>Full preference snapshot.</td><td>Reactive in ActivePrefs.</td></tr>
<tr><td><code>state.effective</code></td><td>Resolved values.</td><td>Combines intent, environment and capabilities.</td></tr>
<tr><td><code>setIntent(key, value)</code></td><td>Apply one user preference intent.</td><td>Use for UI settings controls.</td></tr>
<tr><td><code>clearIntent(key)</code></td><td>Return one preference to environment/defaults.</td><td>Useful for "system" mode.</td></tr>
<tr><td><code>resetIntent(next?)</code></td><td>Replace the whole intent map.</td><td>Use for hydration or full reset.</td></tr>
<tr><td><code>refreshEnvironment(next)</code></td><td>Update detected environment.</td><td>Browser/server adapters call this.</td></tr>
<tr><td><code>setCapabilities(capabilities)</code></td><td>Constrain valid values.</td><td>Prevents unsupported modes.</td></tr>
<tr><td><code>pending / lastError</code></td><td>Async bridge placeholders.</td><td>Reserved for storage bridge and future async adapters.</td></tr>
</tbody>
</table>
<h2>Adapters</h2>
<ul>
<li><code>detectBrowserEnvironment()</code> reads browser locale, timezone, color scheme and motion hints.</li>
<li><code>watchBrowserEnvironment()</code> keeps environment in sync when browser settings change.</li>
<li><code>detectServerEnvironment()</code> derives initial values from request headers.</li>
<li><code>createPrefsStorageBridge()</code> persists intent through the storage module.</li>
</ul>
<h2>Testing</h2>
<table>
<thead>
<tr><th>Target</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>src/arts/prefs/test/engine-prefs.test.ts</code></td><td>Resolution engine.</td><td>Intent, environment, capabilities and reset behavior.</td></tr>
<tr><td><code>src/arts/prefs/test/sources.test.ts</code></td><td>Module bridges.</td><td>Locale/theme/density/direction sources.</td></tr>
<tr><td><code>src/arts/prefs/test/storage-bridge.test.ts</code></td><td>Persistence bridge.</td><td>Storage integration without UI coupling.</td></tr>
</tbody>
</table>
<PageNav />
</article>

@ -40,7 +40,7 @@ const Auth = createEngineAuth({
store: createMemoryAuthAdapter(),
actors: createMemoryAuthActors(),
sess: createMemoryAuthSessPort(),
logger: App.Logger,
logger,
timer: { nowMs: () => Date.now() },
crypto: createWebCryptoAuthCrypto(),
passwordHasher: createTestPasswordHasher()
@ -66,7 +66,7 @@ const Perms = createEnginePerms({
policies: definePolicies(schema, [
allow('invoice.read').when(attr('actor.role').eq('admin'))
]),
logger: App.Logger
logger
});
const handlers = createPermHttpHandlers(Perms, resolveActorFromRequest);`;
@ -86,7 +86,7 @@ const Cache = createEngineCache({
actorId: event.locals.auth?.actor?.id,
permissionHash: event.locals.permissionsHash
}),
logger: App.Logger
logger
});`;
const svelteKit = `// +hooks.server.ts
@ -132,7 +132,7 @@ export const POST = async ({ request }) => {
{
name: 'Mixing auth, permission and cache responsibilities',
why: 'Identity proof, access decisions and data freshness become impossible to reason about.',
fix: 'Auth proves identity, perm authorizes, cach manages freshness/scopes.'
fix: 'Auth proves identity, perm authorizes, cache manages freshness/scopes.'
}
] as const;
@ -155,7 +155,7 @@ export const POST = async ({ request }) => {
{
step: 'Security boundaries',
where: '$svrs/auth, $svrs/perm, $svrs/cache',
rule: 'Auth proves identity, perm authorizes actions and cach preserves private scopes. Do not merge those responsibilities.'
rule: 'Auth proves identity, perm authorizes actions and cache preserves private scopes. Do not merge those responsibilities.'
},
{
step: 'Diagnostics',
@ -199,7 +199,7 @@ export const POST = async ({ request }) => {
<code>$svrs/perm</code> and <code>$svrs/cache</code>. They exist because these artifacts
have a backend half and a frontend half. The shared language lives in
<code>$libs/*</code>, the server authority lives in <code>$svrs/*</code>, and the
reactive browser/client wrappers live in <code>$arts/*</code>.
reactive browser/client wrappers live under <code>src/arts/*</code>.
</p>
<p>
This is not a duplicate of App. <code>$active-app</code> is a browser/client composition root.
@ -211,7 +211,7 @@ export const POST = async ({ request }) => {
<p>
Server modules exist for artifacts that have a real backend authority. The shared
language lives in <code>$libs</code>, the server engine lives in <code>$svrs</code>, and
the reactive browser facade lives in <code>$arts</code>. That split matters because only
the reactive browser facade lives in <code>src/arts</code>. That split matters because only
the server has trusted request context, secure cookies, database access and private ports.
</p>
<p>
@ -269,7 +269,7 @@ export const POST = async ({ request }) => {
<tr><td>Engine</td><td><code>createEngineAuth</code>, <code>EngineAuth</code></td><td>Current view, password flows, CSRF, recovery, devices, OAuth/MFA primitives and event subscriptions.</td></tr>
<tr><td>Handlers</td><td><code>createAuthRouteHandlers</code>, <code>createSvelteKitAuthHandle</code></td><td>HTTP/SvelteKit integration. Default route handlers cover current, CSRF, password, recovery and sign-out.</td></tr>
<tr><td>Adapters</td><td><code>createMemoryAuthAdapter</code>, <code>createDbAuthAdapter</code>, password/crypto/mailer/test adapters</td><td>Persistence and mechanism ports without hard dependency on an ORM/provider.</td></tr>
<tr><td>Integrations</td><td><code>AuthSessPort</code>, <code>AuthCachePort</code>, <code>AuthPermsPort</code>, <code>AuthHttpPort</code></td><td>Ports for sess, cach, perm, http, timer and logr.</td></tr>
<tr><td>Integrations</td><td><code>AuthSessPort</code>, <code>AuthCachePort</code>, <code>AuthPermsPort</code>, <code>AuthHttpPort</code></td><td>Ports for session, cache, perm, http, timer and logger.</td></tr>
</tbody>
</table>
@ -329,7 +329,7 @@ export const POST = async ({ request }) => {
<ul>
<li><code>$libs/*</code> defines shared contracts, constants and pure helpers.</li>
<li><code>$svrs/*</code> owns server authority, secrets, ports and backend adapters.</li>
<li><code>$arts/*</code> owns active/client state and browser ergonomics.</li>
<li><code>src/arts/*</code> owns active/client state and browser ergonomics.</li>
<li><code>$active-app</code> composes client roots; it should not be imported as the server authority.</li>
<li>Do not expose server stores, password hashes, refresh tokens, CSRF secrets or provider tokens to active/client modules.</li>
</ul>

@ -17,7 +17,7 @@
const layerRules = `libs/* → shared contracts, constants and pure helpers
svrs/* → server authority, ports, backend adapters, secrets
arts/* → public runtime artifacts and active/client wrappers
aapp → client composition root, not server authority`;
$active-app → client composition root: fixed core plus typed service schema, not server authority`;
const importRules = `// Good: modules depend on shared contracts
import type { Logger } from '$libs/logger';
@ -55,7 +55,7 @@ logger.debug(LOGGER_CATEGORY, CONNECTION_LOG_MESSAGES.RECONNECT_SCHEDULED, {
});
// Bad
logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
logger.debug('connection', 'reconnect scheduled', { name, attempt });`;
const completionChecklist = `Before final response:
- focused tests for the touched module pass
@ -120,7 +120,7 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
<tr><td>Is this a constant, type or pure helper?</td><td><code>libs</code></td><td>Shared by server, active and tests.</td></tr>
<tr><td>Does this decide identity, access or private cache scope?</td><td><code>svrs</code></td><td>The server is authoritative.</td></tr>
<tr><td>Does this expose Svelte state or browser UX?</td><td><code>arts</code></td><td>Active wrappers live in <code>.svelte.ts</code>.</td></tr>
<tr><td>Does this wire existing modules for the app?</td><td><code>aapp</code></td><td>Composition, not new domain logic.</td></tr>
<tr><td>Does this wire existing modules for the app?</td><td><code>$active-app</code></td><td>Composition, not new domain logic.</td></tr>
</tbody>
</table>
@ -143,13 +143,13 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
<tbody>
<tr><td><code>createEngineXxx</code></td><td>Imperative/pure runtime, no Svelte state.</td><td><code>arts</code> or <code>svrs</code>, depending on authority.</td></tr>
<tr><td><code>createActiveXxx</code></td><td>Reactive Svelte wrapper over runtime state.</td><td><code>arts/*/*.svelte.ts</code>.</td></tr>
<tr><td><code>App.createXxx</code></td><td>Composition helper that injects shared services.</td><td><code>aapp</code>.</td></tr>
<tr><td><code>defineActiveXxx</code> / <code>defineEngineXxx</code></td><td>Service-schema factory for App composition.</td><td><code>$active-app/services</code>.</td></tr>
</tbody>
</table>
<Callout variant="info" title="Server-backed modules">
<p>
<code>auth</code>, <code>perm</code> and <code>cach</code> have server modules under
<code>auth</code>, <code>perm</code> and <code>cache</code> have server modules under
<code>$svrs</code>. Their active modules are client reflectors. Do not move
authoritative logic into active/browser code.
</p>
@ -181,7 +181,7 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
<h2>Security rules</h2>
<ul>
<li><code>auth</code> proves identity; <code>sess</code> keeps continuity; <code>perm</code> decides access.</li>
<li><code>auth</code> proves identity; <code>session</code> keeps continuity; <code>perm</code> decides access.</li>
<li>The browser never decides authorization. ActivePerms is UX only.</li>
<li>Storage must not persist passwords, refresh tokens, OTPs, CSRF secrets or provider tokens.</li>
<li>Private cache entries must include actor, tenant or permission scope when session/permission data exists.</li>
@ -232,11 +232,11 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
code={`Audit this Active framework module against its ecosystem rules:
- Verify public docs match real exports and types.
- Check layer boundaries: libs vs svrs vs arts vs aapp.
- Check layer boundaries: libs vs svrs vs arts vs active-app.
- Find magic strings in logs, events, errors, methods, protocol messages and routes.
- Check logger usage: modules should accept Logger from $libs/logger.
- Verify ActiveEngine consistency: loading, lastError, disposed, snapshot, clearError, onChange, dispose.
- Verify server authority for auth, perm and cach.
- Verify server authority for auth, perm and cache.
- Identify duplicated boilerplate, oversized files, missing constants and missing tests.
- Do not propose new APIs without showing where they fit in the existing conventions.
- Write findings with file paths, severity, rationale and concrete remediation.`}

@ -3,21 +3,80 @@
import Callout from '../../_components/Callout.svelte';
import PageNav from '../../_components/PageNav.svelte';
const dependencyDiagram = ` lang logr
\\ / | \\
\\ / | \\
fmts http timer
\\ | /|\\
adom ─── fend \\ | / | conn
\\ \\ \\ | / | \\
\\ \\ \\ | / auth perm
──────────────── aapp ─ stor ─ cach
:
sium`;
const dependencyDiagram = `createActiveApp()
core, always present:
Logger -> Bus
Logger -> Timers
Logger + Bus + Timers -> Orca
Prefs
services, opt-in:
core Prefs -> lang
core Prefs -> format
core Prefs + lang + dom -> frontend
storage + http + timers -> session
session -> auth / perm / connections
lang -> sium
orchestration:
session events -> App.bus -> App.orca actions -> cache / perm / connections`;
const appExample = `import { createActiveApp } from '$active-app';
import {
defineActiveLang,
defineActiveFormat,
defineActiveFrontend,
defineActiveStorage,
defineEngineHttp,
defineActiveSession,
defineActiveCache
} from '$active-app/services';
import { applyStandardOrca } from '$active-app/presets';
import { LogLevel, consoleTransport } from '$logger';
export const App = createActiveApp({
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
orca: { maxDepth: 24 },
prefs: {
capabilities,
environment,
intent: { language: 'es', locale: 'es-ES', theme: 'system' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
format: defineActiveFormat(),
frontend: defineActiveFrontend(),
storage: defineActiveStorage(),
http: defineEngineHttp({ baseUrl: '/api' }),
session: defineActiveSession({ schemas, storage, onRefresh, onRevoke }),
cache: defineActiveCache()
}
});
applyStandardOrca(App);`;
const orcaExample = `import {
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange,
applyConnectionsReauthOnIdentityChange
} from '$active-app/presets';
applyCacheClearOnIdentityChange(App);
applyPermInvalidateOnIdentityChange(App);
applyConnectionsReauthOnIdentityChange(App);`;
const prefsExample = `App.prefs.language.set('es');
App.prefs.locale.set('es-MX');
App.prefs.theme.set('dark');
// App.prefs drives downstream services when they are declared:
// - App.lang follows App.prefs.language.get()
// - App.format follows App.prefs.locale.get()
// - App.frontend follows App.prefs theme, density, motion and direction`;
</script>
<svelte:head>
<title>Composition — Active</title>
<title>Composition - Active</title>
</svelte:head>
<article class="article">
@ -29,144 +88,123 @@ adom ─── fend \\ | / | conn
<h1>Composition</h1>
<p class="lead">
Active is wired through one entry point — <code>$active-app</code> — and follows two factory
conventions and one shared contract. Once you internalise the three pieces, every artifact
looks the same.
Active is wired through <code>$active-app</code>. The current model is a fixed runtime
core plus a typed service schema: <code>Logger</code>, <code>Bus</code>,
<code>Timers</code>, <code>Orca</code> and <code>Prefs</code> always exist; the rest of
the ecosystem is declared explicitly under <code>services</code>.
</p>
<h2>Two factories</h2>
<h2>Factories</h2>
<ul>
<li>
<strong><code>createEngineXxx(options)</code></strong> — pure factory. Public methods
over private state (or no state at all). No runes, safe to import from server-only
modules.
<strong><code>createEngineXxx(options)</code></strong> - pure factory. It has no
Svelte runes and is safe for server code, tests and adapters.
</li>
<li>
<strong><code>createActiveXxx(options)</code></strong> — wraps an engine and exposes
public reactive state. Lives in a <code>.svelte.ts</code> file because it owns
<code>$state</code>. Imports must target the file directly to keep the rest of the
artifact runes-free.
<strong><code>createActiveXxx(options)</code></strong> - reactive runtime wrapper. It
lives in a <code>.svelte.ts</code> file when it owns <code>$state</code>.
</li>
<li>
<strong><code>defineActiveXxx()</code> / <code>defineEngineXxx()</code></strong> -
service-schema adapters used only by <code>$active-app/services</code>.
</li>
</ul>
<Callout variant="info" title="Server vs client artifacts">
<Callout variant="info" title="Core is not a service">
<p>
When an artifact has a true server-authoritative counterpart — <code>auth</code>,
<code>perm</code>, <code>cach</code> — the engine lives under <code>$svrs/</code> and
the client side keeps an Active reflector under <code>$arts/</code>.
The App core is configured with root options on <code>createActiveApp()</code>. Do not
declare <code>logger</code>, <code>bus</code>, <code>timers</code>,
<code>orca</code> or <code>prefs</code> inside <code>services</code>.
</p>
</Callout>
<h2>One contract</h2>
<p>Every Active root implements:</p>
<CodeBlock
lang="ts"
code={`interface ActiveEngine<TSnapshot, TError> {
readonly loading: boolean;
readonly lastError: TError | null;
readonly disposed: boolean;
snapshot(): TSnapshot;
clearError(): void;
onChange(listener: (snapshot: TSnapshot) => void): () => void;
dispose(): void;
}`}
/>
<ul>
<li>Direct getters (<code>Auth.current</code>, <code>Cache.loading</code>) — no <code>.state</code> object.</li>
<li>Always <code>loading</code>, never <code>pending</code>.</li>
<li><code>dispose()</code> is idempotent and clears every owned listener / entry / resource.</li>
<li>Roots that create entries (<code>ActiveCache.entry()</code>, <code>ActiveConnections.connection()</code>) own those entries and dispose them when the root is disposed.</li>
</ul>
<h2>Cross-artifact dependencies</h2>
<p>
<code>lang</code> and <code>logr</code> are the dependency-free roots. Everything else
composes upward:
</p>
<CodeBlock lang="text" code={dependencyDiagram} title="Dependency map" />
<ul>
<li><code>fmts</code> consumes <code>logr</code> only inside its currency rate fetcher diagnostics.</li>
<li><code>sium</code> takes <code>lang</code> and <code>logr</code> via injection and falls back to local message interpolation when omitted.</li>
<li><code>timer</code> is the deterministic scheduler consumed by <code>sess</code> and <code>conn</code>.</li>
<li><code>http</code> takes <code>logr</code> via injection (auto-wired through <code>aapp</code>).</li>
<li><code>sess</code> uses <code>stor</code> for persistence, <code>timer</code> for auto-refresh, and <code>http</code> for 401-rescue integration.</li>
<li><code>conn</code> uses <code>timer</code> for reconnect / heartbeat / ack timeouts and accepts the App session bridge when composed through <code>aapp</code>.</li>
<li><code>auth</code> / <code>perm</code> / <code>cach</code> split cleanly: server side under <code>$svrs/</code>, client reflector under <code>$arts/</code>.</li>
</ul>
<h2>Fixed core</h2>
<table>
<thead>
<tr>
<th>Member</th>
<th>Created by</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr><td><code>App.logger</code></td><td><code>createEngineLogger()</code></td><td>Structured logs and transports.</td></tr>
<tr><td><code>App.bus</code></td><td><code>createSvelteEngineBus()</code></td><td>Typed application event bus.</td></tr>
<tr><td><code>App.timers</code></td><td><code>createActiveTimers()</code></td><td>Deterministic scheduler and shared clock.</td></tr>
<tr><td><code>App.orca</code></td><td><code>createEngineOrca()</code></td><td>Cross-module orchestration. It is inert until actions or presets are registered.</td></tr>
<tr><td><code>App.prefs</code></td><td><code>createActivePrefs()</code></td><td>Core preference engine. Uses root <code>prefs</code> options or neutral defaults.</td></tr>
</tbody>
</table>
<h2>Always-present roots vs scoped factories</h2>
<h2>Typed services</h2>
<p>
The composer in <code>$active-app</code> distinguishes two kinds of artifacts:
Every declared service becomes a typed lowercase property on <code>App</code>. Services can
be lazy or immediate, can request a subset of the core, and can depend on other declared
services. Undeclared services do not exist on the App type.
</p>
<table>
<thead>
<tr>
<th>Kind</th>
<th>Members</th>
<th>Lifetime</th>
<th>How to access</th>
<th>Service</th>
<th>Factory</th>
<th>Important wiring</th>
</tr>
</thead>
<tbody>
<tr>
<td>Always-present</td>
<td>Logger, Lang, Format, Frontend, Dom, Storage, Http, Timers, Cache</td>
<td>App-scoped</td>
<td><code>App.lang</code>, <code>App.cache</code>, …</td>
</tr>
<tr>
<td>Schema services</td>
<td>Cache, Format, Frontend, Dom, Storage, Http, Lang, Sium, Session, Auth, Perm, Connections</td>
<td>App-scoped, opt-in</td>
<td><code>defineActiveSession({`{...}`})</code>, <code>defineEngineSium({`{}`})</code>, …</td>
</tr>
<tr><td><code>lang</code></td><td><code>defineActiveLang()</code></td><td>Always follows <code>App.prefs.language.get()</code>.</td></tr>
<tr><td><code>format</code></td><td><code>defineActiveFormat()</code></td><td>Always reads its locale source from <code>App.prefs</code> (or an explicit <code>localeSource</code> override).</td></tr>
<tr><td><code>frontend</code></td><td><code>defineActiveFrontend()</code></td><td>Always consumes <code>App.prefs</code> for theme/density/motion/direction; optionally <code>dom</code>.</td></tr>
<tr><td><code>dom</code></td><td><code>defineActiveDom()</code></td><td>Browser document adapter; inert on the server.</td></tr>
<tr><td><code>storage</code></td><td><code>defineActiveStorage()</code></td><td>Runtime storage adapter, memory-backed by default.</td></tr>
<tr><td><code>http</code></td><td><code>defineEngineHttp()</code></td><td>Engine service for API calls and request diagnostics.</td></tr>
<tr><td><code>cache</code></td><td><code>defineActiveCache()</code></td><td>Passive cache. Identity reactions belong to Orca presets.</td></tr>
<tr><td><code>sium</code></td><td><code>defineEngineSium()</code></td><td>Validation engine; consumes <code>lang</code> if declared.</td></tr>
<tr><td><code>session</code></td><td><code>defineActiveSession()</code></td><td>Publishes session events on <code>App.bus</code>.</td></tr>
<tr><td><code>auth</code></td><td><code>defineActiveAuth()</code></td><td>Client reflector for server-authoritative auth flows.</td></tr>
<tr><td><code>perm</code></td><td><code>defineActivePerm()</code></td><td>Permission reflector. Invalidation is opt-in through Orca.</td></tr>
<tr><td><code>connections</code></td><td><code>defineActiveConnections()</code></td><td>Realtime registry; reauth/close reactions are Orca presets.</td></tr>
</tbody>
</table>
<Callout variant="info" title="Single-instance by construction">
<p>
A duplicate slot in <code>services: {`{ ... }`}</code> is a JavaScript object-literal
error — the schema makes "factory called twice" structurally impossible. There are no
runtime "already created" exceptions for the schema-driven services.
</p>
</Callout>
<h2>Dependency graph</h2>
<CodeBlock lang="text" code={dependencyDiagram} title="Current composition map" />
<h2>Disposal</h2>
<h2>App composition</h2>
<CodeBlock lang="ts" code={appExample} title="src/lib/app.ts" />
<h2>Orca reactions</h2>
<p>
<code>App.dispose()</code> tears down every artifact in the right order
(<em>consumers → providers</em>): <code>Auth</code> and <code>Perms</code> first, then
<code>Connections</code>, then <code>Sess</code>, then <code>Cache</code>,
<code>Timers</code>, <code>Frontend</code>, <code>Dom</code>, <code>Format</code>,
<code>Storage</code>, <code>Lang</code>, and finally <code>Logger</code>. Subsequent calls
are no-ops.
Module events are public typed contracts on <code>App.bus</code>. The bus does not run
destructive behavior by itself; <code>App.orca</code> owns those cross-module reactions.
Use <code>applyStandardOrca(App)</code> for the default set, or cherry-pick individual
presets when the application needs a narrower policy.
</p>
<CodeBlock lang="ts" code={orcaExample} />
<CodeBlock
lang="ts"
code={`onDestroy(() => App.dispose());`}
/>
<h2>Preferences propagation</h2>
<p>
<code>App.prefs</code> is the ecosystem-wide source of user intent. Locale is no longer a
Lang-only concern: language drives translations, locale drives regional formats, and
Frontend receives theme, density, motion and direction from the same effective snapshot.
</p>
<CodeBlock lang="ts" code={prefsExample} />
<h2>Locale propagation</h2>
<h2>Disposal</h2>
<p>
<code>App.lang.setLocale(locale)</code> is the single source of truth. It propagates through
<code>Lang</code> first, which then notifies <code>Format</code> and <code>Frontend</code>
via the shared <code>localeSource</code> bridge, so every consumer ends up agreeing on the
same BCP 47 tag.
<code>App.dispose()</code> publishes the dispose-starting event, disposes constructed
services in reverse construction order, tears down the prefs storage bridge, then disposes
<code>Prefs</code>, <code>Orca</code>, <code>Bus</code>, <code>Timers</code> and finally
<code>Logger</code>. The operation is idempotent.
</p>
<CodeBlock
lang="ts"
code={`App.lang.setLocale('es-MX');
// → Lang resolves to 'es-MX' (with fallback chain)
// → Format picks up the new locale for numbers / currency / dates
// → Frontend updates the document direction (LTR / RTL)`}
/>
<CodeBlock lang="ts" code={`onDestroy(() => App.dispose());`} />
<h2>Server boundary</h2>
<p>
<code>$active-app</code> is the client composition root. Server authority stays in
<code>$svrs</code> and pure engines. Shared contracts belong in <code>$libs</code>.
</p>
<PageNav />
</article>

@ -8,10 +8,16 @@ src/svrs/* server-authoritative engines and backend adapters
src/arts/* client/runtime artifacts, active wrappers and browser ergonomics
src/web/routes documentation, test pages and app routes
$active-app client composition root that wires the active ecosystem`;
$active-app client composition root: Logger, Bus, Timers, Orca, Prefs core + typed services`;
const appFlow = `const App = createActiveApp({
const appFlow = `const App = createActiveApp({
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
orca: { maxDepth: 24 },
prefs: {
capabilities,
environment,
intent: { language: 'es', locale: 'es-ES', theme: 'system' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
storage: defineActiveStorage({ adapter: localAdapter, namespace: 'app' }),
@ -26,7 +32,7 @@ $active-app client composition root that wires the active ecosystem`;
}
});
applyStandardOrca(App); // wires cache.clear / perm.invalidate on identity events`;
applyStandardOrca(App); // wires cache, perm and connections reactions through App.orca`;
const serverFlow = `const Auth = createEngineAuth({
security,
@ -60,18 +66,19 @@ const Cache = createEngineCache({
const identityFlow = `signInPassword()
→ ActiveAuth obtains CSRF and posts to server
→ EngineAuth validates credential and binds session through sess port
→ EngineAuth validates credential and binds session through the session port
→ Session state changes
→ Perms snapshot/cache must be invalidated for the new actor
→ Cache private scopes change actorId / permissionHash
→ Connections can reauthenticate or disconnect through the App session bridge`;
const localeFlow = `App.lang.setLocale('ar-EG')
→ App.lang.setLocale('ar-EG')
→ App.format.setLocale('ar-EG')
→ App.frontend sees localeSource change
→ Frontend updates dir when dir is auto
→ Dom.apply writes dir="rtl" to the configured target`;
const localeFlow = `App.prefs.language.set('ar')
App.prefs.locale.set('ar-EG')
-> prefs resolves effective language and regional locale
-> App.lang follows App.prefs.language.get()
-> App.format follows App.prefs.locale.get()
-> App.frontend follows App.prefs language, theme, density, motion and direction
-> Dom writes dir="rtl" to the configured target when frontend direction changes`;
</script>
<svelte:head>
@ -125,9 +132,9 @@ const Cache = createEngineCache({
<td>No server secrets, no authoritative authorization, no password/token persistence.</td>
</tr>
<tr>
<td><code>aapp</code></td>
<td>Client composition root.</td>
<td>No replacement for server engines; it wires active clients, not backend authority.</td>
<td><code>$active-app</code></td>
<td>Client composition root with fixed core plus typed services.</td>
<td>No replacement for server engines; it wires runtime modules, not backend authority.</td>
</tr>
</tbody>
</table>
@ -155,13 +162,14 @@ const Cache = createEngineCache({
</tr>
</thead>
<tbody>
<tr><td>Composition</td><td><code>aapp</code></td><td>Single client root, always-present services, feature factories, disposal order.</td></tr>
<tr><td>Identity</td><td><code>auth</code>, <code>sess</code>, <code>perm</code></td><td>Prove identity, keep session continuity, decide access.</td></tr>
<tr><td>Data</td><td><code>http</code>, <code>cache</code>, <code>stor</code></td><td>Remote calls, coherent cached data, safe local persistence.</td></tr>
<tr><td>I18n and formats</td><td><code>lang</code>, <code>fmts</code></td><td>Text translation plus locale-driven numbers, currency, units and dates.</td></tr>
<tr><td>Frontend runtime</td><td><code>fend</code>, <code>adom</code></td><td>Global visual preferences, direction, theme, DOM writes and responsive helpers.</td></tr>
<tr><td>Validation</td><td><code>sium</code></td><td>Page-scoped schemas, issues, metadata and translated validation messages.</td></tr>
<tr><td>Infrastructure</td><td><code>logr</code>, <code>timer</code>, <code>conn</code></td><td>Structured logs, deterministic timers and realtime connections.</td></tr>
<tr><td>Composition</td><td><code>$active-app</code></td><td>Single client root, fixed Logger/Bus/Timers/Orca/Prefs core, typed service schema and disposal order.</td></tr>
<tr><td>Identity</td><td><code>$auth</code>, <code>$session</code>, <code>$perm</code></td><td>Prove identity, keep session continuity, decide access.</td></tr>
<tr><td>Data</td><td><code>$http</code>, <code>$cache</code>, <code>$storage</code></td><td>Remote calls, coherent cached data, safe local persistence.</td></tr>
<tr><td>Preferences</td><td><code>$prefs</code></td><td>User intent, environment defaults and effective locale/theme/density/timezone values.</td></tr>
<tr><td>I18n and formats</td><td><code>$lang</code>, <code>$format</code></td><td>Text translation plus preference-driven numbers, currency, units and dates.</td></tr>
<tr><td>Frontend runtime</td><td><code>$frontend</code>, <code>$adom</code></td><td>Effective visual state, direction, theme, DOM writes and responsive helpers.</td></tr>
<tr><td>Validation</td><td><code>$sium</code></td><td>Page-scoped schemas, issues, metadata and translated validation messages.</td></tr>
<tr><td>Infrastructure</td><td><code>$bus</code>, <code>$logger</code>, <code>$timer</code>, <code>$orca</code>, <code>$connection</code></td><td>Events, structured logs, deterministic timers, orchestration and realtime connections.</td></tr>
</tbody>
</table>
@ -184,15 +192,17 @@ const Cache = createEngineCache({
<tr><td><code>createEngineXxx()</code></td><td>You need deterministic, non-runes runtime logic.</td><td><code>createEngineHttp</code>, <code>createEngineTimers</code>, <code>createEngineSium</code></td></tr>
<tr><td><code>createActiveXxx()</code></td><td>You need Svelte 5 reactive state and lifecycle.</td><td><code>createActiveStorage</code>, <code>createActiveConnections</code></td></tr>
<tr><td><code>$svrs/createEngineXxx()</code></td><td>The result must be server-authoritative.</td><td><code>createEngineAuth</code>, <code>createEnginePerms</code>, <code>createEngineCache</code></td></tr>
<tr><td><code>App.logger / App.bus / App.timers / App.orca / App.prefs</code></td><td>You need the fixed App core.</td><td>Always present; configured from root options, never declared as services.</td></tr>
<tr><td><code>defineActiveXxx() / defineEngineXxx()</code></td><td>You want App to declare a service in its schema and inject core deps automatically.</td><td><code>defineEngineSium({`{}`})</code>, <code>defineActiveAuth({`{...}`})</code></td></tr>
</tbody>
</table>
<h2>App composition</h2>
<p>
<code>createActiveApp()</code> gives the browser/client side a stable surface. Some roots
are always present; feature-scoped pieces are created explicitly so pages only pay for what
they use.
<code>createActiveApp()</code> gives the browser/client side a stable surface. The fixed
core is always present: <code>Logger</code>, <code>Bus</code>, <code>Timers</code>,
<code>Orca</code> and <code>Prefs</code>. Feature modules are declared explicitly in
<code>services</code>, so pages only pay for what the app composes.
</p>
<CodeBlock code={appFlow} lang="ts" title="Client composition" />
@ -213,16 +223,15 @@ const Cache = createEngineCache({
<h2>Identity flow</h2>
<p>
Identity-sensitive work follows a stricter chain. <code>auth</code> proves identity,
<code>sess</code> keeps continuity, <code>perm</code> decides access, and
<code>$session</code> keeps continuity, <code>$perm</code> decides access, and
<code>cache</code> must scope or invalidate private data.
</p>
<CodeBlock code={identityFlow} lang="text" />
<h2>Locale flow</h2>
<p>
Locale is another ecosystem-wide source of truth. Text, formatting and frontend direction
all respond to the same locale source unless the user explicitly overrides a specific
preference.
Preferences are the ecosystem-wide source of user intent. Translation language, regional
format locale and frontend direction can be related, but they are not the same value.
</p>
<CodeBlock code={localeFlow} lang="text" />
@ -236,15 +245,15 @@ const Cache = createEngineCache({
</tr>
</thead>
<tbody>
<tr><td>Translate labels, messages or fallbacks.</td><td><code>lang</code></td><td><code>fmts</code> or ad-hoc dictionaries.</td></tr>
<tr><td>Format numbers, dates, currency or units.</td><td><code>fmts</code></td><td><code>lang</code>.</td></tr>
<tr><td>Persist non-secret preferences or drafts.</td><td><code>stor</code></td><td><code>sess</code>, localStorage calls spread through pages.</td></tr>
<tr><td>Keep logged-in continuity.</td><td><code>sess</code></td><td><code>auth</code> alone.</td></tr>
<tr><td>Translate labels, messages or fallbacks.</td><td><code>$lang</code></td><td><code>$format</code> or ad-hoc dictionaries.</td></tr>
<tr><td>Format numbers, dates, currency or units.</td><td><code>$format</code></td><td><code>$lang</code>.</td></tr>
<tr><td>Persist non-secret preferences or drafts.</td><td><code>$storage</code></td><td><code>$session</code>, localStorage calls spread through pages.</td></tr>
<tr><td>Keep logged-in continuity.</td><td><code>$session</code></td><td><code>$auth</code> alone.</td></tr>
<tr><td>Prove identity or run login/recovery flows.</td><td><code>$svrs/auth</code> plus <code>$auth</code></td><td><code>perm</code> or client-only checks.</td></tr>
<tr><td>Decide if an actor can do something.</td><td><code>$svrs/perm</code> plus <code>$perm</code></td><td><code>auth</code>, roles hard-coded in UI.</td></tr>
<tr><td>Cache data with scopes and invalidation.</td><td><code>$svrs/cache</code> or <code>$cache</code></td><td><code>stor</code> as a query cache.</td></tr>
<tr><td>Cache data with scopes and invalidation.</td><td><code>$svrs/cache</code> or <code>$cache</code></td><td><code>$storage</code> as a query cache.</td></tr>
<tr><td>Schedule retries, refreshes or timeouts.</td><td><code>timer</code></td><td>raw <code>setTimeout</code> scattered across modules.</td></tr>
<tr><td>Open realtime sockets and channels.</td><td><code>conn</code></td><td>custom WebSocket state in components.</td></tr>
<tr><td>Open realtime sockets and channels.</td><td><code>$connection</code></td><td>custom WebSocket state in components.</td></tr>
<tr><td>Validate forms and generate issues.</td><td><code>sium</code></td><td><code>perm</code> or manual string errors.</td></tr>
</tbody>
</table>
@ -285,8 +294,8 @@ const Cache = createEngineCache({
</thead>
<tbody>
<tr><td>Unit</td><td><code>src/arts/*/test</code>, <code>src/libs/*/test</code>, <code>src/svrs/*/test</code></td><td>Each artifact obeys its own contract.</td></tr>
<tr><td>Integration</td><td><code>src/arts/aapp/test</code></td><td>App wiring, locale propagation, session bridge, disposal order.</td></tr>
<tr><td>Scenario</td><td><code>/test/ecosystem</code></td><td>A realistic app story with auth, sess, perm, cache, http, sium, conn and UI state together.</td></tr>
<tr><td>Integration</td><td><code>src/arts/active-app/test</code></td><td>App wiring, prefs propagation, orca presets, session bridge, disposal order.</td></tr>
<tr><td>Scenario</td><td><code>/test/ecosystem</code></td><td>A realistic app story with auth, session, perm, cache, http, sium, connection and UI state together.</td></tr>
</tbody>
</table>

@ -40,24 +40,29 @@
title="svelte.config.js"
lang="js"
code={`alias: {
$active-app: 'src/arts/aapp',
$adom: 'src/arts/adom',
$auth: 'src/arts/auth',
$cache: 'src/arts/cach',
$connection: 'src/arts/conn',
$frontend: 'src/arts/fend',
$format: 'src/arts/fmts',
$http: 'src/arts/http',
$lang: 'src/arts/lang',
$logger: 'src/arts/logr',
$perm: 'src/arts/perm',
$session: 'src/arts/sess',
$sium: 'src/arts/sium',
$storage: 'src/arts/stor',
$svrs: 'src/svrs',
$timer: 'src/arts/timer',
$libs: 'src/libs',
$locale: 'src/libs/locale',
'$active-app/services': 'src/arts/active-app/service-factories',
'$active-app/presets': 'src/arts/active-app/presets',
'$active-app': 'src/arts/active-app',
$adom: 'src/arts/adom',
$auth: 'src/arts/auth',
$bus: 'src/arts/bus',
$cache: 'src/arts/cache',
$connection: 'src/arts/connection',
$frontend: 'src/arts/frontend',
$format: 'src/arts/format',
$http: 'src/arts/http',
$lang: 'src/arts/lang',
$logger: 'src/arts/logger',
$orca: 'src/arts/orca',
$perm: 'src/arts/perm',
$prefs: 'src/arts/prefs',
$session: 'src/arts/session',
$sium: 'src/arts/sium',
$storage: 'src/arts/storage',
$svrs: 'src/svrs',
$timer: 'src/arts/timer',
$libs: 'src/libs',
$locale: 'src/libs/locale',
$reactive: 'src/libs/reactive'
}`}
/>
@ -88,7 +93,7 @@
<p>
As a result, the minimum <code>createActiveApp(&#123;&#125;)</code> import has a measured budget
instead of a guess. <code>npm run test:bundle</code> builds a virtual Vite entry with OXC,
gzips the emitted JavaScript and fails above the <code>0.1</code> budget.
gzips the emitted JavaScript and fails above the configured budget.
</p>
<CodeBlock
@ -103,7 +108,7 @@ ACTIVE_BUNDLE_GZIP_LIMIT_KB=70 npm run test:bundle`}
<p>
The default budget is <code>70 KB gzip</code>. That is intentionally a smoke gate, not a
micro-benchmark: it protects the root runtime from accidental graph explosions while still
leaving room for the always-present App contract.
leaving room for the fixed App core.
</p>
<h2>First app</h2>
@ -119,32 +124,45 @@ ACTIVE_BUNDLE_GZIP_LIMIT_KB=70 npm run test:bundle`}
export const App = createActiveApp();
App.Logger.info('app.boot', 'Active app ready');`}
App.logger.info('app.boot', 'Active app ready');`}
/>
<p>
Even with no options, every member of <code>App</code> is present. <code>App.lang</code>
falls back to a passthrough mono-locale, <code>App.Logger</code> to a console transport,
<code>App.format</code> to the default locale. Call sites stay uniform whether or not the
feature is configured.
Even with no options, the fixed core is present: <code>App.logger</code>,
<code>App.bus</code>, <code>App.timers</code>, <code>App.orca</code> and
<code>App.prefs</code>. Feature modules are added explicitly under <code>services</code>.
</p>
<h2>Realistic setup</h2>
<p>For a typed i18n schema, console logger, and persisted preferences:</p>
<p>For typed i18n, preferences, formatting, frontend state and storage:</p>
<CodeBlock
lang="ts"
title="src/lib/app.ts"
code={`import { createActiveApp } from '$active-app';
import {
defineActiveLang,
defineActiveFormat,
defineActiveFrontend,
defineActiveStorage
} from '$active-app/services';
import { LogLevel, consoleTransport } from '$logger';
import { localAdapter } from '$storage';
import { schema } from './i18n/schema';
export const App = createActiveApp({
lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] },
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
storage: { adapter: localAdapter },
frontend: { theme: 'base', persist: ['theme', 'mode'] }
prefs: {
capabilities,
environment,
intent: { language: 'es', locale: 'es-ES', theme: 'system' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
format: defineActiveFormat(),
frontend: defineActiveFrontend({ theme: 'base' }),
storage: defineActiveStorage({ adapter: localAdapter })
}
});`}
/>

@ -3,21 +3,27 @@
import Callout from '../../_components/Callout.svelte';
import PageNav from '../../_components/PageNav.svelte';
const stableSurface = `// Always-present roots are snapshot-tested for the 0.1 line.
const stableSurface = `// Fixed App core for the 1.0 line.
const App = createActiveApp();
App.Logger;
App.lang;
App.format;
App.frontend;
App.dom;
App.storage;
App.http;
App.Timers;
App.cache;`;
const scopedSurface = `// Schema-declared services are stable by slot name, but their option
// shapes may still grow during 0.1.x if the change is additive.
App.logger;
App.bus;
App.timers;
App.orca;
App.prefs;
App.dispose();`;
const scopedSurface = `// Core options (logger, timers, bus, orca, prefs) and schema-declared
// services are stable by slot name, but their option shapes may grow
// during 1.x if the change is additive.
createActiveApp({ prefs: { capabilities }, services: { /* ... */ } });
defineActiveLang({ schema });
defineActiveFormat({});
defineActiveFrontend({});
defineActiveStorage({});
defineEngineHttp({});
defineActiveCache({});
defineEngineSium({});
defineActiveSession({ /* ... */ });
defineActiveConnections({ /* ... */ });
@ -26,7 +32,7 @@ defineActivePerm({ /* ... */ });`;
const deprecation = `/**
* @deprecated Use setCurrency('auto') or clearCurrency() instead.
* Deprecated in 0.1.3. Earliest removal: 0.2.0.
* Deprecated in 1.0.3. Earliest removal: 2.0.0.
*/
function resetCurrency(): void {
logger.warn('format.currency.deprecated.reset_currency', {
@ -40,7 +46,7 @@ function resetCurrency(): void {
verifyRegistration
};
// Experimental exports may change without the 0.1.x stability guarantee.
// Experimental exports may change without the 1.x stability guarantee.
// They must be clearly named and documented as experimental.`;
</script>
@ -57,33 +63,37 @@ function resetCurrency(): void {
<h1>Versioning</h1>
<p class="lead">
Active is pre-<code>0.1.0</code>. The first stable cut is not a promise of production
maturity; it is a promise that the public runtime shape will stop moving silently.
Active is being hardened for <code>1.0</code>. The stable cut is a contract: public
runtime shape, documented module boundaries and release checks must stop moving silently.
</p>
<h2>What 0.1 means</h2>
<h2>What 1.0 means</h2>
<p>
<code>0.1.x</code> is the line where external developers can evaluate and build against
the framework without reading every commit. The guarantee is focused: always-present App
roots keep their public shape, security-sensitive exported methods either work or are not
exported, and breaking changes are announced through deprecation before removal.
<code>1.0.x</code> is the line where application code can build against the ecosystem
without reading every commit. The guarantee is focused: the fixed App core keeps its
public shape, services are declared through stable schema slots, security-sensitive
exported methods either work or are not exported, and breaking removals wait for a major
version.
</p>
<Callout variant="info" title="Not production-ready">
<Callout variant="info" title="Stable contract, explicit scope">
<p>
<code>0.1.0</code> does not mean OAuth provider catalog, MFA, production WebAuthn,
regulated workload readiness or npm distribution are complete. Those are explicit
future milestones.
<code>1.0</code> means the documented runtime contract is stable. It does not require
every possible product feature to exist: OAuth provider catalog, MFA, production
WebAuthn, regulated workload readiness and npm distribution remain explicit future
milestones unless they are documented as part of the release.
</p>
</Callout>
<h2>Stable during 0.1.x</h2>
<h2>Stable during 1.x</h2>
<p>
The always-present App roots are the core contract. They are snapshot-tested in
<code>src/arts/aapp/test/active-app.test.ts</code> and changes should be additive unless
they go through the deprecation process.
The fixed App core is the baseline contract: <code>Logger</code>, <code>Bus</code>,
<code>Timers</code>, <code>Orca</code>, <code>Prefs</code> and <code>dispose()</code>.
The service schema and preset behavior are covered under <code>src/arts/active-app/test</code>;
changes should be additive unless they go through the deprecation process and wait for the
next major.
</p>
<CodeBlock code={stableSurface} lang="ts" title="Always-present App surface" />
<CodeBlock code={stableSurface} lang="ts" title="Fixed App core" />
<table>
<thead>
@ -94,7 +104,7 @@ function resetCurrency(): void {
</tr>
</thead>
<tbody>
<tr><td>Root property names</td><td>No silent removal or rename.</td><td>Additive new roots only after docs/tests.</td></tr>
<tr><td>Core property names</td><td>No silent removal or rename.</td><td>Additive core members only after docs/tests.</td></tr>
<tr><td>Existing method names</td><td>No silent removal or semantic inversion.</td><td>Optional parameters and overloads.</td></tr>
<tr><td>Error classes and guards</td><td>Keep type guards valid.</td><td>New error subclasses/codes.</td></tr>
<tr><td>Constants for public strings</td><td>Names stay searchable and centralized.</td><td>New constants for new events/routes/methods.</td></tr>
@ -104,11 +114,11 @@ function resetCurrency(): void {
<h2>Scoped factories</h2>
<p>
Scoped factories are stable entry points, but some child surfaces are still younger than
the core roots. They may grow during <code>0.1.x</code>, especially <code>Auth</code>,
<code>Perms</code>, <code>Connections</code> and <code>Sium</code>.
Service factories are stable entry points. Their child surfaces may grow during
<code>1.x</code> when the change is additive, especially <code>Auth</code>,
<code>Perm</code>, <code>Connections</code>, <code>Orca</code> and <code>Sium</code>.
</p>
<CodeBlock code={scopedSurface} lang="ts" title="Scoped App factories" />
<CodeBlock code={scopedSurface} lang="ts" title="Service-schema factories" />
<p>
Additive changes are allowed. Removing a method, changing a return shape, moving a method
@ -117,8 +127,9 @@ function resetCurrency(): void {
<h2>Deprecation policy</h2>
<p>
Deprecated APIs remain compatible for at least one minor release. The deprecated path must
have JSDoc, docs, tests, and a runtime warning only when the old path is actually used.
Deprecated stable APIs remain compatible through the <code>1.x</code> line. The deprecated
path must have JSDoc, docs, tests, and a runtime warning only when the old path is actually
used. Removal belongs to <code>2.0</code> unless the API was explicitly experimental.
</p>
<CodeBlock code={deprecation} lang="ts" title="Deprecation pattern" />
@ -133,7 +144,7 @@ function resetCurrency(): void {
<tbody>
<tr><td>Mark</td><td>Add <code>@deprecated</code> JSDoc with replacement and earliest removal.</td><td>Editors and docs surface the migration.</td></tr>
<tr><td>Warn</td><td>Emit a constant-backed warning when the old path is used.</td><td>Runtime users discover it without log spam.</td></tr>
<tr><td>Keep</td><td>Maintain compatibility through at least one minor.</td><td>No silent breakage inside <code>0.1.x</code>.</td></tr>
<tr><td>Keep</td><td>Maintain compatibility through the current major.</td><td>No silent breakage inside <code>1.x</code>.</td></tr>
<tr><td>Test</td><td>Keep a regression test for old and new paths until removal.</td><td>Deprecation is behavior, not a comment.</td></tr>
<tr><td>Remove</td><td>Remove only after the documented window and changelog entry.</td><td>Consumers can plan upgrades.</td></tr>
</tbody>
@ -143,7 +154,7 @@ function resetCurrency(): void {
<p>
If a feature is useful but not contract-ready, it must be exported under an explicit
<code>__EXPERIMENTAL_*</code> name. Experimental APIs are not covered by the
<code>0.1.x</code> stability guarantee.
<code>1.x</code> stability guarantee.
</p>
<CodeBlock code={experimental} lang="ts" title="Experimental namespace" />

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.