# 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.language` feeds `langs`. - `prefs.locale` feeds `format`. - `prefs.direction` resolves the effective direction. - `prefs.motion`, `prefs.sound` and `prefs.haptic` are cross-cutting perception/interaction preferences. - `prefs.mode` is one of them. See "One engine" below — the 2026-05-14 rule that `colorScheme` must not become a preference is REVOKED. - `createActivePrefsDomProjection(...)` projects only `dir`, `lang`, `data-motion`, `data-sound` and `data-haptic`. `lang` travels with `dir` because 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 the `language` dimension yields no slot, so nothing is projected and an app that owns `lang` server-side (i18n by routing) is untouched. - `modeDimension(...)` ships in the built-in catalog; `theme`, `density` and `scaling` are declared by the UIX composition root (`createDefaultUixPrefsSchema`, `$active-uix/prefs-schema`), because their vocabulary is UIX's and `arts/prefs` imports nothing from `src/uix`. - `ActiveEidos` still 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: ```text 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: ```ts import { createPrefsIntentDocument, readPrefsIntentDocument, PREFS_STORAGE_KEY } from '$libs/prefs'; ``` ```text 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: ```ts 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`: - `ActiveApp` creates or receives `prefs`. - `createActiveUix(...)` creates `prefs` when UIX boots standalone. - `attachActiveUix(app)` reuses `app.prefs`. Consuming layers read specific slots or receive scoped views. They must not create another compensatory preferences instance. ```text 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: ```ts type PrefsSchema = Record>; ``` 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: ```text 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: ```ts 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: ```text 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: ```ts 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: ```ts 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: ```ts const slot = readActivePrefsSlot(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?)` — `apply` receives the environment patch (`(patch) => void`); `overrides` is only `{ matchMedia }` Environment examples: ```text 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: ```ts 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](../../../docs/canon/direction-contract.md), §6. Attribute contract: ```text 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`](../../../docs/canon/direction-contract.md). ## Eidos Boundary For a visual shell: ```ts const uix = createActiveUix({ langs, prefs: { schema } }); const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom }); const eidos = ActiveEidos.create({ applyDom: true }); ``` Practical rule: ```text 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: ```ts 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: ```text 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 ```bash 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 ```