21 KiB
Prefs
prefs is the Active preference resolution module. It is part of the
core (App.prefs) and is generic over a user-defined PrefsSchema = Record<string, PrefsDimension<TIntent, TEffective>>.
Handoff 2026-05-13
prefs es la pieza que debe resolver las preferencias compartidas entre
ActiveApp y las capas consumidoras, no una bolsa generica duplicada por cada
capa. Queda decidido:
prefs.languagealimentalangs;prefs.localealimentaformat;prefs.directionresuelve la direccion efectiva;prefs.motion,prefs.soundyprefs.hapticson preferencias transversales de percepcion/interaccion;createActivePrefsDomProjection(...)proyecta esas preferencias al DOM cuando el composition root le pasa unActiveDom;theme,modeydensityson visuales y pertenecen a Eidos, no al preset core deprefsni aActiveUix.
Heads-up: the sections below describe the original four-layer design (capabilities + environment + intent → effective). The current implementation is schema-based: each preference is a
PrefsDimensionthat owns its own validator, environment-fed resolver and (optionally) sibling-derived value. Built-in dimensions (localeDimension,motionDimension, …) live inarts/prefs/dimensions/*and thestandardPrefsDimensions(catalog)preset composes the canonical set. The active surface exposes one slot per schema key with.get()/.set()/.clear()/.onChange()verbs:createActiveApp({ prefs: { schema: { ...standardPrefsDimensions({ languages, locales, currencies }), sidebarCollapsed: booleanDimension({ default: false }) } } }); App.prefs.locale.get(); App.prefs.locale.set('es-ES'); App.prefs.sidebarCollapsed.set(true);The historical text below is kept for archival reference until this README is rewritten in full.
Core Rule
prefs is not a Svelte store and must not depend on svelte/store.
The core is a pure preference resolver:
capabilities + environment + intent -> effective
The Svelte layer is only an adapter around that core:
libs/prefs = pure contracts, validation and resolution
arts/prefs = engine + active rune adapter
active-app later = composition and wiring
No resolver may read window, navigator, cookies, headers, localStorage,
IndexedDB, Svelte stores, DOM APIs or framework services directly. Those are
adapters/ports.
Purpose
prefs owns application preferences that influence translation, formatting and
UI presentation.
It answers:
- Which locale does the user explicitly want?
- Which locale did the server or browser detect?
- Which values does this application support?
- Which values are effective after validation and fallback?
- Which subset should be persisted as user intent?
It does not translate, format, persist by itself, apply DOM changes, own security policy, or become a generic settings bag.
Boundary:
Prefs = preference resolution
Langs = translation/catalog lookup
Format = number/date/currency/unit formatting
Prefs DOM projector = optional cross-modal DOM attrs
Eidos = visual theme/mode/density projection
Storage = persistence adapter
Bus = optional event publication bridge
Orca = optional orchestration bridge
Conceptual Model
There are four state layers plus defaults.
capabilities = what the application can offer
environment = what the current request/browser/system suggests
intent = what the user explicitly selected
effective = what the system actually uses
defaults = final fallback inside capabilities
Rules:
capabilitiesis declared by application composition.environmentcan contain unsupported values.intentis sparse and stores only explicit user choices.effectiveis total, derived and always valid against capabilities.defaultsare part of capabilities and must be valid.- Only
intentis persisted. environmentis recalculated.effectiveis never persisted.
Resolution:
effective[key] =
valid(intent[key], capabilities)
?? resolveFrom(environment, capabilities, key)
?? capabilities.defaults[key]
If an old persisted intent becomes unsupported after an app update, it is not
deleted automatically. It is ignored for effective until capabilities allow it
again or the app explicitly migrates/clears it.
Naming
Use:
capabilities
environment
intent
effective
defaults
Avoid:
allowed // sounds like permissions/security
settings // too broad; becomes a junk drawer
current // ambiguous
active // already overloaded in Active
chosen // ambiguous between intent and effective
formatLocale // duplicate source of truth
Domain Types
export type PrefsLocale = string;
export type PrefsCurrency = string;
export type PrefsTimezone = string;
export type PrefsUnitSystem = 'metric' | 'imperial';
export type PrefsThemeIntent = 'light' | 'dark' | 'system';
export type PrefsThemeEffective = 'light' | 'dark';
export type PrefsDensity = 'compact' | 'comfortable' | 'spacious';
export type PrefsMotion = 'allow' | 'reduce' | 'system';
export type PrefsMotionEffective = 'allow' | 'reduce';
export type PrefsSoundEffective = 'allow' | 'reduce';
export type PrefsDirection = 'ltr' | 'rtl';
system can be a real intent for theme/motion. It means "the user explicitly
wants to follow the environment". It must not appear in effective.
Capabilities
capabilities is the legal universe of user-selectable values.
export interface PrefsCapabilities {
readonly languages: readonly PrefsLocale[];
readonly locales: readonly PrefsLocale[];
readonly currencies: readonly PrefsCurrency[];
readonly unitSystems: readonly PrefsUnitSystem[];
readonly themes: readonly PrefsThemeIntent[];
readonly densities: readonly PrefsDensity[];
readonly motions: readonly PrefsMotion[];
readonly sounds: readonly PrefsSoundEffective[];
readonly timezones?: readonly PrefsTimezone[];
readonly defaults: PrefsEffective;
}
The app composes capabilities. prefs does not discover them by importing other
modules.
Example future composition:
const capabilities: PrefsCapabilities = {
languages: intersect(appConfig.languages, Langs.availableLocales),
locales: intersect(appConfig.locales, Langs.availableLocales),
currencies: appConfig.currencies,
unitSystems: appConfig.unitSystems,
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
sounds: ['allow', 'reduce'],
defaults: {
locale: 'es-ES',
language: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
sound: 'allow',
direction: 'ltr'
}
};
If Langs does not provide es-MX and the application requires user-selectable
locales to exist in Langs, then es-MX must not be in capabilities.locales.
Environment
environment is detected context. It is not user intent.
export interface PrefsEnvironment {
readonly locales?: readonly PrefsLocale[];
readonly timezone?: PrefsTimezone;
readonly currency?: PrefsCurrency;
readonly region?: string;
readonly unitSystem?: PrefsUnitSystem;
readonly colorScheme?: 'light' | 'dark';
readonly reducedMotion?: boolean;
readonly source?: 'server' | 'browser' | 'mixed' | 'test';
}
Examples:
Accept-Language: es-MX,es;q=0.9,en;q=0.8
navigator.languages: ['es-MX', 'es', 'en-US']
Intl timezone: America/Mexico_City
matchMedia prefers-color-scheme: dark
Environment may contain values outside capabilities. That is useful diagnostic information and should not be confused with selectable values.
Intent
intent is what the user explicitly selected.
It is sparse. Missing means "derive it".
export interface PrefsIntent {
readonly language?: PrefsLocale;
readonly locale?: PrefsLocale;
readonly currency?: PrefsCurrency;
readonly timezone?: PrefsTimezone;
readonly unitSystem?: PrefsUnitSystem;
readonly theme?: PrefsThemeIntent;
readonly density?: PrefsDensity;
readonly motion?: PrefsMotion;
readonly sound?: PrefsSoundEffective;
readonly direction?: PrefsDirection;
}
Intent writes must validate against capabilities.
setIntent('locale', 'es-ES') -> ok if capabilities.locales includes es-ES
setIntent('locale', 'es-MX') -> rejected if capabilities.locales excludes es-MX
setIntent('currency', 'MXN') -> rejected if capabilities.currencies excludes MXN
Do not use undefined as a write command. Clearing intent must be explicit:
Prefs.setIntent('locale', 'es-ES');
Prefs.clearIntent('locale');
Prefs.resetIntent();
Effective
effective is the only layer consumers should read.
export interface PrefsEffective {
readonly language: PrefsLocale;
readonly locale: PrefsLocale;
readonly currency: PrefsCurrency;
readonly timezone: PrefsTimezone;
readonly unitSystem: PrefsUnitSystem;
readonly theme: PrefsThemeEffective;
readonly density: PrefsDensity;
readonly motion: PrefsMotionEffective;
readonly sound: PrefsSoundEffective;
readonly direction: PrefsDirection;
}
effective is total, readonly and always valid.
Langs reads effective.language
Format reads effective.locale, currency, timezone, unitSystem
PrefsDomProjection reads effective.direction, motion, sound, haptic
Eidos owns visual theme/mode/density
language and locale are intentionally distinct. language drives
translations and writing direction; locale drives regional formatting. There
is no formatLocale alias: Format uses the effective locale that the app
allowed.
Snapshot
export interface PrefsSnapshot {
readonly capabilities: PrefsCapabilities;
readonly environment: PrefsEnvironment;
readonly intent: PrefsIntent;
readonly effective: PrefsEffective;
readonly version: number;
}
Snapshots are serializable and immutable. Consumers must not mutate returned objects.
Resolver
The resolver is pure.
export interface PrefsResolveInput {
readonly capabilities: PrefsCapabilities;
readonly environment: PrefsEnvironment;
readonly intent: PrefsIntent;
}
export function resolvePrefs(input: PrefsResolveInput): PrefsEffective;
The resolver does not persist, emit events, read browser APIs or touch Svelte state. It only returns data.
Locale Matching
Locale matching should use canonical locale data, not string splitting.
Recommended order:
1. exact match
2. language + script match
3. language + region match
4. language match
5. default locale
Example:
environment.locales = ['es-MX', 'en-US']
capabilities.locales = ['es-ES', 'en-US']
effective.locale = 'es-ES'
environment.locales keeps es-MX; effective.locale uses es-ES.
Pseudo-helper:
function safeLocale(tag: string): Intl.Locale | undefined {
try {
return new Intl.Locale(Intl.getCanonicalLocales(tag)[0]);
} catch {
return undefined;
}
}
The exact implementation belongs in libs/prefs/match-locale.ts.
Currency
Currency is strict against capabilities.
intent.currency -> accepted only if in capabilities.currencies
environment.currency -> used only if in capabilities.currencies
default.currency -> must be in capabilities.currencies
Locale does not imply currency. A user can use en-US with EUR if the app
allows that currency.
Timezone
Timezone may be configured as either:
open IANA validation
closed capabilities.timezones allowlist
Validation should canonicalize when possible:
function normalizeTimezone(timezone: string): string | undefined {
try {
return new Intl.DateTimeFormat('en-US', { timeZone: timezone })
.resolvedOptions()
.timeZone;
} catch {
return undefined;
}
}
If capabilities.timezones exists, the normalized timezone must be present in
that list.
Engine Contract
EnginePrefs is the non-Svelte runtime.
export interface EnginePrefs {
readonly kind: 'prefs';
snapshot(): PrefsSnapshot;
capabilities(): PrefsCapabilities;
environment(): PrefsEnvironment;
intent(): Readonly<PrefsIntent>;
effective(): PrefsEffective;
setIntent<K extends keyof PrefsIntent>(
key: K,
value: NonNullable<PrefsIntent[K]>
): PrefsSnapshot;
clearIntent<K extends keyof PrefsIntent>(key: K): PrefsSnapshot;
resetIntent(next?: PrefsIntent): PrefsSnapshot;
refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot;
patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot;
setCapabilities(next: PrefsCapabilities): PrefsSnapshot;
subscribe(handler: PrefsChangeHandler): PrefsUnsubscribe;
dispose(): void;
}
Writes recompute effective and notify subscribers only when the effective view
or relevant snapshot layer changes.
dispose() must be idempotent.
Active Contract
ActivePrefs is a Svelte rune adapter over EnginePrefs.
It may use $state/$derived inside .svelte.ts, but the public contract is
still the Active interface. It must not expose svelte/store as the module
contract.
export interface ActivePrefs extends EnginePrefs {
readonly state: {
readonly snapshot: PrefsSnapshot;
readonly effective: PrefsEffective;
readonly pending: boolean;
readonly lastError: unknown;
};
}
The active layer must not invent different rules. All validation and resolution comes from the core.
Adapters And Ports
External IO lives behind ports.
export interface PrefsEnvironmentDetector {
detect(): PrefsEnvironment;
}
export interface PrefsIntentStorage {
load(): PrefsIntent | null | Promise<PrefsIntent | null>;
save(intent: PrefsIntent): void | Promise<void>;
clear(): void | Promise<void>;
}
prefs should not import storage; a bridge connects them later:
createPrefsStorageBridge({
prefs,
storage,
key: 'active:prefs'
});
Storage bridge rules:
- Persist only
intent. - Do not persist
environment. - Do not persist
effective. - Do not persist secrets.
- Do not write during initial hydrate unless explicitly configured.
- Storage failures must not corrupt in-memory preferences.
Consumer Ports
Consumers should define ports in their own modules. prefs can satisfy those
ports, but it should not know them.
Example future ports:
// langs
export interface LangsPrefsPort {
readonly locale: PrefsLocale;
subscribe(handler: (locale: PrefsLocale) => void): PrefsUnsubscribe;
}
// format
export interface FormatPrefsPort {
readonly locale: PrefsLocale;
readonly currency: PrefsCurrency;
readonly timezone: PrefsTimezone;
readonly unitSystem: PrefsUnitSystem;
subscribe(handler: (view: FormatPrefsView) => void): PrefsUnsubscribe;
}
// active-uix / eidos
export interface UixPrefsPort {
readonly theme: PrefsThemeEffective;
readonly density: PrefsDensity;
readonly motion: PrefsMotionEffective;
readonly sound: PrefsSoundEffective;
readonly direction: PrefsDirection;
subscribe(handler: (view: UixPrefsView) => void): PrefsUnsubscribe;
}
Consumers build filtered views/proxies from App.prefs or uix.prefs.
prefs itself remains unaware of langs, format, uix, eidos or DOM.
Events
The engine exposes local subscriptions. It does not import buss.
export interface PrefsChangeEvent {
readonly previous: PrefsSnapshot;
readonly next: PrefsSnapshot;
readonly effectiveDiff: Partial<PrefsEffective>;
readonly cause:
| 'intent:set'
| 'intent:clear'
| 'intent:reset'
| 'environment:refresh'
| 'capabilities:set'
| 'hydrate';
}
Later, active-app can bridge this to Bus:
Prefs.subscribe((event) => {
App.bus.publish(PREFS_EVENT_CHANGED, event);
});
All event names must be constants.
Server And Browser Initialization
Server flow:
1. app builds capabilities
2. server detector reads request/session/profile/cookies/Accept-Language
3. optional server storage loads persisted intent
4. engine resolves snapshot synchronously
5. snapshot is serialized into SSR payload
Browser flow:
1. hydrate from server snapshot
2. browser detector reads navigator/matchMedia/Intl APIs
3. optional client storage loads persisted intent
4. engine re-resolves
5. emit change only if effective values changed
Hydration rule:
server snapshot wins first paint
browser detection may refine after hydration
browser detection must not override explicit intent
Avoid first-paint theme flashes at the UI shell boundary, not inside prefs:
an inline head script may apply early data-theme/data-mode before Svelte
mounts, then ActiveEidos adopts the same visual state during hydration.
Validation
Validation is field-specific:
locale -> capability membership, with environment matching fallback
currency -> strict capability membership
timezone -> IANA validation, optionally capability membership
unitSystem -> strict capability membership
motion -> strict capability membership; system allowed as intent
sound -> strict capability membership
haptic -> strict capability membership
theme and density factories still exist for apps that declare their own
custom preference schema, but they are no longer part of the standard core
preset. Eidos owns visual theme / mode / density.
DOM projection
ActivePrefs is pure state and never writes the DOM by itself. When an app
wants global preference attrs it wires the explicit projector:
import { createActivePrefsDomProjection } from '$prefs';
const prefsProjection = createActivePrefsDomProjection({
prefs: App.prefs,
dom: App.dom
});
The projector only owns cross-modal attrs:
prefs.direction -> dir
prefs.motion -> data-motion
prefs.sound -> data-sound
prefs.haptic -> data-haptic
Visual attrs belong to ActiveEidos:
eidos.theme -> data-theme
eidos.mode -> data-mode
eidos.density -> data-density
Invalid user writes should return/throw structured prefs errors when the
errs module is available. Until then, tests should assert stable error names.
Stability
The engine should avoid unnecessary reactions:
- Emit only when snapshot/effective data actually changes.
- Provide field-level diffs.
- Allow consumer ports to subscribe to focused views.
- Return readonly snapshots.
The resolver may create new objects internally, but subscribers should not be notified if values are shallow-equal.
Future File Layout
src/
libs/
prefs/
index.ts
consts.ts
types.ts
ports.ts
guards.ts
match-locale.ts
validate-intent.ts
resolve-prefs.ts
arts/
prefs/
index.ts
README.md
engine-prefs.ts
active-prefs.svelte.ts
adapters/
browser-environment.ts
server-environment.ts
storage-bridge.ts
test/
Implementation Agenda
- Implement
libs/prefsdomain types and constants. - Implement
match-locale.tswith exact/script/region/language/default order. - Implement field validation and intent sanitization.
- Implement pure
resolvePrefs(). - Implement
createEnginePrefs()with local subscriptions and diffs. - Implement
active-prefs.svelte.tsas a rune adapter over the engine. - Implement environment detector adapters.
- Implement optional storage bridge that persists only
intent. - Add tests for locale fallback, currency strictness, timezone normalization,
explicit clear, theme
system, hydration and dispose. - Only after that, integrate through
active-appasApp.prefs.
Test Agenda
Required tests before integration:
- Default snapshot is valid against capabilities.
- Intent writes reject unsupported values.
clearIntent()restores derived behavior without storingundefined.- Only
intentis persisted. environmentmay contain unsupported values.effectivenever contains unsupported values.- Locale matching handles exact, language/script, language/region and language.
- Currency never falls back by locale unless the app explicitly implements that policy in the resolver.
- Timezone is canonicalized or rejected according to policy.
theme: 'system'persists as intent but resolves tolightordark.- Browser detection cannot override explicit intent.
setCapabilities()revalidates without deleting old intent.- Subscribers receive diffs and can unsubscribe.
dispose()is idempotent.
IA Agents
When working on prefs:
- Do not wire it into
active-appunless explicitly requested. - Do not import
svelte/storein the core. - Do not import
langs,format,frontend,storage,buss,orcaoractive-appfromlibs/prefs. - Do not persist
environmentoreffective. - Do not create
formatLocale. - Do not let
Langsown preferences. - Do not let
Formatkeep parallel preference state. - Do not add arbitrary settings.
- Keep IO behind ports/adapters.
- Add tests before another artifact consumes
prefs.