Bloque L1 — prefs foundation: capability libs + Source<T> port

Introduce the pure preference layer and the framework-wide value port that the
runtime layers (`arts/prefs`, format, frontend) consume.

- `libs/reactive` gains `Source<T>` — a small `{ get, onChange? }` port any
  artifact uses to observe an external value (locale, currency, theme, …).
- `libs/locale` extracts the lookup-style `matchLocale` helper plus aliases
  `LocaleSource` to `Source<string>`. Tests added for the four-step
  exact / lang+script / lang+region / lang priority.
- New per-domain libs (`currency`, `density`, `direction`, `motion`, `theme`,
  `timezone`, `units`) own their own primitives, capability sources and pure
  helpers (`*FromLocale(s)`, `directionFromLanguage`, `resolveTheme`,
  `resolveMotion`). The currency catalogue + region table moved here from
  `arts/format/currency`.
- `libs/prefs` is the pure preference layer: `PrefsCapabilities`,
  `PrefsEnvironment`, `PrefsIntent`, `PrefsEffective`, intent validation
  (with stable `PrefsValidationFailure` codes) and the parallel-projection
  resolver. Each `effective` field is now an INDEPENDENT projection of
  `environment.locales[]` against its own capability catalog — `language`
  and `locale` are split (i18n catalog vs regional formatting); `currency`,
  `unitSystem`, `direction` derive per-dimension instead of from a single
  `effective.locale` anchor.
- `arts/format` and `arts/frontend` migrated to the new `Source<T>` shape:
  `localeSource?.getLocale()` → `localeSource?.get()` and
  `onLocaleChange?` → `onChange?`. `arts/format/currency/locale-currencies`
  and `arts/format/units/locale-defaults` collapse to thin re-exports of
  their `libs/*` counterparts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 178e76bfd3
commit 29dcd6b8ce

