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
parent
178e76bfd3
commit
29dcd6b8ce
@ -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];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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…
Reference in new issue