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

710 lines
18 KiB

eidos: pilot wrapper pattern + doctrinal API conventions Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper + types + index + README) replacing the flat CSS-only form. Pattern is documented in eidos/components/README.md and the toggle README. Shared types live in eidos/lib/types.ts. First export is `Size` (8 values xxs..xxl + full); components narrow with `Extract<Size, ...>` per the per-component-subset doctrine. No `Eidos` prefix on types — module path already conveys the layer. API doctrine: - soma stays compound (Toggle.Provider) for symmetry with multi-part - eidos exports both default + Provider so single-part components accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>` (compound-style consumers) SoundChannel eager-init fixes the autoplay race: AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener registered in the constructor), avoiding the previous race where the first emit() scheduled the resume in a microtask outside the gesture window. Demo page (web/routes/toggle/+page.svelte) restructured so the live preview renders ALWAYS above the tablist — Sema-tab Play buttons can fire on the real toggle. Motion preview amplifies scale ×8 visually only; doctrinal values stay in the <dl>. Conventions 7-13 added to src/docs/sema-implementation-guide.md covering: directory structure, wrapper composition, no Eidos prefix, soma compound vs eidos flat, iconOnly sr-only body, sound eager-init, docs-preview amplification. CLAUDE.md gets a session hand-off block listing where things stand and next concrete steps (migrate switch/collapsible/dialog/drawer/popover/ toast/avatar; wire topbar sound mute to masterGain; rename theme tokens to drop the success/warning/danger fallback aliases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
# 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>>`.
> **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`, `themeDimension`, …) 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:
>
> ```ts
> 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:
```txt
capabilities + environment + intent -> effective
```
The Svelte layer is only an adapter around that core:
```txt
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
frontend behavior.
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:
```txt
Prefs = preference resolution
Lang = translation/catalog lookup
Format = number/date/currency/unit formatting
Frontend = DOM/UI application
Storage = persistence adapter
Bus = optional event publication bridge
Orca = optional orchestration bridge
```
## Conceptual Model
There are four state layers plus defaults.
```txt
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:
```txt
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:
```txt
capabilities
environment
intent
effective
defaults
```
Avoid:
```txt
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
```ts
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 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.
```ts
export interface PrefsCapabilities {
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 timezones?: readonly PrefsTimezone[];
readonly defaults: PrefsEffective;
}
```
The app composes capabilities. `prefs` does not discover them by importing other
modules.
Example future composition:
```ts
const capabilities: PrefsCapabilities = {
locales: intersect(appConfig.locales, Lang.availableLocales),
currencies: appConfig.currencies,
unitSystems: appConfig.unitSystems,
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
defaults: {
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
};
```
If `Lang` does not provide `es-MX` and the application requires user-selectable
locales to exist in `Lang`, then `es-MX` must not be in `capabilities.locales`.
## Environment
`environment` is detected context. It is not user intent.
```ts
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:
```txt
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".
```ts
export interface PrefsIntent {
readonly locale?: PrefsLocale;
readonly currency?: PrefsCurrency;
readonly timezone?: PrefsTimezone;
readonly unitSystem?: PrefsUnitSystem;
readonly theme?: PrefsThemeIntent;
readonly density?: PrefsDensity;
readonly motion?: PrefsMotion;
}
```
Intent writes must validate against capabilities.
```txt
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:
```ts
Prefs.setIntent('locale', 'es-ES');
Prefs.clearIntent('locale');
Prefs.resetIntent();
```
## Effective
`effective` is the only layer consumers should read.
```ts
export interface PrefsEffective {
readonly locale: PrefsLocale;
readonly currency: PrefsCurrency;
readonly timezone: PrefsTimezone;
readonly unitSystem: PrefsUnitSystem;
readonly theme: PrefsThemeEffective;
readonly density: PrefsDensity;
readonly motion: PrefsMotionEffective;
readonly direction: PrefsDirection;
}
```
`effective` is total, readonly and always valid.
```txt
Lang reads effective.locale
Format reads effective.locale, currency, timezone, unitSystem
Frontend reads effective.theme, density, motion, direction
```
There is one locale in preferences. There is no `formatLocale`. If the app wants
`es-MX` formatting, it must allow `es-MX` as a locale capability. If it does not
allow `es-MX`, `Format` uses the effective locale that the app did allow.
## Snapshot
```ts
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.
```ts
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:
```txt
1. exact match
2. language + script match
3. language + region match
4. language match
5. default locale
```
Example:
```txt
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:
```ts
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.
```txt
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:
```txt
open IANA validation
closed capabilities.timezones allowlist
```
Validation should canonicalize when possible:
```ts
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.
```ts
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.
```ts
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.
```ts
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:
```ts
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:
```ts
// lang
export interface LangPrefsPort {
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;
}
// frontend
export interface FrontendPrefsPort {
readonly theme: PrefsThemeEffective;
readonly density: PrefsDensity;
readonly motion: PrefsMotionEffective;
readonly direction: PrefsDirection;
subscribe(handler: (view: FrontendPrefsView) => void): PrefsUnsubscribe;
}
```
`active-app` will eventually build filtered views/proxies from `App.prefs` to
each consumer.
## Events
The engine exposes local subscriptions. It does not import `buss`.
```ts
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`:
```ts
Prefs.subscribe((event) => {
App.bus.publish(PREFS_EVENT_CHANGED, event);
});
```
All event names must be constants.
## Server And Browser Initialization
Server flow:
```txt
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:
```txt
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:
```txt
server snapshot wins first paint
browser detection may refine after hydration
browser detection must not override explicit intent
```
Avoid first-paint theme flashes in `frontend`, not in `prefs`: an inline head
script may apply early theme before Svelte mounts, then `Prefs` adopts the same
state during hydration.
## Validation
Validation is field-specific:
```txt
locale -> capability membership, with environment matching fallback
currency -> strict capability membership
timezone -> IANA validation, optionally capability membership
unitSystem -> strict capability membership
theme -> strict capability membership; system allowed as intent
density -> strict capability membership
motion -> strict capability membership; system allowed as intent
```
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
```txt
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 `lang`, `format`, `frontend`, `storage`, `buss`, `orca` or
`active-app` from `libs/prefs`.
- Do not persist `environment` or `effective`.
- Do not create `formatLocale`.
- Do not let `Lang` 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.