7.7 KiB
Prefs
prefs is the active preferences artifact. Its job is to resolve, generically,
the relationship between user intent, detected environment and effective value
for a schema declared by the app.
It does not translate, does not format, does not persist by itself, and does not
write the DOM except when the composition root explicitly wires
createActivePrefsDomProjection(...).
Status 2026-05-14
Current decisions:
prefs.languagefeedslangs.prefs.localefeedsformat.prefs.directionresolves the effective direction.prefs.motion,prefs.soundandprefs.hapticare cross-cutting perception/interaction preferences.createActivePrefsDomProjection(...)projects onlydir,lang,data-motion,data-soundanddata-haptic.langtravels withdirbecause they answer the same question about the document and the browser reads BOTH from the DOM — fonts, hyphenation, quote glyphs and every screen reader take the language from there. A schema without thelanguagedimension yields no slot, so nothing is projected and an app that ownslangserver-side (i18n by routing) is untouched.- Visual
theme,modeanddensitybelong toActiveEidos, not to the core preset ofprefs,ActiveApporActiveUix. - There is no
themeDimension(...)ordensityDimension(...)in the public prefs catalog: if an app needs custom dimensions, it uses the generic primitives (enumDimension,stringDimension, etc.) or its ownPrefsDimension.
Composition Rule
Only composition roots create ActivePrefs:
ActiveAppcreates or receivesprefs.createActiveUix(...)createsprefswhen UIX boots standalone.attachActiveUix(app)reusesapp.prefs.
Consuming layers read specific slots or receive scoped views. They must not create another compensatory preferences instance.
ActiveApp/createActiveUix -> ActivePrefs
langs -> prefs.language
format -> prefs.locale, currency, timezone, unitSystem
ActivePrefsDomProjection -> direction, language, motion, sound, haptic
ActiveEidos -> its own visual theme/mode/density
Schema Model
The current implementation is schema-based:
type PrefsSchema = Record<string, PrefsDimension<TIntent, TEffective>>;
Each dimension declares:
defaultValue: fallback value.validate(value): validates user intent.resolve(intent, env): optional; turns intent + environment into the effective value.catalog(): optional; list of selectable values.
The engine keeps three planes:
intent = what the user explicitly chose
environment = what the server/browser/system suggest
effective = the final value consumers read
Only intent is persisted. environment is recomputed and effective is
derived.
Standard Preset
standardPrefsDimensions(catalog) composes the cross-cutting preset:
const schema = {
...standardPrefsDimensions({
languages: ['es', 'en'],
locales: ['es-ES', 'en-US'],
currencies: ['EUR', 'USD'],
defaults: {
language: 'es',
locale: 'es-ES',
currency: 'EUR'
}
}),
sidebarCollapsed: booleanDimension({ default: false })
};
Includes:
language
locale
currency
timezone
unitSystem
motion
sound
haptic
direction
Does not include:
theme
mode
density
Those values are visual in UIX. A shell must pass them to ActiveEidos via
theme, modeSource and densitySource.
Active Surface
createActivePrefs({ schema }) returns a reactive surface with one slot per
dimension:
const prefs = createActivePrefs({ schema });
prefs.locale.get();
prefs.locale.set('en-US');
prefs.locale.clear();
prefs.locale.onChange((locale) => {});
prefs.locale.catalog();
It also exposes generic methods for adapters:
prefs.setIntent('locale', 'en-US');
prefs.clearIntent('locale');
prefs.resetIntent();
prefs.patchEnvironment({ reducedMotion: true });
prefs.refreshEnvironment(nextEnvironment);
prefs.subscribe((event) => {});
prefs.dispose();
Services that receive an open ActivePrefs and do not know its schema at
compile time should read defensively:
const slot = readActivePrefsSlot<Locale>(prefs, 'locale');
const locale = slot?.get();
If the slot does not exist, the consumer decides whether it can degrade or must throw its own configuration error.
Environment
The environment comes in through adapters. No dimension reads window, cookies,
headers, localStorage or the DOM directly.
Available adapters:
detectServerEnvironment(input)detectBrowserEnvironment(overrides?)applyBrowserEnvironment(engine, overrides?)watchBrowserEnvironment(apply, overrides?)—applyreceives the environment patch ((patch) => void);overridesis only{ matchMedia }
Environment examples:
Accept-Language -> language/locale candidates
Intl timezone -> timezone
matchMedia -> reducedMotion/colorScheme
navigator -> languages, reduced sound/haptics when available
colorScheme can exist in the environment because the browser exposes it, but
UIX does not turn it into prefs.theme; ActiveEidos can read the system
through its own modeSource.
DOM Projection
ActivePrefs does not write the DOM by itself. If the app wants global
attributes, it wires the projector:
const prefsProjection = createActivePrefsDomProjection({
prefs: App.prefs,
dom: App.dom
});
The projector is idempotent, subscribes to the available slots and clears the
attributes it manages on dispose().
Attribute contract:
prefs.direction -> dir
prefs.language -> lang
prefs.motion -> data-motion
prefs.sound -> data-sound
prefs.haptic -> data-haptic
It does not project data-theme, data-mode or data-density.
The projected dir is the app-global half of the direction contract: the page
declares its direction once at the root and every component inherits it. How an
individual component obtains its own direction, and when it must assert one on
its own element, is the other half —
docs/canon/direction-contract.md.
Eidos Boundary
For a visual shell:
const uix = createActiveUix({ langs, prefs: { schema } });
const prefsProjection = createActivePrefsDomProjection({
prefs: uix.prefs,
dom: uix.dom
});
const eidos = ActiveEidos.create({
theme: 'base',
modeSource,
densitySource,
applyDom: true
});
Practical rule:
NO: uix.prefs.setIntent('theme', 'dark')
YES: modeSource notifies 'dark' to ActiveEidos
If a non-UIX app decides to declare its own visual dimension in prefs, that is
a local contract of that app. It must not leak into ActiveUix, Soma, Sema or
Morfo.
Storage
createPrefsStorageBridge(...) persists intents, not effective values:
const bridge = createPrefsStorageBridge({
engine, // EnginePrefs
storage, // PrefsIntentStorage
onError: (error, op) => report(error, op), // optional
skipHydrate: false // optional (default false)
});
Rules:
- Persist only
intent. - Do not persist
environment. - Do not persist
effective. - Do not write during hydrate unless explicitly configured.
- A storage failure must not corrupt in-memory preferences.
Errors
Public errors use the prefs::* family:
prefs::unknown_dimensionprefs::intent_invalidprefs::reserved_keyprefs::disposed
Known example:
prefs::unknown_dimension: [prefs] no such dimension in schema: theme
In UIX that error usually means a shell tried to write prefs.theme. The fix is
to pass the visual mode to ActiveEidos.
Tests
npx vitest run src/arts/prefs/test/engine-prefs.test.ts
npx vitest run src/arts/prefs/test/active-prefs.svelte.test.ts
npx vitest run src/arts/prefs/test/dom-projection.test.ts