From 29dcd6b8cec60de4370f74da57f5ccc70c4ef902 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 5 May 2026 13:17:45 +0200 Subject: [PATCH] =?UTF-8?q?Bloque=20L1=20=E2=80=94=20prefs=20foundation:?= =?UTF-8?q?=20capability=20libs=20+=20Source=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the pure preference layer and the framework-wide value port that the runtime layers (`arts/prefs`, format, frontend) consume. - `libs/reactive` gains `Source` — 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`. 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` 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) --- src/arts/format/active-runtime.svelte.ts | 22 +- .../format/currency/active-currency.svelte.ts | 2 +- src/arts/format/currency/locale-currencies.ts | 263 +----------------- src/arts/format/currency/locale-defaults.ts | 20 +- .../currency/test/active-currency.test.ts | 4 +- src/arts/format/dates/active-dates.svelte.ts | 2 +- .../format/dates/test/active-dates.test.ts | 4 +- .../format/numbers/active-numbers.svelte.ts | 2 +- .../numbers/test/active-numbers.test.ts | 4 +- src/arts/format/units/active-units.svelte.ts | 2 +- src/arts/format/units/locale-defaults.ts | 16 +- .../format/units/test/active-units.test.ts | 4 +- src/arts/frontend/active-frontend.svelte.ts | 6 +- src/libs/currency/from-locale.ts | 61 ++++ src/libs/currency/index.ts | 3 + src/libs/currency/region-currencies.ts | 255 +++++++++++++++++ src/libs/currency/types.ts | 19 ++ src/libs/density/index.ts | 2 + src/libs/density/types.ts | 15 + src/libs/direction/from-language.ts | 63 +++++ src/libs/direction/index.ts | 3 + src/libs/direction/test/from-language.test.ts | 41 +++ src/libs/direction/types.ts | 18 ++ src/libs/locale/index.ts | 3 +- src/libs/locale/match-locale.ts | 117 ++++++++ src/libs/locale/test/match-locale.test.ts | 85 ++++++ src/libs/locale/types.ts | 42 +-- src/libs/motion/index.ts | 3 + src/libs/motion/resolve.ts | 29 ++ src/libs/motion/test/resolve.test.ts | 24 ++ src/libs/motion/types.ts | 31 +++ src/libs/prefs/consts.ts | 26 ++ src/libs/prefs/index.ts | 35 +++ src/libs/prefs/resolve-prefs.ts | 210 ++++++++++++++ src/libs/prefs/test/resolve-prefs.test.ts | 230 +++++++++++++++ src/libs/prefs/test/validate-intent.test.ts | 153 ++++++++++ src/libs/prefs/types.ts | 206 ++++++++++++++ src/libs/prefs/validate-intent.ts | 145 ++++++++++ src/libs/reactive/index.ts | 2 +- src/libs/reactive/types.ts | 31 +++ src/libs/theme/index.ts | 3 + src/libs/theme/resolve.ts | 29 ++ src/libs/theme/test/resolve.test.ts | 24 ++ src/libs/theme/types.ts | 31 +++ src/libs/timezone/index.ts | 1 + src/libs/timezone/types.ts | 16 ++ src/libs/units/from-locale.ts | 54 ++++ src/libs/units/index.ts | 3 + src/libs/units/types.ts | 17 ++ 49 files changed, 2059 insertions(+), 322 deletions(-) create mode 100644 src/libs/currency/from-locale.ts create mode 100644 src/libs/currency/index.ts create mode 100644 src/libs/currency/region-currencies.ts create mode 100644 src/libs/currency/types.ts create mode 100644 src/libs/density/index.ts create mode 100644 src/libs/density/types.ts create mode 100644 src/libs/direction/from-language.ts create mode 100644 src/libs/direction/index.ts create mode 100644 src/libs/direction/test/from-language.test.ts create mode 100644 src/libs/direction/types.ts create mode 100644 src/libs/locale/match-locale.ts create mode 100644 src/libs/locale/test/match-locale.test.ts create mode 100644 src/libs/motion/index.ts create mode 100644 src/libs/motion/resolve.ts create mode 100644 src/libs/motion/test/resolve.test.ts create mode 100644 src/libs/motion/types.ts create mode 100644 src/libs/prefs/consts.ts create mode 100644 src/libs/prefs/index.ts create mode 100644 src/libs/prefs/resolve-prefs.ts create mode 100644 src/libs/prefs/test/resolve-prefs.test.ts create mode 100644 src/libs/prefs/test/validate-intent.test.ts create mode 100644 src/libs/prefs/types.ts create mode 100644 src/libs/prefs/validate-intent.ts create mode 100644 src/libs/theme/index.ts create mode 100644 src/libs/theme/resolve.ts create mode 100644 src/libs/theme/test/resolve.test.ts create mode 100644 src/libs/theme/types.ts create mode 100644 src/libs/timezone/index.ts create mode 100644 src/libs/timezone/types.ts create mode 100644 src/libs/units/from-locale.ts create mode 100644 src/libs/units/index.ts create mode 100644 src/libs/units/types.ts diff --git a/src/arts/format/active-runtime.svelte.ts b/src/arts/format/active-runtime.svelte.ts index 0bb53ca..80afed2 100644 --- a/src/arts/format/active-runtime.svelte.ts +++ b/src/arts/format/active-runtime.svelte.ts @@ -29,24 +29,24 @@ export interface ActiveFormatRuntime { export function createActiveFormatLocaleSource( options: ActiveFormatLocaleSourceOptions ): ActiveFormatLocaleSource { - let currentLocale = options.localeSource?.getLocale() ?? options.locale; + let currentLocale = options.localeSource?.get() ?? options.locale; // Single fan-out point: every submodule (numbers/currency/units/dates) - // subscribes through `source.onLocaleChange`, and this set is also - // fired when the parent calls `setLocale()` directly. Previously the - // parent's `setLocale` only mutated `currentLocale` and the parent - // then re-called `setLocale` on every submodule manually — two - // notifications per change. The unified source delivers exactly one. + // subscribes through `source.onChange`, and this set is also fired + // when the parent calls `setLocale()` directly. Previously the parent's + // `setLocale` only mutated `currentLocale` and then re-called + // `setLocale` on every submodule manually — two notifications per + // change. The unified source delivers exactly one. const listeners = new Set<(locale: string) => void>(); function getLocaleNow(): string { - return currentLocale ?? options.localeSource?.getLocale() ?? options.locale ?? ''; + return currentLocale ?? options.localeSource?.get() ?? options.locale ?? ''; } const source: FormatLocaleSource = { - getLocale: getLocaleNow, - onLocaleChange: (fn) => { + get: getLocaleNow, + onChange: (fn) => { listeners.add(fn); - const unsubscribeUpstream = options.localeSource?.onLocaleChange?.((locale) => { + const unsubscribeUpstream = options.localeSource?.onChange?.((locale) => { currentLocale = locale; for (const listener of listeners) listener(locale); }); @@ -89,7 +89,7 @@ export function createActiveFormatRuntime( localeListeners.forEach((fn) => fn(options.getLocale())); } - const unsubscribeLocale = options.localeSource?.onLocaleChange?.(syncLocale); + const unsubscribeLocale = options.localeSource?.onChange?.(syncLocale); return { read, diff --git a/src/arts/format/currency/active-currency.svelte.ts b/src/arts/format/currency/active-currency.svelte.ts index 4bf0e8e..d8fbfe3 100644 --- a/src/arts/format/currency/active-currency.svelte.ts +++ b/src/arts/format/currency/active-currency.svelte.ts @@ -22,7 +22,7 @@ export function createActiveCurrency(options: ActiveCurrencyOptions = {}): Activ const engine = createEngineCurrency({ ...options, rates, - locale: localeSource?.getLocale() ?? options.locale + locale: localeSource?.get() ?? options.locale }); const currencyListeners = new Set<(currency: CurrencyCode) => void>(); diff --git a/src/arts/format/currency/locale-currencies.ts b/src/arts/format/currency/locale-currencies.ts index c5838e2..7ba73e9 100644 --- a/src/arts/format/currency/locale-currencies.ts +++ b/src/arts/format/currency/locale-currencies.ts @@ -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 = {}; - -export const REGION_CURRENCY: Record = { - 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'; diff --git a/src/arts/format/currency/locale-defaults.ts b/src/arts/format/currency/locale-defaults.ts index e83bf62..259d357 100644 --- a/src/arts/format/currency/locale-defaults.ts +++ b/src/arts/format/currency/locale-defaults.ts @@ -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); } diff --git a/src/arts/format/currency/test/active-currency.test.ts b/src/arts/format/currency/test/active-currency.test.ts index 75fc92c..7af5af8 100644 --- a/src/arts/format/currency/test/active-currency.test.ts +++ b/src/arts/format/currency/test/active-currency.test.ts @@ -51,8 +51,8 @@ describe('createActiveCurrency()', () => { let locale = 'en-US'; let listener: ((locale: string) => void) | undefined; const source = { - getLocale: () => locale, - onLocaleChange(fn: (nextLocale: string) => void) { + get: () => locale, + onChange(fn: (nextLocale: string) => void) { listener = fn; return () => { listener = undefined; diff --git a/src/arts/format/dates/active-dates.svelte.ts b/src/arts/format/dates/active-dates.svelte.ts index 796a9fa..a025aa3 100644 --- a/src/arts/format/dates/active-dates.svelte.ts +++ b/src/arts/format/dates/active-dates.svelte.ts @@ -6,7 +6,7 @@ export function createActiveDates(options: ActiveDatesOptions = {}): ActiveDates const localeSource = options.localeSource; const engine = createEngineDates({ ...options, - locale: localeSource?.getLocale() ?? options.locale + locale: localeSource?.get() ?? options.locale }); const runtime = createActiveFormatRuntime({ diff --git a/src/arts/format/dates/test/active-dates.test.ts b/src/arts/format/dates/test/active-dates.test.ts index b517e10..1677eee 100644 --- a/src/arts/format/dates/test/active-dates.test.ts +++ b/src/arts/format/dates/test/active-dates.test.ts @@ -6,8 +6,8 @@ describe('createActiveDates()', () => { let locale = 'en-US'; let listener: ((locale: string) => void) | undefined; const source = { - getLocale: () => locale, - onLocaleChange(fn: (nextLocale: string) => void) { + get: () => locale, + onChange(fn: (nextLocale: string) => void) { listener = fn; return () => { listener = undefined; diff --git a/src/arts/format/numbers/active-numbers.svelte.ts b/src/arts/format/numbers/active-numbers.svelte.ts index 608c9ee..89b08cb 100644 --- a/src/arts/format/numbers/active-numbers.svelte.ts +++ b/src/arts/format/numbers/active-numbers.svelte.ts @@ -6,7 +6,7 @@ export function createActiveNumbers(options: ActiveNumbersOptions = {}): ActiveN const localeSource = options.localeSource; const engine = createEngineNumbers({ ...options, - locale: localeSource?.getLocale() ?? options.locale + locale: localeSource?.get() ?? options.locale }); const runtime = createActiveFormatRuntime({ diff --git a/src/arts/format/numbers/test/active-numbers.test.ts b/src/arts/format/numbers/test/active-numbers.test.ts index fc07eaa..b05de5a 100644 --- a/src/arts/format/numbers/test/active-numbers.test.ts +++ b/src/arts/format/numbers/test/active-numbers.test.ts @@ -6,8 +6,8 @@ describe('createActiveNumbers()', () => { let locale = 'en-US'; let listener: ((locale: string) => void) | undefined; const source = { - getLocale: () => locale, - onLocaleChange(fn: (nextLocale: string) => void) { + get: () => locale, + onChange(fn: (nextLocale: string) => void) { listener = fn; return () => { listener = undefined; diff --git a/src/arts/format/units/active-units.svelte.ts b/src/arts/format/units/active-units.svelte.ts index 226f69b..8876f4d 100644 --- a/src/arts/format/units/active-units.svelte.ts +++ b/src/arts/format/units/active-units.svelte.ts @@ -6,7 +6,7 @@ export function createActiveUnits(options: ActiveUnitsOptions = {}): ActiveUnits const localeSource = options.localeSource; const engine = createEngineUnits({ ...options, - locale: localeSource?.getLocale() ?? options.locale + locale: localeSource?.get() ?? options.locale }); const runtime = createActiveFormatRuntime({ diff --git a/src/arts/format/units/locale-defaults.ts b/src/arts/format/units/locale-defaults.ts index 3b25322..4bb7013 100644 --- a/src/arts/format/units/locale-defaults.ts +++ b/src/arts/format/units/locale-defaults.ts @@ -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'; } diff --git a/src/arts/format/units/test/active-units.test.ts b/src/arts/format/units/test/active-units.test.ts index fe04c5e..61093ea 100644 --- a/src/arts/format/units/test/active-units.test.ts +++ b/src/arts/format/units/test/active-units.test.ts @@ -7,8 +7,8 @@ describe('createActiveUnits()', () => { let locale = 'en-US'; let listener: ((locale: string) => void) | undefined; const source = { - getLocale: () => locale, - onLocaleChange(fn: (nextLocale: string) => void) { + get: () => locale, + onChange(fn: (nextLocale: string) => void) { listener = fn; return () => { listener = undefined; diff --git a/src/arts/frontend/active-frontend.svelte.ts b/src/arts/frontend/active-frontend.svelte.ts index a92f7a7..c2f08b3 100644 --- a/src/arts/frontend/active-frontend.svelte.ts +++ b/src/arts/frontend/active-frontend.svelte.ts @@ -98,7 +98,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ const dom: FrontendDom | undefined = options.applyDom === false ? undefined : (options.dom ?? { apply: applyChange }); - let currentLocale = $state(localeSource?.getLocale() ?? options.locale ?? ''); + let currentLocale = $state(localeSource?.get() ?? options.locale ?? ''); let dirOverride = $state( options.dir === 'auto' || options.dir === undefined ? null : options.dir ); @@ -119,7 +119,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ let disposed = false; function getLocale(): string { - return currentLocale || localeSource?.getLocale() || options.locale || ''; + return currentLocale || localeSource?.get() || options.locale || ''; } function getDir(): Direction { @@ -169,7 +169,7 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ notify(); } - const unsubscribeLocale = localeSource?.onLocaleChange?.(setLocale); + const unsubscribeLocale = localeSource?.onChange?.(setLocale); const unsubscribeDarkMode = subscribeMedia('(prefers-color-scheme: dark)', (matches) => { osDarkMode = matches; notify(); diff --git a/src/libs/currency/from-locale.ts b/src/libs/currency/from-locale.ts new file mode 100644 index 0000000..5df8e3d --- /dev/null +++ b/src/libs/currency/from-locale.ts @@ -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; +} diff --git a/src/libs/currency/index.ts b/src/libs/currency/index.ts new file mode 100644 index 0000000..d0c877a --- /dev/null +++ b/src/libs/currency/index.ts @@ -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'; diff --git a/src/libs/currency/region-currencies.ts b/src/libs/currency/region-currencies.ts new file mode 100644 index 0000000..a35b1da --- /dev/null +++ b/src/libs/currency/region-currencies.ts @@ -0,0 +1,255 @@ +import type { Currency } from "./types"; + +export const LOCALE_CURRENCY_OVERRIDES: Record = {}; + +export const REGION_CURRENCY: Record = { + 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' +}; diff --git a/src/libs/currency/types.ts b/src/libs/currency/types.ts new file mode 100644 index 0000000..e4adb66 --- /dev/null +++ b/src/libs/currency/types.ts @@ -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` 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` is expected. + */ +export type CurrencySource = Source; diff --git a/src/libs/density/index.ts b/src/libs/density/index.ts new file mode 100644 index 0000000..4e0a089 --- /dev/null +++ b/src/libs/density/index.ts @@ -0,0 +1,2 @@ +export { DENSITIES } from './types'; +export type { Density, DensitySource } from './types'; diff --git a/src/libs/density/types.ts b/src/libs/density/types.ts new file mode 100644 index 0000000..52080e7 --- /dev/null +++ b/src/libs/density/types.ts @@ -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`. + */ +export type DensitySource = Source; diff --git a/src/libs/direction/from-language.ts b/src/libs/direction/from-language.ts new file mode 100644 index 0000000..dd114d2 --- /dev/null +++ b/src/libs/direction/from-language.ts @@ -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 = 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'; + } +} diff --git a/src/libs/direction/index.ts b/src/libs/direction/index.ts new file mode 100644 index 0000000..998a2e9 --- /dev/null +++ b/src/libs/direction/index.ts @@ -0,0 +1,3 @@ +export { DIRECTIONS } from './types'; +export type { Direction, DirectionSource } from './types'; +export { directionFromLanguage } from './from-language.ts'; diff --git a/src/libs/direction/test/from-language.test.ts b/src/libs/direction/test/from-language.test.ts new file mode 100644 index 0000000..60450a7 --- /dev/null +++ b/src/libs/direction/test/from-language.test.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'); + }); +}); diff --git a/src/libs/direction/types.ts b/src/libs/direction/types.ts new file mode 100644 index 0000000..f716dbc --- /dev/null +++ b/src/libs/direction/types.ts @@ -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`. + * + * 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; diff --git a/src/libs/locale/index.ts b/src/libs/locale/index.ts index 25460de..6608814 100644 --- a/src/libs/locale/index.ts +++ b/src/libs/locale/index.ts @@ -1 +1,2 @@ -export type { LocaleSource } from './types'; +export type { Locale, LocaleSource } from './types'; +export { matchLocale, type MatchLocaleInput } from './match-locale.ts'; diff --git a/src/libs/locale/match-locale.ts b/src/libs/locale/match-locale.ts new file mode 100644 index 0000000..0706ab1 --- /dev/null +++ b/src/libs/locale/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; +} diff --git a/src/libs/locale/test/match-locale.test.ts b/src/libs/locale/test/match-locale.test.ts new file mode 100644 index 0000000..f692aad --- /dev/null +++ b/src/libs/locale/test/match-locale.test.ts @@ -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'); + }); +}); diff --git a/src/libs/locale/types.ts b/src/libs/locale/types.ts index daefdd3..1bc9dc2 100644 --- a/src/libs/locale/types.ts +++ b/src/libs/locale/types.ts @@ -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`) and pass instances to consumers - * that accept the wider `LocaleSource` — 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 { - /** 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. - */ - onLocaleChange?: (fn: (locale: L) => void) => () => void; -} +/** + * Reactive locale provider. Alias of the framework-wide `Source` 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 }`). + * + * The type parameter `L` defaults to `string` so consumers stay open; + * producers with a narrowed catalog (`SupportedLocale`) can declare + * `LocaleSource` and pass it where `LocaleSource` + * is expected — structural assignment is safe because `SupportedLocale + * extends string`. + */ +export type LocaleSource = Source; diff --git a/src/libs/motion/index.ts b/src/libs/motion/index.ts new file mode 100644 index 0000000..1790498 --- /dev/null +++ b/src/libs/motion/index.ts @@ -0,0 +1,3 @@ +export { MOTIONS_EFFECTIVE, MOTIONS_INTENT } from './types'; +export type { MotionEffective, MotionIntent, MotionSource } from './types'; +export { resolveMotion } from './resolve.ts'; diff --git a/src/libs/motion/resolve.ts b/src/libs/motion/resolve.ts new file mode 100644 index 0000000..7a6bf68 --- /dev/null +++ b/src/libs/motion/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; +} diff --git a/src/libs/motion/test/resolve.test.ts b/src/libs/motion/test/resolve.test.ts new file mode 100644 index 0000000..80bffd3 --- /dev/null +++ b/src/libs/motion/test/resolve.test.ts @@ -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'); + }); +}); diff --git a/src/libs/motion/types.ts b/src/libs/motion/types.ts new file mode 100644 index 0000000..eb12cb7 --- /dev/null +++ b/src/libs/motion/types.ts @@ -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`. + * + * 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; diff --git a/src/libs/prefs/consts.ts b/src/libs/prefs/consts.ts new file mode 100644 index 0000000..1b103c0 --- /dev/null +++ b/src/libs/prefs/consts.ts @@ -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; diff --git a/src/libs/prefs/index.ts b/src/libs/prefs/index.ts new file mode 100644 index 0000000..f7372cd --- /dev/null +++ b/src/libs/prefs/index.ts @@ -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'; diff --git a/src/libs/prefs/resolve-prefs.ts b/src/libs/prefs/resolve-prefs.ts new file mode 100644 index 0000000..bb6b089 --- /dev/null +++ b/src/libs/prefs/resolve-prefs.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( + key: K, + value: PrefsIntent[K], + capabilities: PrefsCapabilities +): PrefsIntent[K] { + if (value === undefined) return undefined; + const r = validateIntentValue( + key, + value as NonNullable, + capabilities + ); + return r.ok ? r.value : undefined; +} diff --git a/src/libs/prefs/test/resolve-prefs.test.ts b/src/libs/prefs/test/resolve-prefs.test.ts new file mode 100644 index 0000000..53a20b0 --- /dev/null +++ b/src/libs/prefs/test/resolve-prefs.test.ts @@ -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' + ]); + }); +}); diff --git a/src/libs/prefs/test/validate-intent.test.ts b/src/libs/prefs/test/validate-intent.test.ts new file mode 100644 index 0000000..e9ee5d7 --- /dev/null +++ b/src/libs/prefs/test/validate-intent.test.ts @@ -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({}); + }); +}); diff --git a/src/libs/prefs/types.ts b/src/libs/prefs/types.ts new file mode 100644 index 0000000..56c37c2 --- /dev/null +++ b/src/libs/prefs/types.ts @@ -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; + 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 = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly reason: PrefsValidationFailure }; diff --git a/src/libs/prefs/validate-intent.ts b/src/libs/prefs/validate-intent.ts new file mode 100644 index 0000000..65b9b62 --- /dev/null +++ b/src/libs/prefs/validate-intent.ts @@ -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( + key: K, + value: NonNullable, + capabilities: PrefsCapabilities +): PrefsValidationResult> { + switch (key) { + case 'language': { + const language = value as Locale; + if (capabilities.languages.includes(language)) { + return { ok: true, value: language as NonNullable }; + } + 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 }; + } + 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 }; + } + 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 }; + } + 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 }; + } + 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 }; + } + 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 }; + } + 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 }; + } + } +} + +/** + * 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) { + const raw = intent[key]; + if (raw === undefined) continue; + const result = validateIntentValue( + key, + raw as NonNullable, + capabilities + ); + if (result.ok) (out as Record)[key] = result.value; + } + + return out; +} diff --git a/src/libs/reactive/index.ts b/src/libs/reactive/index.ts index 2ec695b..6d292b9 100644 --- a/src/libs/reactive/index.ts +++ b/src/libs/reactive/index.ts @@ -1,5 +1,5 @@ // ─── Types ──────────────────────────────────────────────────────────────────── -export type { Active, State, Getter, MaybeActiveOrGetter, Flattened } from './types.ts'; +export type { Active, State, Getter, MaybeActiveOrGetter, Source, Flattened } from './types.ts'; // ─── Symbols (needed for advanced consumers extending the system) ───────────── export { ActiveSymbol, WritableSymbol } from './symbols.ts'; diff --git a/src/libs/reactive/types.ts b/src/libs/reactive/types.ts index 4ad37a7..2c81189 100644 --- a/src/libs/reactive/types.ts +++ b/src/libs/reactive/types.ts @@ -1,5 +1,36 @@ import type { ActiveSymbol, WritableSymbol } from './symbols.ts'; +// ─── Framework-agnostic value port ──────────────────────────────────────────── + +/** + * A reactive value port: synchronous read + optional change subscription. + * + * Any artifact that needs to observe an external value (locale, currency, + * theme, …) consumes this shape. The producer is unknown to the consumer — + * a Svelte `$state` wrapper, a `prefs` projection, an RxJS observable bridge + * and a hardcoded test stub all satisfy `Source`. + * + * Distinct from `Active` / `State` in this same lib: those are + * Svelte-rune-backed containers carrying `.current` and the framework + * symbols. `Source` is the abstract port; the rune containers can + * satisfy it via a thin adapter, but every implementation route is + * equally valid. + * + * The change subscription is OPTIONAL — a static source whose value never + * changes can omit it, and consumers MUST treat `onChange === undefined` as + * "value is effectively immutable, do not subscribe". + */ +export interface Source { + /** Read the current value. Synchronous and idempotent. */ + get: () => T; + + /** + * Subscribe to value changes. Returns an unsubscribe function. + * Optional — static sources omit it. + */ + onChange?: (fn: (value: T) => void) => () => void; +} + // ─── Core reactive contracts ────────────────────────────────────────────────── /** diff --git a/src/libs/theme/index.ts b/src/libs/theme/index.ts new file mode 100644 index 0000000..f1fa9a1 --- /dev/null +++ b/src/libs/theme/index.ts @@ -0,0 +1,3 @@ +export { THEMES_EFFECTIVE, THEMES_INTENT } from './types'; +export type { ThemeEffective, ThemeIntent, ThemeSource } from './types'; +export { resolveTheme } from './resolve.ts'; diff --git a/src/libs/theme/resolve.ts b/src/libs/theme/resolve.ts new file mode 100644 index 0000000..2d33cf5 --- /dev/null +++ b/src/libs/theme/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; +} diff --git a/src/libs/theme/test/resolve.test.ts b/src/libs/theme/test/resolve.test.ts new file mode 100644 index 0000000..bcd1a13 --- /dev/null +++ b/src/libs/theme/test/resolve.test.ts @@ -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'); + }); +}); diff --git a/src/libs/theme/types.ts b/src/libs/theme/types.ts new file mode 100644 index 0000000..5f55f8b --- /dev/null +++ b/src/libs/theme/types.ts @@ -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`. + * + * 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; diff --git a/src/libs/timezone/index.ts b/src/libs/timezone/index.ts new file mode 100644 index 0000000..9f211d8 --- /dev/null +++ b/src/libs/timezone/index.ts @@ -0,0 +1 @@ +export type { Timezone, TimezoneSource } from './types'; diff --git a/src/libs/timezone/types.ts b/src/libs/timezone/types.ts new file mode 100644 index 0000000..94a6651 --- /dev/null +++ b/src/libs/timezone/types.ts @@ -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` 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 = Source; diff --git a/src/libs/units/from-locale.ts b/src/libs/units/from-locale.ts new file mode 100644 index 0000000..85a2985 --- /dev/null +++ b/src/libs/units/from-locale.ts @@ -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 = 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; +} diff --git a/src/libs/units/index.ts b/src/libs/units/index.ts new file mode 100644 index 0000000..4d2c83b --- /dev/null +++ b/src/libs/units/index.ts @@ -0,0 +1,3 @@ +export { UNIT_SYSTEMS } from './types'; +export type { UnitSystem, UnitSystemSource } from './types'; +export { unitSystemFromLocale, unitSystemFromLocales } from './from-locale.ts'; diff --git a/src/libs/units/types.ts b/src/libs/units/types.ts new file mode 100644 index 0000000..e240ad4 --- /dev/null +++ b/src/libs/units/types.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`. + * Consumers depend on the capability; the producer is unknown to them. + */ +export type UnitSystemSource = Source;