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 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'
|
||||
};
|
||||
export { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from '$libs/currency';
|
||||
|
||||
@ -1,20 +1,12 @@
|
||||
import { getLocaleRegion, normalizeLocaleTag } from '../helpers';
|
||||
import { LOCALE_CURRENCY_OVERRIDES, REGION_CURRENCY } from './locale-currencies';
|
||||
import { currencyFromLocale } from '$libs/currency';
|
||||
import type { CurrencyCode } from './types';
|
||||
|
||||
/**
|
||||
* Resolve currency from an explicit locale region.
|
||||
*
|
||||
* No language fallback is applied: `es-AR` resolves from `AR`, while plain
|
||||
* `es` returns undefined so callers can use their own default currency.
|
||||
* Resolve currency from an explicit locale region. Thin wrapper over
|
||||
* `$libs/currency`'s pure helper, kept here under the existing name so
|
||||
* format internals don't have to retarget their imports. The actual
|
||||
* lookup table and logic live in `$libs/currency`.
|
||||
*/
|
||||
export function resolveCurrency(locale: string): CurrencyCode | undefined {
|
||||
const normalized = normalizeLocaleTag(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];
|
||||
return currencyFromLocale(locale);
|
||||
}
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { getLocaleRegion } from '../helpers';
|
||||
import { unitSystemFromLocale } from '$libs/units';
|
||||
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 {
|
||||
const region = getLocaleRegion(locale);
|
||||
return region !== undefined && IMPERIAL_REGIONS.has(region) ? 'imperial' : 'metric';
|
||||
return unitSystemFromLocale(locale) ?? '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.
|
||||
*
|
||||
* Any artifact that needs to react to a locale change (formats, frontend,
|
||||
* 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`.
|
||||
* 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
|
||||
* `Intl.Locale` / `Intl.getCanonicalLocales`.
|
||||
*/
|
||||
export interface LocaleSource<L extends string = string> {
|
||||
/** Read the current locale. Must be synchronous and idempotent. */
|
||||
getLocale: () => L;
|
||||
export type Locale = string;
|
||||
|
||||
/**
|
||||
* Subscribe to locale changes. Returns an unsubscribe function. Optional —
|
||||
* a static source can omit it and consumers must treat the locale as
|
||||
* effectively immutable.
|
||||
/**
|
||||
* Reactive locale provider. Alias of the framework-wide `Source<L>` port —
|
||||
* kept under the `LocaleSource` name because every existing consumer
|
||||
* (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