14 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-09-14
Current decisions:
prefs.languagefeedslangs.prefs.localefeedsformat.prefs.directionresolves the effective direction.prefs.motion,prefs.soundandprefs.hapticare cross-cutting perception/interaction preferences.prefs.modeis one of them. See "One engine" below — the 2026-05-14 rule thatcolorSchememust not become a preference is REVOKED.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.modeDimension(...)ships in the built-in catalog;theme,densityandscalingare declared by the UIX composition root (createDefaultUixPrefsSchema,$active-uix/prefs-schema), because their vocabulary is UIX's andarts/prefsimports nothing fromsrc/uix.ActiveEidosstill OWNS the four attributes it writes (data-theme,data-mode,data-density,data-scaling) and the projection still must not touch them. What changed is where eidos READS from, not who writes.
One engine (revocation, 2026-09-14)
Until this date prefs refused the light/dark axis: colorScheme could sit in
the environment, but nothing turned it into a preference, and ActiveEidos
resolved mode / density / scaling through its own per-option sources. That
made TWO preference engines in one page, with two resolutions, two defaults and
no shared answer.
It was revoked for a concrete reason: a page cannot stamp its preference
attributes before hydration unless one function, given (schema, environment, intent), answers for ALL of them. mode is a cross-modal preference with
exactly the shape of motion — an intent the user picks (light / dark /
system), an environment hint the browser exposes, and an effective value the
page projects. Once it resolves through resolvePrefs, a pre-hydration script
compiled from these same modules reaches the values the runtime will apply, and
the dark-mode flash disappears.
What moved, and what did not:
mode -> arts/prefs/dimensions/mode.ts (vocabulary is $libs/theme)
theme -> $active-uix/prefs-schema (vocabulary is UIX's)
density -> $active-uix/prefs-schema
scaling -> $active-uix/prefs-schema
ActiveEidos still writes data-theme / data-mode / data-density /
data-scaling; the projection still must not
(UIX_LAYER_CONTRACTS.prefsDomProjection.forbiddenAttrs). Only the READ side
moved.
Persistence envelope
PrefsIntentStorage is a port, so before 2026-09-14 the bytes on disk were
whatever adapter an app happened to write. A second reader — the pre-hydration
boot — cannot read "whatever", so $libs/prefs now names one shape and one key:
import { createPrefsIntentDocument, readPrefsIntentDocument, PREFS_STORAGE_KEY } from '$libs/prefs';
key uix.prefs
kind uix.prefs-intent
version 1
body { intent } // INTENT only — never effective, never environment
readPrefsIntentDocument is strict on all three counts (shape, kind, version);
an unknown version is REFUSED, not migrated. It does not validate the intent
VALUES — the schema does that on the way into the engine (sanitizeIntent), so
one stale entry cannot discard the good ones beside it.
createActiveUix wires the canonical localStorage adapter by default. Pass
your own through prefs.storage, or false for none:
const uix = createActiveUix({
langs: { schema },
prefs: { storage: false }
});
Synchronous hydration
The root reads the envelope ITSELF, before createActivePrefs, and passes the
result as the engine's initial intent; the storage bridge is then created with
skipHydrate: true and does nothing but persist.
This is not an optimisation. createPrefsStorageBridge awaits load() even
when the adapter answers synchronously, so letting the bridge hydrate would
resolve once on defaults and jump to the stored intent one microtask later —
the same flash the boot exists to remove, moved a tick. Reading first makes the
FIRST resolution the final one.
A corrupt or unknown-version envelope is logged (logger.warn) and ignored:
the app starts on defaults. An explicit prefs.intent wins over the persisted
one — the app speaking now beats the user speaking last visit.
An asynchronous custom adapter cannot be read synchronously; the bridge then
hydrates the old way (one late commit) and its load() runs twice.
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 -> prefs.theme, mode, density, scaling
motion source -> prefs.motion (see below)
The motion source
createMotionSourceFromPrefs(prefs) (motion-source.ts) is the motion slot as
the MotionSource port of $libs/motion, and it is where the effective
reduced-motion value is decided for everything downstream: EngineMotion,
EngineScene, sema's haptic channel, soma's runtime (the morfo's
a11ySemantic.reducedMotionFallback) and ActiveEidos.reducedMotion, which the
eidos components read. Before it, each of them re-derived the policy from
ActiveDom.prefersReducedMotion — the raw media query — so an explicit
motion: 'allow' reached none of them (changelog §62).
It lives HERE and not in the UIX root because three roots consume it:
createActiveUix, the arts/active-app service factories (which cannot import
src/uix) and defineEngineSemantic. A schema without the motion dimension
yields undefined, and every consumer reads that as "no preference to honour"
and allows motion.
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 })
};
standardPrefsDimensions includes:
language
locale
currency
timezone
unitSystem
motion
sound
haptic
direction
It does not include mode, theme, density or scaling: a composition with
no visual system has no light/dark axis to project. The UIX root adds all four
on top — see createDefaultUixPrefsSchema in $active-uix/prefs-schema.
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 is the environment hint for the mode dimension, exactly as
reducedMotion is the hint for motion. (Until 2026-09-14 this paragraph said
the opposite — see "One engine".)
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 and subscribes to the available slots. Every dir
it writes carries UIX's ownership mark (PREFS_DIR_PROJECTED_ATTR, whose value
names this instance), and dispose() clears the attributes it manages only
while that mark still names it. The doctrine is the
direction contract's, §6.
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({ applyDom: true });
Practical rule:
The USER's preference: uix.prefs.setIntent('mode', 'dark')
A NAILED instance: ActiveEidos.create({ mode: 'dark' })
They are different questions. setIntent moves the preference for the whole
app and persists it. A scalar on ActiveEidos is a pin: that axis is
nailed on that instance and wins over prefs — a preview panel, a hero that
stays dark whatever the reader prefers. Everything not pinned keeps following
prefs, and the pinned axis stays subscribed, so a preference change still
re-applies and still finds it unmoved.
Per-axis source options (modeSource / densitySource / scalingSource) no
longer exist: they were the second engine's API. Replacing the resolution
whole is preferences, and the pre-hydration boot takes the same pins
(renderUixBootScript({ pins })) so both readers agree before hydration.
The four slots eidos reads — mode, theme, density, scaling — must be IN
the schema. createActiveUix guarantees it: uixVisualPrefsDimensions() is
merged under whatever schema the app passes, so an app schema may redefine an
axis but never drop it. An attaching app owns its prefs engine and gets no
such merge — it spreads uixVisualPrefsDimensions() (from $active-uix)
into its own schema; otherwise eidos warns once per absent slot under the
eidos.prefs category and falls back to its own default, which on a dark-mode
machine means a light page.
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_dimension -
prefs::intent_invalid -
prefs::reserved_key -
prefs::disposed -
prefs::document— the persistence envelope could not be read.
Known example:
prefs::unknown_dimension: [prefs] no such dimension in schema: theme
In UIX that error means the app composed its own prefs.schema and left the
visual dimensions out. Spread createDefaultUixPrefsSchema(locale) or add them
by hand.
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