You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/prefs/README.md

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.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:

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:

  • 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.

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?) — apply receives the environment patch ((patch) => void); overrides is 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

Powered by TurnKey Linux.