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
parent
3ca1945dd9
commit
7adf93ca57
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,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;
|
||||
}
|
||||
|
||||
@ -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,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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue