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

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.language alimenta langs;
  • prefs.locale alimenta format;
  • prefs.direction resuelve la direccion efectiva;
  • prefs.motion, prefs.sound y prefs.haptic son preferencias transversales de percepcion/interaccion;
  • createActivePrefsDomProjection(...) proyecta esas preferencias al DOM cuando el composition root le pasa un ActiveDom;
  • theme, mode y density son visuales y pertenecen a Eidos, no al preset core de prefs ni a ActiveUix.

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 PrefsDimension that owns its own validator, environment-fed resolver and (optionally) sibling-derived value. Built-in dimensions (localeDimension, motionDimension, …) live in arts/prefs/dimensions/* and the standardPrefsDimensions(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:

  • capabilities is declared by application composition.
  • environment can contain unsupported values.
  • intent is sparse and stores only explicit user choices.
  • effective is total, derived and always valid against capabilities.
  • defaults are part of capabilities and must be valid.
  • Only intent is persisted.
  • environment is recalculated.
  • effective is 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

  1. Implement libs/prefs domain types and constants.
  2. Implement match-locale.ts with exact/script/region/language/default order.
  3. Implement field validation and intent sanitization.
  4. Implement pure resolvePrefs().
  5. Implement createEnginePrefs() with local subscriptions and diffs.
  6. Implement active-prefs.svelte.ts as a rune adapter over the engine.
  7. Implement environment detector adapters.
  8. Implement optional storage bridge that persists only intent.
  9. Add tests for locale fallback, currency strictness, timezone normalization, explicit clear, theme system, hydration and dispose.
  10. Only after that, integrate through active-app as App.prefs.

Test Agenda

Required tests before integration:

  • Default snapshot is valid against capabilities.
  • Intent writes reject unsupported values.
  • clearIntent() restores derived behavior without storing undefined.
  • Only intent is persisted.
  • environment may contain unsupported values.
  • effective never 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 to light or dark.
  • 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-app unless explicitly requested.
  • Do not import svelte/store in the core.
  • Do not import langs, format, frontend, storage, buss, orca or active-app from libs/prefs.
  • Do not persist environment or effective.
  • Do not create formatLocale.
  • Do not let Langs own preferences.
  • Do not let Format keep parallel preference state.
  • Do not add arbitrary settings.
  • Keep IO behind ports/adapters.
  • Add tests before another artifact consumes prefs.

Powered by TurnKey Linux.