@ -29,24 +29,24 @@ export interface ActiveFormatRuntime {
export function createActiveFormatLocaleSource( export function createActiveFormatLocaleSource(
options: ActiveFormatLocaleSourceOptions options: ActiveFormatLocaleSourceOptions
): ActiveFormatLocaleSource { ): ActiveFormatLocaleSource {
let currentLocale = options.localeSource?.getLocale() ?? options.locale; let currentLocale = options.localeSource?.get() ?? options.locale;
// Single fan-out point: every submodule (numbers/currency/units/dates) // Single fan-out point: every submodule (numbers/currency/units/dates)
// subscribes through `source.onLocaleChange`, and this set is also // subscribes through `source.onChange`, and this set is also fired
// fired when the parent calls `setLocale()` directly. Previously the // when the parent calls `setLocale()` directly. Previously the parent's
// parent's `setLocale` only mutated `currentLocale` and the parent // `setLocale` only mutated `currentLocale` and then re-called
// then re-called `setLocale` on every submodule manually — two // `setLocale` on every submodule manually — two notifications per
// notifications per change. The unified source delivers exactly one. // change. The unified source delivers exactly one.
const listeners = new Set<(locale: string) => void>(); const listeners = new Set<(locale: string) => void>();
function getLocaleNow(): string { function getLocaleNow(): string {
return currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? ''; return currentLocale ?? options.localeSource?.get() ?? options.locale ?? '';
} }
const source: FormatLocaleSource = { const source: FormatLocaleSource = {
getLocale: getLocaleNow, get: getLocaleNow,
onLocaleChange: (fn) => { onChange: (fn) => {
listeners.add(fn); listeners.add(fn);
const unsubscribeUpstream = options.localeSource?.onLocaleChange?.((locale) => { const unsubscribeUpstream = options.localeSource?.onChange?.((locale) => {
currentLocale = locale; currentLocale = locale;
for (const listener of listeners) listener(locale); for (const listener of listeners) listener(locale);
}); });
@ -89,7 +89,7 @@ export function createActiveFormatRuntime(
localeListeners.forEach((fn) => fn(options.getLocale())); localeListeners.forEach((fn) => fn(options.getLocale()));
} }
const unsubscribeLocale = options.localeSource?.onLocaleChange?.(syncLocale); const unsubscribeLocale = options.localeSource?.onChange?.(syncLocale);
return { return {
read, read,

@ -22,7 +22,7 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ
const engine = createEngineCurrency({ const engine = createEngineCurrency({
...options, ...options,
rates, rates,
locale: localeSource?.getLocale() ?? options.locale locale: localeSource?.get() ?? options.locale
}); });
const currencyListeners = new Set<(currency: CurrencyCode) => void>(); const currencyListeners = new Set<(currency: CurrencyCode) => void>();

@ -1,255 +1,10 @@
import type { CurrencyCode } from './types'; /**
* Currency-by-region tables. Moved to `$libs/currency` so non-format
* consumers (`prefs`, future revenue/locale dashboards, mail templates)
* can reach them without pulling in the format runtime.
*
* Re-exported here under the same names so existing format internals
* keep their imports stable.
*/
export const LOCALE_CURRENCY_OVERRIDES: Record<string, CurrencyCode> = {}; export { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from '$libs/currency';
export const REGION_CURRENCY: Record<string, CurrencyCode> = {
AD: 'EUR',
AE: 'AED',
AF: 'AFN',
AG: 'XCD',
AI: 'XCD',
AL: 'ALL',
AM: 'AMD',
AO: 'AOA',
AR: 'ARS',
AS: 'USD',
AT: 'EUR',
AU: 'AUD',
AW: 'AWG',
AX: 'EUR',
AZ: 'AZN',
BA: 'BAM',
BB: 'BBD',
BD: 'BDT',
BE: 'EUR',
BF: 'XOF',
BG: 'BGN',
BH: 'BHD',
BI: 'BIF',
BJ: 'XOF',
BL: 'EUR',
BM: 'BMD',
BN: 'BND',
BO: 'BOB',
BQ: 'USD',
BR: 'BRL',
BS: 'BSD',
BT: 'BTN',
BV: 'NOK',
BW: 'BWP',
BY: 'BYN',
BZ: 'BZD',
CA: 'CAD',
CC: 'AUD',
CD: 'CDF',
CF: 'XAF',
CG: 'XAF',
CH: 'CHF',
CI: 'XOF',
CK: 'NZD',
CL: 'CLP',
CM: 'XAF',
CN: 'CNY',
CO: 'COP',
CR: 'CRC',
CU: 'CUP',
CV: 'CVE',
CW: 'ANG',
CX: 'AUD',
CY: 'EUR',
CZ: 'CZK',
DE: 'EUR',
DJ: 'DJF',
DK: 'DKK',
DM: 'XCD',
DO: 'DOP',
DZ: 'DZD',
EC: 'USD',
EE: 'EUR',
EG: 'EGP',
EH: 'MAD',
ER: 'ERN',
ES: 'EUR',
ET: 'ETB',
FI: 'EUR',
FJ: 'FJD',
FK: 'FKP',
FM: 'USD',
FO: 'DKK',
FR: 'EUR',
GA: 'XAF',
GB: 'GBP',
GD: 'XCD',
GE: 'GEL',
GF: 'EUR',
GG: 'GBP',
GH: 'GHS',
GI: 'GIP',
GL: 'DKK',
GM: 'GMD',
GN: 'GNF',
GP: 'EUR',
GQ: 'XAF',
GR: 'EUR',
GS: 'GBP',
GT: 'GTQ',
GU: 'USD',
GW: 'XOF',
GY: 'GYD',
HK: 'HKD',
HM: 'AUD',
HN: 'HNL',
HR: 'EUR',
HT: 'HTG',
HU: 'HUF',
ID: 'IDR',
IE: 'EUR',
IL: 'ILS',
IM: 'GBP',
IN: 'INR',
IO: 'USD',
IQ: 'IQD',
IR: 'IRR',
IS: 'ISK',
IT: 'EUR',
JE: 'GBP',
JM: 'JMD',
JO: 'JOD',
JP: 'JPY',
KE: 'KES',
KG: 'KGS',
KH: 'KHR',
KI: 'AUD',
KM: 'KMF',
KN: 'XCD',
KP: 'KPW',
KR: 'KRW',
KW: 'KWD',
KY: 'KYD',
KZ: 'KZT',
LA: 'LAK',
LB: 'LBP',
LC: 'XCD',
LI: 'CHF',
LK: 'LKR',
LR: 'LRD',
LS: 'LSL',
LT: 'EUR',
LU: 'EUR',
LV: 'EUR',
LY: 'LYD',
MA: 'MAD',
MC: 'EUR',
MD: 'MDL',
ME: 'EUR',
MF: 'EUR',
MG: 'MGA',
MH: 'USD',
MK: 'MKD',
ML: 'XOF',
MM: 'MMK',
MN: 'MNT',
MO: 'MOP',
MP: 'USD',
MQ: 'EUR',
MR: 'MRU',
MS: 'XCD',
MT: 'EUR',
MU: 'MUR',
MV: 'MVR',
MW: 'MWK',
MX: 'MXN',
MY: 'MYR',
MZ: 'MZN',
NA: 'NAD',
NC: 'XPF',
NE: 'XOF',
NF: 'AUD',
NG: 'NGN',
NI: 'NIO',
NL: 'EUR',
NO: 'NOK',
NP: 'NPR',
NR: 'AUD',
NU: 'NZD',
NZ: 'NZD',
OM: 'OMR',
PA: 'PAB',
PE: 'PEN',
PF: 'XPF',
PG: 'PGK',
PH: 'PHP',
PK: 'PKR',
PL: 'PLN',
PM: 'EUR',
PN: 'NZD',
PR: 'USD',
PS: 'ILS',
PT: 'EUR',
PW: 'USD',
PY: 'PYG',
QA: 'QAR',
RE: 'EUR',
RO: 'RON',
RS: 'RSD',
RU: 'RUB',
RW: 'RWF',
SA: 'SAR',
SB: 'SBD',
SC: 'SCR',
SD: 'SDG',
SE: 'SEK',
SG: 'SGD',
SH: 'SHP',
SI: 'EUR',
SJ: 'NOK',
SK: 'EUR',
SL: 'SLE',
SM: 'EUR',
SN: 'XOF',
SO: 'SOS',
SR: 'SRD',
SS: 'SSP',
ST: 'STN',
SV: 'USD',
SX: 'ANG',
SY: 'SYP',
SZ: 'SZL',
TC: 'USD',
TD: 'XAF',
TF: 'EUR',
TG: 'XOF',
TH: 'THB',
TJ: 'TJS',
TK: 'NZD',
TL: 'USD',
TM: 'TMT',
TN: 'TND',
TO: 'TOP',
TR: 'TRY',
TT: 'TTD',
TV: 'AUD',
TW: 'TWD',
TZ: 'TZS',
UA: 'UAH',
UG: 'UGX',
UM: 'USD',
US: 'USD',
UY: 'UYU',
UZ: 'UZS',
VA: 'EUR',
VC: 'XCD',
VE: 'VES',
VG: 'USD',
VI: 'USD',
VN: 'VND',
VU: 'VUV',
WF: 'XPF',
WS: 'WST',
XK: 'EUR',
YE: 'YER',
YT: 'EUR',
ZA: 'ZAR',
ZM: 'ZMW',
ZW: 'ZWG'
};

@ -1,20 +1,12 @@
import { getLocaleRegion, normalizeLocaleTag } from '../helpers'; import { currencyFromLocale } from '$libs/currency';
import { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from './locale-currencies';
import type { CurrencyCode } from './types'; import type { CurrencyCode } from './types';
/** /**
* Resolve currency from an explicit locale region. * Resolve currency from an explicit locale region. Thin wrapper over
* * `$libs/currency`'s pure helper, kept here under the existing name so
* No language fallback is applied: `es-AR` resolves from `AR`, while plain * format internals don't have to retarget their imports. The actual
* `es` returns undefined so callers can use their own default currency. * lookup table and logic live in `$libs/currency`.
*/ */
export function resolveCurrency(locale: string): CurrencyCode | undefined { export function resolveCurrency(locale: string): CurrencyCode | undefined {
const normalized = normalizeLocaleTag(locale); return currencyFromLocale(locale);
if (normalized === '') return undefined;
const override = LOCALE_CURRENCY_OVERRIDES[normalized];
if (override !== undefined) return override;
const region = getLocaleRegion(normalized);
return region === undefined ? undefined : REGION_CURRENCY[region];
} }

@ -51,8 +51,8 @@ describe('createActiveCurrency()', () => {
let locale = 'en-US'; let locale = 'en-US';
let listener: ((locale: string) => void) | undefined; let listener: ((locale: string) => void) | undefined;
const source = { const source = {
getLocale: () => locale, get: () => locale,
onLocaleChange(fn: (nextLocale: string) => void) { onChange(fn: (nextLocale: string) => void) {
listener = fn; listener = fn;
return () => { return () => {
listener = undefined; listener = undefined;

@ -6,7 +6,7 @@ export function createActiveDates(options: ActiveDatesOptions = {}): ActiveDates
const localeSource = options.localeSource; const localeSource = options.localeSource;
const engine = createEngineDates({ const engine = createEngineDates({
...options, ...options,
locale: localeSource?.getLocale() ?? options.locale locale: localeSource?.get() ?? options.locale
}); });
const runtime = createActiveFormatRuntime({ const runtime = createActiveFormatRuntime({

@ -6,8 +6,8 @@ describe('createActiveDates()', () => {
let locale = 'en-US'; let locale = 'en-US';
let listener: ((locale: string) => void) | undefined; let listener: ((locale: string) => void) | undefined;
const source = { const source = {
getLocale: () => locale, get: () => locale,
onLocaleChange(fn: (nextLocale: string) => void) { onChange(fn: (nextLocale: string) => void) {
listener = fn; listener = fn;
return () => { return () => {
listener = undefined; listener = undefined;

@ -6,7 +6,7 @@ export function createActiveNumbers(options: ActiveNumbersOptions = {}): ActiveN
const localeSource = options.localeSource; const localeSource = options.localeSource;
const engine = createEngineNumbers({ const engine = createEngineNumbers({
...options, ...options,
locale: localeSource?.getLocale() ?? options.locale locale: localeSource?.get() ?? options.locale
}); });
const runtime = createActiveFormatRuntime({ const runtime = createActiveFormatRuntime({

@ -6,8 +6,8 @@ describe('createActiveNumbers()', () => {
let locale = 'en-US'; let locale = 'en-US';
let listener: ((locale: string) => void) | undefined; let listener: ((locale: string) => void) | undefined;
const source = { const source = {
getLocale: () => locale, get: () => locale,
onLocaleChange(fn: (nextLocale: string) => void) { onChange(fn: (nextLocale: string) => void) {
listener = fn; listener = fn;
return () => { return () => {
listener = undefined; listener = undefined;

@ -6,7 +6,7 @@ export function createActiveUnits(options: ActiveUnitsOptions = {}): ActiveUnits
const localeSource = options.localeSource; const localeSource = options.localeSource;
const engine = createEngineUnits({ const engine = createEngineUnits({
...options, ...options,
locale: localeSource?.getLocale() ?? options.locale locale: localeSource?.get() ?? options.locale
}); });
const runtime = createActiveFormatRuntime({ const runtime = createActiveFormatRuntime({

@ -1,9 +1,15 @@
import { getLocaleRegion } from '../helpers'; import { unitSystemFromLocale } from '$libs/units';
import type { UnitSystem } from './types'; import type { UnitSystem } from './types';
const IMPERIAL_REGIONS = new Set(['US', 'LR', 'MM']); /**
* Resolve unit system from a locale's region. Thin wrapper over
* `$libs/units`'s pure helper. The actual logic and IMPERIAL_REGIONS set
* live in `$libs/units/from-locale.ts`.
*
* Note: the libs helper returns `undefined` when the tag has no region;
* format's contract historically returns `'metric'` as the default in
* that case, so this wrapper preserves the old behavior.
*/
export function resolveUnitSystem(locale: string): UnitSystem { export function resolveUnitSystem(locale: string): UnitSystem {
const region = getLocaleRegion(locale); return unitSystemFromLocale(locale) ?? 'metric';
return region !== undefined && IMPERIAL_REGIONS.has(region) ? 'imperial' : 'metric';
} }

@ -7,8 +7,8 @@ describe('createActiveUnits()', () => {
let locale = 'en-US'; let locale = 'en-US';
let listener: ((locale: string) => void) | undefined; let listener: ((locale: string) => void) | undefined;
const source = { const source = {
getLocale: () => locale, get: () => locale,
onLocaleChange(fn: (nextLocale: string) => void) { onChange(fn: (nextLocale: string) => void) {
listener = fn; listener = fn;
return () => { return () => {
listener = undefined; listener = undefined;

@ -98,7 +98,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ
const dom: FrontendDom | undefined = const dom: FrontendDom | undefined =
options.applyDom === false ? undefined : (options.dom ?? { apply: applyChange }); options.applyDom === false ? undefined : (options.dom ?? { apply: applyChange });
let currentLocale = $state(localeSource?.getLocale() ?? options.locale ?? ''); let currentLocale = $state(localeSource?.get() ?? options.locale ?? '');
let dirOverride = $state<Direction | null>( let dirOverride = $state<Direction | null>(
options.dir === 'auto' || options.dir === undefined ? null : options.dir options.dir === 'auto' || options.dir === undefined ? null : options.dir
); );
@ -119,7 +119,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ
let disposed = false; let disposed = false;
function getLocale(): string { function getLocale(): string {
return currentLocale || localeSource?.getLocale() || options.locale || ''; return currentLocale || localeSource?.get() || options.locale || '';
} }
function getDir(): Direction { function getDir(): Direction {
@ -169,7 +169,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ
notify(); notify();
} }
const unsubscribeLocale = localeSource?.onLocaleChange?.(setLocale); const unsubscribeLocale = localeSource?.onChange?.(setLocale);
const unsubscribeDarkMode = subscribeMedia('(prefers-color-scheme: dark)', (matches) => { const unsubscribeDarkMode = subscribeMedia('(prefers-color-scheme: dark)', (matches) => {
osDarkMode = matches; osDarkMode = matches;
notify(); notify();

@ -0,0 +1,61 @@
import type { Locale } from '$libs/locale';
import { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from './region-currencies.ts';
import type { Currency } from './types.ts';
/**
* Map a single BCP-47 locale tag to its conventional currency, or
* `undefined` when the tag has no region (`'es'` → undefined; `'es-AR'` →
* `'ARS'`).
*
* - Exact-tag overrides win first (`LOCALE_CURRENCY_OVERRIDES`).
* - Otherwise the region (`Intl.Locale(tag).region`) is looked up in
* `REGION_CURRENCY`.
*
* Pure helper — no IO, no globals. The companion `currencyFromLocales`
* applies this in priority order across an array of candidate locales,
* which is the shape `prefs` (and other consumers that read
* `Accept-Language`-style lists) need.
*/
export function currencyFromLocale(tag: Locale): Currency | undefined {
// `Accept-Language` and legacy callers sometimes emit underscore
// separators (`en_GB`). Normalize before parsing — `Intl.Locale`
// rejects underscores on most runtimes.
const normalized = tag.trim().replace(/_/g, '-');
if (normalized === '') return undefined;
const override = LOCALE_CURRENCY_OVERRIDES[normalized];
if (override !== undefined) return override;
let region: string | undefined;
try {
region = new Intl.Locale(normalized).region;
} catch {
return undefined;
}
if (region === undefined) return undefined;
return REGION_CURRENCY[region];
}
/**
* Walk the candidate priority list and return the first currency that
* (a) maps from a candidate's region and (b) is in the caller's
* `available` catalog. Falls back to `fallback` when no candidate
* produces a supported currency.
*
* This is the projection shape `prefs` uses: each `effective` field is
* an independent walk of `environment.locales[]` against its own
* capability catalog. A Mexican user landing on a US-locale-only app
* still gets MXN if the app's currency catalog includes MXN — the user's
* Mexican preference doesn't get collapsed by the locale fallback.
*/
export function currencyFromLocales(
candidates: readonly Locale[],
available: readonly Currency[],
fallback: Currency
): Currency {
for (const tag of candidates) {
const currency = currencyFromLocale(tag);
if (currency !== undefined && available.includes(currency)) return currency;
}
return fallback;
}

@ -0,0 +1,3 @@
export type { Currency, CurrencySource } from './types';
export { currencyFromLocale, currencyFromLocales } from './from-locale.ts';
export { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from './region-currencies.ts';

@ -0,0 +1,255 @@
import type { Currency } from "./types";
export const LOCALE_CURRENCY_OVERRIDES: Record<string, Currency> = {};
export const REGION_CURRENCY: Record<string, Currency> = {
AD: 'EUR',
AE: 'AED',
AF: 'AFN',
AG: 'XCD',
AI: 'XCD',
AL: 'ALL',
AM: 'AMD',
AO: 'AOA',
AR: 'ARS',
AS: 'USD',
AT: 'EUR',
AU: 'AUD',
AW: 'AWG',
AX: 'EUR',
AZ: 'AZN',
BA: 'BAM',
BB: 'BBD',
BD: 'BDT',
BE: 'EUR',
BF: 'XOF',
BG: 'BGN',
BH: 'BHD',
BI: 'BIF',
BJ: 'XOF',
BL: 'EUR',
BM: 'BMD',
BN: 'BND',
BO: 'BOB',
BQ: 'USD',
BR: 'BRL',
BS: 'BSD',
BT: 'BTN',
BV: 'NOK',
BW: 'BWP',
BY: 'BYN',
BZ: 'BZD',
CA: 'CAD',
CC: 'AUD',
CD: 'CDF',
CF: 'XAF',
CG: 'XAF',
CH: 'CHF',
CI: 'XOF',
CK: 'NZD',
CL: 'CLP',
CM: 'XAF',
CN: 'CNY',
CO: 'COP',
CR: 'CRC',
CU: 'CUP',
CV: 'CVE',
CW: 'ANG',
CX: 'AUD',
CY: 'EUR',
CZ: 'CZK',
DE: 'EUR',
DJ: 'DJF',
DK: 'DKK',
DM: 'XCD',
DO: 'DOP',
DZ: 'DZD',
EC: 'USD',
EE: 'EUR',
EG: 'EGP',
EH: 'MAD',
ER: 'ERN',
ES: 'EUR',
ET: 'ETB',
FI: 'EUR',
FJ: 'FJD',
FK: 'FKP',
FM: 'USD',
FO: 'DKK',
FR: 'EUR',
GA: 'XAF',
GB: 'GBP',
GD: 'XCD',
GE: 'GEL',
GF: 'EUR',
GG: 'GBP',
GH: 'GHS',
GI: 'GIP',
GL: 'DKK',
GM: 'GMD',
GN: 'GNF',
GP: 'EUR',
GQ: 'XAF',
GR: 'EUR',
GS: 'GBP',
GT: 'GTQ',
GU: 'USD',
GW: 'XOF',
GY: 'GYD',
HK: 'HKD',
HM: 'AUD',
HN: 'HNL',
HR: 'EUR',
HT: 'HTG',
HU: 'HUF',
ID: 'IDR',
IE: 'EUR',
IL: 'ILS',
IM: 'GBP',
IN: 'INR',
IO: 'USD',
IQ: 'IQD',
IR: 'IRR',
IS: 'ISK',
IT: 'EUR',
JE: 'GBP',
JM: 'JMD',
JO: 'JOD',
JP: 'JPY',
KE: 'KES',
KG: 'KGS',
KH: 'KHR',
KI: 'AUD',
KM: 'KMF',
KN: 'XCD',
KP: 'KPW',
KR: 'KRW',
KW: 'KWD',
KY: 'KYD',
KZ: 'KZT',
LA: 'LAK',
LB: 'LBP',
LC: 'XCD',
LI: 'CHF',
LK: 'LKR',
LR: 'LRD',
LS: 'LSL',
LT: 'EUR',
LU: 'EUR',
LV: 'EUR',
LY: 'LYD',
MA: 'MAD',
MC: 'EUR',
MD: 'MDL',
ME: 'EUR',
MF: 'EUR',
MG: 'MGA',
MH: 'USD',
MK: 'MKD',
ML: 'XOF',
MM: 'MMK',
MN: 'MNT',
MO: 'MOP',
MP: 'USD',
MQ: 'EUR',
MR: 'MRU',
MS: 'XCD',
MT: 'EUR',
MU: 'MUR',
MV: 'MVR',
MW: 'MWK',
MX: 'MXN',
MY: 'MYR',
MZ: 'MZN',
NA: 'NAD',
NC: 'XPF',
NE: 'XOF',
NF: 'AUD',
NG: 'NGN',
NI: 'NIO',
NL: 'EUR',
NO: 'NOK',
NP: 'NPR',
NR: 'AUD',
NU: 'NZD',
NZ: 'NZD',
OM: 'OMR',
PA: 'PAB',
PE: 'PEN',
PF: 'XPF',
PG: 'PGK',
PH: 'PHP',
PK: 'PKR',
PL: 'PLN',
PM: 'EUR',
PN: 'NZD',
PR: 'USD',
PS: 'ILS',
PT: 'EUR',
PW: 'USD',
PY: 'PYG',
QA: 'QAR',
RE: 'EUR',
RO: 'RON',
RS: 'RSD',
RU: 'RUB',
RW: 'RWF',
SA: 'SAR',
SB: 'SBD',
SC: 'SCR',
SD: 'SDG',
SE: 'SEK',
SG: 'SGD',
SH: 'SHP',
SI: 'EUR',
SJ: 'NOK',
SK: 'EUR',
SL: 'SLE',
SM: 'EUR',
SN: 'XOF',
SO: 'SOS',
SR: 'SRD',
SS: 'SSP',
ST: 'STN',
SV: 'USD',
SX: 'ANG',
SY: 'SYP',
SZ: 'SZL',
TC: 'USD',
TD: 'XAF',
TF: 'EUR',
TG: 'XOF',
TH: 'THB',
TJ: 'TJS',
TK: 'NZD',
TL: 'USD',
TM: 'TMT',
TN: 'TND',
TO: 'TOP',
TR: 'TRY',
TT: 'TTD',
TV: 'AUD',
TW: 'TWD',
TZ: 'TZS',
UA: 'UAH',
UG: 'UGX',
UM: 'USD',
US: 'USD',
UY: 'UYU',
UZ: 'UZS',
VA: 'EUR',
VC: 'XCD',
VE: 'VES',
VG: 'USD',
VI: 'USD',
VN: 'VND',
VU: 'VUV',
WF: 'XPF',
WS: 'WST',
XK: 'EUR',
YE: 'YER',
YT: 'EUR',
ZA: 'ZAR',
ZM: 'ZMW',
ZW: 'ZWG'
};

@ -0,0 +1,19 @@
import type { Source } from '$libs/reactive';
/**
* ISO-4217 currency code (e.g. `'EUR'`, `'USD'`). Free `string` at the type
* level so callers can adopt their own brand or strict union; runtime
* validation typically goes through a currency catalog.
*/
export type Currency = string;
/**
* Reactive currency provider. Alias of the framework-wide `Source<C>` port.
* The domain-specific name keeps call-site signatures self-documenting
* (`opts: { currency?: CurrencySource }`).
*
* `C` defaults to `string` so consumers stay open; producers with a narrow
* catalog can declare `CurrencySource<'EUR' | 'USD'>` and pass it where
* `CurrencySource<string>` is expected.
*/
export type CurrencySource<C extends string = string> = Source<C>;

@ -0,0 +1,2 @@
export { DENSITIES } from './types';
export type { Density, DensitySource } from './types';

@ -0,0 +1,15 @@
import type { Source } from '$libs/reactive';
/**
* UI density levels. Closed set — apps that need a richer scale layer
* their own type on top, but the lowest common denominator stays here so
* cross-module wiring (`prefs`, frontend, design tokens) agrees.
*/
export const DENSITIES = ['compact', 'comfortable', 'spacious'] as const;
export type Density = (typeof DENSITIES)[number];
/**
* Reactive density provider. Alias of `Source<Density>`.
*/
export type DensitySource = Source<Density>;

@ -0,0 +1,63 @@
import type { Locale } from '$libs/locale';
import type { Direction } from './types.ts';
/**
* Curated list of primary language subtags whose default writing
* direction is right-to-left. Used as a fallback for runtimes that do
* not expose `Intl.Locale.textInfo.direction` (older browsers, some
* Node builds).
*
* The list errs on the side of completeness — `ku` (Kurdish) for
* instance has multiple scripts, but treating the bare `ku` tag as RTL
* is safer than treating it as LTR. Apps that need finer granularity
* (`ku-Latn` vs `ku-Arab`) get the right answer through `Intl.Locale`
* when the runtime supports it; the fallback is a coarse safety net.
*/
const RTL_LANGUAGES: ReadonlySet<string> = new Set([
'ar', // Arabic
'arc', // Aramaic
'ckb', // Central Kurdish (Sorani)
'dv', // Dhivehi
'fa', // Persian / Farsi
'he',
'iw', // legacy code for Hebrew
'khw', // Khowar
'ks', // Kashmiri
'ku', // Kurdish (Kurmanji is Latin, listed defensively)
'ps', // Pashto
'sd', // Sindhi
'ug', // Uyghur
'ur', // Urdu
'yi' // Yiddish
]);
/**
* Direction inferred from a BCP-47 language tag.
*
* Direction is a property of the LANGUAGE / writing system, not of the
* region — Arabic is RTL whether the speaker is in Egypt or Japan, and
* English is LTR everywhere. Callers should pass the language tag
* (`effective.language` in a prefs context), not the regional formatting
* locale.
*
* Implementation uses `Intl.Locale.textInfo.direction` when the runtime
* exposes it (modern browsers + Node 22+), and falls back to a curated
* RTL primary-language allowlist otherwise. Malformed tags fall back to
* `'ltr'` after a lowercased-prefix RTL check — never throws.
*
* Pure helper. No dependency on `prefs`.
*/
export function directionFromLanguage(language: Locale): Direction {
try {
const intl = new Intl.Locale(language);
const ti = (intl as Intl.Locale & { textInfo?: { direction?: string } }).textInfo;
if (ti?.direction === 'rtl') return 'rtl';
if (ti?.direction === 'ltr') return 'ltr';
return RTL_LANGUAGES.has(intl.language) ? 'rtl' : 'ltr';
} catch {
// Fall back on lowercased prefix when `Intl.Locale` rejects the
// tag (very old runtime, malformed input that slipped through).
const base = language.split('-')[0]?.toLowerCase() ?? '';
return RTL_LANGUAGES.has(base) ? 'rtl' : 'ltr';
}
}

@ -0,0 +1,3 @@
export { DIRECTIONS } from './types';
export type { Direction, DirectionSource } from './types';
export { directionFromLanguage } from './from-language.ts';

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { directionFromLanguage } from '../from-language.ts';
describe('directionFromLanguage', () => {
it('returns rtl for Arabic language variants', () => {
expect(directionFromLanguage('ar')).toBe('rtl');
expect(directionFromLanguage('ar-EG')).toBe('rtl');
// `ar-Latn-EG` (Arabic in Latin script) is intentionally not asserted:
// modern `Intl.Locale.textInfo.direction` honors script over
// language and returns `'ltr'`, which is the correct semantic
// answer. Older runtimes lacking `textInfo` would fall back to
// the language allowlist and return `'rtl'`. Skipping the case
// avoids a runtime-dependent assertion.
});
it('returns rtl for Hebrew, Persian, Urdu, Pashto, Yiddish', () => {
expect(directionFromLanguage('he-IL')).toBe('rtl');
expect(directionFromLanguage('fa-IR')).toBe('rtl');
expect(directionFromLanguage('ur-PK')).toBe('rtl');
expect(directionFromLanguage('ps-AF')).toBe('rtl');
expect(directionFromLanguage('yi')).toBe('rtl');
});
it('returns ltr for the typical Western languages', () => {
expect(directionFromLanguage('en-US')).toBe('ltr');
expect(directionFromLanguage('es-ES')).toBe('ltr');
expect(directionFromLanguage('fr-FR')).toBe('ltr');
expect(directionFromLanguage('de-DE')).toBe('ltr');
});
it('returns ltr for CJK languages', () => {
expect(directionFromLanguage('zh-CN')).toBe('ltr');
expect(directionFromLanguage('ja-JP')).toBe('ltr');
expect(directionFromLanguage('ko-KR')).toBe('ltr');
});
it('falls back to ltr for malformed tags without throwing', () => {
expect(directionFromLanguage('not a language')).toBe('ltr');
expect(directionFromLanguage('')).toBe('ltr');
});
});

@ -0,0 +1,18 @@
import type { Source } from '$libs/reactive';
/**
* Writing direction. Two values, no `'auto'` — auto-detection is a
* resolver concern (derive from locale), not a stored value.
*/
export const DIRECTIONS = ['ltr', 'rtl'] as const;
export type Direction = (typeof DIRECTIONS)[number];
/**
* Reactive direction provider. Alias of `Source<Direction>`.
*
* In a `prefs`-wired app the value is derived from `effective.locale`,
* but that is not part of the port — a hardcoded `get: () => 'ltr'` is a
* valid producer.
*/
export type DirectionSource = Source<Direction>;

@ -1 +1,2 @@
export type { LocaleSource } from './types'; export type { Locale, LocaleSource } from './types';
export { matchLocale, type MatchLocaleInput } from './match-locale.ts';

@ -0,0 +1,117 @@
import type { Locale } from './types.ts';
/**
* Lookup-style locale matching against a closed set of available tags.
*
* Priority order:
* 1. exact match
* 2. language + script match
* 3. language + region match
* 4. language match
* 5. fallback locale
*
* Matching uses canonical locale data (`Intl.Locale` /
* `Intl.getCanonicalLocales`) rather than string splitting, so
* `'es-ES'` and `'es-Latn-ES'` parse correctly even when the caller's
* `available` list mixes terse and rich tags.
*
* Per-candidate priority: each candidate (in user-preference order) is
* tried with the four match steps before moving to the next candidate.
* That mirrors CLDR's "Lookup" algorithm — the user's first stated
* preference wins even via a fuzzy match, ahead of a later candidate's
* exact match. Apps that prefer "exact-across-all then fuzzy" can
* pre-process `candidates` (e.g. canonicalize then dedupe) before
* calling.
*
* Pure helper — no dependency on `prefs`, no IO. Used by `prefs` for the
* locale layer of `effective`, but reusable from any context where a
* BCP-47 tag needs to be projected onto a closed support set
* (route guards, mail templates, SDK initialization).
*/
export interface MatchLocaleInput {
/**
* Ordered list of locales the user/environment prefers. Typical
* sources: `Accept-Language`, `navigator.languages`, an explicit
* intent value (`[intent.locale]`).
*/
readonly candidates: readonly Locale[];
/** Closed set the caller supports. */
readonly available: readonly Locale[];
/**
* Returned when no candidate matches at any priority level. The
* caller is expected to ensure this is itself a member of
* `available` (or accept that the result may not be).
*/
readonly fallback: Locale;
}
interface ParsedLocale {
readonly tag: Locale;
readonly intl: Intl.Locale;
}
function safeParse(tag: string): ParsedLocale | undefined {
try {
const canonical = Intl.getCanonicalLocales(tag)[0];
if (canonical === undefined) return undefined;
return { tag: canonical, intl: new Intl.Locale(canonical) };
} catch {
return undefined;
}
}
/**
* Find the best `available` locale for the given `candidates`. Returns
* `fallback` when no candidate matches at any priority level.
*
* Pure: no IO, no globals, no side effects. Safe to memoize per
* (candidates, available) tuple if profiling shows hot calls.
*/
export function matchLocale(input: MatchLocaleInput): Locale {
const available = input.available
.map((tag) => safeParse(tag))
.filter((parsed): parsed is ParsedLocale => parsed !== undefined);
if (available.length === 0) return input.fallback;
for (const candidateTag of input.candidates) {
const candidate = safeParse(candidateTag);
if (candidate === undefined) continue;
// Step 1 — exact match on the canonical tag.
const exact = available.find((entry) => entry.tag === candidate.tag);
if (exact !== undefined) return exact.tag;
// Step 2 — language + script. Both sides must declare a script;
// otherwise the match degrades to step 3 / step 4 naturally.
if (candidate.intl.script !== undefined) {
const langScript = available.find(
(entry) =>
entry.intl.language === candidate.intl.language &&
entry.intl.script === candidate.intl.script
);
if (langScript !== undefined) return langScript.tag;
}
// Step 3 — language + region. Same gating: candidate must have
// a region for this step to apply.
if (candidate.intl.region !== undefined) {
const langRegion = available.find(
(entry) =>
entry.intl.language === candidate.intl.language &&
entry.intl.region === candidate.intl.region
);
if (langRegion !== undefined) return langRegion.tag;
}
// Step 4 — language only. Picks the first available with the
// same primary subtag (`'es-MX'` candidate matches `'es-ES'`,
// `'es-AR'`, `'es'` — first one in `available` wins).
const langOnly = available.find(
(entry) => entry.intl.language === candidate.intl.language
);
if (langOnly !== undefined) return langOnly.tag;
}
return input.fallback;
}

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { matchLocale } from '../match-locale.ts';
describe('matchLocale', () => {
it('returns the canonical exact match when present', () => {
const result = matchLocale({
candidates: ['en-US'],
available: ['es-ES', 'en-US', 'fr-FR'],
fallback: 'es-ES'
});
expect(result).toBe('en-US');
});
it('falls back to language+region match when exact is missing', () => {
// Candidate `es-MX` has no exact match; `available` has `es-ES`
// and `es-MX-x-private` is absent. The match should drop to
// language-only because region differs.
const result = matchLocale({
candidates: ['es-MX'],
available: ['es-ES', 'en-US'],
fallback: 'en-US'
});
expect(result).toBe('es-ES');
});
it('honors language+script before language+region', () => {
// Candidate `zh-Hans-CN`: prefer `zh-Hans-XX` (lang+script)
// even when a different region is available with the same lang.
const result = matchLocale({
candidates: ['zh-Hans-CN'],
available: ['zh-Hant-TW', 'zh-Hans-SG', 'zh-CN'],
fallback: 'zh-CN'
});
expect(result).toBe('zh-Hans-SG');
});
it('uses the candidate priority order — first candidate that matches wins, even via fuzzy', () => {
// Candidate `es-MX` (lang match → es-ES) wins over `en-US`
// (exact match) because user preference order matters.
const result = matchLocale({
candidates: ['es-MX', 'en-US'],
available: ['es-ES', 'en-US'],
fallback: 'es-ES'
});
expect(result).toBe('es-ES');
});
it('returns the fallback when no candidate matches at any priority', () => {
const result = matchLocale({
candidates: ['ja-JP', 'ko-KR'],
available: ['es-ES', 'en-US'],
fallback: 'es-ES'
});
expect(result).toBe('es-ES');
});
it('skips malformed candidates and tries the next', () => {
const result = matchLocale({
candidates: ['not a locale', 'en-US'],
available: ['es-ES', 'en-US'],
fallback: 'es-ES'
});
expect(result).toBe('en-US');
});
it('returns the fallback when `available` is empty', () => {
const result = matchLocale({
candidates: ['es-ES'],
available: [],
fallback: 'en-US'
});
expect(result).toBe('en-US');
});
it('canonicalizes mismatched casing on both sides', () => {
// `EN_US` (underscore + uppercase) is a common BCP-47 typo;
// canonicalization fixes it to `en-US` and then matches.
const result = matchLocale({
candidates: ['en-us'],
available: ['EN-US', 'es-ES'],
fallback: 'es-ES'
});
expect(result).toBe('en-US');
});
});

@ -1,24 +1,24 @@
import type { Source } from '$libs/reactive';
/** /**
* Generic, dependency-free contract for a reactive locale provider. * BCP-47 locale tag. Stays a plain `string` at the type level so callers can
* * adopt their own brand if they want; runtime helpers can validate via
* Any artifact that needs to react to a locale change (formats, frontend, * `Intl.Locale` / `Intl.getCanonicalLocales`.
* future date pickers, etc.) consumes this shape. The producer (lang, an
* app-level state, a SvelteKit route) implements it.
*
* The type parameter `L` defaults to `string` so consumers don't have to know
* about lang's `SupportedLocale`. Producers that want stricter typing can
* narrow it (`LocaleSource<SupportedLocale>`) and pass instances to consumers
* that accept the wider `LocaleSource<string>` — the structural assignment is
* safe because `SupportedLocale extends string`.
*/ */
export interface LocaleSource<L extends string = string> { export type Locale = string;
/** Read the current locale. Must be synchronous and idempotent. */
getLocale: () => L;
/** /**
* Subscribe to locale changes. Returns an unsubscribe function. Optional — * Reactive locale provider. Alias of the framework-wide `Source<L>` port —
* a static source can omit it and consumers must treat the locale as * kept under the `LocaleSource` name because every existing consumer
* effectively immutable. * (format/frontend/lang) refers to it that way and the domain-specific
* name reads better at the call site
* (`opts: { locale?: LocaleSource }` documents intent better than
* `opts: { locale?: Source<Locale> }`).
*
* The type parameter `L` defaults to `string` so consumers stay open;
* producers with a narrowed catalog (`SupportedLocale`) can declare
* `LocaleSource<SupportedLocale>` and pass it where `LocaleSource<string>`
* is expected — structural assignment is safe because `SupportedLocale
* extends string`.
*/ */
onLocaleChange?: (fn: (locale: L) => void) => () => void; export type LocaleSource<L extends string = string> = Source<L>;
}

@ -0,0 +1,3 @@
export { MOTIONS_EFFECTIVE, MOTIONS_INTENT } from './types';
export type { MotionEffective, MotionIntent, MotionSource } from './types';
export { resolveMotion } from './resolve.ts';

@ -0,0 +1,29 @@
import type { MotionEffective, MotionIntent } from './types.ts';
/**
* Resolve a (`MotionIntent`, optional system hint, fallback) triple
* into the `MotionEffective` value a downstream consumer can apply.
*
* Pure helper. No dependency on `prefs`.
*
* - `intent === 'allow'` / `'reduce'` → returned as-is.
* - `intent === 'system'` → consult `systemHint` (the OS / browser
* `prefers-reduced-motion` value: `true` means reduce, `false` means
* allow); fall back to `fallback` when the environment did not
* provide a hint.
* - `intent === undefined` → same as `'system'` for resolution
* purposes (the difference between explicit-system and no-intent
* lives at the prefs layer; this helper produces the same effective
* value either way).
*/
export function resolveMotion(
intent: MotionIntent | undefined,
systemHint: boolean | undefined,
fallback: MotionEffective
): MotionEffective {
if (intent === 'allow' || intent === 'reduce') return intent;
// intent === 'system' or undefined → derive from hint.
if (systemHint === true) return 'reduce';
if (systemHint === false) return 'allow';
return fallback;
}

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { resolveMotion } from '../resolve.ts';
describe('resolveMotion', () => {
it('returns explicit allow/reduce intent untouched', () => {
expect(resolveMotion('allow', true, 'reduce')).toBe('allow');
expect(resolveMotion('reduce', false, 'allow')).toBe('reduce');
});
it('routes intent=system through the system hint', () => {
expect(resolveMotion('system', true, 'allow')).toBe('reduce');
expect(resolveMotion('system', false, 'reduce')).toBe('allow');
});
it('routes undefined intent through the system hint', () => {
expect(resolveMotion(undefined, true, 'allow')).toBe('reduce');
expect(resolveMotion(undefined, false, 'reduce')).toBe('allow');
});
it('falls back when system hint is missing and intent is system/undefined', () => {
expect(resolveMotion('system', undefined, 'allow')).toBe('allow');
expect(resolveMotion(undefined, undefined, 'reduce')).toBe('reduce');
});
});

@ -0,0 +1,31 @@
import type { Source } from '$libs/reactive';
/**
* Reduced-motion intent.
*
* - `'allow'` — animations and transitions play normally.
* - `'reduce'` — explicit user preference for reduced motion.
* - `'system'` — follow `prefers-reduced-motion: reduce` from the
* environment. A genuine intent value that persists; resolves to
* `MotionEffective` against the environment.
*/
export const MOTIONS_INTENT = ['allow', 'reduce', 'system'] as const;
export type MotionIntent = (typeof MOTIONS_INTENT)[number];
/**
* What downstream consumers (Frontend, animation engines) read. Never
* `'system'` — that is an intent, not an applied value.
*/
export const MOTIONS_EFFECTIVE = ['allow', 'reduce'] as const;
export type MotionEffective = (typeof MOTIONS_EFFECTIVE)[number];
/**
* Reactive motion provider. Alias of `Source<MotionEffective>`.
*
* Like `ThemeSource`, exposes the effective value (what consumers apply)
* not the intent. Intent ↔ effective resolution belongs to the preference
* layer that owns user input, not to the consumer.
*/
export type MotionSource = Source<MotionEffective>;

@ -0,0 +1,26 @@
/**
* Module identifier for `prefs`. Used as the diagnostic category and as
* the `arts/*` alias suffix (`$prefs`). Matches the directory name; the
* runtime never reads `__dirname`.
*/
export const PREFS_MODULE = 'prefs';
/**
* Discriminator for change event causes. Listed exhaustively so
* subscribers can match without falling back to a string compare.
*/
export const PREFS_CHANGE_CAUSES = [
'intent:set',
'intent:clear',
'intent:reset',
'environment:refresh',
'capabilities:set',
'hydrate'
] as const;
/**
* Internal version constant carried in every `PrefsSnapshot`. Bumping
* this is a hint to consumers that the snapshot shape changed in a
* non-additive way; they can branch on it during migration windows.
*/
export const PREFS_SNAPSHOT_VERSION = 1;

@ -0,0 +1,35 @@
/**
* Public surface of `libs/prefs`. Exposes the layered state model
* (`PrefsCapabilities`, `PrefsEnvironment`, `PrefsIntent`,
* `PrefsEffective`), snapshot/event types, the resolver input shape and
* validation result types.
*
* Domain primitives (`Locale`, `Currency`, `ThemeIntent`, …) and their
* capability sources (`LocaleSource`, `CurrencySource`, …) live in their
* own libs (`$libs/locale`, `$libs/currency`, …) so consumers can depend
* on a single capability port without importing `prefs`.
*/
export {
PREFS_CHANGE_CAUSES,
PREFS_MODULE,
PREFS_SNAPSHOT_VERSION
} from './consts.ts';
export { resolvePrefs } from './resolve-prefs.ts';
export { sanitizeIntent, validateIntentValue } from './validate-intent.ts';
export type {
PrefsCapabilities,
PrefsChangeCause,
PrefsChangeEvent,
PrefsChangeHandler,
PrefsEffective,
PrefsEnvironment,
PrefsIntent,
PrefsResolveInput,
PrefsSnapshot,
PrefsUnsubscribe,
PrefsValidationFailure,
PrefsValidationResult
} from './types.ts';

@ -0,0 +1,210 @@
import { currencyFromLocales } from '$libs/currency';
import type { Currency } from '$libs/currency';
import type { Density } from '$libs/density';
import { directionFromLanguage } from '$libs/direction';
import type { Direction } from '$libs/direction';
import { matchLocale } from '$libs/locale';
import type { Locale } from '$libs/locale';
import { resolveMotion } from '$libs/motion';
import { resolveTheme } from '$libs/theme';
import type { Timezone } from '$libs/timezone';
import { unitSystemFromLocales } from '$libs/units';
import type { UnitSystem } from '$libs/units';
import type {
PrefsCapabilities,
PrefsEffective,
PrefsEnvironment,
PrefsIntent,
PrefsResolveInput
} from './types.ts';
import { validateIntentValue } from './validate-intent.ts';
/**
* Pure orchestrator: compute `effective` from `(capabilities,
* environment, intent)`. Never reads browser APIs, persists, emits
* events or touches Svelte state — given the same input it always
* returns the same output.
*
* Per-field strategy: each effective field is an INDEPENDENT projection
* of `environment.locales[]` against its own capability catalog, with
* `intent` overriding the projection when it validates and a
* field-specific environment hint (e.g. `environment.currency`) winning
* over the locale-derived guess when present.
*
* The user's most-preferred locale wins per dimension, even when the
* overall locale fallback diverges: an `Accept-Language: es-MX, en-US`
* with `capabilities.locales = ['es-ES', 'en-US']` may resolve language
* to `es-ES` (closest match for `es-MX`) and currency to `USD` (no
* region match for `MX` in the catalog → walk continues to `en-US`).
*
* Domain-specific helpers live in their respective capability libs
* (`$libs/locale/match-locale`, `$libs/direction/from-language`,
* `$libs/currency/from-locale`, `$libs/units/from-locale`,
* `$libs/theme/resolve`, `$libs/motion/resolve`); this file is just
* composition over the prefs layered model.
*
* `direction` is the one derivation: it follows from the resolved
* `language` because direction is a property of the writing system, not
* the region.
*/
export function resolvePrefs(input: PrefsResolveInput): PrefsEffective {
const { capabilities, environment, intent } = input;
const defaults = capabilities.defaults;
const envLocales = environment.locales ?? [];
const language = resolveFromLocales(
'language',
capabilities,
intent.language,
envLocales,
capabilities.languages,
defaults.language
);
const locale = resolveFromLocales(
'locale',
capabilities,
intent.locale,
envLocales,
capabilities.locales,
defaults.locale
);
const currency = resolveCurrency(capabilities, environment, intent, envLocales, defaults.currency);
const unitSystem = resolveUnitSystem(capabilities, environment, intent, envLocales, defaults.unitSystem);
const timezone = resolveTimezone(capabilities, environment, intent, defaults.timezone);
const density = resolveDensity(capabilities, intent, defaults.density);
const theme = resolveTheme(
validatedIntent('theme', intent.theme, capabilities),
environment.colorScheme,
defaults.theme
);
const motion = resolveMotion(
validatedIntent('motion', intent.motion, capabilities),
environment.reducedMotion,
defaults.motion
);
const direction: Direction = directionFromLanguage(language);
return {
language,
locale,
currency,
timezone,
unitSystem,
theme,
density,
motion,
direction
};
}
// ─────────────────────────────────────────────────────────────────────
// Per-field resolvers
// ─────────────────────────────────────────────────────────────────────
/**
* Shared shape for `language` and `locale`: validate intent, then walk
* `environment.locales[]` through `matchLocale` against the field's own
* capability catalog. Same input array, different available list — that
* is what makes language and locale orthogonal projections.
*/
function resolveFromLocales(
key: 'language' | 'locale',
capabilities: PrefsCapabilities,
intentValue: Locale | undefined,
envLocales: readonly Locale[],
available: readonly Locale[],
fallback: Locale
): Locale {
if (intentValue !== undefined) {
const r = validateIntentValue(key, intentValue, capabilities);
if (r.ok) return r.value;
}
return matchLocale({ candidates: envLocales, available, fallback });
}
function resolveCurrency(
capabilities: PrefsCapabilities,
environment: PrefsEnvironment,
intent: PrefsIntent,
envLocales: readonly Locale[],
fallback: Currency
): Currency {
if (intent.currency !== undefined) {
const r = validateIntentValue('currency', intent.currency, capabilities);
if (r.ok) return r.value;
}
if (environment.currency !== undefined) {
const r = validateIntentValue('currency', environment.currency, capabilities);
if (r.ok) return r.value;
}
return currencyFromLocales(envLocales, capabilities.currencies, fallback);
}
function resolveUnitSystem(
capabilities: PrefsCapabilities,
environment: PrefsEnvironment,
intent: PrefsIntent,
envLocales: readonly Locale[],
fallback: UnitSystem
): UnitSystem {
if (intent.unitSystem !== undefined) {
const r = validateIntentValue('unitSystem', intent.unitSystem, capabilities);
if (r.ok) return r.value;
}
if (environment.unitSystem !== undefined) {
const r = validateIntentValue('unitSystem', environment.unitSystem, capabilities);
if (r.ok) return r.value;
}
return unitSystemFromLocales(envLocales, capabilities.unitSystems, fallback);
}
function resolveTimezone(
capabilities: PrefsCapabilities,
environment: PrefsEnvironment,
intent: PrefsIntent,
fallback: Timezone
): Timezone {
if (intent.timezone !== undefined) {
const r = validateIntentValue('timezone', intent.timezone, capabilities);
if (r.ok) return r.value;
}
if (environment.timezone !== undefined) {
const r = validateIntentValue('timezone', environment.timezone, capabilities);
if (r.ok) return r.value;
}
return fallback;
}
function resolveDensity(
capabilities: PrefsCapabilities,
intent: PrefsIntent,
fallback: Density
): Density {
if (intent.density !== undefined) {
const r = validateIntentValue('density', intent.density, capabilities);
if (r.ok) return r.value;
}
return fallback;
}
/**
* Pass-through filter that drops an intent value when it fails
* validation against `capabilities`. Used for `theme` and `motion`,
* whose dedicated `resolve*` helpers (`resolveTheme`, `resolveMotion`)
* accept `intent | undefined` directly — passing `undefined` triggers
* the helper's "fall back to system hint" branch, which is exactly what
* we want when the stored intent is no longer in capabilities.
*/
function validatedIntent<K extends keyof PrefsIntent>(
key: K,
value: PrefsIntent[K],
capabilities: PrefsCapabilities
): PrefsIntent[K] {
if (value === undefined) return undefined;
const r = validateIntentValue(
key,
value as NonNullable<PrefsIntent[K]>,
capabilities
);
return r.ok ? r.value : undefined;
}

@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest';
import { resolvePrefs } from '../resolve-prefs.ts';
import type { PrefsCapabilities, PrefsEnvironment, PrefsIntent } from '../types.ts';
const CAPS: PrefsCapabilities = {
languages: ['es-ES', 'en-US', 'ar-EG'],
locales: ['es-ES', 'en-US', 'ar-EG'],
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
};
const EMPTY_ENV: PrefsEnvironment = {};
describe('resolvePrefs', () => {
it('returns capabilities.defaults when intent and environment are empty', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: EMPTY_ENV,
intent: {}
});
expect(effective).toEqual(CAPS.defaults);
});
it('intent wins over environment when both are valid', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'], currency: 'USD' },
intent: { language: 'es-ES', locale: 'es-ES', currency: 'EUR' }
});
expect(effective.language).toBe('es-ES');
expect(effective.locale).toBe('es-ES');
expect(effective.currency).toBe('EUR');
});
it('environment.locales wins when intent is absent', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'], currency: 'USD' },
intent: {}
});
expect(effective.language).toBe('en-US');
expect(effective.locale).toBe('en-US');
expect(effective.currency).toBe('USD');
});
it('falls through invalid intent to environment, then defaults', () => {
const intent: PrefsIntent = {
language: 'fr-FR',
locale: 'fr-FR',
currency: 'JPY'
};
const env: PrefsEnvironment = { locales: ['en-US'], currency: 'USD' };
const effective = resolvePrefs({
capabilities: CAPS,
environment: env,
intent
});
expect(effective.language).toBe('en-US');
expect(effective.locale).toBe('en-US');
expect(effective.currency).toBe('USD');
});
it('routes environment.locales through the matcher (es-MX → es-ES)', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['es-MX', 'en-US'] },
intent: {}
});
expect(effective.language).toBe('es-ES');
expect(effective.locale).toBe('es-ES');
});
it('language and locale resolve INDEPENDENTLY against their own catalogs', () => {
// capabilities.languages = ['es-ES'] only — but capabilities.locales
// supports en-US too. Same env.locales[] must produce different
// effective fields.
const splitCaps: PrefsCapabilities = {
...CAPS,
languages: ['es-ES'],
locales: ['es-ES', 'en-US']
};
const effective = resolvePrefs({
capabilities: splitCaps,
environment: { locales: ['en-US', 'es-ES'] },
intent: {}
});
// language can't be en-US (not in languages catalog) → falls to es-ES
expect(effective.language).toBe('es-ES');
// locale CAN be en-US (first valid candidate against locales catalog)
expect(effective.locale).toBe('en-US');
});
it('currency derives from environment.locales when intent and env.currency are absent', () => {
// es-MX has no region match in EUR/USD catalog → walk continues
// to en-US → USD.
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['es-MX', 'en-US'] },
intent: {}
});
expect(effective.currency).toBe('USD');
});
it('unitSystem derives from environment.locales when intent and env.unitSystem are absent', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'] },
intent: {}
});
expect(effective.unitSystem).toBe('imperial');
});
it('environment.currency wins over locale-derived currency', () => {
// en-US would derive USD via locale projection, but env.currency=EUR
// is the explicit hint and wins.
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'], currency: 'EUR' },
intent: {}
});
expect(effective.currency).toBe('EUR');
});
it('resolves theme=system to the environment colorScheme', () => {
const effectiveDark = resolvePrefs({
capabilities: CAPS,
environment: { colorScheme: 'dark' },
intent: { theme: 'system' }
});
expect(effectiveDark.theme).toBe('dark');
const effectiveLight = resolvePrefs({
capabilities: CAPS,
environment: { colorScheme: 'light' },
intent: { theme: 'system' }
});
expect(effectiveLight.theme).toBe('light');
});
it('resolves theme=system to defaults when environment lacks colorScheme', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: {},
intent: { theme: 'system' }
});
expect(effective.theme).toBe(CAPS.defaults.theme);
});
it('resolves motion=system through environment.reducedMotion', () => {
const reduce = resolvePrefs({
capabilities: CAPS,
environment: { reducedMotion: true },
intent: { motion: 'system' }
});
expect(reduce.motion).toBe('reduce');
const allow = resolvePrefs({
capabilities: CAPS,
environment: { reducedMotion: false },
intent: { motion: 'system' }
});
expect(allow.motion).toBe('allow');
});
it('derives direction from the resolved language (RTL)', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: {},
intent: { language: 'ar-EG', locale: 'ar-EG' }
});
expect(effective.language).toBe('ar-EG');
expect(effective.direction).toBe('rtl');
});
it('direction follows language even when locale is LTR', () => {
// Pathological-but-legal: language ar-EG, locale en-US (the user
// wants Arabic UI but US-style number/date formatting). Direction
// must follow language.
const effective = resolvePrefs({
capabilities: CAPS,
environment: {},
intent: { language: 'ar-EG', locale: 'en-US' }
});
expect(effective.language).toBe('ar-EG');
expect(effective.locale).toBe('en-US');
expect(effective.direction).toBe('rtl');
});
it('density has no environment hint and falls back to default', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: {},
intent: {}
});
expect(effective.density).toBe(CAPS.defaults.density);
});
it('effective is total: every field present', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: EMPTY_ENV,
intent: {}
});
expect(Object.keys(effective).sort()).toEqual([
'currency',
'density',
'direction',
'language',
'locale',
'motion',
'theme',
'timezone',
'unitSystem'
]);
});
});

@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import type { PrefsCapabilities } from '../types.ts';
import { sanitizeIntent, validateIntentValue } from '../validate-intent.ts';
const CAPS: PrefsCapabilities = {
languages: ['es-ES', 'en-US'],
locales: ['es-ES', 'en-US'],
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
timezones: ['Europe/Madrid', 'America/New_York'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
};
describe('validateIntentValue', () => {
it('accepts a language that is in capabilities.languages', () => {
expect(validateIntentValue('language', 'en-US', CAPS)).toEqual({
ok: true,
value: 'en-US'
});
});
it('rejects a language that is NOT in capabilities.languages', () => {
expect(validateIntentValue('language', 'fr-FR', CAPS)).toEqual({
ok: false,
reason: 'unsupported_language'
});
});
it('language and locale validate against independent catalogs', () => {
// `capabilities.languages` does not have to equal
// `capabilities.locales` — a value in one list may legitimately
// be absent from the other.
const splitCaps: PrefsCapabilities = {
...CAPS,
languages: ['es-ES'],
locales: ['es-ES', 'en-US']
};
expect(validateIntentValue('language', 'en-US', splitCaps)).toEqual({
ok: false,
reason: 'unsupported_language'
});
expect(validateIntentValue('locale', 'en-US', splitCaps)).toEqual({
ok: true,
value: 'en-US'
});
});
it('accepts a locale that is in capabilities', () => {
expect(validateIntentValue('locale', 'en-US', CAPS)).toEqual({
ok: true,
value: 'en-US'
});
});
it('rejects a locale that is NOT in capabilities', () => {
expect(validateIntentValue('locale', 'fr-FR', CAPS)).toEqual({
ok: false,
reason: 'unsupported_locale'
});
});
it('rejects a currency outside the catalog', () => {
expect(validateIntentValue('currency', 'JPY', CAPS)).toEqual({
ok: false,
reason: 'unsupported_currency'
});
});
it('accepts theme `system` when capabilities allow it', () => {
expect(validateIntentValue('theme', 'system', CAPS)).toEqual({
ok: true,
value: 'system'
});
});
it('rejects theme `system` when capabilities exclude it', () => {
const restrictedCaps: PrefsCapabilities = { ...CAPS, themes: ['light', 'dark'] };
expect(validateIntentValue('theme', 'system', restrictedCaps)).toEqual({
ok: false,
reason: 'unsupported_theme'
});
});
it('returns the runtime canonical form of a valid timezone', () => {
// Whatever `Intl.DateTimeFormat(...).resolvedOptions().timeZone`
// produces on this runtime is, by definition, the canonical
// form. The validator returns that exact string so callers
// persist a stable identifier — exact equality with a target
// alias is runtime-dependent and not part of the contract.
const result = validateIntentValue('timezone', 'Europe/Madrid', CAPS);
expect(result.ok).toBe(true);
if (result.ok) expect(result.value).toBe('Europe/Madrid');
});
it('rejects an unrecognizable timezone string with `invalid_timezone`', () => {
expect(validateIntentValue('timezone', 'Not/A/Zone', CAPS)).toEqual({
ok: false,
reason: 'invalid_timezone'
});
});
it('rejects a valid IANA timezone outside `capabilities.timezones`', () => {
expect(validateIntentValue('timezone', 'Asia/Tokyo', CAPS)).toEqual({
ok: false,
reason: 'unsupported_timezone'
});
});
it('accepts any valid IANA timezone when `capabilities.timezones` is omitted', () => {
const openCaps: PrefsCapabilities = { ...CAPS, timezones: undefined };
expect(validateIntentValue('timezone', 'Asia/Tokyo', openCaps)).toEqual({
ok: true,
value: 'Asia/Tokyo'
});
});
});
describe('sanitizeIntent', () => {
it('drops fields whose value fails validation', () => {
const cleaned = sanitizeIntent(
{
locale: 'fr-FR', // not in caps
currency: 'EUR', // ok
theme: 'light' // ok
},
CAPS
);
expect(cleaned).toEqual({ currency: 'EUR', theme: 'light' });
});
it('preserves the canonical form returned by the validator', () => {
const cleaned = sanitizeIntent({ timezone: 'Europe/Madrid' }, CAPS);
expect(cleaned).toEqual({ timezone: 'Europe/Madrid' });
});
it('returns an empty object when the input is fully invalid', () => {
const cleaned = sanitizeIntent({ locale: 'fr-FR', currency: 'JPY' }, CAPS);
expect(cleaned).toEqual({});
});
});

@ -0,0 +1,206 @@
import type { Currency } from '$libs/currency';
import type { Density } from '$libs/density';
import type { Direction } from '$libs/direction';
import type { Locale } from '$libs/locale';
import type { MotionEffective, MotionIntent } from '$libs/motion';
import type { ThemeEffective, ThemeIntent } from '$libs/theme';
import type { Timezone } from '$libs/timezone';
import type { UnitSystem } from '$libs/units';
import type { PREFS_CHANGE_CAUSES } from './consts.ts';
// ─────────────────────────────────────────────────────────────────────
// Layered state
// ─────────────────────────────────────────────────────────────────────
//
// The four layers (`capabilities`, `environment`, `intent`, `effective`)
// plus `defaults` are the model `prefs` owns. Domain primitives
// (`Locale`, `Currency`, `Theme*`, …) live in their own libs so
// consumers can depend on a single capability port without importing
// `prefs`.
/**
* The legal universe of user-selectable values. The application
* composes this from its own configuration and from peer artifacts'
* advertised catalogs (Lang's translation set, the currency catalog,
* etc.). `prefs` does NOT discover capabilities by importing other
* modules — composition lives one layer above.
*
* `languages` and `locales` are independent lists with different
* sources and intent: `languages` is the i18n catalog (what Lang has
* translations for), `locales` is the regional formatting catalog
* (what Format / Intl-driven layout supports). They may overlap in
* simple apps but the framework treats them as orthogonal.
*/
export interface PrefsCapabilities {
/** BCP-47 tags Lang has translations for. Driven by i18n. */
readonly languages: readonly Locale[];
/** BCP-47 tags the app supports for regional formatting. Driven by product/legal/ops. */
readonly locales: readonly Locale[];
readonly currencies: readonly Currency[];
readonly unitSystems: readonly UnitSystem[];
readonly themes: readonly ThemeIntent[];
readonly densities: readonly Density[];
readonly motions: readonly MotionIntent[];
/**
* Optional allowlist of IANA time zones. When omitted, `prefs`
* accepts any value that canonicalizes through `Intl.DateTimeFormat`.
* When present, the canonicalized timezone must match an entry.
*/
readonly timezones?: readonly Timezone[];
/**
* Final fallback for every effective field. Must be valid against
* the rest of `capabilities` — `prefs` validates this at
* `setCapabilities()` time.
*/
readonly defaults: PrefsEffective;
}
/**
* Detected context — server header parsing, browser APIs, system
* settings. Different from `intent`: the user has not chosen these
* values, the runtime observed them.
*
* Fields are optional because detection is best-effort; SSR may know
* `locales` from `Accept-Language` but not have `prefers-color-scheme`.
*
* Values may fall outside `capabilities` (e.g. `Accept-Language: es-MX`
* when only `es-ES` is in `capabilities.locales`). The resolver bridges
* the gap; `environment` keeps the raw observation for diagnostics.
*/
export interface PrefsEnvironment {
readonly locales?: readonly Locale[];
readonly timezone?: Timezone;
readonly currency?: Currency;
readonly region?: string;
readonly unitSystem?: UnitSystem;
readonly colorScheme?: 'light' | 'dark';
readonly reducedMotion?: boolean;
/**
* Origin of the detection so consumers / tests can branch on it.
* `'mixed'` is for SSR + browser hydrate paths where the engine
* merges both sources.
*/
readonly source?: 'server' | 'browser' | 'mixed' | 'test';
}
/**
* What the user explicitly selected. Sparse: a missing field means
* "derive from environment + defaults", NOT "clear it". To clear an
* intent the engine exposes `clearIntent(key)` so callers cannot
* confuse "did not write" with "wrote undefined".
*/
export interface PrefsIntent {
readonly language?: Locale;
readonly locale?: Locale;
readonly currency?: Currency;
readonly timezone?: Timezone;
readonly unitSystem?: UnitSystem;
readonly theme?: ThemeIntent;
readonly density?: Density;
readonly motion?: MotionIntent;
}
/**
* The resolved view of every preference, total and always valid against
* `capabilities`. `prefs` produces this; consumers (or the wiring layer
* that builds capability proxies on top of it) read from here.
*
* Each field is the result of an INDEPENDENT projection of
* `environment.locales[]` against its own capability catalog (with
* `intent` override). No field derives from another except `direction`,
* which derives from the resolved `language` (because direction is a
* property of the writing system, not of the region).
*
* - `language` (BCP-47) — what Lang reads for translations.
* - `locale` (BCP-47) — what Format / Intl reads for regional layout.
* - `theme` is `'light' | 'dark'` — `'system'` is an intent, not an
* effective value.
* - `motion` is `'allow' | 'reduce'` — same reason.
* - `direction` is derived from `language`; not independently
* selectable as intent.
*/
export interface PrefsEffective {
readonly language: Locale;
readonly locale: Locale;
readonly currency: Currency;
readonly timezone: Timezone;
readonly unitSystem: UnitSystem;
readonly theme: ThemeEffective;
readonly density: Density;
readonly motion: MotionEffective;
readonly direction: Direction;
}
// ─────────────────────────────────────────────────────────────────────
// Snapshot / events
// ─────────────────────────────────────────────────────────────────────
/**
* Serializable, immutable view of every layer at one point in time.
* Used for SSR payloads, devtools panels, change-event diffs, and
* `getSnapshot()` reads. Consumers must not mutate the returned tree.
*/
export interface PrefsSnapshot {
readonly capabilities: PrefsCapabilities;
readonly environment: PrefsEnvironment;
readonly intent: PrefsIntent;
readonly effective: PrefsEffective;
/**
* Bumped on every committed write. Equivalent snapshots compare
* unequal across writes, so consumers can use it as an optimistic
* version key without doing a deep compare.
*/
readonly version: number;
}
export type PrefsChangeCause = (typeof PREFS_CHANGE_CAUSES)[number];
/**
* Payload delivered to every `subscribe()` listener. Carries the
* previous and next snapshots PLUS a pre-computed shallow diff over
* `effective` — by far the most common consumer concern.
*/
export interface PrefsChangeEvent {
readonly previous: PrefsSnapshot;
readonly next: PrefsSnapshot;
/** Sparse map: `{ locale: 'es-ES' }` when only locale changed. */
readonly effectiveDiff: Partial<PrefsEffective>;
readonly cause: PrefsChangeCause;
}
export type PrefsChangeHandler = (event: PrefsChangeEvent) => void;
export type PrefsUnsubscribe = () => void;
// ─────────────────────────────────────────────────────────────────────
// Resolver / engine I/O
// ─────────────────────────────────────────────────────────────────────
export interface PrefsResolveInput {
readonly capabilities: PrefsCapabilities;
readonly environment: PrefsEnvironment;
readonly intent: PrefsIntent;
}
// ─────────────────────────────────────────────────────────────────────
// Validation
// ─────────────────────────────────────────────────────────────────────
/**
* Reasons `validateIntentValue` rejects a write. Stable string codes so
* tests and downstream UIs can branch on them without depending on
* message wording.
*/
export type PrefsValidationFailure =
| 'unsupported_language'
| 'unsupported_locale'
| 'unsupported_currency'
| 'unsupported_timezone'
| 'unsupported_unit_system'
| 'unsupported_theme'
| 'unsupported_density'
| 'unsupported_motion'
| 'invalid_timezone';
export type PrefsValidationResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly reason: PrefsValidationFailure };

@ -0,0 +1,145 @@
import type { Currency } from '$libs/currency';
import type { Density } from '$libs/density';
import type { Locale } from '$libs/locale';
import type { MotionIntent } from '$libs/motion';
import type { ThemeIntent } from '$libs/theme';
import type { Timezone } from '$libs/timezone';
import type { UnitSystem } from '$libs/units';
import type {
PrefsCapabilities,
PrefsIntent,
PrefsValidationResult
} from './types.ts';
/**
* Canonicalize an IANA timezone string. Returns `undefined` when the
* value is not a recognizable timezone — consumers treat that as
* `'invalid_timezone'`.
*
* Uses `Intl.DateTimeFormat(...).resolvedOptions().timeZone` because it
* applies the same canonicalization the rest of the platform uses
* (`'Asia/Calcutta'` → `'Asia/Kolkata'`, etc.).
*/
function canonicalizeTimezone(timezone: string): string | undefined {
try {
return new Intl.DateTimeFormat('en-US', { timeZone: timezone })
.resolvedOptions()
.timeZone;
} catch {
return undefined;
}
}
/**
* Validate a single (key, value) pair against `capabilities`. Returns
* `{ ok: true, value }` (with `value` possibly canonicalized — only
* `timezone` does this today) or `{ ok: false, reason }` with a stable
* machine-readable reason code from `PrefsValidationFailure`.
*
* The function is the single source of truth for intent validation.
* Both the engine (`setIntent`, `resetIntent`) and the resolver call it,
* so an environment value that "passes" is exactly the same set of
* values an explicit intent could have stored.
*/
export function validateIntentValue<K extends keyof PrefsIntent>(
key: K,
value: NonNullable<PrefsIntent[K]>,
capabilities: PrefsCapabilities
): PrefsValidationResult<NonNullable<PrefsIntent[K]>> {
switch (key) {
case 'language': {
const language = value as Locale;
if (capabilities.languages.includes(language)) {
return { ok: true, value: language as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_language' };
}
case 'locale': {
const locale = value as Locale;
if (capabilities.locales.includes(locale)) {
return { ok: true, value: locale as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_locale' };
}
case 'currency': {
const currency = value as Currency;
if (capabilities.currencies.includes(currency)) {
return { ok: true, value: currency as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_currency' };
}
case 'unitSystem': {
const unitSystem = value as UnitSystem;
if (capabilities.unitSystems.includes(unitSystem)) {
return { ok: true, value: unitSystem as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_unit_system' };
}
case 'theme': {
const theme = value as ThemeIntent;
if (capabilities.themes.includes(theme)) {
return { ok: true, value: theme as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_theme' };
}
case 'density': {
const density = value as Density;
if (capabilities.densities.includes(density)) {
return { ok: true, value: density as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_density' };
}
case 'motion': {
const motion = value as MotionIntent;
if (capabilities.motions.includes(motion)) {
return { ok: true, value: motion as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_motion' };
}
case 'timezone': {
const canonical = canonicalizeTimezone(value as string);
if (canonical === undefined) {
return { ok: false, reason: 'invalid_timezone' };
}
if (
capabilities.timezones !== undefined &&
!capabilities.timezones.includes(canonical as Timezone)
) {
return { ok: false, reason: 'unsupported_timezone' };
}
// Return the canonicalized form so callers can persist a
// stable identifier even when the input was an alias.
return { ok: true, value: canonical as NonNullable<PrefsIntent[K]> };
}
}
}
/**
* Sanitize a sparse `PrefsIntent` map by dropping every field whose
* value fails validation. Used by `resolvePrefs` and at hydrate time so
* a stale persisted intent (e.g. the app shrank `capabilities.locales`)
* does not poison the effective view.
*
* Note: rejected entries are dropped, NOT replaced with environment or
* defaults — the resolver does that downstream. Keeps responsibilities
* separated: validation says yes/no; resolution decides the substitute.
*/
export function sanitizeIntent(
intent: PrefsIntent,
capabilities: PrefsCapabilities
): PrefsIntent {
const out: { -readonly [K in keyof PrefsIntent]?: PrefsIntent[K] } = {};
for (const key of Object.keys(intent) as Array<keyof PrefsIntent>) {
const raw = intent[key];
if (raw === undefined) continue;
const result = validateIntentValue(
key,
raw as NonNullable<PrefsIntent[typeof key]>,
capabilities
);
if (result.ok) (out as Record<string, unknown>)[key] = result.value;
}
return out;
}

@ -1,5 +1,5 @@
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
export type { Active, State, Getter, MaybeActiveOrGetter, Flattened } from './types.ts'; export type { Active, State, Getter, MaybeActiveOrGetter, Source, Flattened } from './types.ts';
// ─── Symbols (needed for advanced consumers extending the system) ───────────── // ─── Symbols (needed for advanced consumers extending the system) ─────────────
export { ActiveSymbol, WritableSymbol } from './symbols.ts'; export { ActiveSymbol, WritableSymbol } from './symbols.ts';

@ -1,5 +1,36 @@
import type { ActiveSymbol, WritableSymbol } from './symbols.ts'; import type { ActiveSymbol, WritableSymbol } from './symbols.ts';
// ─── Framework-agnostic value port ────────────────────────────────────────────
/**
* A reactive value port: synchronous read + optional change subscription.
*
* Any artifact that needs to observe an external value (locale, currency,
* theme, …) consumes this shape. The producer is unknown to the consumer —
* a Svelte `$state` wrapper, a `prefs` projection, an RxJS observable bridge
* and a hardcoded test stub all satisfy `Source<T>`.
*
* Distinct from `Active<T>` / `State<T>` in this same lib: those are
* Svelte-rune-backed containers carrying `.current` and the framework
* symbols. `Source<T>` is the abstract port; the rune containers can
* satisfy it via a thin adapter, but every implementation route is
* equally valid.
*
* The change subscription is OPTIONAL — a static source whose value never
* changes can omit it, and consumers MUST treat `onChange === undefined` as
* "value is effectively immutable, do not subscribe".
*/
export interface Source<T> {
/** Read the current value. Synchronous and idempotent. */
get: () => T;
/**
* Subscribe to value changes. Returns an unsubscribe function.
* Optional — static sources omit it.
*/
onChange?: (fn: (value: T) => void) => () => void;
}
// ─── Core reactive contracts ────────────────────────────────────────────────── // ─── Core reactive contracts ──────────────────────────────────────────────────
/** /**

@ -0,0 +1,3 @@
export { THEMES_EFFECTIVE, THEMES_INTENT } from './types';
export type { ThemeEffective, ThemeIntent, ThemeSource } from './types';
export { resolveTheme } from './resolve.ts';

@ -0,0 +1,29 @@
import type { ThemeEffective, ThemeIntent } from './types.ts';
/**
* Resolve a (`ThemeIntent`, optional system hint, fallback) triple into
* the `ThemeEffective` value a downstream consumer can apply.
*
* Pure helper. No dependency on `prefs`.
*
* - `intent === 'light'` / `'dark'` → returned as-is.
* - `intent === 'system'` → consult `systemHint` (the OS / browser
* `prefers-color-scheme` value); fall back to `fallback` when the
* environment did not provide a hint.
* - `intent === undefined` → consult `systemHint`; fall back to
* `fallback`. (Equivalent to `'system'` for resolution purposes —
* the difference between "user picked system" and "user has no
* intent" lives at the prefs layer; this helper produces the same
* effective value either way.)
*/
export function resolveTheme(
intent: ThemeIntent | undefined,
systemHint: 'light' | 'dark' | undefined,
fallback: ThemeEffective
): ThemeEffective {
if (intent === 'light' || intent === 'dark') return intent;
// intent === 'system' or undefined → derive from hint.
if (systemHint === 'dark') return 'dark';
if (systemHint === 'light') return 'light';
return fallback;
}

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { resolveTheme } from '../resolve.ts';
describe('resolveTheme', () => {
it('returns explicit light/dark intent untouched', () => {
expect(resolveTheme('light', 'dark', 'light')).toBe('light');
expect(resolveTheme('dark', 'light', 'light')).toBe('dark');
});
it('routes intent=system through the system hint', () => {
expect(resolveTheme('system', 'dark', 'light')).toBe('dark');
expect(resolveTheme('system', 'light', 'dark')).toBe('light');
});
it('routes undefined intent through the system hint (no-intent equivalent)', () => {
expect(resolveTheme(undefined, 'dark', 'light')).toBe('dark');
expect(resolveTheme(undefined, 'light', 'dark')).toBe('light');
});
it('falls back when system hint is missing and intent is system/undefined', () => {
expect(resolveTheme('system', undefined, 'light')).toBe('light');
expect(resolveTheme(undefined, undefined, 'dark')).toBe('dark');
});
});

@ -0,0 +1,31 @@
import type { Source } from '$libs/reactive';
/**
* What the user can pick for theme.
*
* - `'light'` / `'dark'` — explicit choice that overrides the environment.
* - `'system'` — explicit "follow whatever the OS / browser reports". A
* genuine choice that should persist; not the same as "no preference".
* Resolved against the environment to produce a `ThemeEffective` value.
*/
export const THEMES_INTENT = ['light', 'dark', 'system'] as const;
export type ThemeIntent = (typeof THEMES_INTENT)[number];
/**
* What downstream consumers (Frontend, paint code, theming engines) read.
* Never `'system'` — that is an intent, not an applied value.
*/
export const THEMES_EFFECTIVE = ['light', 'dark'] as const;
export type ThemeEffective = (typeof THEMES_EFFECTIVE)[number];
/**
* Reactive theme provider. Alias of `Source<ThemeEffective>`.
*
* Exposes the EFFECTIVE value because that is what consumers apply to the
* DOM / canvas / native shell. The intent ↔ effective resolution lives in
* whichever module owns the user-facing layer (`prefs` in this framework);
* consumers stay decoupled from that.
*/
export type ThemeSource = Source<ThemeEffective>;

@ -0,0 +1 @@
export type { Timezone, TimezoneSource } from './types';

@ -0,0 +1,16 @@
import type { Source } from '$libs/reactive';
/**
* IANA time zone identifier (e.g. `'Europe/Madrid'`). Free `string` at the
* type level; runtime helpers can canonicalize via
* `Intl.DateTimeFormat(...).resolvedOptions().timeZone`.
*/
export type Timezone = string;
/**
* Reactive timezone provider. Alias of the framework-wide `Source<TZ>` port.
* Consumers (date formatters, calendar components, scheduling logic)
* depend on this; the producer (`prefs`, an app setting, a static value)
* is unknown to them.
*/
export type TimezoneSource<TZ extends string = string> = Source<TZ>;

@ -0,0 +1,54 @@
import type { Locale } from '$libs/locale';
import type { UnitSystem } from './types.ts';
/**
* Regions that conventionally use the imperial system. Outside this
* set, `metric` is the default. The list mirrors common practice (US,
* Liberia, Myanmar) and is intentionally tiny — apps that need finer
* regional control inject their intent explicitly.
*/
const IMPERIAL_REGIONS: ReadonlySet<string> = new Set(['US', 'LR', 'MM']);
/**
* Map a single BCP-47 locale tag to the conventional unit system for
* its region. Tags without a region (e.g. plain `'es'`) return
* `undefined` so the caller can fall through to the next candidate or
* to defaults.
*
* Pure helper — no IO, no globals.
*/
export function unitSystemFromLocale(tag: Locale): UnitSystem | undefined {
const normalized = tag.trim().replace(/_/g, '-');
if (normalized === '') return undefined;
let region: string | undefined;
try {
region = new Intl.Locale(normalized).region;
} catch {
return undefined;
}
if (region === undefined) return undefined;
return IMPERIAL_REGIONS.has(region) ? 'imperial' : 'metric';
}
/**
* Walk the candidate priority list and return the first unit system
* that (a) maps from a candidate's region and (b) is in the caller's
* `available` catalog. Falls back to `fallback` when no candidate
* produces a supported value.
*
* Same projection shape as `currencyFromLocales` — independent walk per
* effective field so the user's most-preferred locale wins per
* dimension, even when the overall locale fallback diverges.
*/
export function unitSystemFromLocales(
candidates: readonly Locale[],
available: readonly UnitSystem[],
fallback: UnitSystem
): UnitSystem {
for (const tag of candidates) {
const system = unitSystemFromLocale(tag);
if (system !== undefined && available.includes(system)) return system;
}
return fallback;
}

@ -0,0 +1,3 @@
export { UNIT_SYSTEMS } from './types';
export type { UnitSystem, UnitSystemSource } from './types';
export { unitSystemFromLocale, unitSystemFromLocales } from './from-locale.ts';

@ -0,0 +1,17 @@
import type { Source } from '$libs/reactive';
/**
* Closed set of unit systems the framework understands. Apps that need a
* richer taxonomy (engineering, scientific, retail, …) layer their own
* type on top; this is the lowest common denominator that every consumer
* (`format`, document templates, dashboards) can reason about.
*/
export const UNIT_SYSTEMS = ['metric', 'imperial'] as const;
export type UnitSystem = (typeof UNIT_SYSTEMS)[number];
/**
* Reactive unit-system provider. Alias of `Source<UnitSystem>`.
* Consumers depend on the capability; the producer is unknown to them.
*/
export type UnitSystemSource = Source<UnitSystem>;
Loading…
Cancel
Save

Powered by TurnKey Linux.