Compare commits

...

126 Commits

Author SHA1 Message Date
dev 217d6969e4 active-uix: uix.runtime(morfo, sources) auto-injects dom + semantic
5 months ago
dev 9009cbfe8c active-uix: standalone mode no longer wraps active-app
5 months ago
dev 83b841a7ca active-uix: introduce UIX layer between active-app and components
5 months ago
dev 5898cc58c7 libs/reactive: export ActiveProps and StateProps types
5 months ago
dev 4bf6dea420 remove backward-compat re-export shims
5 months ago
dev d9b6521020 fix dependency direction: morfo no longer imports from soma
5 months ago
dev 9b07cead6b uix: consolidate DOM + relocate morfo runtime to its layer
5 months ago
dev a6e2c1e443 sema: align vocabulary with libro_semantica (signal, shift, loss)
5 months ago
dev 3faa4473f2 eidos/accordion: fix trigger rotating 90deg when open
5 months ago
dev 930d4c8c16 soma: emit perceptual signal on open in dialog/drawer/popover
5 months ago
dev 86f01f5d8e sema: revive resolver + signatures + real SoundChannel
5 months ago
dev 9338be2662 soma+layouts: migrate App composition to createActiveApp (Phase 4 part 3)
5 months ago
dev a7adfc43b1 sema: add defineEngineSemantic factory for createActiveApp composition
5 months ago
dev 746ce8dbe8 sema: rename SemanticEngine → EngineSemantic + .destroy() → .dispose()
5 months ago
dev c3dbd47f5b morfo/schema: migrate $lib/sium/core → $sium (Phase 3)
5 months ago
dev b532b3a1e4 migrate $lib/util/* → $libs/* in soma + test pages (Phase 2)
5 months ago
dev 62eb00fe8a deprecate src/lib + wire new ecosystem aliases (Phase 0+1)
5 months ago
dev 800e794cea eidos batch: Accordion + Tabs + Checkbox recipes + demos
5 months ago
dev b5453af9c4 popover: extend morfo with events + soft-migrate provider + exit tinting
5 months ago
dev 106b972075 drawer: extend morfo with events + soft-migrate provider + exit tinting
5 months ago
dev d76f4eaf11 test/eidos: 2x2 grid with breathing room for tooltip + popover side demos
5 months ago
dev 5f6373e200 eidos/tooltip: recipe + per-side fade animations + demo
5 months ago
dev d31bbc23f0 eidos/popover: recipe + per-side fade/scale animations + demo
5 months ago
dev abf197a37c eidos/drawer: recipe + slide-in/out per side + demo (modal & non-modal)
5 months ago
dev b096237d3e soma/dialog: dismissWith(action) public API + tinted demo section
5 months ago
dev bb8c8657e1 soma/dialog: soft-migrate close-* events through MorfoRuntime
5 months ago
dev 084322d3db eidos/dialog: add [data-dialog-trigger] envelope (button-style)
5 months ago
dev a50b18d1f1 eidos: drop legacy hand-rolled tokens.css, consume air-translated tokens
5 months ago
dev fa6b01c319 test/eidos: split into per-component demos with shared layout
5 months ago
dev b8dec62b35 eidos phase 2 batch 2: Switch, Collapsible, Toast translated
5 months ago
dev e48f227f89 eidos phase 2: Toggle + Dialog recipes translated from air
5 months ago
dev 0ebc9a9d83 eidos: phase 1 — translate air's foundation (contracts, tokens, themes/base)
5 months ago
dev e4d131b58d sema + morfo: per-event hold override (declarative in morfo)
5 months ago
dev 57590f2d60 scripts/check-eidos-recipes: rAF-grained transition sampling
5 months ago
dev 25dbce936b eidos: dialog entrance/exit transitions (perceptual anchoring)
5 months ago
dev dce320d7ff eidos: focus ring uses box-shadow (project kills outlines globally),
5 months ago
dev d682506df2 eidos: focus ring full opacity + darker hue for accent-bg contrast
5 months ago
dev 375e769428 eidos: V1.5 — Toggle + Switch component recipes
5 months ago
dev 14233af4a7 eidos: drop --_eidos-exit-tint indirection, inline in dialog recipe
5 months ago
dev 286191339e eidos: V1.5 — Dialog component recipe + last-action coverage
5 months ago
dev e69e0cba80 sema: namespace signal attrs under data-event-* (channel never touches state)
5 months ago
dev b66039adb1 sema: VisualChannel preserves structural data-intent on cleanup
5 months ago
dev 4f0108f839 eidos: V1.5 — Toast component recipe
5 months ago
dev 3d9a834886 docs: align cross-layer docs with channel-based Sema
5 months ago
dev b4ceb1bb09 docs: SOMA_ARCHITECTURE — update sema timing reference after channel refactor
6 months ago
dev 5871cbca6e sema: refactor into channel modules — engine becomes registry + dispatch
6 months ago
dev e664b59c96 sema: perceptually-anchored hold + sequential strict emit semantics
6 months ago
dev 44a499d052 eidos: V1 minimum viable — tokens + archetype rules + event reactions
6 months ago
dev 760575c419 docs: src/uix/active_architecture.md — cross-layer vision document
6 months ago
dev 28f93ab089 docs: cross-layer articulation — eidos README + 2-of-3 rule across all layer docs
6 months ago
dev 2e062f216b sema: canonical action verb vocabulary (SEMA_VERBS)
6 months ago
dev aba18117ba morfo: catalog `archetype` across 65 components (~265 part declarations)
6 months ago
dev bf394873e0 morfo: add `archetype` field — cross-layer part categorization
6 months ago
dev e7e32a8355 soma: Avatar migrated to runtime + V2 props bridge
6 months ago
dev 55a5ad2647 soma: runtime.keydown() + Switch keyboard pilot
6 months ago
dev eb0f128c3e soma: V2 props bridge (OptsFromProps + bindProps) — Toggle refactor
6 months ago
dev 6d805a4529 gitignore: ignore .kilo/ (Kilo IDE plugin scratch dir)
6 months ago
dev ddde4601b0 scripts: probe-toast-dismiss — verify the runtime.trigger contract end-to-end
6 months ago
dev 14aa218658 soma: Toast pilot — multi-instance runtime + trigger() integration
6 months ago
dev 2b39a2423d soma + app: runtime.trigger() contract + Sema wired into App/Soma
6 months ago
dev 53d6539abe soma: Collapsible pilot — multi-part runtime migration
6 months ago
dev 62bcb1268f soma/morfo: fix conditional attr cleanup in resolvePartAttrs
6 months ago
dev 8ede535305 soma: MorfoRuntime V1 + Toggle pilot
6 months ago
dev 02fa963cb8 adom + sema: dom.apply + semantic.emit contract skeleton
6 months ago
dev 13caaf215f docs: closed architecture (Morfo + MorfoRuntime + Provider + Effects + Sema + Dom)
6 months ago
dev d87421580d adom + lib/dom: foundation for morfo runtime
6 months ago
dev a0a1485b9f Refactor sema and add shared dom runtime
6 months ago
dev f62ede4a23 sema: add runtime foundation and dialog integration
6 months ago
dev 51dba711e9 sema: spec v0.3.1 + cleanup (drop sema_pre + accordion audit_1)
6 months ago
dev 0d8de2869f soma test: split forms demo into dedicated switch/checkbox/radio-group pages
6 months ago
dev db3ad3497f soma test: complete permutation runner instrumentation (63/66 demos)
6 months ago
dev b68b831320 soma test: instrument 7 more demos for permutation runner (composite + toggleables batch)
6 months ago
dev 1e8500bfac soma test: instrument 5 overlay demos for permutation runner
6 months ago
dev 545d47148c morfo: permutation runner v3 — per-demo axis matrix via `<meta name="perm-axes">`
6 months ago
dev 575748f079 morfo: permutation runner v2 — URL-driven state seeds + RTL variant
6 months ago
dev 61e4ba6d26 morfo: add permutation runner (A37) — third validation layer for state transitions
6 months ago
dev 6efbfeb795 sium: color demo widgets inline + queue CDX-212 (services registry)
6 months ago
dev a722d443b2 sium: migrate 6 remaining demos to App+Soma+extend pattern + specimen-book design
6 months ago
dev 8a4bbb4aa6 sium: v2.0 → v2.2 — Issue idlangref, SiumProvider, progressive forms, plurals
6 months ago
dev 2627dc2094 soma: fix slider thumb alignment + document morfo in README
6 months ago
dev 50821aae44 soma: P3 cleanup — LinkPreview a11y section + TagGroup key API consistency
6 months ago
dev 8d4f53a73f soma/morfo: typed createAttrs + 6 audit batches + form hang fix + A36
6 months ago
dev e1837e8727 docs: add A35 (ref+parent-write effect loop) + update CLAUDE.md
6 months ago
dev 91257e8526 soma/morfo: rename root part → provider + fix toolbar effect loop
6 months ago
dev 0222af7a0b morfo: enrich 65 components with aria + keyboard + focus contracts
6 months ago
dev 8a5b402dd3 morfo: port 65 components + sium.lazy + morfo-check passes 66/66
6 months ago
dev 8c16ffac81 morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired
6 months ago
dev 3b704286ba sium: v1.0 — schema core, domain types, i18n, Svelte adapter, AutoFields
6 months ago
dev 72a32e0176 soma: 10 new components + reactivity audit + guide rules A30–A33
6 months ago
dev 16f706439e soma: tier 3 dates + tier 4 time (partial), dias/segments, docs
6 months ago
dev f07addf07b soma: tier 1/2 components, Field/NumberField integration, codex_audit fixes
6 months ago
dev 69a8e8d4b5 soma: dias date library, Calendar + RangeCalendar, Command fixes, doc updates
6 months ago
dev ca768f068a docs: update planned components with all 6 tiers (color + sound added)
6 months ago
dev a8498770f0 docs: add rationale for reactive layer in README §5
6 months ago
dev a1f03b4626 docs: update SOMA_ARCHITECTURE, README, COMPONENT_GUIDE for current state
6 months ago
dev bc9b8246fc soma: resolve all remaining audit issues
6 months ago
dev 015d5e258d soma: gesture layer, drawer component, full audit fixes across 26 components
6 months ago
dev a1d0bee655 refactor: App layer with reactive lang, soma migration, 26 component audits, RTL fixes
6 months ago
dev 8a88ad577a soma: audit fixes — 84 issues across 25 components, Table complete
6 months ago
dev 1fd0b46b2e soma: refactor providers to follow terra patterns, fix translations, add table/number-field
6 months ago
dev dad3d26139 soma: add TagsInput component (23 total)
6 months ago
dev c26e26f7c1 soma: 10 new components, README docs for all 22, PrimitiveAnchorAttributes
6 months ago
dev 94c499db96 soma: 12 components, architecture docs, naming fixes, form tier complete
6 months ago
dev 98e3c741a1 Add Tooltip component, export Side/Align/Boundary from $soma/types
6 months ago
dev bb0cdeb554 Add Accordion component (5 parts, keyboard nav, single/multiple, animations)
6 months ago
dev 193bfcdca2 Fix modal popover pointer-events blocked by ScrollLock
6 months ago
dev 9cb5bf8dce Move soma.svelte.ts to core/ directory
6 months ago
dev 1655871619 Add soma external/dates boundary + adapter for glob DatiInstance
6 months ago
dev b0d5407478 Add SOMA_ARCHITECTURE.md — architectural reference for soma framework
6 months ago
dev b6d9f51c47 Add Popover component, Dialog variant/restoreScrollDelay, fix floating z-index
6 months ago
dev 7b65026982 Refactor soma: consolidate layers, add Soma class, Dialog component, animation system
6 months ago
dev 574f9165ad Add soma foundation + air components (Pagination, Editable, PinInput, RatingGroup, Stepper, FileUpload)
6 months ago
dev 265fc9afc6 Add AlertDialog, DropdownMenu, ContextMenu air components; fix terra menu concurrent open
6 months ago
dev 42c3055bf4 Semantic motion+sound engine: activate presets, enrich animations, activate dormant semantics, add drawer modal prop
6 months ago
dev cb3fa2a3c7 Add air components (toggle-group, toolbar, avatar, meter, collapsible), fix CSS audit issues, fix combobox scroll, rebuild documentation
6 months ago
dev b31bc2f055 .
6 months ago
dev 6748851a88 Add remaining air components, semantics, motion, and layout system
6 months ago
dev bb18edc6fe Audit and refactor air/terra system: fix layer 3 violations, rename config→system, add missing motion classes
6 months ago
dev 5eda0c6aef Expand air component system and translator integration
6 months ago
dev b30092dd06 Refactor air architecture: clean dependency boundaries, enrich Behavior/Dom, fix AudioContext
6 months ago
dev 9a9c4d4f14 Add air sound/motion/behavior systems, refactor responsive, update terra config and tokens
6 months ago
dev 19711efa84 Expand air design system with components, tokens, and typography
6 months ago
dev 786658f81f Build out air design system foundation
6 months ago
dev a86ebdcf69 Stabilize terra architecture and runtime contracts
6 months ago
dev 0766cfe49f Add comprehensive CSS documentation to all Terra UI components
6 months ago
dev 978a6f3463 Fix Command demo with Dialog integration
6 months ago

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}

@ -0,0 +1,159 @@
---
name: css_documentation_progress
description: Progreso de documentación de atributos CSS en componentes Terra UI
type: project
---
# Documentación de Atributos CSS - COMPLETADO ✅
## Resumen
Agregar documentación completa de los atributos `data-*` para CSS en todos los componentes de Terra UI. Incluyendo:
- Eliminar el sufijo `-root` de todos los atributos data-_-root → data-_
- Documentar TODOS los atributos para estilos en cada componente
## Cambios Completados ✅
### 1. Sistema de atributos (core)
- [x] **src/uix/terra/utils/attrs.ts** - Modificado `getAttr()` para retornar `data-component` en lugar de `data-component-root` para la parte "root"
- [x] **src/uix/terra/utils/contracts.ts** - Todos los contratos actualizados sin `-root`
- [x] **src/uix/terra/README.md** - Documentación actualizada
### 2. Componentes con documentación CSS completa
**Form Controls:**
- [x] accordion (root, trigger, content, item, header)
- [x] button
- [x] switch (root, thumb)
- [x] checkbox (root, group, group-label)
- [x] radio-group (root, item)
- [x] toggle
- [x] toggle-group (root, item)
- [x] slider (root, thumb, range)
**Disclosure:**
- [x] collapsible (root, trigger, content)
**Overlays/Dialogs:**
- [x] dialog (root, content, trigger, overlay, title, description, close)
- [x] drawer (root, content)
- [x] popover (root, content, trigger)
- [x] tooltip (root)
- [x] alert-dialog (root, content, action, cancel)
**Selection:**
- [x] select (root, trigger, item)
- [x] tabs (root, trigger, content)
- [x] menu (root, content, item)
- [x] menubar (root, menu)
**Date/Time:**
- [x] calendar (root, cell)
- [x] date-picker (root, trigger)
- [x] date-field (root, input, segment)
- [x] date-range-field (root, input, label)
- [x] date-range-picker (root, trigger)
- [x] time-field (root, input, label)
- [x] time-range-field (root, label)
- [x] range-calendar (root)
**Progress/Loading:**
- [x] progress
- [x] meter
- [x] spinner
**Otros:**
- [x] avatar (root, image, fallback)
- [x] aspect-ratio
- [x] label
- [x] separator
- [x] toolbar
- [x] scroll-area
- [x] pagination
- [x] rating-group
- [x] toast (ya tenía documentación previa)
- [x] context-menu (root, content, trigger)
- [x] dropdown-menu (root, content)
- [x] command (root, input, item, list, empty)
- [x] combobox (root, input)
- [x] editable (area, preview, input, edit-trigger, cancel-trigger)
- [x] field (Input, Label, Error, HelperText)
- [x] file-upload (dropzone, item-name)
- [x] form (Label, Error, Control)
- [x] link-preview (root, trigger, content)
- [x] pin-input (root, cell)
- [x] stepper (root)
- [x] splitter (root)
- [x] calendar (day, header, heading, grid, grid-row, grid-head, head-cell, month-select, year-select, next-button, prev-button)
- [x] color-picker (root, trigger, content, area, input, swatches)
## Componentes Pendientes ❌
Para continuar, ejecutar:
```bash
cd "g:\dev\svelte\vicen - copia\src\uix\terra"
grep -L "Selectores CSS" */component/*.svelte 2>/dev/null
```
Posibles componentes pendientes (sin confirmar):
- menu-sub, menu-sub-content, menu-sub-trigger
- menu-checkbox-group, menu-checkbox-item
- menu-radio-group, menu-radio-item
- menu-separator, menu-group, menu-group-heading
- menu-arrow
- drawer (trigger, overlay, title, description, close)
- menubar (trigger, content)
- select (content, content-static, group, group-heading, scroll-up-button, scroll-down-button, viewport)
- tabs-list
- toast (trigger, title, description, close)
- color-picker (alpha-slider, area-thumb, eyedropper, hue-slider, swatch, trigger)
- dialog (trigger)
- form (Field, Description)
- range-calendar (cell, day)
- date-range-picker (calendar, trigger)
- date-picker (calendar)
- scroll-area (viewport, scrollbar, thumb, corner)
- command (loading, separator, viewport, link-item, checkbox-group, checkbox-item, radio-group, radio-item)
- stepper (increment-button, decrement-button)
- rating-group (item)
- file-upload (file-list, item-progress, item-remove)
- dropdown-menu (checkbox-item, radio-item, separator)
- context-menu (separator)
- virtual-list
## Formato de Documentación a Usar
````svelte
/** * ## Selectores CSS * ``` * [data-component] → Descripción * [data-state="open"] → Estado
abierto * [data-state="closed"] → Estado cerrado * [data-disabled] → Presente cuando disabled=true *
... * ``` */
````
## Importante
- NO usar `-root` en los atributos (ej: usar `data-switch`, no `data-switch-root`)
- Documentar TODOS los atributos data-\* que genera cada componente
- Incluir descripción en español para cada selector
---
## ESTADO: COMPLETADO ✅
**Fecha de finalización:** 2026-04-02
**Commit:** 0766cfe - "Add comprehensive CSS documentation to all Terra UI components"
**Total de archivos modificados:** 276 archivos
Todos los componentes de Terra UI ahora tienen documentación completa de sus atributos CSS.

@ -16,7 +16,76 @@
"Bash(grep -v \"^//\")",
"Bash(grep -r \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
"Bash(grep -v '^\\\\s*//')",
"Bash(git -C g:/dev/svelte/vicen status --short -- src/uix/acel/date-field/)"
"Bash(git -C g:/dev/svelte/vicen status --short -- src/uix/acel/date-field/)",
"Bash(npx tsx:*)",
"Bash(for file:*)",
"Bash(sed -i 's/: \\\\\\([^.]*\\\\\\) ? \"\"\"\" : undefined,/: boolToEmptyStrOrUndef\\(\\\\1\\),/g' \"$file\")",
"Bash(node -e \":*)",
"Bash(while read:*)",
"Bash(do if:*)",
"Bash(! grep:*)",
"Bash(sed -i 's/DatePickerRootContext/DatePickerRootState.ctx/g' \"$file\")",
"Bash(sed -i 's/DateRangePickerRootContext/DateRangePickerRootState.ctx/g' \"$file\")",
"Bash(sed -i 's/MenuCheckboxGroupContext/MenuCheckboxGroupState.ctx/g' \"$file\")",
"Bash(sed -i 's/ScrollAreaRootContext/ScrollAreaRootState.ctx/g' \"$file\")",
"Bash(echo \"Fixed: $file\")",
"Read(//g/dev/svelte/vicen - copia/**)",
"Bash(bash /tmp/fix-imports.sh)",
"Bash(sed -i 's/CalendarRootContext/CalendarRootState.ctx/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)",
"Bash(sed -i 's/CalendarRootState\\\\.ctx\\\\.get/CalendarRootState.ctx.get/g' src/uix/terra/range-calendar/range-calendar.svelte.ts)",
"Bash(sed -i \"s/data-switch-root/data-switch/g\" contracts.ts)",
"Bash(sed -i \"s/data-checkbox-root/data-checkbox/g\" contracts.ts)",
"Bash(sed -i \"s/data-toggle-root/data-toggle/g\" contracts.ts)",
"Bash(sed -i \"s/data-toggle-group-root/data-toggle-group/g\" contracts.ts)",
"Bash(sed -i \"s/data-slider-root/data-slider/g\" contracts.ts)",
"Bash(xxd \"G:/dev/svelte/vicen - copia/src/uix/terra/date-picker/components/date-picker-calendar.svelte\")",
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] \\\\u2192 Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el campo est\\\\u00e1 deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] \\\\u2192 Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] \\\\u2192 Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el tag est\\\\u00e1 deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] -> Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el campo esta deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] -> Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] -> Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el tag esta deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
"Bash(sed -i '43s/->/\\\\u2192/; 44s/->/\\\\u2192/' tags-input-root.svelte)",
"Bash(sed -i '28s/->/\\\\u2192/; 29s/->/\\\\u2192/; 30s/->/\\\\u2192/' tags-input-tag.svelte)",
"Bash(sed -i 's/data-terra-dialog-content/data-dialog-content/g' contracts.ts)",
"Bash(sed -i 's/data-terra-dialog-trigger/data-dialog-trigger/g' contracts.ts)",
"Bash(sed -i 's/data-terra-dialog-overlay/data-dialog-overlay/g' contracts.ts)",
"Bash(sed -i 's/data-terra-dialog-title/data-dialog-title/g' contracts.ts)",
"Bash(sed -i 's/data-terra-dialog-description/data-dialog-description/g' contracts.ts)",
"Bash(sed -i 's/data-alert-dialog-content/data-dialog-content/g' contracts.ts)",
"Bash(sed -i 's/data-alert-dialog-trigger/data-dialog-trigger/g' contracts.ts)",
"Bash(sed -i 's/data-alert-dialog-overlay/data-dialog-overlay/g' contracts.ts)",
"Bash(sed -i 's/data-alert-dialog-title/data-dialog-title/g' contracts.ts)",
"Bash(sed -i 's/data-alert-dialog-description/data-dialog-description/g' contracts.ts)",
"Bash(sed -i 's/AlertDialog usa el mismo contrato que Dialog con variant=\"alert-dialog\"/AlertDialog es una variante de Dialog, usa los mismos attrs data-dialog-*/g' contracts.ts)",
"Bash(sed -i '/Los attrs se generan con data-alert-dialog-/d' contracts.ts)",
"Bash(python3)",
"Bash(pnpm run:*)",
"Bash(sed -i 's/<div {...mergedProps}>/<button {...mergedProps}>/g' \"g:/dev/svelte/vicen - copia/src/uix/terra/splitter/components/splitter-resize-trigger.svelte\")",
"Bash(sed -i 's/<\\\\/div>/<\\\\/button>/g' \"g:/dev/svelte/vicen - copia/src/uix/terra/splitter/components/splitter-resize-trigger.svelte\")",
"Bash(sed -n '106p' \"g:/dev/svelte/vicen - copia/src/routes/test/air/spinner/+page.svelte\")",
"Bash(sed -n '123p' \"g:/dev/svelte/vicen - copia/src/routes/test/editable/+page.svelte\")",
"Bash(sed -n '151p' \"g:/dev/svelte/vicen - copia/src/routes/test/editable/+page.svelte\")",
"Bash(sed -n '93p' \"g:/dev/svelte/vicen - copia/src/routes/test/file-upload/+page.svelte\")",
"Bash(sed -n '151p' \"g:/dev/svelte/vicen - copia/src/routes/test/file-upload/+page.svelte\")",
"Bash(perl -i -pe 's/\\\\.\"\\\\$$\"/\"/g' air/spinner/+page.svelte editable/+page.svelte file-upload/+page.svelte link-preview/+page.svelte stepper/+page.svelte)",
"Bash(mkdir -p external/dates)",
"Bash(cp -r adapters/dates/* external/dates/)",
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' -o -name '*.md' '\\)' -exec sed -i 's|from ['\\\\'']\\\\$terra/adapters/dates['\\\\'']|from '\\\\''$terra/external/dates'\\\\''|g' '{}' +)",
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' '\\)' -exec perl -pi -e 's|from [\"\\\\x27]\\\\$terra/adapters/dates[\"\\\\x27]|from \"$terra/external/dates\"|g' '{}' +)",
"Bash(find src/uix/terra -type f '\\(' -name '*.ts' -o -name '*.svelte' '\\)' -exec sed -i 's|from \"/external/dates\"|from \"$terra/external/dates\"|g' '{}' ';')",
"Bash(xargs -n1 basename)",
"Bash(find \"g:/dev/svelte/vicen - copia/src/uix/air/icons/lib\" -name \"*.svelte\" -exec basename {} \\\\;)",
"Bash(find \"g:/dev/svelte/vicen - copia/src/uix/air/icons/lib\" -name \"*.svelte\" -exec basename {} .svelte \\\\;)",
"Read(//tmp/**)",
"Read(//g/dev/svelte/**)",
"Bash(sed -i -e 's/\\\\bairReveal\\\\b/reveal/g' -e 's/\\\\bairDismiss\\\\b/dismiss/g' -e 's/export function airContext\\\\b/export function contextIn/g' -e 's/export function airContextOut\\\\b/export function contextOut/g' -e 's/\\\\bairExpand\\\\b/expand/g' -e 's/\\\\bairExpandOut\\\\b/expandOut/g' g:/dev/svelte/vicen/src/uix/air/internal/motion/transitions.ts)",
"Bash(sed -i -e 's/\\\\bairFeedback\\\\b/feedback/g' -e 's/\\\\bairAttention\\\\b/attention/g' -e 's/\\\\bairEmphasis\\\\b/emphasis/g' -e 's/\\\\bairPersistence\\\\b/persistence/g' -e 's/\\\\bairSelection\\\\b/selection/g' -e 's/\\\\bairCompletion\\\\b/completion/g' g:/dev/svelte/vicen/src/uix/air/internal/behavior/behaviors.svelte.ts)",
"Read(//c/Users/dev/.claude/projects/g--dev-svelte-vicen/memory/**)",
"Bash(npm run *)",
"Bash(echo \"dev pid: $!\")",
"Bash(pkill -f \"vite dev\")"
],
"additionalDirectories": [
"g:\\dev\\svelte\\vicen\\src\\uix\\soma\\components\\search-field"
]
}
}

@ -51,7 +51,117 @@
"Bash(__NEW_LINE_9a92beff9c90538f__ TERRA=\"G:/dev/svelte/vicen/src/uix/terra\" __NEW_LINE_9a92beff9c90538f__ find \"$TERRA\" \\\\\\( -name \"*.ts\" -o -name \"*.svelte.ts\" -o -name \"*.svelte\" \\\\\\))",
"Bash(git -C G:/dev/svelte/vicen status --short)",
"Bash(git:*)",
"Bash(ls:*)"
"Bash(ls:*)",
"Bash(npm install:*)",
"Bash(xargs wc:*)",
"Bash(wc:*)",
"Bash(sed:*)",
"Bash(find:*)",
"Bash(cat)",
"Bash(chmod +x /tmp/audit.sh)",
"Bash(bash /tmp/audit.sh)",
"Bash(chmod +x /tmp/final_check.sh)",
"Bash(bash /tmp/final_check.sh)",
"Bash(awk '{print $NF}')",
"Bash(cp -r G:/dev/svelte/vicen/src/uix/terra/config G:/dev/svelte/vicen/src/uix/terra/system)",
"Bash(cp -r G:/dev/svelte/vicen/src/uix/air/config G:/dev/svelte/vicen/src/uix/air/system)",
"WebFetch(domain:www.radix-ui.com)",
"WebSearch",
"WebFetch(domain:ui.shadcn.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:bits-ui.com)",
"WebFetch(domain:www.shadcn.io)",
"WebFetch(domain:chakra-ui.com)",
"WebFetch(domain:ark-ui.com)",
"Bash(xargs -I{} basename {})",
"Bash(xargs -I {} sh -c 'echo \"=== {} ===\" && grep \"air\\\\.interaction\\\\.play\" {}')",
"WebFetch(domain:www.shadcn-svelte.com)",
"WebFetch(domain:next.shadcn-svelte.com)",
"WebFetch(domain:www.bits-ui.com)",
"WebFetch(domain:shadcn-svelte.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(curl -sL \"https://api.github.com/search/code?q=pagination-link+repo:huntabyte/shadcn-svelte+extension:svelte\")",
"Bash(timeout 10 npx vite dev --port 5173)",
"Bash(curl -s http://localhost:5175/test/air/color-picker)",
"Bash(curl -s http://localhost:5173/test/air/color-picker)",
"Bash(curl -s http://localhost:5174/test/air/color-picker)",
"Bash(npx vite:*)",
"Bash(bash /tmp/layer_stats.sh)",
"Bash(cat:*)",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/dialog\")",
"Bash(curl -s \"https://ark-ui.com/docs/components/dialog\")",
"Bash(curl -s \"https://bits-ui.com/docs/components/dialog\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/popover\")",
"Bash(curl -s \"https://ark-ui.com/docs/components/popover\")",
"Bash(curl -s \"https://bits-ui.com/docs/components/popover\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/accordion\")",
"Bash(curl -s \"https://ark-ui.com/docs/components/accordion\")",
"Bash(curl -s \"https://bits-ui.com/docs/components/accordion\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/tooltip\")",
"Bash(curl -s \"https://ark-ui.com/docs/components/tooltip\")",
"Bash(curl -s \"https://bits-ui.com/docs/components/tooltip\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/dropdown-menu\")",
"Bash(curl -s \"https://ark-ui.com/docs/components/menu\")",
"Bash(curl -s \"https://bits-ui.com/docs/components/dropdown-menu\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/overview/introduction\")",
"Bash(curl -s \"https://ark-ui.com/docs/overview/introduction\")",
"Bash(curl -s \"https://bits-ui.com/docs\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/switch\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/checkbox\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/radio-group\")",
"Bash(curl -s \"https://www.radix-ui.com/primitives/docs/components/select\")",
"Bash(curl -s \"https://ark-ui.com/docs/components/select\")",
"Bash(curl -s \"https://next.bits-ui.com/docs/components/select\")",
"WebFetch(domain:next.bits-ui.com)",
"Bash(curl -s \"https://ark-ui.com/docs/components/combobox\")",
"Bash(curl -s \"https://next.bits-ui.com/docs/components/combobox\")",
"Bash(curl -s http://localhost:5173/test/soma/tabs)",
"WebFetch(domain:sonner.emilkowal.ski)",
"WebFetch(domain:www.radix-vue.com)",
"WebFetch(domain:react-resizable-panels.vercel.app)",
"WebFetch(domain:mantine.dev)",
"WebFetch(domain:react-aria.adobe.com)",
"WebFetch(domain:reka-ui.com)",
"WebFetch(domain:www.w3.org)",
"WebFetch(domain:zagjs.com)",
"WebFetch(domain:react-spectrum.adobe.com)",
"WebFetch(domain:headless-tree.lukasbach.com)",
"WebFetch(domain:mui.com)",
"WebFetch(domain:primereact.org)",
"WebFetch(domain:a11ymatters.com)",
"WebFetch(domain:base-ui.com)",
"WebFetch(domain:tanstack.com)",
"WebFetch(domain:icflorescu.github.io)",
"WebFetch(domain:www.ag-grid.com)",
"Bash(sort -t'\"' -k2)",
"Bash(uniq -f1)",
"Bash(netstat -tlnp)",
"Bash(xargs -I {} basename {})",
"Bash(curl:*)",
"Bash(gh api:*)",
"Bash(python -c \"import sys,json; [print\\(f['name']\\) for f in json.loads\\(sys.stdin.read\\(\\)\\)]\")",
"Bash(npx vitest:*)",
"WebFetch(domain:vaul.emilkowal.ski)",
"Bash(npm ls *)",
"Bash(npx -y degit chakra-ui/ark/packages/react/src/components/dialog ark-dialog)",
"Bash(ls ark-dialog/)",
"Bash(npx -y degit emilkowalski/vaul vaul-source --force)",
"Bash(npx -y degit chakra-ui/ark/packages/react/src/components/drawer ark-drawer)",
"Bash(ls ark-drawer/)",
"Bash(npx -y degit radix-ui/primitives/packages/react/dialog/src radix-dialog-source --force)",
"Bash(npx -y degit chakra-ui/zag/packages/machines/drawer/src zag-drawer)",
"Bash(ls zag-drawer/)",
"Bash(npx -y degit chakra-ui/zag/packages/machines/drawer zag-drawer-pkg)",
"Bash(ls zag-drawer-pkg/)",
"Bash(ls zag-drawer-pkg/src/)",
"WebFetch(domain:deepwiki.com)",
"WebFetch(domain:www.vaul-svelte.com)",
"WebFetch(domain:emilkowal.ski)",
"WebFetch(domain:www.npmjs.com)",
"Bash(awk NR==507||NR==635||NR==723||NR==866 { print NR\": \"$0 } *)"
],
"additionalDirectories": [
"\\tmp"
]
}
}

7
.gitignore vendored

@ -21,3 +21,10 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Runtime artifacts (dev server logs, agent worker scratch, preview worktrees)
/tmp
/worker.js
/workers
.claude/worktrees/
.kilo/

@ -3,6 +3,7 @@
<words>
<w>acel</w>
<w>crossorigin</w>
<w>langs</w>
<w>numr</w>
<w>referrerpolicy</w>
</words>

@ -0,0 +1,203 @@
# Plan: Tema "pretty" — Basado en Chakra UI
## Objetivo
Crear un nuevo tema llamado **"pretty"** para el sistema Air, inspirado en los design tokens de Chakra UI v3. Incluye variantes **light** y **dark**.
---
## Mapeo Chakra UI → Air Tokens
### Colores primarios
Chakra usa **purple** como color de acento principal (`#faf5ff` → `#1a032e`). Se mapea a `--air-primitive-primary-*`.
### Paleta neutral
Chakra usa **gray** (`#fafafa` → `#111111`). Se mapea a `--air-primitive-neutral-*`.
### Paletas semánticas
| Chakra | Air |
| -------------------------------- | --------------------------- |
| `red` (`#fef2f2` → `#1f0808`) | `--air-primitive-danger-*` |
| `green` (`#f0fdf4` → `#03190c`) | `--air-primitive-success-*` |
| `orange` (`#fff7ed` → `#220a04`) | `--air-primitive-warning-*` |
| `blue` (`#eff6ff` → `#0c142e`) | `--air-primitive-info-*` |
### Tipografía
- **Sans**: Chakra usa Inter → se cambia `--air-font-sans` a Inter con fallbacks del sistema
- **Serif**: Se mantiene Lora (ya en el sistema Air)
- **Mono**: Se mantiene Azeret Mono
- Escala de 12 pasos: Se ajustan sizes/line-heights para coincidir con escala Chakra
### Border radius
Chakra es más redondeado: `sm: 4px`, `md: 6px`, `lg: 8px`, `xl: 12px`, `2xl: 16px`, `3xl: 24px`
→ Se ajustan `--air-radius-*` para reflejar la personalidad "pretty" (más suave/redondeado)
### Sombras
Se adaptan las sombras semánticas de Chakra (light/dark con `gray.900/10` y `black/64`)
### Spacing
Chakra tiene una escala más granular (con `.5`). Se mantiene la escala Air existente ya que los componentes la consumen.
### Motion
Se usan los easings y duraciones de Chakra: `moderate: 200ms`, `ease-in-smooth: cubic-bezier(0.32, 0.72, 0, 1)`
---
## Archivos a crear
### 1. `src/uix/air/themes/pretty/_static.css`
Overrides estáticos del tema pretty:
- `--air-font-sans` → Inter con fallbacks
- `--air-radius-*` → valores más redondeados (estilo Chakra)
- `--air-motion-distance-*` → distancias de Chakra
- `--air-duration-*` → duraciones de Chakra
- `--air-ease-*` → easings de Chakra
### 2. `src/uix/air/themes/pretty/light.css`
Tokens de color para variante light:
- Primitivos de color: neutral (gray Chakra), primary (purple), info (blue), success (green), warning (orange), danger (red)
- Tokens semánticos: surfaces, content, borders, paletas por categoría
- Sombras light
### 3. `src/uix/air/themes/pretty/dark.css`
Tokens de color para variante dark:
- Primitivos de color: versiones dark de cada escala
- Tokens semánticos: surfaces dark, content dark, borders dark
- Sombras dark
### 4. `src/uix/air/themes/pretty/index.css`
Barrel CSS: importa `_static.css`, `light.css`, `dark.css`
### 5. `src/uix/air/themes/pretty/index.ts`
Exporta `PRETTY_LIGHT_THEME` y `PRETTY_DARK_THEME` como objetos `AirTheme`
## Archivos a modificar
### 6. `src/uix/air/themes/index.css`
Añadir `@import './pretty/index.css';` después de earth
### 7. `src/uix/air/themes/index.ts`
- Añadir `'pretty-light' | 'pretty-dark'` al union type `AirThemeId`
- Añadir exports de `PRETTY_LIGHT_THEME` y `PRETTY_DARK_THEME`
---
## Valores concretos (light)
### Primitivos neutral (gray Chakra)
```
1: #fafafa 2: #f5f5f5 3: #e5e5e5 4: #d4d4d4
5: #a3a3a3 6: #737373 7: #525252 8: #404040
9: #262626 10: #171717 11: #111111 12: #09090b
```
### Primitivos primary (purple Chakra)
```
1: #faf5ff 2: #f3e8ff 3: #e9d5ff 4: #d8b4fe
5: #c084fc 6: #a855f7 7: #9333ea 8: #7e22ce
9: #6b21a8 10: #581c87 11: #3b0764 12: #1a032e
```
### Primitivos info (blue Chakra)
```
1: #eff6ff 2: #dbeafe 3: #bfdbfe 4: #93c5fd
5: #60a5fa 6: #3b82f6 7: #2563eb 8: #1d4ed8
9: #1e40af 10: #1e3a8a 11: #172554 12: #0c142e
```
### Primitivos success (green Chakra)
```
1: #f0fdf4 2: #dcfce7 3: #bbf7d0 4: #86efac
5: #4ade80 6: #22c55e 7: #16a34a 8: #15803d
9: #166534 10: #14532d 11: #052e16 12: #03190c
```
### Primitivos warning (orange Chakra)
```
1: #fff7ed 2: #ffedd5 3: #fed7aa 4: #fdba74
5: #fb923c 6: #f97316 7: #ea580c 8: #c2410c
9: #9a3412 10: #7c2d12 11: #431407 12: #220a04
```
### Primitivos danger (red Chakra)
```
1: #fef2f2 2: #fee2e2 3: #fecaca 4: #fca5a5
5: #f87171 6: #ef4444 7: #dc2626 8: #b91c1c
9: #991b1b 10: #7f1d1d 11: #450a0a 12: #1f0808
```
### Primitivos dark (versiones invertidas)
Los primitivos dark se generan invirtiendo las escalas de Chakra (claro→oscuro) manteniendo la misma lógica que base-dark.
### Radius (pretty = más redondeado)
```
sm: 6px md: 8px lg: 12px xl: 16px xxl: 24px
```
### Sombras light
```
subtle: 0 1px 2px rgb(9 9 11 / 0.1), 0 0 0 1px rgb(9 9 11 / 0.2)
raised: 0 2px 4px rgb(9 9 11 / 0.1), 0 0 0 1px rgb(9 9 11 / 0.3)
overlay: 0 4px 8px rgb(9 9 11 / 0.1), 0 0 0 1px rgb(9 9 11 / 0.3)
```
### Sombras dark
```
subtle: 0 1px 1px rgb(0 0 0 / 0.64), 0 0 0 1px inset rgb(163 163 163 / 0.2)
raised: 0 2px 4px rgb(0 0 0 / 0.64), 0 0 0 1px inset rgb(163 163 163 / 0.3)
overlay: 0 4px 8px rgb(0 0 0 / 0.64), 0 0 0 1px inset rgb(163 163 163 / 0.3)
```
### Motion (overrides Chakra)
```
duration-fast: 150ms
duration-normal: 200ms
duration-moderate: 300ms
duration-slow: 400ms
ease-default: cubic-bezier(0.4, 0, 0.2, 1)
ease-out: cubic-bezier(0, 0, 0.58, 1)
ease-in: cubic-bezier(0.42, 0, 1, 1)
ease-spring: cubic-bezier(0.32, 0.72, 0, 1)
```
---
## Orden de implementación
1. Crear `src/uix/air/themes/pretty/_static.css` con overrides de radius, motion, tipografía
2. Crear `src/uix/air/themes/pretty/light.css` con primitivos + semánticos + sombras light
3. Crear `src/uix/air/themes/pretty/dark.css` con primitivos + semánticos + sombras dark
4. Crear `src/uix/air/themes/pretty/index.css` (barrel CSS)
5. Crear `src/uix/air/themes/pretty/index.ts` (exports TS)
6. Modificar `src/uix/air/themes/index.css` — añadir import pretty
7. Modificar `src/uix/air/themes/index.ts` — añadir tipos y exports
8. Verificar con `npm run check` y `npm run build`

@ -3,6 +3,7 @@
This file provides guidance to agents when working with code in this repository.
## Build/Test Commands
```bash
npm run dev # Start dev server
npm run build # Production build (static site)
@ -18,7 +19,9 @@ npm run format # Auto-format with Prettier
## Critical Architecture
### Dual Alias Configuration Required
Path aliases must be synced in BOTH [`svelte.config.js`](svelte.config.js:11) AND [`vite.config.ts`](vite.config.ts:14) for TypeScript, Svelte compiler, and Vitest to resolve consistently:
- `@/` → `src/`
- `@/ling` → `src/lib/ling` (i18n)
- `@/logr` → `src/lib/logr` (logging)
@ -27,16 +30,21 @@ Path aliases must be synced in BOTH [`svelte.config.js`](svelte.config.js:11) AN
- `$uix` → `src/uix` (UI components)
### Svelte 5 Runes Mode Enforced
[`svelte.config.js`](svelte.config.js:31) forces `runes: true` for all project files via `dynamicCompileOptions`. All components must use Svelte 5 runes (`$state`, `$derived`, `$effect`, etc.).
### Vitest Two-Project Structure
[`vite.config.ts`](vite.config.ts:35) defines separate test projects:
- **client**: Browser tests via Playwright for `*.svelte.{test,spec}.{js,ts}` files
- **server**: Node environment for `*.{test,spec}.{js,ts}` files (excludes svelte tests)
### Internal Library Pattern
Each library (`ling`, `logr`, `glob`, `actx`) uses factory functions (`createLing`, `createLogr`, etc.) that return instances with internal state. The `logr` library depends on `ling` for localized messages.
## Code Style
- Tabs for indentation, single quotes, no trailing commas, 100 char print width
- Spanish comments in code are acceptable

@ -0,0 +1,291 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build/Test Commands
```bash
npm run dev # Start dev server
npm run build # Production build (static site via adapter-static)
npm run test # Run all tests once
npm run test:unit # Run tests in watch mode
npx vitest run src/lib/ling/test/ling.test.ts # Run single test file
npx vitest run -t "describe name" # Run tests matching pattern
npm run check # Type check with svelte-check
npm run lint # Check formatting with Prettier
npm run format # Auto-format with Prettier
```
## Code Style
- Tabs for indentation, single quotes, no trailing commas, 100 char print width (Prettier)
- Spanish comments in code are acceptable
- Svelte 5 runes mode enforced globally (`svelte.config.js` `dynamicCompileOptions`)
- DOM event handlers: lowercase (`onclick`, `onfocus`). User callbacks: camelCase (`onComplete`, `onInteractOutside`)
- State fields: `$state()` without `#`. Derived: `readonly x = $derived.by(...)`
- Boolean attrs: always use helpers (`boolToTrueOrUndef`, `boolToEmptyStrOrUndef`, `boolToStr`) — never ternaries
## Path Aliases
Aliases must be synced in BOTH `svelte.config.js` AND `vite.config.ts`:
| Alias | Target |
| ----------------------- | ------------------------------------------------------ |
| `@/` | `src/` |
| `@/ling` | `src/lib/ling` (i18n) |
| `@/logr` | `src/lib/logr` (logging) |
| `@/glob` | `src/lib/glob` (legacy; do not use for new code) |
| `@/actx` | `src/lib/actx` (app context) |
| `@/uiux` | `src/lib/uiux` |
| `$uix` | `src/uix` (UI layers) |
| `$terra` | `src/uix/terra` (headless primitives) |
| `$terra/utils` | `src/uix/terra/utils/sound.svelte.ts` |
| `$terra/system` | `src/uix/terra/system/exports.ts` |
| `$terra/external/dates` | `src/uix/terra/external/dates/sound.svelte.ts` |
| `$lib` | `src/lib` |
| `$lib/util/dias` | `src/lib/util/dias` (date/time library — canonical) |
## Vitest Two-Project Structure
`vite.config.ts` defines two test projects:
- **client**: Browser tests via Playwright for `*.svelte.{test,spec}.{js,ts}`
- **server**: Node environment for `*.{test,spec}.{js,ts}` (excludes svelte tests)
## Architecture: Three-Layer UI System
```
terra → headless primitives: behavior, accessibility, data-* contracts, context
air → visual layer on top of terra: tokens, themes, recipes, styled components
glob → domain logic: dates, numbers, currency, units, locale
```
The dependency flows one way: `air` → `terra`. Terra never imports from air.
### terra (`src/uix/terra/`)
Headless primitive library. Each component follows `Element.SubElement` pattern (`Dialog.Root`, `Dialog.Trigger`, `Dialog.Content`).
**Key patterns:**
- Thin `.svelte` wrappers + logic in `*.svelte.ts` State classes
- State classes use `static create()` for context registration, `createComposed()` for internal composition without context
- Reactive system: `Active<T>`, `State<T>`, `readableActive()`, `writableActive()` — values travel as boxes with `.current`
- Context via `createTerraContext()` (wraps runed's `Context`)
- Props merging: `mergeProps(restProps, state.props)` in every wrapper
- `data-*` attrs are formal public API, validated via `assertDataContract()` against `utils/contracts.ts`
- `child` snippet replaces the rendered node; `children` fills the interior
**Structure:**
- `[component]/components/` — Svelte wrappers
- `[component]/types.ts` — public props
- `[component]/*.svelte.ts` — State classes
- `[component]/exports.ts` + `sound.svelte.ts` — barrel exports
- `layers/` — shared infra (portal, floating, presence, focus-scope, dismissible, escape, scroll)
- `dom/` — browser-dependent utils (focus, roving focus, arrow navigation)
- `utils/` — pure helpers (state system, attrs, merge-props, events, keyboard, css, datetime)
- `system/` — `TerraConfig` for cross-cutting services (translator, formatters, logger, dir, portalTo)
- `external/dates/` — adapter boundary for date library
**Import rules within terra:** use `$terra/utils` or relative paths internally, never `$uix/terra`.
### air (`src/uix/air/`)
Visual layer built on terra. Consumes terra's public anatomy and `data-*` attrs — never internal classes or incidental DOM structure.
**Three-level token system:**
1. **Primitive tokens** (`tokens/primitive.css`) — absolute scale values, never used directly by components
2. **Semantic tokens** (`tokens/semantic.css`) — theme-aware, switch via `[data-theme]`
3. **Component tokens** (`tokens/components/*.css`) — scoped per component (`--air-button-*`), the only level component CSS writes
**Token naming:**
- Public: `--air-{component}-*` (stable, overridable)
- Private: `--_air-{component}-*` (implementation detail, may change)
**Contracts** (`contracts/`):
- `contract.css` — minimum semantic tokens any theme must implement
- `contract-categorical.css` — optional categorical color families
- `contract-primitives.css` — optional advanced 12-step scales
- `contracts/components/*.css` — per-component public token API
**CSS rules:**
- Tokens and `data-*` selectors must be global CSS (imported `.css` files), not `<style scoped>`
- Svelte scoped styles only for properties that don't depend on `data-*` or terra internals
- Components respond to terra states purely via `data-*` selectors: `[data-disabled]`, `[data-state='open']`, etc.
- Theme activation via `data-theme` attribute on any DOM container (supports nesting)
- Variant dimensions are orthogonal: `size` → dimensions, `variant` → appearance, `state` → transversal overrides
**Structure:**
- `components/` — styled wrappers over terra primitives
- `tokens/` — token implementation (primitive, semantic, per-component)
- `contracts/` — public token interfaces for theme authors
- `themes/` — theme implementations (base light/dark)
- `icons/` — icon system (`Icon` + per-icon components in `icons/lib/`)
### Internal Libraries (`src/lib/`)
- `ling` — i18n system (factory: `createLing`)
- `logr` — logging (factory: `createLogr`, depends on `ling`)
- `ext/` — App-layer reactive services: `app`, `dates`, `lang`, `money`, `nums`, `presentation`, `units`
- `util/dias` — canonical date/time library. Single public door at `$lib/util/dias`. `_vendor/` holds the internal implementation (Adobe Apache 2.0 vendored code) and MUST NOT be imported by consumers
- `glob` — legacy (pre-App-layer); do not use for new code
- `actx` — app context
- `vice` — app-level utilities
**Dates — canonical layout:**
- Value types (`CalendarDate`, `CalendarDateTime`, `Time`, `ZonedDateTime`), 9 calendars, queries, operations, parsing: all re-exported from `$lib/util/dias`
- All date-related `Intl` (`DateFormatter`, cache, `resolveDateOrder`, `resolveHourCycle`, placeholders, defaults) lives in `$lib/util/dias/format.ts`
- `ext/dates` is a thin reactive wrapper for App-level preferences (dateOrder, hourCycle) that delegates formatting to `dias`
- `HourCycle` is canonically numeric — `12 | 24` — matching `Intl.DateTimeFormat`'s `hour12` resolved option. Never `'12h' | '24h'`
- Soma consumes via `$soma/external/dates`; terra via `$terra/external/dates`. Both re-export from `dias`. Components never import `dias` directly
**Number formatting:** use `createNumr` from the App layer, NEVER raw `Intl.NumberFormat`.
### soma (`src/uix/soma/`)
Headless component library. Follows terra's architecture exactly — same patterns, same conventions. Uses `$soma` alias.
**Key patterns (must match terra):**
- Child providers reference parent as `provider`, NEVER `root`
- **The root export is always `Provider`, never `Root`**. Consumer writes `<Dialog.Provider>`, `<Calendar.Provider>`. `createAttrs` still uses the part name `'root'` internally — that is the DOM attribute part, not the export
- **Data-attr naming is canonical**: `data-{component}` (root) and `data-{component}-{part}` (sub-parts), emitted by `createAttrs`. **Never** `data-soma-*`. QuerySelectors, CSS selectors, README tables, and inline strings must all match exactly what `createAttrs` writes on the DOM — the contract validator does NOT check this (only enum values)
- `onChange` callbacks go in `writableActive` setter in the `.svelte` wrapper, NOT in provider Opts
- Translations: idlangref constants (`#?components.xxx.yyy|Fallback`) defined in the component's `langs.ts`. Root-level entries live in `src/uix/soma/core/langs.ts` under `components.{kebab-name}`. Providers call `soma?.langs.ts(IDLANGREF)` — no `resolveSomaTranslationPath` wrapper
- Props defined ONCE in `types.ts` (consumer API). Provider Opts only has reactive wrappers + internal fields
- `Soma.get()` returns the Soma config from context. `Soma.require()` throws when missing
- Test pages use shared layout at `src/routes/test/soma/+layout.svelte` with real `createLangs()` + `createPresentation()` + `App.create()`
**Structure:** mirrors terra exactly:
- `[component]/components/` — Svelte wrappers
- `[component]/types.ts` — public props with JSDoc
- `[component]/*-provider.svelte.ts` — Provider classes
- `[component]/exports.ts` + `index.ts` — barrel exports
- `core/` — Soma class, translator adapter, prop-resolvers, formatters
- `provider/` — base Provider class, context utility
- `layers/` — shared infra (floating, presence, focus-scope, dismissal, scroll-lock)
**Import rules within soma:** use `$soma/` alias paths, never relative paths across component boundaries.
### Test Pages
Component demos live in `src/routes/test/`. Each terra/air/soma component should have a test page demonstrating its states and variants.
Soma test pages share a layout at `src/routes/test/soma/+layout.svelte` that provides:
- Real ling instance with soma translations via `extendSomaTranslationModule`
- `createSomaTranslator` adapter connecting to ling
- Locale/dir switcher in a sticky bar
- `<Soma>` wrapper for all children
Test pages should NOT create their own `<Soma>` or translator — use the layout's.
## TerraConfig / Soma Integration
```svelte
<!-- Terra -->
<TerraConfig {translator} {dateTimeFormatter}>
<Dialog.Root />
</TerraConfig>
<!-- Soma -->
<Soma {translator} {presentation}>
<Dialog.Provider />
</Soma>
```
Priority chain for resolved props: explicit component prop → config context value → internal fallback. Both support nesting (child overrides parent partially).
## Key Conventions
- When creating terra components: follow the checklist in `src/uix/terra/README.md` §21
- When creating soma components: follow the full checklist in `src/uix/soma/COMPONENT_GUIDE.md` (rules A1–A37, 40 checklist items). Walk every item explicitly before reporting done — this is mandatory, not optional. Compare features against ark-ui, bits-ui, radix-ui, react-aria and document the gap table in the component's README. Run `npm run perm:check` (permutation runner) before shipping; instrument the demo with `data-perm-step="N"` annotations for every distinct state transition.
- When reading existing modules: start with `exports.ts` → `types.ts` → root `.svelte` → `*-provider.svelte.ts` → child wrappers → test page → README
- `$bindable()` without fallback when parent might pass `undefined`; apply defaults via coalescing (see `src/uix/terra/README.md` §13.1)
- Components that use Portal: consumer must manage `z-index` explicitly (layers don't impose z-indices)
- Every soma component ships with its own `README.md` following the format of `dialog/README.md` — anatomy, parts, props, ARIA, data-attrs, keyboard, comparison table, example
## Critical Rules
- **NEVER modify terra source code.** Terra is the reference architecture. Soma copies its patterns.
- **NEVER delete files without explicit instruction** ("delete", "remove", "borra", "elimina"). If ambiguous, ASK first. Reading costs nothing, deleting can be irreversible.
- **NEVER create fake translators** in test pages. Use real ling instances with `createSomaTranslator` + `extendSomaTranslationModule`.
- **READ before acting.** When told to read a file, read it. Don't interpret "léete" as "eléte".
- **Verify before reporting done.** Run `npm run check`, test the UI in the browser. `npm run smoke` and `npx tsx scripts/morfo-check.ts` must both be green — smoke proves 200 OK, morfo-check proves the DOM matches the morfo contract and catches `effect_update_depth_exceeded` (it shows up as "Execution context was destroyed" on the affected route — see COMPONENT_GUIDE A35).
- Soma audits: the two most recent are `src/uix/soma/soma-audit-2026-04-20.md` and `soma-audit-2026-04-21.md`. All their findings are resolved. Older audits (`AUDIT_1.md`, `codex_audit.md`) are historical. Read the latest audit before making architectural changes.
- Morfo layer: part naming is `kebab: 'provider'` for the root part (never `'root'`). `createAttrs` still emits `data-{component}` for it. See `src/uix/morfo/README.md`.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

File diff suppressed because it is too large Load Diff

586
package-lock.json generated

@ -23,6 +23,7 @@
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.5.0",
"@vitest/browser-playwright": "^4.1.0",
"jsdom": "^29.0.2",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
@ -31,12 +32,52 @@
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.0",
"vitest-browser-svelte": "^2.0.2"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.10",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz",
"integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz",
"integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@blazediff/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
@ -44,6 +85,159 @@
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
@ -486,6 +680,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@ -1594,6 +1806,16 @@
"node": ">= 0.4"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -1646,6 +1868,20 @@
"node": ">= 0.6"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1665,6 +1901,27 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -1714,6 +1971,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@ -1832,6 +2102,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -1839,6 +2122,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@ -1858,6 +2161,47 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jsdom": {
"version": "29.0.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.5",
"@asamuzakjp/dom-selector": "^7.0.6",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7",
"parse5": "^8.0.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.24.5",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -2135,6 +2479,16 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@ -2153,6 +2507,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -2203,6 +2564,19 @@
],
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@ -2218,9 +2592,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"devOptional": true,
"license": "MIT",
"engines": {
@ -2421,6 +2795,16 @@
}
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -2435,6 +2819,26 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rollup": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
@ -2521,6 +2925,19 @@
"node": ">=6"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@ -2625,6 +3042,13 @@
"typescript": ">=5.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
@ -2706,6 +3130,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.28",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
"integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.28"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.28",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz",
"integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@ -2716,6 +3160,67 @@
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2730,6 +3235,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@ -2954,6 +3469,54 @@
"vitest": "^4.0.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -2993,6 +3556,23 @@
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

@ -7,13 +7,20 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"worker": "node worker.js",
"worker:daemon": "node worker.js --daemon",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
"test": "npm run test:unit -- --run",
"smoke": "node scripts/smoke-check.mjs",
"morfo:check": "node --import tsx/esm scripts/morfo-check.ts",
"morfo:vocabulary": "node --import tsx/esm scripts/morfo-vocabulary-check.ts",
"perm:check": "node --import tsx/esm scripts/permutation-check.ts",
"generate:contracts-docs": "node --import tsx/esm scripts/generate-contracts-docs.ts"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
@ -23,6 +30,7 @@
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.5.0",
"@vitest/browser-playwright": "^4.1.0",
"jsdom": "^29.0.2",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
@ -31,6 +39,7 @@
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.0",

@ -0,0 +1,120 @@
/**
* Script para agregar assertDataContract a todos los componentes de Terra
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Archivos a procesar (rutas relativas al proyecto)
const files = [
'accordion/accordion.svelte.ts',
'alert-dialog/alert-dialog.svelte.ts',
'aspect-ratio/aspect-ratio.svelte.ts',
'avatar/avatar.svelte.ts',
'calendar/calendar.svelte.ts',
'collapsible/collapsible.svelte.ts',
'color-picker/color-picker.svelte.ts',
'command/command.svelte.ts',
'date-field/date-field.svelte.ts',
'date-picker/date-picker.svelte.ts',
'date-range-field/date-range-field.svelte.ts',
'date-range-picker/date-range-picker.svelte.ts',
'dialog/dialog.svelte.ts',
'drawer/drawer.svelte.ts',
'dropdown-menu/dropdown-menu.svelte.ts',
'editable/editable.svelte.ts',
'file-upload/file-upload.svelte.ts',
'form/form.svelte.ts',
'link-preview/link-preview.svelte.ts',
'menu/menu.svelte.ts',
'menubar/menubar.svelte.ts',
'meter/meter.svelte.ts',
'number-field/number-field.svelte.ts',
'pagination/pagination.svelte.ts',
'pin-input/pin-input.svelte.ts',
'popover/popover.svelte.ts',
'progress/progress.svelte.ts',
'range-calendar/range-calendar.svelte.ts',
'rating-group/rating-group.svelte.ts',
'scroll-area/scroll-area.svelte.ts',
'select/select.svelte.ts',
'separator/separator.svelte.ts',
'slider/slider.svelte.ts',
'spinner/spinner.svelte.ts',
'splitter/splitter.svelte.ts',
'stepper/stepper.svelte.ts',
'tabs/tabs.svelte.ts',
'tags-input/tags-input.svelte.ts',
'thumb/thumb.svelte.ts',
'time-field/time-field.svelte.ts',
'time-range-field/time-range-field.svelte.ts',
'toggle-group/toggle-group.svelte.ts',
'toolbar/toolbar.svelte.ts',
'tooltip/tooltip.svelte.ts',
'virtual-list/virtual-list.svelte.ts'
].map((f) => resolve(__dirname, '../src/uix/terra', f));
let modified = 0;
let skipped = 0;
let notFound = 0;
for (const file of files) {
try {
if (!existsSync(file)) {
notFound++;
continue;
}
let content = readFileSync(file, 'utf-8');
const original = content;
// Ya tiene el import
if (content.includes('assertDataContract')) {
skipped++;
continue;
}
// Buscar la última línea de import desde utils/attrs o ../utils/attrs
const attrsImportMatch = content.match(/from ["'].*?\/utils\/attrs["']/);
if (!attrsImportMatch) {
console.log(`⚠️ No se encontró import de attrs en: ${file}`);
skipped++;
continue;
}
const lastAttrsLineEnd = content.indexOf(
'\n',
attrsImportMatch.index + attrsImportMatch[0].length
);
if (lastAttrsLineEnd === -1) {
skipped++;
continue;
}
// Insertar el nuevo import después del import de attrs
const importStatement = `\nimport { assertDataContract } from "$terra/utils/contracts";`;
content =
content.slice(0, lastAttrsLineEnd + 1) +
importStatement +
content.slice(lastAttrsLineEnd + 1);
if (content !== original) {
writeFileSync(file, content, 'utf-8');
modified++;
const componentName = file.replace(/.*src\\uix\\terra\\/, '').replace(/\.svelte\.ts$/, '');
console.log(`✅ ${componentName}`);
}
} catch (e) {
console.log(`❌ Error procesando ${file}:`, e.message);
}
}
console.log(`\n📊 Resumen:`);
console.log(` Modificados: ${modified}`);
console.log(` Skippeados (ya tenían import): ${skipped}`);
console.log(` No encontrados: ${notFound}`);
console.log(` Total procesados: ${modified + skipped + notFound}`);

@ -0,0 +1,260 @@
/**
* One-shot catalog pass: walks every morfo file under
* `src/uix/morfo/components/` and inserts `archetype: 'xxx',` after the
* `kebab:` line of each part whose kebab maps to a canonical archetype.
*
* Usage:
* node --import tsx/esm scripts/catalog-archetypes.ts
*
* Idempotent — skips parts that already declare archetype. Reports counts
* per file at the end.
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Mapping of part `kebab` → canonical archetype. Conservative: only maps
* kebabs whose role is unambiguously cross-component. Genuinely-unique
* parts (e.g. `area-background`, `transparency-grid`) get nothing.
*/
const KEBAB_TO_ARCHETYPE: Record<string, string> = {
// 'provider' defaults to 'provider' archetype (root container).
// Components where Provider IS the interactive button (Toggle, Switch,
// Checkbox, …) need a manual override to 'trigger' afterwards — see the
// trailing patch step below.
provider: 'provider',
trigger: 'trigger',
content: 'content',
overlay: 'overlay',
viewport: 'viewport',
item: 'item',
option: 'option',
indicator: 'indicator',
thumb: 'thumb',
track: 'track',
label: 'label',
title: 'title',
description: 'description',
close: 'close',
action: 'action',
header: 'header',
image: 'image',
fallback: 'fallback',
arrow: 'arrow',
separator: 'separator',
group: 'group',
input: 'input',
segment: 'segment',
preview: 'preview',
// Variant aliases — same role, different spelling
'sub-trigger': 'trigger',
'sub-content': 'content',
'prev-trigger': 'trigger',
'next-trigger': 'trigger',
'prev-button': 'trigger',
'next-button': 'trigger',
'swatch-trigger': 'trigger',
'eye-dropper': 'trigger',
'checkbox-item': 'item',
'radio-item': 'item',
'link-item': 'item',
'cell': 'item',
'day': 'item',
'swatch': 'item',
'link': 'trigger',
'cancel': 'action',
'list': 'group',
'item-group': 'group',
'indicator-group': 'group',
'swatch-group': 'group',
'radio-group': 'group',
'group-items': 'group',
'group-heading': 'header',
'group-label': 'label',
'channel-input': 'input',
'channel-segment': 'segment',
'channel-slider-track': 'track',
'channel-slider-thumb': 'thumb',
'area-thumb': 'thumb',
'handle': 'thumb',
'value-swatch': 'indicator',
'swatch-indicator': 'indicator',
'value-text': 'description',
region: 'content',
'head-cell': 'header',
'week-number': 'label',
heading: 'header',
loading: 'indicator',
'format-select': 'input',
'month-select': 'input',
'year-select': 'input',
// Tree / table / list-flavored containers and rows
branch: 'item',
row: 'item',
'row-detail': 'content',
'column-header': 'header',
'grid-row': 'item',
'grid-head': 'header',
'file-list': 'group',
panel: 'content',
// Form-flavored
submit: 'action',
reset: 'action',
'submit-trigger': 'action',
'cancel-trigger': 'action',
'clear-trigger': 'action',
'remove-button': 'action',
'item-delete-trigger': 'action',
'item-remove': 'action',
'helper-text': 'description',
'error-text': 'description',
'error-summary': 'description',
'hidden-input': 'input',
'selection-checkbox': 'input',
// Generic interactive sub-triggers — anything ending `-trigger` is one
'edit-trigger': 'trigger',
'expand-trigger': 'trigger',
'decrement-trigger': 'trigger',
'increment-trigger': 'trigger',
'resize-trigger': 'trigger',
'row-detail-trigger': 'trigger',
// Tree-view auxiliaries
'branch-content': 'content',
'branch-indicator': 'indicator',
// Article / feed sub-parts
article: 'item',
'article-title': 'title',
'article-description': 'description',
// Misc visualization bits
tick: 'indicator',
scrollbar: 'track',
scrubber: 'thumb',
'window-viewport': 'viewport',
value: 'description',
'item-indicator': 'indicator',
'item-progress': 'indicator',
'item-preview': 'preview',
'item-name': 'description',
'item-text': 'description',
'item-size': 'description'
};
interface Counts {
parts: number;
added: number;
skippedUnmapped: number;
skippedAlreadyDeclared: number;
}
function processFile(path: string): Counts {
const original = readFileSync(path, 'utf8');
const lines = original.split(/\r?\n/);
const out: string[] = [];
const counts: Counts = {
parts: 0,
added: 0,
skippedUnmapped: 0,
skippedAlreadyDeclared: 0
};
const KEBAB_RE = /^(\s*)kebab:\s*'([^']+)',?\s*$/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
out.push(line);
const m = KEBAB_RE.exec(line);
if (!m) continue;
const [, indent, kebab] = m;
// Distinguish morfo-root kebab (single tab) from part kebab (deeper).
// The morfo root sits at indent depth 1 inside the `as const satisfies
// Morfo` object; parts live inside `parts: [{ ... }]` so they're
// indented at least 3 tabs. Anything at <2 tabs is the morfo root —
// archetype belongs only on parts.
const tabCount = (indent.match(/\t/g) ?? []).length;
if (tabCount < 2) continue;
counts.parts++;
// Skip if archetype already on the next non-empty line.
let next = i + 1;
while (next < lines.length && lines[next].trim() === '') next++;
if (lines[next]?.includes('archetype:')) {
counts.skippedAlreadyDeclared++;
continue;
}
const archetype = KEBAB_TO_ARCHETYPE[kebab];
if (!archetype) {
counts.skippedUnmapped++;
continue;
}
out.push(`${indent}archetype: '${archetype}',`);
counts.added++;
}
const updated = out.join('\n');
if (updated !== original) {
writeFileSync(path, updated, 'utf8');
}
return counts;
}
const DIR = 'src/uix/morfo/components';
const files = readdirSync(DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
let totalParts = 0;
let totalAdded = 0;
let totalSkippedUnmapped = 0;
let totalSkippedAlreadyDeclared = 0;
const unmappedKebabs = new Set<string>();
for (const f of files) {
const path = join(DIR, f);
const counts = processFile(path);
totalParts += counts.parts;
totalAdded += counts.added;
totalSkippedUnmapped += counts.skippedUnmapped;
totalSkippedAlreadyDeclared += counts.skippedAlreadyDeclared;
if (counts.added > 0 || counts.skippedUnmapped > 0) {
console.log(
`${f}: +${counts.added} added · ${counts.skippedUnmapped} unmapped · ${counts.skippedAlreadyDeclared} already declared`
);
}
}
// Second pass: list which kebabs were unmapped so we can extend KEBAB_TO_ARCHETYPE.
for (const f of files) {
const path = join(DIR, f);
const text = readFileSync(path, 'utf8');
const matches = [...text.matchAll(/^\s*kebab:\s*'([^']+)',?\s*$/gm)];
for (const m of matches) {
const kebab = m[1];
if (!KEBAB_TO_ARCHETYPE[kebab]) unmappedKebabs.add(kebab);
}
}
console.log('\n────────────────────────────────────────');
console.log(`Total parts examined: ${totalParts}`);
console.log(`Archetypes added: ${totalAdded}`);
console.log(`Skipped (unmapped): ${totalSkippedUnmapped}`);
console.log(`Skipped (declared): ${totalSkippedAlreadyDeclared}`);
// Filter out the morfo root kebabs (component names, never archetypes).
const morfoRoots = new Set(files.map((f) => f.replace(/\.ts$/, '')));
const unmappedPartKebabs = [...unmappedKebabs].filter((k) => !morfoRoots.has(k)).sort();
console.log(`\nUnmapped part kebabs (${unmappedPartKebabs.length}):`);
for (const k of unmappedPartKebabs) console.log(` ${k}`);

@ -0,0 +1,253 @@
/**
* Verifies eidos recipes render correctly in /test/eidos.
*
* 1. Toggle (★) — has data-archetype + focus ring on Tab.
* 2. Switch — has data-archetype + focus ring on Tab.
* 3. Dialog — opens via click, content + overlay visible, persists.
*
* Saves screenshots to /tmp/eidos-*.png so I can inspect.
*/
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
const URL = 'http://localhost:5173/test/eidos';
const OUT = 'g:/tmp/eidos';
async function probePort(): Promise<string | null> {
for (const port of [5173, 5174, 5175]) {
try {
const r = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) });
if (r.ok || r.status === 404) return `http://localhost:${port}`;
} catch {}
}
return null;
}
async function main() {
const base = await probePort();
if (!base) {
console.error('No dev server. Run npm run dev.');
process.exit(2);
}
await mkdir(OUT, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 900, height: 1100 } });
const page = await ctx.newPage();
page.on('console', (msg) => {
if (msg.type() === 'error') console.log(`[console error] ${msg.text()}`);
});
// ── 1. Toggle ────────────────────────────────────────────────────────────
await page.goto(`${base}/test/eidos/toggle`, { waitUntil: 'networkidle' });
await page.waitForTimeout(300);
const toggle = page.locator('[data-toggle]').first();
const toggleAttrs = await toggle.evaluate((el) => ({
dataToggle: el.getAttribute('data-toggle'),
dataArchetype: el.getAttribute('data-archetype'),
tag: el.tagName.toLowerCase()
}));
console.log('Toggle attrs:', toggleAttrs);
// Click body first so we have a focus origin, then tab into the toggle.
await page.locator('body').click({ position: { x: 5, y: 5 } });
await page.waitForTimeout(50);
// Tab until we hit the toggle button (skip the Soma layout bar if present).
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
const isToggle = await page.evaluate(() => document.activeElement?.hasAttribute('data-toggle') ?? false);
if (isToggle) break;
}
await page.waitForTimeout(100);
const toggleFocusOutline = await toggle.evaluate((el) => {
const cs = getComputedStyle(el);
return {
matchesFocusVisible: el.matches(':focus-visible'),
outlineStyle: cs.outlineStyle,
outlineWidth: cs.outlineWidth,
boxShadow: cs.boxShadow
};
});
console.log('Toggle ring (Tab focused):', toggleFocusOutline);
await page.screenshot({ path: `${OUT}/01-toggle-focused.png`, fullPage: false });
// ── 2. Switch ────────────────────────────────────────────────────────────
await page.goto(`${base}/test/eidos/switch`, { waitUntil: 'networkidle' });
await page.waitForTimeout(300);
const sw = page.locator('[data-switch]').first();
const swAttrs = await sw.evaluate((el) => ({
dataSwitch: el.getAttribute('data-switch'),
dataArchetype: el.getAttribute('data-archetype'),
dataState: el.getAttribute('data-state'),
tag: el.tagName.toLowerCase()
}));
console.log('Switch attrs:', swAttrs);
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
const isSwitch = await page.evaluate(() => document.activeElement?.hasAttribute('data-switch') ?? false);
if (isSwitch) break;
}
await page.waitForTimeout(100);
const swFocusOutline = await sw.evaluate((el) => {
const cs = getComputedStyle(el);
return {
matchesFocusVisible: el.matches(':focus-visible'),
boxShadow: cs.boxShadow,
width: cs.width,
height: cs.height,
background: cs.backgroundColor
};
});
console.log('Switch ring (Tab focused):', swFocusOutline);
await page.screenshot({ path: `${OUT}/02-switch-focused.png`, fullPage: false });
// ── 3. Dialog ────────────────────────────────────────────────────────────
await page.goto(`${base}/test/eidos/dialog`, { waitUntil: 'networkidle' });
await page.waitForTimeout(300);
const dialogTrigger = page.locator('[data-dialog-trigger]').first();
const triggerExists = (await dialogTrigger.count()) > 0;
console.log('Dialog trigger exists:', triggerExists);
if (triggerExists) {
// Inject a sampler that records opacity at every rAF after the click.
await page.evaluate(`(() => {
window.__samples = [];
const trigger = document.querySelector('[data-dialog-trigger]');
const t0 = performance.now();
trigger.click();
const tick = () => {
const t = performance.now() - t0;
const el = document.querySelector('[data-dialog-content]');
if (el) {
const cs = getComputedStyle(el);
window.__samples.push({
t: Math.round(t),
opacity: parseFloat(cs.opacity),
hasStartingStyle: el.hasAttribute('data-starting-style')
});
}
if (t < 500) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
await page.waitForTimeout(550);
const entranceSamples = await page.evaluate('window.__samples') as Array<{
t: number;
opacity: number;
hasStartingStyle: boolean;
}>;
const decimated = entranceSamples.filter((_, i) => i % 3 === 0 || i === entranceSamples.length - 1);
console.log('Entrance samples (t-ms, opacity, hasStartingStyle):');
for (const s of decimated) {
console.log(` t=${s.t}ms opacity=${s.opacity.toFixed(3)} starting=${s.hasStartingStyle}`);
}
const dialogContent = page.locator('[data-dialog-content]');
await page.screenshot({ path: `${OUT}/03a-dialog-entrance-mid.png`, fullPage: false });
const dialogOverlay = page.locator('[data-dialog-overlay]');
const overlayCount = await dialogOverlay.count();
const contentCount = await dialogContent.count();
console.log(`Dialog opened — overlay count: ${overlayCount}, content count: ${contentCount}`);
if (contentCount > 0) {
const contentBox = await dialogContent.first().boundingBox();
const overlayBox = await dialogOverlay.first().boundingBox();
const overlayStyles = await dialogOverlay.first().evaluate((el) => {
const cs = getComputedStyle(el);
return {
position: cs.position,
inset: cs.inset,
top: cs.top,
left: cs.left,
right: cs.right,
bottom: cs.bottom,
width: cs.width,
height: cs.height,
background: cs.backgroundColor,
zIndex: cs.zIndex,
display: cs.display,
visibility: cs.visibility,
opacity: cs.opacity,
dataArchetype: el.getAttribute('data-archetype'),
parentTag: el.parentElement?.tagName.toLowerCase()
};
});
const contentStyles = await dialogContent.first().evaluate((el) => {
const cs = getComputedStyle(el);
return {
position: cs.position,
top: cs.top,
left: cs.left,
transform: cs.transform,
background: cs.backgroundColor,
zIndex: cs.zIndex,
display: cs.display,
visibility: cs.visibility,
opacity: cs.opacity
};
});
console.log('Dialog content box:', contentBox);
console.log('Dialog overlay box:', overlayBox);
console.log('Dialog overlay styles:', overlayStyles);
console.log('Dialog content styles:', contentStyles);
}
await page.screenshot({ path: `${OUT}/03-dialog-open.png`, fullPage: false });
await page.evaluate(`(() => {
window.__exitSamples = [];
const closeBtn = document.querySelector('[data-dialog-close]');
const t0 = performance.now();
closeBtn.click();
const tick = () => {
const t = performance.now() - t0;
const el = document.querySelector('[data-dialog-content]');
if (el) {
const cs = getComputedStyle(el);
window.__exitSamples.push({
t: Math.round(t),
opacity: parseFloat(cs.opacity),
hasEndingStyle: el.hasAttribute('data-ending-style'),
lastAction: el.getAttribute('data-last-action'),
borderTopColor: cs.borderTopColor,
mounted: true
});
} else {
window.__exitSamples.push({
t: Math.round(t), opacity: 0, hasEndingStyle: false, lastAction: null,
borderTopColor: '', mounted: false
});
}
if (t < 500) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
await page.waitForTimeout(550);
const exitSamples = await page.evaluate('window.__exitSamples') as Array<{
t: number;
opacity: number;
hasEndingStyle: boolean;
lastAction: string | null;
borderTopColor: string;
mounted: boolean;
}>;
const exitDecimated = exitSamples.filter((_, i) => i % 3 === 0 || i === exitSamples.length - 1);
console.log('Exit samples (t-ms, opacity, hasEndingStyle, lastAction, borderTop, mounted):');
for (const s of exitDecimated) {
console.log(
` t=${s.t}ms opacity=${s.opacity.toFixed(3)} ending=${s.hasEndingStyle} lastAction=${s.lastAction} border=${s.borderTopColor} mounted=${s.mounted}`
);
}
await page.screenshot({ path: `${OUT}/04-dialog-closed.png`, fullPage: false });
}
await browser.close();
console.log(`\nScreenshots saved to ${OUT}/`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,153 @@
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
const ICONS_SOURCE = join(process.cwd(), 'static/lucide/icons');
const ICONS_TARGET = join(process.cwd(), 'src/uix/air/icons/lib');
const INDEX_FILE = join(process.cwd(), 'src/uix/air/icons/sound.svelte.ts');
function getExistingIcons() {
const files = readdirSync(ICONS_TARGET);
return new Set(files.map((f) => f.replace('.svelte', '')));
}
function parseSvgContent(svgContent) {
// Extract all child elements (path, circle, rect, polyline, line, polygon, ellipse)
const elements = [];
const patterns = [
/<path\s+([^>]*)>/g,
/<circle\s+([^>]*)>/g,
/<rect\s+([^>]*)>/g,
/<polyline\s+([^>]*)>/g,
/<line\s+([^>]*)>/g,
/<polygon\s+([^>]*)>/g,
/<ellipse\s+([^>]*)>/g
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(svgContent)) !== null) {
const tagName = pattern.source.replace('\\s+([^>]*)>', '').replace('<', '').replace('\\', '');
const attrs = match[1];
elements.push(`<${tagName} ${attrs}>`);
}
}
return elements.join('\n\t');
}
function toPascalCase(str) {
return str
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
}
function convertIcon(iconName) {
const svgPath = join(ICONS_SOURCE, `${iconName}.svg`);
if (!existsSync(svgPath)) {
return null;
}
const svgContent = readFileSync(svgPath, 'utf-8');
const innerContent = parseSvgContent(svgContent);
// Generate Svelte component
const component = `<script lang="ts">
import Icon from '../icon.svelte';
import type { AirIconProps } from '../types';
let props: AirIconProps = $props();
</script>
<Icon {...props}>
${innerContent}
</Icon>
`;
return component;
}
function updateIndex(icons) {
const existingIndex = readFileSync(INDEX_FILE, 'utf-8');
const lines = existingIndex.split('\n');
// Find the last export line (before export type)
const lastExportLine = lines.findIndex((l) => l.startsWith('export type'));
// Collect existing exports
const existingExports = new Set();
lines.forEach((line) => {
const match = line.match(/export \{ default as (\w+) \}/);
if (match) existingExports.add(match[1]);
});
// Add new exports
const newExports = [];
for (const icon of icons) {
const pascalName = toPascalCase(icon);
if (!existingExports.has(pascalName)) {
newExports.push(`export { default as ${pascalName} } from './lib/${icon}.svelte';`);
existingExports.add(pascalName);
}
}
if (newExports.length === 0) return;
// Insert new exports before the export type line
lines.splice(lastExportLine, 0, ...newExports);
// Sort exports (keep TooltipContext first, then alphabetically)
const rootLine = lines[0];
const otherExports = lines.slice(1, lastExportLine + newExports.length);
const typeLine = lines[lastExportLine + newExports.length];
otherExports.sort();
const newContent = [
rootLine,
...otherExports,
typeLine,
...lines.slice(lastExportLine + newExports.length + 1)
].join('\n');
writeFileSync(INDEX_FILE, newContent);
}
function main() {
const existingIcons = getExistingIcons();
const allSvgFiles = readdirSync(ICONS_SOURCE).filter((f) => f.endsWith('.svg'));
const missingIcons = allSvgFiles
.map((f) => f.replace('.svg', ''))
.filter((name) => !existingIcons.has(name));
console.log(`Found ${missingIcons.length} icons to convert...`);
if (missingIcons.length === 0) {
console.log('No new icons to convert!');
return;
}
// Limit to first 100 for testing
const toConvert = process.argv[2] === 'all' ? missingIcons : missingIcons.slice(0, 100);
console.log(`Converting ${toConvert.length} icons...`);
const converted = [];
for (const iconName of toConvert) {
const component = convertIcon(iconName);
if (component) {
const targetPath = join(ICONS_TARGET, `${iconName}.svelte`);
writeFileSync(targetPath, component);
converted.push(iconName);
console.log(`✓ ${iconName}`);
}
}
updateIndex(converted);
console.log(`\nDone! Converted ${converted.length} icons.`);
}
main();

@ -0,0 +1,171 @@
/**
* Actualiza imports de contextos que ya no existen
*/
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const terraDir = join(__dirname, '../src');
// Mapeo de nombres de contextos antiguos a nuevos patrones
const contextMap: Record<string, { class: string; newPattern: string }> = {
AccordionRootContext: { class: 'AccordionRoot', newPattern: 'AccordionRoot.ctx' },
AccordionItemContext: { class: 'AccordionItemState', newPattern: 'AccordionItemState.ctx' },
AvatarRootContext: { class: 'AvatarRootState', newPattern: 'AvatarRootState.ctx' },
CalendarCellContext: { class: 'CalendarCellState', newPattern: 'CalendarCellState.ctx' },
CalendarRootContext: { class: 'CalendarRootState', newPattern: 'CalendarRootState.ctx' },
CheckboxGroupContext: { class: 'CheckboxGroupState', newPattern: 'CheckboxGroupState.ctx' },
CheckboxRootContext: { class: 'CheckboxRootState', newPattern: 'CheckboxRootState.ctx' },
CollapsibleRootContext: { class: 'CollapsibleRootState', newPattern: 'CollapsibleRootState.ctx' },
CommandGroupContainerContext: {
class: 'CommandGroupContainerState',
newPattern: 'CommandGroupContainerState.ctx'
},
CommandListContext: { class: 'CommandListState', newPattern: 'CommandListState.ctx' },
CommandRootContext: { class: 'CommandRootState', newPattern: 'CommandRootState.ctx' },
DateFieldRootContext: { class: 'DateFieldRootState', newPattern: 'DateFieldRootState.ctx' },
DatePickerRootContext: { class: 'DatePickerRootState', newPattern: 'DatePickerRootState.ctx' },
DateRangeFieldRootContext: {
class: 'DateRangeFieldRootState',
newPattern: 'DateRangeFieldRootState.ctx'
},
DateRangePickerRootContext: {
class: 'DateRangePickerRootState',
newPattern: 'DateRangePickerRootState.ctx'
},
DialogRootContext: { class: 'DialogRootState', newPattern: 'DialogRootState.ctx' },
EditableRootContext: { class: 'EditableRootState', newPattern: 'EditableRootState.ctx' },
FieldRootContext: { class: 'FieldRootState', newPattern: 'FieldRootState.ctx' },
FileUploadItemContext: { class: 'FileUploadItemState', newPattern: 'FileUploadItemState.ctx' },
FileUploadRootContext: { class: 'FileUploadRootState', newPattern: 'FileUploadRootState.ctx' },
FormFieldContext: { class: 'FormFieldState', newPattern: 'FormFieldState.ctx' },
FormRootContext: { class: 'FormRootState', newPattern: 'FormRootState.ctx' },
LinkPreviewRootContext: { class: 'LinkPreviewRootState', newPattern: 'LinkPreviewRootState.ctx' },
MenuCheckboxGroupContext: {
class: 'MenuCheckboxGroupState',
newPattern: 'MenuCheckboxGroupState.ctx'
},
MenuContentContext: { class: 'MenuContentState', newPattern: 'MenuContentState.ctx' },
MenuMenuContext: { class: 'MenuMenuState', newPattern: 'MenuMenuState.ctx' },
MenuRadioGroupContext: { class: 'MenuRadioGroupState', newPattern: 'MenuRadioGroupState.ctx' },
MenuRootContext: { class: 'MenuRootState', newPattern: 'MenuRootState.ctx' },
MenubarMenuContext: { class: 'MenubarMenuState', newPattern: 'MenubarMenuState.ctx' },
MenubarRootContext: { class: 'MenubarRootState', newPattern: 'MenubarRootState.ctx' },
NumberFieldRootContext: { class: 'NumberFieldRootState', newPattern: 'NumberFieldRootState.ctx' },
PaginationRootContext: { class: 'PaginationRootState', newPattern: 'PaginationRootState.ctx' },
PopoverRootContext: { class: 'PopoverRootState', newPattern: 'PopoverRootState.ctx' },
RadioGroupRootContext: { class: 'RadioGroupRootState', newPattern: 'RadioGroupRootState.ctx' },
RangeCalendarCellContext: {
class: 'RangeCalendarCellState',
newPattern: 'RangeCalendarCellState.ctx'
},
RatingGroupRootContext: { class: 'RatingGroupRootState', newPattern: 'RatingGroupRootState.ctx' },
ScrollAreaScrollbarAxisContext: { class: 'ScrollbarAxis', newPattern: 'ScrollbarAxis.ctx' },
ScrollAreaScrollbarSharedContext: {
class: 'ScrollAreaScrollbarSharedState',
newPattern: 'ScrollAreaScrollbarSharedState.ctx'
},
ScrollAreaScrollbarVisibleContext: {
class: 'ScrollAreaScrollbarVisibleState',
newPattern: 'ScrollAreaScrollbarVisibleState.ctx'
},
ScrollAreaScrollbarContext: {
class: 'ScrollAreaScrollbarState',
newPattern: 'ScrollAreaScrollbarState.ctx'
},
ScrollAreaRootContext: { class: 'ScrollAreaRootState', newPattern: 'ScrollAreaRootState.ctx' },
SelectContentContext: { class: 'SelectContentState', newPattern: 'SelectContentState.ctx' },
SelectGroupContext: { class: 'SelectGroupState', newPattern: 'SelectGroupState.ctx' },
SelectRootContext: { class: 'SelectRoot', newPattern: 'SelectRoot.ctx' },
SliderRootContext: { class: 'SliderRoot', newPattern: 'SliderRoot.ctx' },
SpinnerRootContext: { class: 'SpinnerRootState', newPattern: 'SpinnerRootState.ctx' },
SplitterRootContext: { class: 'SplitterRootState', newPattern: 'SplitterRootState.ctx' },
StepperItemContext: { class: 'StepperItemState', newPattern: 'StepperItemState.ctx' },
StepperRootContext: { class: 'StepperRootState', newPattern: 'StepperRootState.ctx' },
SwitchRootContext: { class: 'SwitchRootState', newPattern: 'SwitchRootState.ctx' },
TabsRootContext: { class: 'TabsRootState', newPattern: 'TabsRootState.ctx' },
TagsInputRootContext: { class: 'TagsInputRootState', newPattern: 'TagsInputRootState.ctx' },
TagsInputTagContext: { class: 'TagsInputTagState', newPattern: 'TagsInputTagState.ctx' },
TimeFieldRootContext: { class: 'TimeFieldRootState', newPattern: 'TimeFieldRootState.ctx' },
TimeRangeFieldRootContext: {
class: 'TimeRangeFieldRootState',
newPattern: 'TimeRangeFieldRootState.ctx'
},
ToastRootContext: { class: 'ToastRootState', newPattern: 'ToastRootState.ctx' },
ToggleGroupRootContext: { class: 'ToggleGroupRootState', newPattern: 'ToggleGroupRootState.ctx' },
ToolbarGroupContext: { class: 'ToolbarGroupState', newPattern: 'ToolbarGroupState.ctx' },
ToolbarRootContext: { class: 'ToolbarRootState', newPattern: 'ToolbarRootState.ctx' },
TooltipProviderContext: { class: 'TooltipProviderState', newPattern: 'TooltipProviderState.ctx' },
TooltipRootContext: { class: 'TooltipRootState', newPattern: 'TooltipRootState.ctx' },
FloatingTooltipRootContext: { class: 'FloatingRootState', newPattern: 'FloatingRootState.ctx' },
FloatingContentContext: { class: 'FloatingContentState', newPattern: 'FloatingContentState.ctx' }
};
function findAllFiles(dir: string): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
files.push(...findAllFiles(fullPath));
} else if (entry.isFile() && (entry.name.endsWith('.svelte') || entry.name.endsWith('.ts'))) {
files.push(fullPath);
}
}
return files;
}
function fixFile(filePath: string): boolean {
const content = readFileSync(filePath, 'utf-8');
let modified = content;
// Para cada contexto en el mapa
for (const [oldContext, info] of Object.entries(contextMap)) {
// 1. Eliminar el contexto de los imports si no se usa elsewhere en el archivo
const importRegex = new RegExp(`,?\\s*${oldContext}\\s*,?\\s*`, 'g');
const hasOldImport = new RegExp(`\\b${oldContext}\\b`).test(content);
if (!hasOldImport) continue;
// 2. Reemplazar referencias al contexto con el nuevo patrón
modified = modified.replace(
new RegExp(`\\b${oldContext}\\.set\\(`, 'g'),
`${info.newPattern}.set(`
);
modified = modified.replace(
new RegExp(`\\b${oldContext}\\.get\\(`, 'g'),
`${info.newPattern}.get(`
);
modified = modified.replace(
new RegExp(`\\b${oldContext}\\.getOr\\(`, 'g'),
`${info.newPattern}.getOr(`
);
}
if (modified !== content) {
writeFileSync(filePath, modified, 'utf-8');
return true;
}
return false;
}
const files = findAllFiles(terraDir);
let modified = 0;
console.log('🔧 Actualizando referencias a contextos...\n');
for (const file of files) {
if (fixFile(file)) {
modified++;
const relPath = file.replace(/.*src\\/, '').replace(/.*src\//, '');
console.log(` ✅ ${relPath}`);
}
}
console.log(`\n📊 Resumen:`);
console.log(` Modificados: ${modified}`);
console.log(` Total procesados: ${files.length}`);

@ -0,0 +1,66 @@
/**
* Repara los problemas generados por la conversión al patrón .ctx
*/
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const terraDir = join(__dirname, '../src/uix/terra');
function findAllTsFiles(dir: string): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.')) {
files.push(...findAllTsFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith('.svelte.ts')) {
files.push(fullPath);
}
}
return files;
}
function fixFile(filePath: string): boolean {
const content = readFileSync(filePath, 'utf-8');
const original = content;
let modified = content;
// 1. Eliminar líneas con "export ;" o ";"
modified = modified.replace(/^\s*export\s*;\s*\n?/gm, '');
modified = modified.replace(/^\s*;\s*\n?/gm, '');
// 2. Eliminar líneas duplicadas vacías consecutivas (más de 2)
modified = modified.replace(/\n{3,}/g, '\n\n');
// 3. Arreglar referencias a contextos antiguos que no se actualizaron
// CalendarRootContext.get() -> CalendarRootState.ctx.get()
modified = modified.replace(/CalendarRootContext\.get\(\)/g, 'CalendarRootState.ctx.get()');
if (modified !== original) {
writeFileSync(filePath, modified, 'utf-8');
return true;
}
return false;
}
const files = findAllTsFiles(terraDir);
let modified = 0;
console.log('🔧 Reparando archivos...\n');
for (const file of files) {
if (fixFile(file)) {
modified++;
const relPath = file.replace(/.*src\\uix\\terra\\/, '').replace(/\.svelte\.ts$/, '');
console.log(` ✅ ${relPath}`);
}
}
console.log(`\n📊 Resumen:`);
console.log(` Modificados: ${modified}`);
console.log(` Total procesados: ${files.length}`);

@ -0,0 +1,103 @@
/**
* Generador de documentación de contratos data-*
*
* Este script lee el registro CONTRACTS y genera un markdown
* con la documentación de todos los data-attrs de cada componente.
*
* Uso:
* npm run generate:contracts-docs
* o
* node scripts/generate-contracts-docs.js
*/
import { CONTRACTS } from '../src/uix/terra/utils/contracts.ts';
import { existsSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ────────────────────────────────────────────────────────────────────────────────
// Helpers de formateo
// ────────────────────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();
}
function formatComponentName(name: string): string {
return `\`${name}\``;
}
function formatAttrValue(values?: readonly string[]): string {
if (!values) return '(flag)';
return values.map((v) => `\`${v}\``).join(' \\| ');
}
// ────────────────────────────────────────────────────────────────────────────────
// Generación de markdown
// ────────────────────────────────────────────────────────────────────────────────
let markdown = `# Terra Data Attributes Contracts
`;
markdown += `> Contrato formal de atributos \`data-*\` entre Terra y Air.
> Generado automáticamente desde \`src/uix/terra/utils/contracts.ts\`.
>
> ⚠️ **Estos attrs son API PÚBLICA.** Cambiarlos es un breaking change.
>
`;
markdown += `---
`;
markdown += `\n`;
markdown += `## Índice
`;
markdown += `\n`;
// Tabla de contenidos
for (const [name, contract] of Object.entries(CONTRACTS)) {
markdown += `- [${name}](#${toKebabCase(name)})\n`;
}
markdown += `\n`;
markdown += `---\n`;
markdown += `\n`;
// Generar documentación por componente
for (const [name, contract] of Object.entries(CONTRACTS)) {
markdown += `## ${name}\n\n`;
markdown += `**Versión del contrato:** ${contract.version}\n\n`;
if (Object.keys(contract.parts).length === 0) {
markdown += `*Sin partes documentadas*\n\n`;
continue;
}
for (const [partName, attrs] of Object.entries(contract.parts)) {
markdown += `### ${partName === 'root' ? 'TooltipContext' : partName.charAt(0).toUpperCase() + partName.slice(1)}\n\n`;
markdown += `| Attr | Valores | Descripción |\n`;
markdown += `|-----|---------|-------------|\n`;
for (const attr of attrs) {
const values = formatAttrValue(attr.values);
const description = attr.description || '';
markdown += `| \`${attr.attr}\` | ${values} | ${description} |\n`;
}
markdown += `\n`;
}
markdown += `---\n\n`;
}
// ────────────────────────────────────────────────────────────────────────────────
// Escribir archivo
// ────────────────────────────────────────────────────────────────────────────────
const outputPath = resolve(__dirname, '..', 'DATA_ATTRS.md');
writeFileSync(outputPath, markdown);
console.log(`✅ Documentación generada: ${outputPath}`);
console.log(` ${Object.keys(CONTRACTS).length} componentes documentados`);

@ -0,0 +1,102 @@
import { readFileSync, readdirSync, writeFileSync } from 'fs';
import { join } from 'path';
const ICONS_SOURCE = join(process.cwd(), 'static/lucide/icons');
const OUTPUT_FILE = join(process.cwd(), 'src/uix/air/icons/categories.ts');
function toPascalCase(str) {
return str
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
}
const categoryLabels = {
arrows: 'Arrows',
communication: 'Communication',
design: 'Design',
dev: 'Development',
editors: 'Editors',
logo: 'Logos',
maps: 'Maps',
media: 'Media',
objects: 'Objects',
people: 'People',
science: 'Science',
sports: 'Sports',
text: 'Text',
weather: 'Weather',
ui: 'UI Elements',
currency: 'Currency',
animals: 'Animals',
food: 'Food',
health: 'Health',
home: 'Home',
navigation: 'Navigation',
shopping: 'Shopping',
social: 'Social',
transportation: 'Transportation',
accessibility: 'Accessibility',
security: 'Security',
files: 'Files',
datetime: 'Date & Time'
};
function main() {
const allFiles = readdirSync(ICONS_SOURCE).filter((f) => f.endsWith('.json'));
const categoryMap = new Map();
for (const file of allFiles) {
const iconName = file.replace('.json', '');
const jsonPath = join(ICONS_SOURCE, file);
try {
const json = JSON.parse(readFileSync(jsonPath, 'utf-8'));
const categories = json.categories || ['uncategorized'];
for (const category of categories) {
if (!categoryMap.has(category)) {
categoryMap.set(category, []);
}
categoryMap.get(category).push({
name: iconName,
pascal: toPascalCase(iconName)
});
}
} catch (e) {
console.error(`Error parsing ${file}:`, e.message);
}
}
// Sort icons within each category
for (const icons of categoryMap.values()) {
icons.sort((a, b) => a.name.localeCompare(b.name));
}
// Generate TypeScript file
const sortedCategories = Array.from(categoryMap.entries()).sort(([a], [b]) => a.localeCompare(b));
let output = '// Auto-generated - DO NOT EDIT\n\n';
output += 'export interface IconCategory {\n';
output += ' name: string;\n';
output += ' label: string;\n';
output += ' icons: Array<{ name: string; pascal: string }>;\n';
output += '}\n\n';
output += 'export const iconCategories: IconCategory[] = [\n';
for (const [category, icons] of sortedCategories) {
const label = categoryLabels[category] || toPascalCase(category);
output += `\t{ name: '${category}', label: '${label}', icons: [\n`;
for (const icon of icons) {
output += `\t\t{ name: '${icon.name}', pascal: '${icon.pascal}' },\n`;
}
output += '\t] },\n';
}
output += '];\n';
writeFileSync(OUTPUT_FILE, output, 'utf-8');
console.log(`Generated ${OUTPUT_FILE} with ${categoryMap.size} categories`);
}
main();

@ -0,0 +1,227 @@
/**
* Morfo DOM-validation check.
*
* For each morfo at `src/uix/morfo/components/*.ts`:
* 1. Navigate to the matching demo route at `/test/soma/{kebab}`.
* 2. For every `public` part declared in the morfo, query the DOM for
* `[data-{component}-{part}]` (or `[data-{component}]` for root).
* 3. For each matching element, verify:
* - Every data-attr the morfo declares with `severity: 'required'`
* is present.
* - Every data-attr with `values: [...]` has a value in that set.
* - No `data-{component}-*` attr is present that isn't declared in
* the morfo (strict mode — excludes `data-_*` privates).
*
* Runs after `npm run smoke` passes. Requires `npm run dev` running.
*
* Exit codes:
* 0 — all morfos validate against their rendered DOM.
* 1 — at least one morfo-vs-DOM discrepancy.
* 2 — no dev server / couldn't load morfos.
*/
import { chromium, type Page } from 'playwright';
import { readdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types';
import { validateMorfo } from '../src/uix/morfo/schema';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
async function probePort(start: number, end: number): Promise<string | null> {
for (let port = start; port <= end; port++) {
try {
const res = await fetch(`http://localhost:${port}/`, {
signal: AbortSignal.timeout(500)
});
if (res.ok || res.status === 404 || res.status === 500) {
return `http://localhost:${port}`;
}
} catch {
// try next
}
}
return null;
}
async function loadMorfos(): Promise<Morfo[]> {
const files = readdirSync(MORFOS_DIR).filter(
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
);
const out: Morfo[] = [];
for (const f of files) {
// Use file:// URL on Windows — absolute paths starting with "g:" are
// rejected by Node's ESM loader.
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
const mod = (await import(url)) as Record<string, unknown>;
for (const v of Object.values(mod)) {
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
out.push(validateMorfo(v));
}
}
}
return out;
}
type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string };
/** Flatten morfo parts into a flat array for validation lookup. */
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
const out: MorfoPart[] = [];
for (const p of parts) {
out.push(p);
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
}
return out;
}
function dataAttrFor(kebab: string, part: MorfoPart): string {
return part.kebab === 'provider' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`;
}
async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise<Issue[]> {
const issues: Issue[] = [];
const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public');
// Collect every part-identifying attr across the morfo so a part can
// legitimately carry a sibling's identifying attr (e.g. tag-group Link
// is a variant of Item, the <a> element carries both data-tag-group-link
// and data-tag-group-item).
const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p)));
for (const part of parts) {
const partAttr = dataAttrFor(morfo.kebab, part);
// All elements on the page that carry this part's identifying attr.
const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) =>
nodes.map((el) =>
Array.from(el.attributes)
.filter((a) => a.name.startsWith('data-'))
.map((a) => ({ name: a.name, value: a.value }))
)
);
if (elementsAttrs.length === 0) {
// Not on the demo page — skip validation entirely. Morfo "optional"
// vs "required" is about composition validity (whether a consumer
// MAY omit the part), not about demo-page presence. Dialog.Content
// is required in a valid composition but only mounts when
// `open=true`, which the static demo page may not exercise.
//
// A dedicated permutation-matrix script (future) will exercise
// each component through its state space and validate part
// presence per permutation. For now, demo presence is advisory.
continue;
}
const declared = new Set(part.data.map((d) => d.attr));
declared.add(partAttr); // the part-identifying attr itself
// Sibling parts' identifying attrs are legitimate (Link-is-Item pattern).
for (const siblingAttr of allPartAttrs) declared.add(siblingAttr);
const declaredByAttr = new Map<string, MorfoData>(
part.data.map((d) => [d.attr, d])
);
for (const elAttrs of elementsAttrs) {
// (a) required attrs present
for (const data of part.data) {
if ((data.severity ?? 'required') !== 'required') continue;
const found = elAttrs.find((a) => a.name === data.attr);
if (!found) {
issues.push({
kind: 'missing',
message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted`
});
}
}
// (b) enum-valued attrs have valid values
for (const { name, value } of elAttrs) {
const decl = declaredByAttr.get(name);
if (decl?.values && !decl.values.includes(value)) {
issues.push({
kind: 'bad-value',
message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]`
});
}
}
// (c) no undeclared public data-{component}-* attrs
for (const { name } of elAttrs) {
if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue;
if (name.startsWith(`data-_`)) continue; // private escape hatch
if (declared.has(name)) continue;
// Allow other components' part attrs (nested composition).
if (!name.startsWith(`data-${morfo.kebab}`)) continue;
issues.push({
kind: 'undeclared',
message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" emitted on element (not in morfo)`
});
}
}
}
return issues;
}
// ── Main ────────────────────────────────────────────────────────────────────
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
if (!BASE) {
console.error('Could not find a running dev server on 5173-5180.');
console.error('Start it with `npm run dev` in another terminal.');
process.exit(2);
}
console.error(`Using dev server at ${BASE}`);
const morfos = await loadMorfos();
if (morfos.length === 0) {
console.error('No morfos found under src/uix/morfo/components/');
process.exit(2);
}
console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`);
const browser = await chromium.launch();
const ctx = await browser.newContext();
const failures: Array<{ morfo: string; issues: Issue[] }> = [];
for (const morfo of morfos) {
const route = `/test/soma/${morfo.kebab}`;
const page = await ctx.newPage();
try {
await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 });
await page.waitForTimeout(500);
const issues = await validateMorfoAgainstDom(page, morfo);
if (issues.length === 0) {
console.log(`PASS ${morfo.kebab.padEnd(24)} ${route}`);
} else {
console.log(`FAIL ${morfo.kebab.padEnd(24)} ${route}`);
issues.forEach((i) => console.log(` [${i.kind}] ${i.message}`));
failures.push({ morfo: morfo.kebab, issues });
}
} catch (e) {
console.log(`ERROR ${morfo.kebab.padEnd(24)} ${(e as Error).message}`);
failures.push({
morfo: morfo.kebab,
issues: [{ kind: 'missing', message: `navigation error: ${(e as Error).message}` }]
});
} finally {
await page.close();
}
}
await browser.close();
console.log('');
if (failures.length === 0) {
console.log(`All ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} validate against their demo DOM.`);
process.exit(0);
} else {
const totalIssues = failures.reduce((sum, f) => sum + f.issues.length, 0);
console.log(
`${failures.length}/${morfos.length} morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}`
);
process.exit(1);
}

@ -0,0 +1,169 @@
/**
* Morfo vocabulary consistency check.
*
* Walks every morfo's `data` declarations and reports any attr with
* `values: string[]` whose set is NOT one of the canonical vocabularies
* declared in `CANONICAL_VOCABULARIES` (exported from morfo/schema.ts).
*
* Rationale (see sema_pre.md §5 + study.md §10):
* Sema selectors rely on consistent state vocabularies across components.
* If Accordion uses `data-state: ['open', 'closed']` then Dialog and Drawer
* must use the same tokens — not `visible|hidden` or `expanded|collapsed`.
*
* Strategy: a whitelist of known vocabularies. Any enum that doesn't exactly
* match one of them is flagged as a WARNING, not a hard error. Promote to
* error only when the vocabulary registry is mature enough that divergence
* is always a bug.
*
* Exit codes:
* 0 — all enums match a canonical vocabulary (or are novel but acceptable).
* 1 — at least one enum diverges and should be investigated.
*/
import { readdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import type { Morfo, MorfoPart } from '../src/uix/morfo/types';
import { validateMorfo, CANONICAL_VOCABULARIES } from '../src/uix/morfo/schema';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
async function loadMorfos(): Promise<Morfo[]> {
const files = readdirSync(MORFOS_DIR).filter(
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
);
const out: Morfo[] = [];
for (const f of files) {
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
const mod = (await import(url)) as Record<string, unknown>;
for (const v of Object.values(mod)) {
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
out.push(validateMorfo(v));
}
}
}
return out;
}
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
const out: MorfoPart[] = [];
for (const p of parts) {
out.push(p);
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
}
return out;
}
type Classification =
| { kind: 'match'; vocabulary: string }
| { kind: 'subset'; vocabulary: string; extra: string[] }
| { kind: 'superset'; vocabulary: string; missing: string[] }
| { kind: 'unknown' };
function classify(values: readonly string[]): Classification {
const valueSet = new Set(values);
for (const [name, canonical] of Object.entries(CANONICAL_VOCABULARIES)) {
const canonicalSet = new Set(canonical);
if (
canonicalSet.size === valueSet.size &&
[...valueSet].every((v) => canonicalSet.has(v))
) {
return { kind: 'match', vocabulary: name };
}
// Enum extends a canonical (added values)
if ([...canonicalSet].every((v) => valueSet.has(v))) {
const extra = [...valueSet].filter((v) => !canonicalSet.has(v));
if (extra.length > 0 && extra.length <= 3) {
return { kind: 'subset', vocabulary: name, extra };
}
}
// Enum shrinks a canonical (removed values)
if ([...valueSet].every((v) => canonicalSet.has(v))) {
const missing = [...canonicalSet].filter((v) => !valueSet.has(v));
if (missing.length > 0 && missing.length <= 3) {
return { kind: 'superset', vocabulary: name, missing };
}
}
}
return { kind: 'unknown' };
}
// ── Main ────────────────────────────────────────────────────────────────────
const morfos = await loadMorfos();
console.error(`Scanning ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} for vocabulary divergence...`);
type Finding = {
morfo: string;
part: string;
attr: string;
values: readonly string[];
classification: Classification;
};
/**
* Attrs whose value sets are per-component by design, not shared vocabulary.
* Skipped by the consistency check — Dialog's `saved|cancelled|…` is correctly
* different from Toast's `dismissed|auto-timeout|action`.
*/
const PER_COMPONENT_ATTRS = new Set(['data-last-action']);
const findings: Finding[] = [];
for (const morfo of morfos) {
for (const part of flatParts(morfo.parts)) {
for (const data of part.data) {
if (!data.values) continue;
if (PER_COMPONENT_ATTRS.has(data.attr)) continue;
const classification = classify(data.values);
if (classification.kind !== 'match') {
findings.push({
morfo: morfo.kebab,
part: part.kebab,
attr: data.attr,
values: data.values,
classification
});
}
}
}
}
if (findings.length === 0) {
console.log(`All enum values match canonical vocabularies.`);
process.exit(0);
}
console.log('');
console.log(`Found ${findings.length} divergent enum${findings.length === 1 ? '' : 's'}:`);
console.log('');
for (const f of findings) {
const loc = `${f.morfo}.${f.part}.${f.attr}`;
const values = `[${f.values.join(', ')}]`;
switch (f.classification.kind) {
case 'subset':
console.log(
`WARN ${loc} ${values} extends "${f.classification.vocabulary}" with: ${f.classification.extra.join(', ')}`
);
break;
case 'superset':
console.log(
`WARN ${loc} ${values} shrinks "${f.classification.vocabulary}" missing: ${f.classification.missing.join(', ')}`
);
break;
case 'unknown':
console.log(
`WARN ${loc} ${values} no canonical vocabulary matches. Consider if this should use one of: ${Object.keys(CANONICAL_VOCABULARIES).join(', ')}`
);
break;
}
}
console.log('');
console.log(
`These are WARNINGS — novel vocabularies may be legitimate. Review each above: if the value set SHOULD match a canonical vocabulary, align it. If the component introduces a NEW canonical vocabulary, add it to CANONICAL_VOCABULARIES in src/uix/morfo/schema.ts.`
);
process.exit(0); // WARN only — don't fail CI yet (until vocabulary registry is mature)

@ -0,0 +1,179 @@
/**
* Normaliza todos los componentes al patrño .ctx
* Convierte: const XxxContext = createTerraContext(...)
* A: class XxxState { static readonly ctx = createTerraContext(...) }
*/
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const terraDir = resolve(__dirname, '../src/uix/terra');
interface ContextDef {
name: string;
className: string;
fullMatch: string;
lineNumber: number;
}
function findContextDefinitions(content: string): ContextDef[] {
const contexts: ContextDef[] = [];
const lines = content.split('\n');
// Patrón para: const XxxContext = createTerraContext<XxxState>("...")
const regex =
/const\s+(\w+Context)\s*=\s*createTerraContext\s*<\s*(\w+)\s*\>\s*\(\s*["']([^"']+)["']\s*\)/g;
let match;
while ((match = regex.exec(content)) !== null) {
const contextName = match[1];
const className = match[2];
const fullMatch = match[0];
// Encontrar número de línea
const beforeMatch = content.substring(0, match.index);
const lineNumber = beforeMatch.split('\n').length;
contexts.push({
name: contextName,
className,
fullMatch,
lineNumber
});
}
return contexts;
}
function findClassDeclaration(
content: string,
className: string
): { start: number; end: number } | null {
const classRegex = new RegExp(`export\\s+class\\s+${className}\\s*\\{`, 'g');
const match = classRegex.exec(content);
if (!match) return null;
const start = match.index;
const openBraces = [start];
let end = start + 1;
let depth = 1;
// Encontrar el cierre de la clase
while (end < content.length && depth > 0) {
if (content[end] === '{') depth++;
else if (content[end] === '}') depth--;
end++;
}
return { start, end };
}
function convertToCtxPattern(content: string, contexts: ContextDef[]): string {
let modified = content;
// Ordenar por número de línea descendente (para modificar de atrás hacia adelante)
contexts.sort((a, b) => b.lineNumber - a.lineNumber);
for (const ctx of contexts) {
console.log(` Procesando: ${ctx.name} -> ${ctx.className}.ctx`);
// 1. Eliminar la definición del contexto externo
modified = modified.replace(ctx.fullMatch, '');
// 2. Encontrar la clase y agregar static readonly ctx
const classInfo = findClassDeclaration(modified, ctx.className);
if (!classInfo) {
console.log(` ⚠️ No se encontró la clase ${ctx.className}`);
continue;
}
// Buscar el primer { de la clase
const classStart = modified.indexOf('{', classInfo.start);
if (classStart === -1 || classStart > classInfo.end) continue;
// Insertar static readonly ctx después de la llave de apertura
const indent = ' '; // Usar tabs como el resto del archivo
const ctxProperty = `${indent}static readonly ctx = createTerraContext<${ctx.className}>('${ctx.className}');\n`;
modified =
modified.slice(0, classStart + 1) + '\n' + ctxProperty + modified.slice(classStart + 1);
// 3. Reemplazar XxxContext.get() por XxxState.ctx.get()
modified = modified.replace(
new RegExp(`\\b${ctx.name}\\.get\\(\\)`, 'g'),
`${ctx.className}.ctx.get()`
);
// 4. Reemplazar XxxContext.set() por XxxState.ctx.set()
modified = modified.replace(
new RegExp(`\\b${ctx.name}\\.set\\(`, 'g'),
`${ctx.className}.ctx.set(`
);
// 5. Reemplazar XxxContext.getOr() por XxxState.ctx.getOr()
modified = modified.replace(
new RegExp(`\\b${ctx.name}\\.getOr\\(`, 'g'),
`${ctx.className}.ctx.getOr(`
);
}
return modified;
}
function processFile(filePath: string): boolean {
const content = readFileSync(filePath, 'utf-8');
const original = content;
const contexts = findContextDefinitions(content);
if (contexts.length === 0) return false;
console.log(`📄 ${filePath}`);
const modified = convertToCtxPattern(content, contexts);
if (modified !== original) {
writeFileSync(filePath, modified, 'utf-8');
console.log(` ✅ ${contexts.length} contexto(s) convertido(s)\n`);
return true;
}
return false;
}
// Buscar todos los archivos .svelte.ts en terra/
function findAllTsFiles(dir: string): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith('.')) {
files.push(...findAllTsFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith('.svelte.ts')) {
files.push(fullPath);
}
}
return files;
}
const files = findAllTsFiles(terraDir);
let modified = 0;
let skipped = 0;
console.log('🔧 Normalizando al patrón .ctx...\n');
for (const file of files) {
if (processFile(file)) {
modified++;
} else {
skipped++;
}
}
console.log(`\n📊 Resumen:`);
console.log(` Modificados: ${modified}`);
console.log(` Skippeados: ${skipped}`);
console.log(` Total: ${modified + skipped}`);

@ -0,0 +1,589 @@
/**
* Permutation runner — cycles soma components through the state space declared
* by their demo pages and validates morfo compliance after every transition.
*
* Closes the gap left by `morfo:check` (single state) and `smoke` (load-time
* errors only): reactivity bugs like toolbar's A35 loop and form's A36
* microtask loop manifest during state transitions, not on first paint.
*
* Opt-in per demo: tag interactive controls with `data-perm-step="N"`. The
* runner discovers them, executes them in order, and re-validates morfo after
* each settle. See `src/uix/morfo/PERMUTATION_RUNNER.md` for the full
* authoring convention.
*
* Exit codes:
* 0 — every demo with `data-perm-step` passes all permutations.
* 1 — at least one permutation failed.
* 2 — no dev server / no morfos found.
*/
import { chromium, type Page } from 'playwright';
import { readdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types';
import { validateMorfo } from '../src/uix/morfo/schema';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components');
// ── Shared with morfo-check.ts ──────────────────────────────────────────────
type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string };
function flatParts(parts: readonly MorfoPart[]): MorfoPart[] {
const out: MorfoPart[] = [];
for (const p of parts) {
out.push(p);
if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts));
}
return out;
}
function dataAttrFor(kebab: string, part: MorfoPart): string {
return part.kebab === 'provider' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`;
}
async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise<Issue[]> {
const issues: Issue[] = [];
const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public');
const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p)));
for (const part of parts) {
const partAttr = dataAttrFor(morfo.kebab, part);
const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) =>
nodes.map((el) =>
Array.from(el.attributes)
.filter((a) => a.name.startsWith('data-'))
.map((a) => ({ name: a.name, value: a.value }))
)
);
if (elementsAttrs.length === 0) continue;
const declared = new Set(part.data.map((d) => d.attr));
declared.add(partAttr);
for (const siblingAttr of allPartAttrs) declared.add(siblingAttr);
const declaredByAttr = new Map<string, MorfoData>(part.data.map((d) => [d.attr, d]));
for (const elAttrs of elementsAttrs) {
for (const data of part.data) {
if ((data.severity ?? 'required') !== 'required') continue;
const found = elAttrs.find((a) => a.name === data.attr);
if (!found) {
issues.push({
kind: 'missing',
message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted`
});
}
}
for (const { name, value } of elAttrs) {
const decl = declaredByAttr.get(name);
if (decl?.values && !decl.values.includes(value)) {
issues.push({
kind: 'bad-value',
message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]`
});
}
}
for (const { name } of elAttrs) {
if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue;
if (name.startsWith(`data-_`)) continue;
if (declared.has(name)) continue;
if (!name.startsWith(`data-${morfo.kebab}`)) continue;
issues.push({
kind: 'undeclared',
message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" (not in morfo)`
});
}
}
}
return issues;
}
// ── Probe port + load morfos (copied from morfo-check) ──────────────────────
async function probePort(start: number, end: number): Promise<string | null> {
for (let port = start; port <= end; port++) {
try {
const res = await fetch(`http://localhost:${port}/`, {
signal: AbortSignal.timeout(500)
});
if (res.ok || res.status === 404 || res.status === 500) {
return `http://localhost:${port}`;
}
} catch {
// try next
}
}
return null;
}
async function loadMorfos(): Promise<Morfo[]> {
const files = readdirSync(MORFOS_DIR).filter(
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts')
);
const out: Morfo[] = [];
for (const f of files) {
const url = pathToFileURL(join(MORFOS_DIR, f)).href;
const mod = (await import(url)) as Record<string, unknown>;
for (const v of Object.values(mod)) {
if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) {
out.push(validateMorfo(v));
}
}
}
return out;
}
// ── Permutation step schema ─────────────────────────────────────────────────
interface PermStep {
index: number;
label: string;
mode: { kind: 'click' } | { kind: 'focus' } | { kind: 'type'; value: string } | { kind: 'key'; key: string };
skipValidate: boolean;
settle: number;
}
/** Parse a `data-perm-mode` attribute value. */
function parseMode(raw: string | null): PermStep['mode'] {
if (!raw || raw === 'click') return { kind: 'click' };
if (raw === 'focus') return { kind: 'focus' };
const typeMatch = /^type="([^"]*)"$/.exec(raw);
if (typeMatch) return { kind: 'type', value: typeMatch[1] };
const keyMatch = /^key="([^"]+)"$/.exec(raw);
if (keyMatch) return { kind: 'key', key: keyMatch[1] };
return { kind: 'click' };
}
/**
* Collect perm steps CURRENTLY present in the DOM, sorted by index. Called
* before every step execution because new `data-perm-step` elements may
* mount as earlier steps fire (e.g. a Dialog.Close inside a portal that
* only exists after `open = true`).
*/
async function collectSteps(page: Page): Promise<PermStep[]> {
const raw = await page.$$eval('[data-perm-step]', (els) =>
els.map((el) => {
const step = Number(el.getAttribute('data-perm-step') ?? '0');
return {
step,
label:
el.getAttribute('data-perm-label') ??
(el.textContent ?? '').trim().slice(0, 40) ??
`step ${step}`,
mode: el.getAttribute('data-perm-mode'),
skipValidate: el.hasAttribute('data-perm-skip-validate'),
settle: Number(el.getAttribute('data-perm-settle') ?? '300')
};
})
);
// Deduplicate by step index — if multiple elements share an index, keep the
// first (demos should not do this, but portals sometimes mirror content).
const byIndex = new Map<number, (typeof raw)[number]>();
for (const r of raw) if (!byIndex.has(r.step)) byIndex.set(r.step, r);
return [...byIndex.values()]
.sort((a, b) => a.step - b.step)
.map((r) => ({
index: r.step,
label: r.label || `step ${r.step}`,
mode: parseMode(r.mode),
skipValidate: r.skipValidate,
settle: Number.isFinite(r.settle) && r.settle > 0 ? r.settle : 300
}));
}
/** Execute one step against the live page. Returns the label for logs. */
async function executeStep(page: Page, step: PermStep): Promise<void> {
const selector = `[data-perm-step="${step.index}"]`;
const locator = page.locator(selector).first();
switch (step.mode.kind) {
case 'click':
await locator.click({ timeout: 3000 });
break;
case 'focus':
await locator.focus({ timeout: 3000 });
break;
case 'type':
await locator.focus({ timeout: 3000 });
await page.keyboard.type(step.mode.value);
break;
case 'key':
await locator.focus({ timeout: 3000 });
await page.keyboard.press(step.mode.key);
break;
}
await page.waitForTimeout(step.settle);
}
// ── URL variant matrix (v2 + v3 axis-at-a-time) ────────────────────────────
/**
* Universal baseline: every instrumented demo runs at least under `ltr`
* (plain URL) and `rtl` (`?perm.dir=rtl`). Additional per-component axes
* are discovered at runtime from the demo's `<meta name="perm-axes">` tag;
* each declared axis adds ONE variant that flips that axis from baseline
* while keeping everything else at the default — linear, not cartesian,
* so the matrix stays bounded.
*
* Supported axes (v3 initial set):
* dir — ltr (baseline) + rtl
* orientation — horizontal (baseline) + vertical
* disabled — unset + true
* readonly — unset + true
* invalid — unset + true
* loading — unset + true
*
* Demos opt in by listing the axes their `<script>` seed actually honours:
*
* ```svelte
* <svelte:head>
* <meta name="perm-axes" content="dir,orientation,disabled" />
* </svelte:head>
* ```
*
* Without the meta tag, only the universal `dir` axis runs. With the tag,
* the runner adds one variant per listed axis.
*/
type Variant = { suffix: string; label: string };
const AXIS_FLIP: Record<string, Variant> = {
dir: { suffix: '?perm.dir=rtl', label: 'rtl' },
orientation: { suffix: '?perm.orientation=vertical', label: 'orientation=vertical' },
disabled: { suffix: '?perm.disabled=true', label: 'disabled' },
readonly: { suffix: '?perm.readonly=true', label: 'readonly' },
invalid: { suffix: '?perm.invalid=true', label: 'invalid' },
loading: { suffix: '?perm.loading=true', label: 'loading' }
};
/** Read `<meta name="perm-axes" content="dir,orientation,...">` from the page. */
async function readDeclaredAxes(page: Page): Promise<string[]> {
try {
const content = await page.$eval(
'meta[name="perm-axes"]',
(el) => (el as HTMLMetaElement).content
);
return content
.split(',')
.map((s) => s.trim())
.filter(Boolean);
} catch {
// Meta not present — demo opted out of per-axis coverage.
return [];
}
}
/**
* Build the per-morfo variant list: baseline `ltr` + `rtl` (universal) +
* one flipped variant per axis the demo declared.
*/
function buildVariants(declaredAxes: string[]): Variant[] {
const base: Variant[] = [{ suffix: '', label: 'ltr' }, AXIS_FLIP.dir];
const extra: Variant[] = [];
for (const axis of declaredAxes) {
if (axis === 'dir') continue; // already in base
const v = AXIS_FLIP[axis];
if (v) extra.push(v);
}
return [...base, ...extra];
}
// ── Main loop ───────────────────────────────────────────────────────────────
type StepResult =
| { kind: 'pass'; label: string }
| { kind: 'fail'; label: string; reason: string }
| { kind: 'error'; label: string; err: string }
| { kind: 'skipped'; label: string };
type VariantResult = {
variant: string;
url: string;
stepsTotal: number;
steps: StepResult[];
pageErrors: string[];
};
type RouteResult = {
morfo: string;
variants: VariantResult[];
};
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
if (!BASE) {
console.error('Could not find a running dev server on 5173-5180.');
console.error('Start it with `npm run dev` in another terminal.');
process.exit(2);
}
console.error(`Using dev server at ${BASE}`);
const morfos = await loadMorfos();
if (morfos.length === 0) {
console.error('No morfos found under src/uix/morfo/components/');
process.exit(2);
}
console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`);
const browser = await chromium.launch();
const ctx = await browser.newContext();
const results: RouteResult[] = [];
const skipped: string[] = [];
/** Run one URL variant: load, cycle click-driven steps, report. */
async function runVariant(
base: string,
url: string,
variantLabel: string,
morfo: Morfo
): Promise<VariantResult | 'no-steps'> {
const page = await ctx.newPage();
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
page.on('console', (m) => {
if (m.type() === 'error') pageErrors.push(m.text());
});
const variantResult: VariantResult = {
variant: variantLabel,
url,
stepsTotal: 0,
steps: [],
pageErrors: []
};
try {
await page.goto(base + url, { waitUntil: 'networkidle', timeout: 20000 });
await page.waitForTimeout(300);
// Meta-only demos (no perm-steps) still get passive `[initial seed]`
// validation at each URL variant — morfo compliance under RTL /
// orientation flips is worth catching even without a click cycle.
// Any errors from the URL-seeded initial state count against the variant.
if (pageErrors.length > 0) {
const captured = pageErrors.splice(0, pageErrors.length).join(' | ');
variantResult.steps.push({
kind: 'fail',
label: '[initial seed]',
reason: `pageerror: ${captured.slice(0, 200)}`
});
} else {
// Validate morfo at the URL-seeded state BEFORE any clicks so v2
// state-injection alone (no click sequence) is exercised.
try {
const seedIssues = await validateMorfoAgainstDom(page, morfo);
if (seedIssues.length === 0) {
variantResult.steps.push({ kind: 'pass', label: '[initial seed]' });
} else {
variantResult.steps.push({
kind: 'fail',
label: '[initial seed]',
reason: seedIssues.map((i) => `[${i.kind}] ${i.message}`).join(' · ')
});
}
} catch (e) {
variantResult.steps.push({
kind: 'error',
label: '[initial seed]',
err: `validation: ${(e as Error).message.slice(0, 120)}`
});
}
}
const executedIndices = new Set<number>();
let safety = 50;
while (safety-- > 0) {
const current = await collectSteps(page);
const next = current
.filter((s) => !executedIndices.has(s.index))
.sort((a, b) => a.index - b.index)[0];
if (!next) break;
const step = next;
executedIndices.add(step.index);
try {
await executeStep(page, step);
} catch (e) {
variantResult.steps.push({
kind: 'error',
label: step.label,
err: (e as Error).message.slice(0, 120)
});
continue;
}
if (pageErrors.length > 0) {
const captured = pageErrors.splice(0, pageErrors.length).join(' | ');
variantResult.steps.push({
kind: 'fail',
label: step.label,
reason: `pageerror: ${captured.slice(0, 200)}`
});
continue;
}
if (step.skipValidate) {
variantResult.steps.push({ kind: 'pass', label: step.label + ' (no-validate)' });
continue;
}
try {
const issues = await validateMorfoAgainstDom(page, morfo);
if (issues.length === 0) {
variantResult.steps.push({ kind: 'pass', label: step.label });
} else {
variantResult.steps.push({
kind: 'fail',
label: step.label,
reason: issues.map((i) => `[${i.kind}] ${i.message}`).join(' · ')
});
}
} catch (e) {
variantResult.steps.push({
kind: 'error',
label: step.label,
err: `validation: ${(e as Error).message.slice(0, 120)}`
});
}
}
variantResult.pageErrors = pageErrors;
variantResult.stepsTotal = variantResult.steps.length;
} catch (e) {
variantResult.steps.push({
kind: 'error',
label: '[navigation]',
err: (e as Error).message.slice(0, 120)
});
} finally {
await page.close();
}
return variantResult;
}
/**
* Discover opt-in signals from the demo page: the declared axes (`perm-axes`
* meta) and whether any `data-perm-step` element exists. The demo is
* considered "instrumented" if it declares at least one of these.
*/
async function probeDemo(
base: string,
morfoKebab: string
): Promise<{ axes: string[]; hasSteps: boolean } | 'no-demo'> {
const page = await ctx.newPage();
try {
const res = await page.goto(`${base}/test/soma/${morfoKebab}`, {
waitUntil: 'networkidle',
timeout: 20000
});
if (!res || res.status() >= 400) return 'no-demo';
await page.waitForTimeout(200);
const axes = await readDeclaredAxes(page);
const hasSteps = (await collectSteps(page)).length > 0;
return { axes, hasSteps };
} catch {
return 'no-demo';
} finally {
await page.close();
}
}
for (const morfo of morfos) {
const variantResults: VariantResult[] = [];
const probe = await probeDemo(BASE, morfo.kebab);
if (probe === 'no-demo') {
skipped.push(morfo.kebab);
continue;
}
// Opt-in signal: either a `perm-axes` meta OR at least one
// `data-perm-step` element. Meta-only demos get passive `[initial seed]`
// validation at each URL variant; step-only demos get the v1 click cycle
// at baseline; declaring both is the full v1+v2+v3 treatment.
if (probe.axes.length === 0 && !probe.hasSteps) {
skipped.push(morfo.kebab);
continue;
}
const variants = buildVariants(probe.axes);
for (const v of variants) {
const url = `/test/soma/${morfo.kebab}${v.suffix}`;
const r = await runVariant(BASE, url, v.label, morfo);
if (r === 'no-steps') continue;
variantResults.push(r);
}
if (variantResults.length === 0) {
skipped.push(morfo.kebab);
continue;
}
results.push({ morfo: morfo.kebab, variants: variantResults });
}
await browser.close();
// ── Report ──────────────────────────────────────────────────────────────────
console.log('');
let passedRoutes = 0;
let failedRoutes = 0;
let totalSteps = 0;
let totalFailed = 0;
for (const r of results) {
const bad = r.variants
.flatMap((v) => v.steps)
.filter((s) => s.kind !== 'pass');
totalSteps += r.variants.reduce((acc, v) => acc + v.steps.length, 0);
totalFailed += bad.length;
if (bad.length === 0) {
const variantCount = r.variants.length;
const stepCount = r.variants.reduce((acc, v) => acc + v.steps.length, 0);
console.log(
`PASS ${r.morfo.padEnd(20)} ${variantCount} variant${variantCount === 1 ? '' : 's'} · ${stepCount} step${stepCount === 1 ? '' : 's'}`
);
for (const v of r.variants) {
console.log(` [${v.variant}] ${v.url}`);
for (const s of v.steps) console.log(` · ${s.label}`);
}
passedRoutes++;
} else {
console.log(`FAIL ${r.morfo.padEnd(20)} ${bad.length} step${bad.length === 1 ? '' : 's'} failed`);
for (const v of r.variants) {
console.log(` [${v.variant}] ${v.url}`);
for (const s of v.steps) {
if (s.kind === 'pass') console.log(` · ${s.label}`);
else if (s.kind === 'skipped') console.log(` · ${s.label} (skipped)`);
else if (s.kind === 'fail') console.log(` ✖ ${s.label} ${s.reason}`);
else console.log(` ✖ ${s.label} ERROR ${s.err}`);
}
}
failedRoutes++;
}
}
console.log('');
if (skipped.length > 0) {
console.log(
`SKIPPED (${skipped.length}): ${skipped.slice(0, 10).join(', ')}${skipped.length > 10 ? ', …' : ''}`
);
console.log(' (demo has no `data-perm-step` annotations yet — see src/uix/morfo/PERMUTATION_RUNNER.md)');
}
console.log('');
const totalVariants = results.reduce((acc, r) => acc + r.variants.length, 0);
const summary =
failedRoutes === 0
? `All ${passedRoutes} instrumented demo${passedRoutes === 1 ? '' : 's'} passed. (${totalSteps} step${totalSteps === 1 ? '' : 's'} across ${totalVariants} URL variant${totalVariants === 1 ? '' : 's'}.)`
: `${failedRoutes} demo${failedRoutes === 1 ? '' : 's'} failed (${totalFailed}/${totalSteps} steps): ${results
.filter((r) => r.variants.some((v) => v.steps.some((s) => s.kind !== 'pass')))
.map((r) => r.morfo)
.join(', ')}`;
console.log(summary);
process.exit(failedRoutes === 0 ? 0 : 1);

@ -0,0 +1,105 @@
/**
* One-shot Playwright probe to verify the Toast dismiss flow emits
* `data-event*` perceptual signals on the item element.
*
* Usage:
* 1. `npm run dev` in another terminal (defaults to port 5173)
* 2. `node --import tsx/esm scripts/probe-toast-dismiss.ts`
*
* Output: list of mutations seen during the dismiss interaction. If the
* runtime + semantic engine are wired correctly, you should see at minimum:
* - data-event="dismiss" added
* - data-event-id="sig-N" added
* - data-event-phase="active" added
* - data-event-family="emerge" added
* …followed shortly after by their removal as the cleanup tick runs.
*/
import { chromium } from 'playwright'
const URL = process.env.PROBE_URL ?? 'http://localhost:5173/test/soma/toast'
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage()
const consoleLines: string[] = []
page.on('console', (msg) => consoleLines.push(`[${msg.type()}] ${msg.text()}`))
await page.goto(URL, { waitUntil: 'networkidle' })
// 1. Trigger a default toast (the demo page has an "Add default" button).
const triggerBtn = page.getByRole('button', { name: /^default$/i }).first()
await triggerBtn.click()
// 2. Wait for the item to mount.
const item = page.locator('[data-toast-item]').first()
await item.waitFor({ state: 'attached', timeout: 5000 })
// 3. Install a MutationObserver in the page context that watches the item.
await page.evaluate(() => {
const item = document.querySelector('[data-toast-item]') as HTMLElement | null
if (!item) {
;(window as unknown as Record<string, unknown>).__probeResult = {
error: 'item not found'
}
return
}
const captured: Array<{ time: number; attr: string; oldValue: string | null; newValue: string | null }> = []
const start = performance.now()
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'attributes' || !m.attributeName) continue
captured.push({
time: Math.round(performance.now() - start),
attr: m.attributeName,
oldValue: m.oldValue,
newValue: (m.target as HTMLElement).getAttribute(m.attributeName)
})
}
})
observer.observe(item, { attributes: true, attributeOldValue: true })
;(window as unknown as Record<string, unknown>).__probeStop = () => {
observer.disconnect()
return captured
}
})
// 4. Click the close button (× inside the toast item).
const closeBtn = item.locator('[data-toast-close]').first()
await closeBtn.click()
// 5. Wait long enough for the dismiss + cleanup to complete (~5 frames).
await page.waitForTimeout(200)
// 6. Pull the captured mutations out.
const result = (await page.evaluate(() => {
const stop = (window as unknown as { __probeStop?: () => unknown; __probeResult?: unknown })
.__probeStop
const errResult = (window as unknown as { __probeResult?: unknown }).__probeResult
if (errResult) return errResult
return stop ? stop() : { error: 'observer not installed' }
})) as Array<{ time: number; attr: string; oldValue: string | null; newValue: string | null }>
console.log('\n=== Console messages ===')
for (const line of consoleLines) console.log(line)
console.log('\n=== Mutations on the toast item during dismiss ===')
if (Array.isArray(result) && result.length === 0) {
console.log('(none)')
} else if (Array.isArray(result)) {
for (const m of result) {
console.log(`+${m.time}ms ${m.attr}: ${JSON.stringify(m.oldValue)} → ${JSON.stringify(m.newValue)}`)
}
const sawSignal = result.some((m) => m.attr === 'data-event' && m.newValue === 'dismiss')
console.log(`\nSignal observed: ${sawSignal ? '✅ YES' : '❌ NO'}`)
} else {
console.log('error result:', result)
}
await browser.close()
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

@ -0,0 +1,92 @@
/**
* Script para eliminar todos los campos privados con # de los componentes Terra
* Motivo: Los campos marcados con # se exponen vía data-* attrs, creando ambigüedad
*/
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const terraDir = resolve(__dirname, '../src/uix/terra');
// Obtener todos los archivos .svelte.ts y .ts recursivamente
function getAllTsFiles(dir: string, baseDir = dir): string[] {
const files: string[] = [];
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and similar
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
files.push(...getAllTsFiles(fullPath, baseDir));
}
} else if (
entry.isFile() &&
(entry.name.endsWith('.svelte.ts') || entry.name.endsWith('.ts'))
) {
files.push(fullPath);
}
}
return files;
}
let modified = 0;
let skipped = 0;
let errors = 0;
const files = getAllTsFiles(terraDir);
for (const file of files) {
try {
let content = readFileSync(file, 'utf-8');
const original = content;
// Skip NAMING_CONVENTIONS.md and other non-code files
if (file.includes('NAMING_CONVENTIONS.md') || file.includes('README.md')) {
skipped++;
continue;
}
// 1. Reemplazar declaraciones de campos privados con readonly
// readonly #foo => readonly foo
content = content.replace(/readonly\s+#(\w+)/g, 'readonly $1');
// 2. Reemplazar declaraciones de campos privados sin readonly
// #foo = $state(...) => foo = $state(...)
content = content.replace(/^(\s*)#(\w+)\s*=/gm, '$1$2 =');
// 3. Reemplazar uso de campos privados
// this.#foo => this.foo
content = content.replace(/this\.#(\w+)/g, 'this.$1');
// 4. Reemplazar métodos privados (opcional - convertir a públicos)
// #methodName() => methodName()
// Solo si no es seguido por "private" keyword (que no usamos)
content = content.replace(/(\s+)#/g, '$1');
if (content !== original) {
writeFileSync(file, content, 'utf-8');
modified++;
const relativePath = file
.replace(/.*src\\uix\\terra\\/, '')
.replace(/\.svelte\.ts$/, '')
.replace(/\.ts$/, '');
console.log(`✅ ${relativePath}`);
} else {
skipped++;
}
} catch (e: any) {
errors++;
console.log(`❌ Error procesando ${file}:`, e.message);
}
}
console.log(`\n📊 Resumen:`);
console.log(` Modificados: ${modified}`);
console.log(` Skippeados (sin cambios): ${skipped}`);
console.log(` Errores: ${errors}`);
console.log(` Total procesados: ${modified + skipped + errors}`);

@ -0,0 +1,146 @@
/**
* Playwright smoke check for the /test/soma/* demo pages.
*
* What it verifies per route (beyond HTTP 200):
* - No uncaught `pageerror` (thrown during component init / hydration).
* - No `console.error` (runtime exceptions that didn't throw synchronously).
* - No translation key missing warnings (`[lang] Translation key not found`).
* - No soma context-not-found warnings (`Context "X" not found`).
*
* Usage:
* node scripts/smoke-check.mjs [base-url]
* npm run smoke
*
* Requires `npm run dev` running in another terminal (or passes an explicit
* base URL). The script tries 5173-5180 if no URL is given.
*/
import { chromium } from 'playwright';
import { readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROUTES_DIR = join(__dirname, '..', 'src', 'routes', 'test', 'soma');
/** Find every `{route}/+page.svelte` under /test/soma recursively. */
function discoverRoutes(dir, prefix) {
const routes = [];
const entries = readdirSync(dir);
for (const name of entries) {
const full = join(dir, name);
const st = statSync(full);
if (st.isDirectory()) {
routes.push(...discoverRoutes(full, `${prefix}/${name}`));
} else if (name === '+page.svelte') {
routes.push(prefix || '/test/soma');
}
}
return routes;
}
async function probePort(startPort, endPort) {
for (let port = startPort; port <= endPort; port++) {
try {
const res = await fetch(`http://localhost:${port}/`, {
signal: AbortSignal.timeout(500)
});
if (res.ok || res.status === 404 || res.status === 500) {
return `http://localhost:${port}`;
}
} catch {
// connection refused — try next
}
}
return null;
}
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
if (!BASE) {
console.error('Could not find a running dev server on 5173-5180.');
console.error('Start it with `npm run dev` in another terminal.');
process.exit(2);
}
console.error(`Using dev server at ${BASE}`);
const routes = discoverRoutes(ROUTES_DIR, '/test/soma').sort();
if (routes.length === 0) {
console.error('No +page.svelte files found under /test/soma.');
process.exit(2);
}
console.error(`Checking ${routes.length} routes...`);
const browser = await chromium.launch();
const ctx = await browser.newContext();
const failures = [];
const MAX_PARALLEL = 4;
async function checkRoute(route) {
const page = await ctx.newPage();
const errors = [];
const warnings = [];
page.on('pageerror', (e) => errors.push(` pageerror: ${e.message}`));
page.on('console', (m) => {
const t = m.text();
if (m.type() === 'error') {
// Filter out some noisy devtools messages unrelated to correctness.
if (t.includes('Download the React DevTools')) return;
errors.push(` console.error: ${t}`);
}
if (m.type() === 'warning' || m.type() === 'warn') {
if (
t.includes('Translation key not found') ||
t.includes('Context') ||
t.includes('[soma]') ||
t.includes('soma context')
) {
warnings.push(` console.warning: ${t}`);
}
}
});
try {
const res = await page.goto(BASE + route, {
waitUntil: 'networkidle',
timeout: 20000
});
const status = res?.status() ?? 0;
// Give reactive effects + lazy demos a moment to settle.
await page.waitForTimeout(500);
if (status !== 200 || errors.length > 0 || warnings.length > 0) {
const lines = [`FAIL ${route} (HTTP ${status})`];
errors.forEach((e) => lines.push(e));
warnings.forEach((w) => lines.push(w));
return { ok: false, lines };
}
return { ok: true, lines: [`PASS ${route}`] };
} catch (e) {
return {
ok: false,
lines: [`FAIL ${route} (navigation error: ${e.message})`]
};
} finally {
await page.close();
}
}
// Run checks with bounded concurrency so the dev server doesn't get hammered.
let cursor = 0;
async function worker() {
while (cursor < routes.length) {
const idx = cursor++;
const route = routes[idx];
const result = await checkRoute(route);
result.lines.forEach((l) => console.log(l));
if (!result.ok) failures.push(route);
}
}
await Promise.all(Array.from({ length: MAX_PARALLEL }, () => worker()));
await browser.close();
console.log('');
console.log(
failures.length === 0
? `All ${routes.length} routes passed.`
: `${failures.length}/${routes.length} routes failed: ${failures.join(', ')}`
);
process.exit(failures.length === 0 ? 0 : 1);

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

@ -0,0 +1,190 @@
/**
* Errors for `arts/active-app`.
*
* Two classes of errors live here:
*
* - **Schema validation** — thrown synchronously during
* `createActiveApp()` when the service schema is malformed
* (`AappServiceNameMismatchError`,
* `AappServiceDependencyCycleError`,
* `AappServiceConstructionFailedError`).
* - **App-event safety** — thrown by `publishAppDisposeStarting` and
* friends when an event payload carries a sensitive key or fires in
* an unsupported runtime (`AappInvalidEventRuntimeError`,
* `AappUnsafeEventPayloadError`).
*
* The Svelte-context bridge `setBus` / `getBus` lives in `arts/bus/`
* (its semantic owner is the bus, not App). The corresponding error
* `BusNoContextError` lives in `libs/bus/errors.ts`.
*
* The legacy `AappAlreadyCreatedError` is gone: the declarative service
* schema makes "factory called twice" structurally impossible — a
* duplicate service key is a JavaScript object-literal error, not a
* runtime concern.
*/
import {
CodeError,
errCode,
moduleSeed,
type ErrCode,
type ErrorMessages,
type ModuleSeed
} from '$libs/errs';
import { APP_MODULE } from './consts.ts';
// ── Error codes ────────────────────────────────────────────────────────
export const APP_ERR: ModuleSeed = moduleSeed(APP_MODULE);
export const APP_ERR_SERVICE_NAME_MISMATCH: ErrCode = errCode(APP_ERR, 'service_name_mismatch');
export const APP_ERR_SERVICE_DEPENDENCY_CYCLE: ErrCode = errCode(
APP_ERR,
'service_dependency_cycle'
);
export const APP_ERR_SERVICE_CONSTRUCTION_FAILED: ErrCode = errCode(
APP_ERR,
'service_construction_failed'
);
export const APP_ERR_EVENT: ErrCode = errCode(APP_ERR, 'event');
export const APP_ERR_EVENT_INVALID_RUNTIME: ErrCode = errCode(APP_ERR_EVENT, 'invalid_runtime');
export const APP_ERR_EVENT_UNSAFE_PAYLOAD: ErrCode = errCode(APP_ERR_EVENT, 'unsafe_payload');
export const APP_ERR_NO_CONTEXT: ErrCode = errCode(APP_ERR, 'no_context');
// ── Error message builders ─────────────────────────────────────────────
export const appServiceNameMismatchMessage = (key: string, factoryName: string): string =>
`[${APP_MODULE}] service factory name "${factoryName}" must match schema key "${key}"`;
export const appServiceDependencyCycleMessage = (cycle: readonly string[]): string =>
`[${APP_MODULE}] dependency cycle detected: ${cycle.join(' -> ')}`;
export const appServiceConstructionFailedMessage = (name: string): string =>
`[${APP_MODULE}] service "${name}" failed to construct`;
export const appInvalidEventRuntimeMessage = (
type: string,
allowed: string,
where: string
): string =>
`[${APP_MODULE}] event cannot fire in this runtime (type=${type}, allowed=${allowed}, where=${where})`;
export const appUnsafeEventPayloadMessage = (type: string, path: string): string =>
`[${APP_MODULE}] app event payload contains a sensitive field (type=${type}, path=${path})`;
export const appNoContextMessage = (): string =>
`[${APP_MODULE}] No ActiveApp found in Svelte context. Call setActiveApp(App) at the layout root.`;
// ── Error messages ─────────────────────────────────────────────────────
export const APP_ERROR_MESSAGES: ErrorMessages = {
[APP_ERR_SERVICE_NAME_MISMATCH]: appServiceNameMismatchMessage,
[APP_ERR_SERVICE_DEPENDENCY_CYCLE]: appServiceDependencyCycleMessage,
[APP_ERR_SERVICE_CONSTRUCTION_FAILED]: appServiceConstructionFailedMessage,
[APP_ERR_EVENT_INVALID_RUNTIME]: appInvalidEventRuntimeMessage,
[APP_ERR_EVENT_UNSAFE_PAYLOAD]: appUnsafeEventPayloadMessage,
[APP_ERR_NO_CONTEXT]: appNoContextMessage
};
// ── Error classes ──────────────────────────────────────────────────────
export class AappServiceNameMismatchError extends CodeError {
readonly key: string;
readonly factoryName: string;
constructor(key: string, factoryName: string) {
super(APP_ERR_SERVICE_NAME_MISMATCH, {
message: appServiceNameMismatchMessage(key, factoryName)
});
this.key = key;
this.factoryName = factoryName;
}
}
export class AappServiceDependencyCycleError extends CodeError {
readonly cycle: readonly string[];
constructor(cycle: readonly string[]) {
super(APP_ERR_SERVICE_DEPENDENCY_CYCLE, {
message: appServiceDependencyCycleMessage(cycle)
});
this.cycle = cycle;
}
}
export class AappServiceConstructionFailedError extends CodeError {
readonly serviceName: string;
constructor(serviceName: string, options?: { cause?: unknown }) {
super(APP_ERR_SERVICE_CONSTRUCTION_FAILED, {
message: appServiceConstructionFailedMessage(serviceName),
cause: options?.cause
});
this.serviceName = serviceName;
}
}
export class AappInvalidEventRuntimeError extends CodeError {
readonly type: string;
readonly allowed: string;
readonly where: string;
constructor(type: string, allowed: string, where: string) {
super(APP_ERR_EVENT_INVALID_RUNTIME, {
message: appInvalidEventRuntimeMessage(type, allowed, where)
});
this.type = type;
this.allowed = allowed;
this.where = where;
}
}
export class AappNoContextError extends CodeError {
constructor() {
super(APP_ERR_NO_CONTEXT, { message: appNoContextMessage() });
}
}
export class AappUnsafeEventPayloadError extends CodeError {
readonly type: string;
readonly path: string;
constructor(type: string, path: string) {
super(APP_ERR_EVENT_UNSAFE_PAYLOAD, {
message: appUnsafeEventPayloadMessage(type, path)
});
this.type = type;
this.path = path;
}
}
// ── Type guards ────────────────────────────────────────────────────────
export function isAappServiceNameMismatchError(
error: unknown
): error is AappServiceNameMismatchError {
return error instanceof AappServiceNameMismatchError;
}
export function isAappServiceDependencyCycleError(
error: unknown
): error is AappServiceDependencyCycleError {
return error instanceof AappServiceDependencyCycleError;
}
export function isAappServiceConstructionFailedError(
error: unknown
): error is AappServiceConstructionFailedError {
return error instanceof AappServiceConstructionFailedError;
}
export function isAappInvalidEventRuntimeError(
error: unknown
): error is AappInvalidEventRuntimeError {
return error instanceof AappInvalidEventRuntimeError;
}
export function isAappUnsafeEventPayloadError(
error: unknown
): error is AappUnsafeEventPayloadError {
return error instanceof AappUnsafeEventPayloadError;
}
export function isAappNoContextError(error: unknown): error is AappNoContextError {
return error instanceof AappNoContextError;
}

@ -0,0 +1,76 @@
/**
* Public entry point of `arts/active-app`.
*
* Three import paths exist:
*
* - `$active-app` — `createActiveApp`, types, errors. What every app
* needs to compose its runtime.
* - `$active-app/services` — `defineActive*` / `defineEngine*`
* factories for the declarative service schema. Loaded only by
* apps that declare services.
* - `$active-app/presets` — orchestration presets registered on
* `App.orca`. Loaded only by apps that opt into the standard
* reactions.
*
* Splitting the entry points lets the bundler tree-shake each layer
* independently. An app that only consumes the core never pulls in
* service factories or presets.
*
* The Svelte-context bridge (`setBus` / `getBus`) lives in `$bus`, not
* here. It is owned by the bus, which is the semantic origin of the
* pattern; App is a consumer that just calls `setBus(App.bus)` once at
* the layout root.
*/
export { createActiveApp } from './active-app.svelte.ts';
export { APP_MODULE } from './consts.ts';
export {
APP_EVENT_DISPOSE_STARTING,
publishAppDisposeStarting,
onAppDisposeStarting,
type AppDisposeStartingPayload,
type AppEventBus,
type AppEventMap,
type AppEventRuntime
} from './events.ts';
export {
AappInvalidEventRuntimeError,
AappNoContextError,
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
AappServiceNameMismatchError,
AappUnsafeEventPayloadError,
isAappInvalidEventRuntimeError,
isAappNoContextError,
isAappServiceConstructionFailedError,
isAappServiceDependencyCycleError,
isAappServiceNameMismatchError,
isAappUnsafeEventPayloadError
} from './errors.ts';
export { setActiveApp, getActiveApp, tryGetActiveApp } from './svelte/context.svelte.ts';
export type {
ActiveApp,
ActiveAppBusEvents,
ActiveAppCore,
ActiveAppOptions,
ActiveAppPrefsOptions,
ActiveAppServicesIntrospection
} from './types.ts';
export type {
AppServiceFactory,
AppServiceSchema,
CoreServices,
CoreServiceKey,
ResolveServiceInstances,
ServiceInitMode,
ServiceStatus
} from './services.ts';
export { buildServiceBuilders } from './service-builder.ts';
export type { ServiceBuilders } from './service-builder.ts';

@ -0,0 +1,45 @@
/**
* Svelte context bridge for the App.
*
* `setActiveApp(app)` writes the App into the component-tree's context
* once near the layout root; `getActiveApp()` retrieves it from anywhere
* deeper without threading it through props. Mirrors the `setBus` /
* `getBus` pattern from `$bus`.
*
* `getActiveApp()` throws `AappNoContextError` if no App is in scope —
* forgetting to call `setActiveApp()` is always a wiring bug, not a
* degraded mode. Components that need to degrade gracefully use
* `tryGetActiveApp()` instead.
*/
import { getContext, setContext } from 'svelte';
import { AappNoContextError } from '../errors.ts';
import type { ActiveApp } from '../types.ts';
import type { AppServiceSchema } from '../services.ts';
import type { PrefsSchema } from '$libs/prefs';
const APP_CONTEXT = Symbol('arts.active-app.context');
export function setActiveApp<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
>(app: ActiveApp<TSchema, TPrefsSchema>): ActiveApp<TSchema, TPrefsSchema> {
setContext(APP_CONTEXT, app);
return app;
}
export function getActiveApp<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
>(): ActiveApp<TSchema, TPrefsSchema> {
const app = getContext<ActiveApp<TSchema, TPrefsSchema> | undefined>(APP_CONTEXT);
if (!app) throw new AappNoContextError();
return app;
}
export function tryGetActiveApp<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
>(): ActiveApp<TSchema, TPrefsSchema> | undefined {
return getContext<ActiveApp<TSchema, TPrefsSchema> | undefined>(APP_CONTEXT);
}

@ -0,0 +1,51 @@
# `src/lib` — DEPRECATED
> **STATUS:** Legacy ecosystem. Slated for removal once all consumers
> migrate to the new `src/arts` + `src/libs` + `src/svrs` ecosystem.
>
> **DO NOT** add new code here. **DO NOT** add new aliases pointing here.
> If you find yourself reaching for one of these modules, the
> replacement already exists in the new ecosystem — see the migration
> map below.
The new ecosystem is documented at [`src/arts/README.md`](../arts/README.md).
Each top-level artifact ships its own README.
---
## Migration map
| Legacy module | Replacement | Notes |
| --- | --- | --- |
| `src/lib/ext/app` (`App.create`) | [`$active-app`](../arts/active-app/README.md) (`createActiveApp`) | Factory signature changes — services declared explicitly. |
| `src/lib/ext/lang` (`createLang`, `LangNode`, `p()`) | [`$lang`](../arts/lang/README.md) (`EngineLang` / `ActiveLang`) + `$libs/lang` types | Reactive wrapper lives in `$lang/active-lang.svelte.ts`. |
| `src/lib/ext/dates` | [`$libs/days`](../libs/days) (vendored dias) | Drop-in path change. |
| `src/lib/ext/money` | [`$format`](../arts/format/README.md) `currency` | API differs. |
| `src/lib/ext/nums` | [`$format`](../arts/format/README.md) `numbers` | API differs. |
| `src/lib/ext/units` | [`$format`](../arts/format/README.md) `units` | API differs. |
| `src/lib/ext/presentation` | [`$frontend`](../arts/frontend/README.md) (`ActiveFrontend`) | dir / locale / density. |
| `src/lib/sium/core` | [`$libs/standard-schema`](../libs/standard-schema.ts) + `$libs/sium/types` | Pure schema contracts. |
| `src/lib/sium/svelte` (`SiumProvider`) | [`$sium`](../arts/sium/README.md) (`EngineSium`) | Service injection — no Context provider. |
| `src/lib/sium/langs` | [`$sium`](../arts/sium/README.md) (`EngineSium.langs`) | Accessed via engine instance. |
| `src/lib/util/dias` | [`$libs/days`](../libs/days) | Drop-in. |
| `src/lib/util/colors` | [`$libs/color`](../libs/color) | Drop-in. |
| `src/lib/util/reactive` | [`$libs/reactive`](../libs/reactive) | Drop-in. The `$reactive` alias already points here. |
| `src/lib/util/{arrays,strings,funcs,objs,numbers}` | [`$libs/{arrays,strings,funcs,objs,numbers}`](../libs) | Drop-in. |
| `@/ling` (`createLing`) | [`$lang`](../arts/lang/README.md) (`createEngineLang`) | Plurals + reactive split. |
| `@/logr` (`createLogr`) | [`$logger`](../arts/logger/README.md) (`EngineLogger`) | Diagnostic events catalogados. |
| `@/glob` | [`$format`](../arts/format/README.md) engines | Per CLAUDE.md "do not use." |
| `@/actx` | _under review_ | Audit before deletion. |
---
## Removal plan
1. **Phase 0 (THIS COMMIT):** mark every top-level barrel with `@deprecated`. Aliases stay wired for backwards-compat. Zero behavior change.
2. **Phase 1:** wire the new ecosystem aliases in `vite.config.ts` + `svelte.config.js`. Already done in this commit.
3. **Phase 2:** mechanical sweep — migrate `src/lib/util/*` imports to `$libs/*` everywhere. Drop-in. Solo paths.
4. **Phase 3:** migrate `morfo/schema.ts` to `$libs/standard-schema`. One file, three imports.
5. **Phase 4:** migrate soma's `App.create()` composition to `createActiveApp` with explicit service slots (incluido `semantic` para sema). Only architecturally substantive step.
6. **Phase 5:** sweep test pages (~82 files) to use the new aliases.
7. **Phase 6:** remove `src/lib/{glob,ling,actx}` (no consumers). Investigate `src/lib/sium` and `src/lib/util/{dates,times,timers}` for any remaining consumers; remove if clean.
After Phase 6, this folder should be empty or contain only files awaiting one final consumer.

@ -17,52 +17,59 @@ import { createLogr } from '../../logr/lib';
* Esta versión usa $state para que Svelte rastree las dependencias automáticamente.
* Los métodos de formateo están envueltos para acceder a locale de forma reactiva.
*/
export function createACtx<S extends LingNode>(
config: ACtxConfig<S>
): ReactiveACtx<S> {
export function createACtx<S extends LingNode>(config: ACtxConfig<S>): ReactiveACtx<S> {
const { ling } = config;
const { ling } = config;
// ── $state de locale — única fuente de verdad reactiva de actx ───────────
let locale = $state<GlobLocale>(ling.getLocale() as GlobLocale);
ling.onLocaleChange((l) => {
locale = l as GlobLocale;
});
// ── $state de locale — única fuente de verdad reactiva de actx ───────────
let locale = $state<GlobLocale>(ling.getLocale() as GlobLocale);
ling.onLocaleChange(l => { locale = l as GlobLocale; });
// Getter reactivo que pasan los módulos para establecer dependencia de locale
const getLocale = () => ling.getLocale() as GlobLocale;
const reactiveLocale = () => locale;
// Getter reactivo que pasan los módulos para establecer dependencia de locale
const getLocale = () => ling.getLocale() as GlobLocale;
const reactiveLocale = () => locale;
// ── Módulos — cada uno gestiona su propio $state ─────────────────────────
const _logr = createLogr(ling, config.logr ?? {});
const _numr = createNumrState(config.numr ?? {}, getLocale, reactiveLocale);
const _dati = createDatiState(config.dati ?? {}, getLocale, reactiveLocale);
const _curr = createCurrState(
config.curr?.definitions ?? {},
ling,
config.curr ?? {},
getLocale,
reactiveLocale
);
const _unit = createUnitState(
ling,
config.unit?.categories ?? {},
config.unit ?? {},
getLocale,
reactiveLocale
);
// ── Módulos — cada uno gestiona su propio $state ─────────────────────────
const _logr = createLogr(ling, config.logr ?? {});
const _numr = createNumrState(config.numr ?? {}, getLocale, reactiveLocale);
const _dati = createDatiState(config.dati ?? {}, getLocale, reactiveLocale);
const _curr = createCurrState(
config.curr?.definitions ?? {},
ling,
config.curr ?? {},
getLocale,
reactiveLocale
);
const _unit = createUnitState(
ling,
config.unit?.categories ?? {},
config.unit ?? {},
getLocale,
reactiveLocale
);
// ── Instancia ─────────────────────────────────────────────────────────────
return {
// t y ts leen locale ($state) para establecer dependencia reactiva
get t() {
locale;
return ling.t.bind(ling);
},
get ts() {
locale;
return ling.ts.bind(ling);
},
// ── Instancia ─────────────────────────────────────────────────────────────
return {
// t y ts leen locale ($state) para establecer dependencia reactiva
get t() { locale; return ling.t.bind(ling); },
get ts() { locale; return ling.ts.bind(ling); },
ling,
logr: _logr,
numr: _numr,
dati: _dati,
curr: _curr,
unit: _unit,
ling,
logr : _logr,
numr : _numr,
dati : _dati,
curr : _curr,
unit : _unit,
get locale() { return locale; },
};
get locale() {
return locale;
}
};
}

@ -13,10 +13,7 @@ import { createUnit } from '../../glob/lib/unit_engine';
// ============================================================================
function missingModule(name: string, hint: string): never {
throw new Error(
`[actx] '${name}' no está configurado. ` +
`Añade { ${hint} } a createACtx().`
);
throw new Error(`[actx] '${name}' no está configurado. ` + `Añade { ${hint} } a createACtx().`);
}
// ============================================================================
@ -31,64 +28,53 @@ function missingModule(name: string, hint: string): never {
*
* Para la versión reactiva con Svelte 5, usa `engine.svelte.ts`.
*/
export function createACtx<S extends LingNode>(
config: ACtxConfig<S>
): ACtx<S> {
export function createACtx<S extends LingNode>(config: ACtxConfig<S>): ACtx<S> {
const { ling } = config;
const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale;
const { ling } = config;
const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale;
let _logr: ReturnType<typeof createLogr> | undefined;
let _numr: ReturnType<typeof createNumr> | undefined;
let _dati: ReturnType<typeof createDati> | undefined;
let _curr: ReturnType<typeof createCurr> | undefined;
let _unit: ReturnType<typeof createUnit> | undefined;
let _logr: ReturnType<typeof createLogr> | undefined;
let _numr: ReturnType<typeof createNumr> | undefined;
let _dati: ReturnType<typeof createDati> | undefined;
let _curr: ReturnType<typeof createCurr> | undefined;
let _unit: ReturnType<typeof createUnit> | undefined;
return {
// Delegates de ling
t: ling.t.bind(ling),
ts: ling.ts.bind(ling),
return {
// Delegates de ling
t : ling.t.bind(ling),
ts : ling.ts.bind(ling),
// Locale reactivo (simulado con getter)
get locale() {
return ling.getLocale() as GlobLocale;
},
// Locale reactivo (simulado con getter)
get locale() { return ling.getLocale() as GlobLocale; },
// Módulo ling
ling,
// Módulo ling
ling,
// Módulos con lazy initialization
get logr() {
if (!config.logr) missingModule('logr', 'logr: { level: LogLevel.WARN }');
return (_logr ??= createLogr(ling, config.logr));
},
// Módulos con lazy initialization
get logr() {
if (!config.logr) missingModule('logr', 'logr: { level: LogLevel.WARN }');
return _logr ??= createLogr(ling, config.logr);
},
get numr() {
if (!config.numr) missingModule('numr', 'numr: {}');
return (_numr ??= createNumr(config.numr, getLocale));
},
get numr() {
if (!config.numr) missingModule('numr', 'numr: {}');
return _numr ??= createNumr(config.numr, getLocale);
},
get dati() {
if (!config.dati) missingModule('dati', 'dati: {}');
return (_dati ??= createDati(config.dati, getLocale));
},
get dati() {
if (!config.dati) missingModule('dati', 'dati: {}');
return _dati ??= createDati(config.dati, getLocale);
},
get curr() {
if (!config.curr) missingModule('curr', 'curr: { definitions: { EUR, USD } }');
return (_curr ??= createCurr(config.curr.definitions ?? {}, ling, config.curr, getLocale));
},
get curr() {
if (!config.curr) missingModule('curr', 'curr: { definitions: { EUR, USD } }');
return _curr ??= createCurr(
config.curr.definitions ?? {},
ling,
config.curr,
getLocale
);
},
get unit() {
if (!config.unit) missingModule('unit', 'unit: { selectedSystem: "metric" }');
return _unit ??= createUnit(
ling,
config.unit.categories ?? {},
config.unit,
getLocale
);
},
};
get unit() {
if (!config.unit) missingModule('unit', 'unit: { selectedSystem: "metric" }');
return (_unit ??= createUnit(ling, config.unit.categories ?? {}, config.unit, getLocale));
}
};
}

@ -1,2 +1,7 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export * from './types.ts';
export * from './engine.svelte.ts';
export * from './engine.svelte.ts';

@ -10,73 +10,68 @@
* Importar en componentes Svelte:
* import { actx } from '$lib/actx/actx.svelte.ts';
*/
import { createLing } from '../../ling/lib/index.ts';
import { LogLevel } from '../../logr/lib/index.ts';
import { p } from '../../ling/lib/index.ts';
import { LogLevel } from '../../logr/lib/index.ts';
import { p } from '../../ling/lib/index.ts';
import { createACtx } from './engine.svelte.ts';
// ── Schema de traducciones de la app ──────────────────────────────────────────
// Importa las traducciones de tu proyecto.
// Los módulos (unit, curr) registran las suyas vía ling.extend() automáticamente.
import { translations } from '../../ling/lib/translations.ts';
// ── Monedas ───────────────────────────────────────────────────────────────────
// Cada CurrDefinition define symbol, code y name (LingPluralFn).
// p() crea la función de pluralización que curr usa para display: 'name'.
const EUR = {
symbol: '€',
code : 'EUR',
name : p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' },
}),
conversionValue: 1,
symbol: '€',
code: 'EUR',
name: p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' }
}),
conversionValue: 1
};
const USD = {
symbol: '$',
code : 'USD',
name : p({
es: { one: 'dólar', other: 'dólares' },
en: { one: 'dollar', other: 'dollars' },
}),
conversionValue: 1.08,
symbol: '$',
code: 'USD',
name: p({
es: { one: 'dólar', other: 'dólares' },
en: { one: 'dollar', other: 'dollars' }
}),
conversionValue: 1.08
};
// ── Singleton ─────────────────────────────────────────────────────────────────
export const actx = createACtx({
ling: createLing(translations, 'es'),
logr: {
level: LogLevel.DEBUG,
},
numr: {
minDecimals: 0,
maxDecimals: 2,
},
dati: {
selectedTimeFormat: '24h',
selectedDateOrder : 'DMY',
},
curr: {
definitions : { EUR, USD },
selectedCurrency: 'EUR',
display : 'symbol',
decimals : 2,
},
unit: {
selectedSystem: 'metric',
// categories: { weight: myCustomWeight }, // sobreescribe si necesitas
},
});
ling: createLing(translations, 'es'),
logr: {
level: LogLevel.DEBUG
},
numr: {
minDecimals: 0,
maxDecimals: 2
},
dati: {
selectedTimeFormat: '24h',
selectedDateOrder: 'DMY'
},
curr: {
definitions: { EUR, USD },
selectedCurrency: 'EUR',
display: 'symbol',
decimals: 2
},
unit: {
selectedSystem: 'metric'
// categories: { weight: myCustomWeight }, // sobreescribe si necesitas
}
});

@ -10,39 +10,39 @@
* Para el singleton reactivo en componentes Svelte usa `actx.svelte.ts`.
*/
import { createLing } from '@/ling/lib';
import { LogLevel } from '@/logr/lib';
import { createLing } from '@/ling/lib';
import { LogLevel } from '@/logr/lib';
import { translations } from '@/ling/lib';
import { createACtx } from './engine';
// import { EUR, USD } from '$lib/currencies.ts';
export const actx = createACtx({
ling: createLing(translations, 'es'),
logr: {
level: LogLevel.WARN,
},
numr: {
minDecimals: 0,
maxDecimals: 2,
},
dati: {
selectedTimeFormat: '24h',
selectedDateOrder : 'DMY',
},
curr: {
// definitions: { EUR, USD },
definitions : {},
selectedCurrency: 'EUR',
display : 'symbol',
decimals : 2,
},
unit: {
selectedSystem: 'metric',
},
});
ling: createLing(translations, 'es'),
logr: {
level: LogLevel.WARN
},
numr: {
minDecimals: 0,
maxDecimals: 2
},
dati: {
selectedTimeFormat: '24h',
selectedDateOrder: 'DMY'
},
curr: {
// definitions: { EUR, USD },
definitions: {},
selectedCurrency: 'EUR',
display: 'symbol',
decimals: 2
},
unit: {
selectedSystem: 'metric'
}
});

@ -5,12 +5,16 @@
import type { LingInstance, LingNode, LingString } from '../../ling/lib/types';
import type { Logr, LogrOptions } from '../../logr/lib/types';
import type { CurrInstance, CurrDefinition, CurrConfig } from '../../glob/lib/curr_types';
import type { UnitInstance, UnitCategoryData, UnitCategoryKey, UnitSystem } from '../../glob/lib/unit_types';
import type {
UnitInstance,
UnitCategoryData,
UnitCategoryKey,
UnitSystem
} from '../../glob/lib/unit_types';
import type { DatiInstance, DatiConfig } from '../../glob/lib/dati_types';
import type { NumrInstance, NumrConfig } from '../../glob/lib/numr_types';
import type { GlobLocale } from '../../glob/lib/comn_types';
// ============================================================================
// CONFIG
// ============================================================================
@ -32,28 +36,28 @@ import type { GlobLocale } from '../../glob/lib/comn_types';
* });
*/
export interface ACtxConfig<S extends LingNode> {
/** Instancia de i18n — fuente de verdad del locale para todo el contexto. */
ling : LingInstance<S>;
/** Configuración del logger. */
logr? : LogrOptions;
/** Configuración del formateador de números. */
numr? : NumrConfig;
/** Configuración del formateador de fechas. */
dati? : DatiConfig;
/** Configuración del formateador de monedas con pluralización completa. */
curr? : CurrConfig & { definitions?: Record<string, CurrDefinition> };
/** Configuración del formateador de unidades con pluralización completa. */
unit? : {
selectedSystem?: UnitSystem;
categories? : Partial<Record<UnitCategoryKey, UnitCategoryData>>;
};
/** Configuración del contexto de UI (tema, breakpoints, toasts, modales, menús). */
/** Instancia de i18n — fuente de verdad del locale para todo el contexto. */
ling: LingInstance<S>;
/** Configuración del logger. */
logr?: LogrOptions;
/** Configuración del formateador de números. */
numr?: NumrConfig;
/** Configuración del formateador de fechas. */
dati?: DatiConfig;
/** Configuración del formateador de monedas con pluralización completa. */
curr?: CurrConfig & { definitions?: Record<string, CurrDefinition> };
/** Configuración del formateador de unidades con pluralización completa. */
unit?: {
selectedSystem?: UnitSystem;
categories?: Partial<Record<UnitCategoryKey, UnitCategoryData>>;
};
/** Configuración del contexto de UI (tema, breakpoints, toasts, modales, menús). */
}
// ============================================================================
// INSTANCE
// ============================================================================
/**
* Contexto global de la aplicación.
*
@ -68,27 +72,24 @@ export interface ACtxConfig<S extends LingNode> {
* actx.unit.weight.fTo(75, 'kg', 'lb')
*/
export interface ACtx<S extends LingNode = LingNode> {
t : LingInstance<S>['t'];
ts : (value: LingString) => string;
readonly locale: GlobLocale;
ling : LingInstance<S>;
logr : Logr;
numr : NumrInstance;
dati : DatiInstance;
curr : CurrInstance;
unit : UnitInstance;
t: LingInstance<S>['t'];
ts: (value: LingString) => string;
readonly locale: GlobLocale;
ling: LingInstance<S>;
logr: Logr;
numr: NumrInstance;
dati: DatiInstance;
curr: CurrInstance;
unit: UnitInstance;
}
// ============================================================================
// REACTIVE INSTANCE — Svelte 5 $state
// ============================================================================
/**
* Contexto global reactivo.
* Devuelto por `createACtx` de `actx_engine.svelte.ts`.
* La propiedad `locale` está respaldada por `$state`.
* La propiedad `locale` est<EFBFBD> respaldada por `$state`.
*/
export interface ReactiveACtx<S extends LingNode = LingNode>
extends ACtx<S> {}
export interface ReactiveACtx<S extends LingNode = LingNode> extends ACtx<S> {}

@ -14,40 +14,40 @@ import type { CurrDefinition } from '../../glob/lib';
import { describe, it, expect } from 'vitest';
import { createLing } from '../../ling/lib';
import { createACtx } from '../lib/engine';
import { LogLevel } from '../../logr/lib';
import { LogLevel } from '../../logr/lib';
// ============================================================================
// FIXTURES
// ============================================================================
const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
messages: {
welcome: { es: 'Bienvenido', en: 'Welcome' },
},
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
messages: {
welcome: { es: 'Bienvenido', en: 'Welcome' }
}
};
const EUR: CurrDefinition = {
symbol: '€',
code: 'EUR',
name: ({ count }) => ({
es: count === 1 ? 'euro' : 'euros',
en: count === 1 ? 'euro' : 'euros',
}),
conversionValue: 1,
symbol: '€',
code: 'EUR',
name: ({ count }) => ({
es: count === 1 ? 'euro' : 'euros',
en: count === 1 ? 'euro' : 'euros'
}),
conversionValue: 1
};
const USD: CurrDefinition = {
symbol: '$',
code: 'USD',
name: ({ count }) => ({
es: count === 1 ? 'dólar' : 'dólares',
en: count === 1 ? 'dollar' : 'dollars',
}),
conversionValue: 1.1,
symbol: '$',
code: 'USD',
name: ({ count }) => ({
es: count === 1 ? 'dólar' : 'dólares',
en: count === 1 ? 'dollar' : 'dollars'
}),
conversionValue: 1.1
};
// ============================================================================
@ -55,443 +55,440 @@ const USD: CurrDefinition = {
// ============================================================================
describe('createACtx (no reactivo)', () => {
describe('configuración mínima (solo ling)', () => {
it('crea una instancia con solo ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
describe('configuración mínima (solo ling)', () => {
it('crea una instancia con solo ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ling).toBeDefined();
expect(actx.locale).toBe('es');
});
expect(actx.ling).toBeDefined();
expect(actx.locale).toBe('es');
});
it('t() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
it('t() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.t('common.ok')).toBe('Aceptar');
});
expect(actx.t('common.ok')).toBe('Aceptar');
});
it('ts() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
it('ts() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
});
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
});
it('ling.setLocale() cambia el locale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
it('ling.setLocale() cambia el locale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
actx.ling.setLocale('en');
expect(actx.locale).toBe('en');
expect(actx.t('common.ok')).toBe('OK');
});
it('locale es un getter que refleja el locale de ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.locale).toBe(ling.getLocale());
});
actx.ling.setLocale('en');
expect(actx.locale).toBe('en');
expect(actx.t('common.ok')).toBe('OK');
});
it('ling.onLocaleChange() permite escuchar cambios', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const spy = [] as string[];
it('locale es un getter que refleja el locale de ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const unsub = actx.ling.onLocaleChange((loc) => spy.push(loc));
actx.ling.setLocale('en');
expect(actx.locale).toBe(ling.getLocale());
});
expect(spy).toEqual(['en']);
unsub();
});
});
describe('acceso a módulos no configurados', () => {
it('lanza error al acceder a logr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.logr).toThrow("[actx] 'logr' no está configurado");
});
it('lanza error al acceder a numr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.numr).toThrow("[actx] 'numr' no está configurado");
});
it('ling.onLocaleChange() permite escuchar cambios', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const spy = [] as string[];
const unsub = actx.ling.onLocaleChange((loc) => spy.push(loc));
actx.ling.setLocale('en');
expect(spy).toEqual(['en']);
unsub();
});
});
describe('acceso a módulos no configurados', () => {
it('lanza error al acceder a logr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.logr).toThrow('[actx] \'logr\' no está configurado');
});
it('lanza error al acceder a numr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.numr).toThrow('[actx] \'numr\' no está configurado');
});
it('lanza error al acceder a dati si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.dati).toThrow('[actx] \'dati\' no está configurado');
});
it('lanza error al acceder a curr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.curr).toThrow('[actx] \'curr\' no está configurado');
});
it('lanza error al acceder a unit si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.unit).toThrow('[actx] \'unit\' no está configurado');
});
});
describe('lazy initialization', () => {
it('logr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, logr: { level: LogLevel.INFO } });
expect(actx.logr).toBeDefined();
expect(actx.logr.info).toBeInstanceOf(Function);
});
it('numr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: { maxDecimals: 3 } });
expect(actx.numr).toBeDefined();
expect(actx.numr.f).toBeInstanceOf(Function);
});
it('dati se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, dati: { selectedTimeFormat: '12h' } });
expect(actx.dati).toBeDefined();
expect(actx.dati.f).toBeInstanceOf(Function);
});
it('curr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
});
expect(actx.curr).toBeDefined();
expect(actx.curr.f).toBeInstanceOf(Function);
});
it('unit se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, unit: { selectedSystem: 'metric' } });
expect(actx.unit).toBeDefined();
expect(actx.unit.weight).toBeDefined();
});
it('múltiples accesos devuelven la misma instancia (singleton)', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const numr1 = actx.numr;
const numr2 = actx.numr;
expect(numr1).toBe(numr2);
});
});
describe('integración de módulos con ling', () => {
it('numr lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const esResult = actx.numr.f(1234.5);
expect(esResult).toBeTruthy();
expect(esResult).toContain('5');
actx.ling.setLocale('en');
const enResult = actx.numr.f(1234.5);
expect(enResult).toBeTruthy();
expect(enResult).toContain('5');
});
it('dati lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, dati: {} });
const testDate = new Date('2024-03-15T14:30:00Z');
const esResult = actx.dati.f(testDate, 'short');
expect(esResult).toBeTruthy();
actx.ling.setLocale('en');
const enResult = actx.dati.f(testDate, 'short');
expect(enResult).toBeTruthy();
});
it('curr lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
});
const esResult = actx.curr.f(99.99, 'name');
expect(esResult).toBeTruthy();
expect(esResult).toContain('99');
actx.ling.setLocale('en');
const enResult = actx.curr.f(99.99, 'name');
expect(enResult).toBeTruthy();
expect(enResult).toContain('99');
});
it('curr usa ling para pluralizar nombres', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
});
expect(actx.curr.f(1, 'name')).toBe('1,00 euro');
expect(actx.curr.f(2, 'name')).toBe('2,00 euros');
});
it('logr usa ling para resolver mensajes localizados', () => {
const ling = createLing(translations, 'es');
const captured: string[] = [];
const actx = createACtx({
ling,
logr: {
level: LogLevel.INFO,
transports: [
{
write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage)
}
],
},
});
actx.logr.info('test', '#?common.ok');
expect(captured[0]).toBe('Aceptar');
actx.ling.setLocale('en');
actx.logr.info('test', '#?common.ok');
expect(captured[1]).toBe('OK');
});
});
describe('cambio de locale', () => {
it('ling.setLocale() cambia el locale para todos los módulos', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
dati: {},
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
unit: { selectedSystem: 'metric' },
});
// Todos los módulos en español
const esNum = actx.numr.f(1234.5);
const esDate = actx.dati.f(new Date());
const esCurr = actx.curr.f(100);
const esUnit = actx.unit.weight.f(75);
expect(esNum).toBeTruthy();
expect(esDate).toBeTruthy();
expect(esCurr).toBeTruthy();
expect(esUnit).toBeTruthy();
actx.ling.setLocale('en');
// Todos los módulos en inglés
const enNum = actx.numr.f(1234.5);
const enDate = actx.dati.f(new Date());
const enCurr = actx.curr.f(100);
const enUnit = actx.unit.weight.f(75);
expect(enNum).toBeTruthy();
expect(enDate).toBeTruthy();
expect(enCurr).toBeTruthy();
expect(enUnit).toBeTruthy();
});
it('no es necesario reconectar módulos tras cambiar locale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const numrRef = actx.numr;
actx.ling.setLocale('en');
// La misma instancia ahora usa el nuevo locale
expect(actx.numr).toBe(numrRef);
const result = actx.numr.f(1234.5);
expect(result).toBeTruthy();
expect(result).toContain('5');
});
});
describe('onLocaleChange', () => {
it('notifica cuando cambia el locale a través de ling.setLocale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const locales: string[] = [];
actx.ling.onLocaleChange((loc) => locales.push(loc));
actx.ling.setLocale('en');
actx.ling.setLocale('fr');
expect(locales).toEqual(['en', 'fr']);
});
it('el unsubscribe deja de notificar', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const locales: string[] = [];
const unsub = actx.ling.onLocaleChange((loc) => locales.push(loc));
unsub();
actx.ling.setLocale('en');
expect(locales).toEqual([]);
});
it('los callbacks se ejecutan después de que el locale cambió', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const capturedLocales: string[] = [];
actx.ling.onLocaleChange(() => {
capturedLocales.push(actx.locale);
});
actx.ling.setLocale('en');
expect(capturedLocales[0]).toBe('en');
});
});
describe('configuración completa de todos los módulos', () => {
it('crea un contexto completo con todos los módulos configurados', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
logr: { level: LogLevel.WARN },
numr: { minDecimals: 0, maxDecimals: 2 },
dati: { selectedTimeFormat: '24h', selectedDateOrder: 'DMY' },
curr: {
definitions: { EUR, USD },
selectedCurrency: 'EUR',
display: 'symbol',
decimals: 2,
},
unit: { selectedSystem: 'metric' },
});
// Todos los módulos están disponibles
expect(actx.ling).toBeDefined();
expect(actx.logr).toBeDefined();
expect(actx.numr).toBeDefined();
expect(actx.dati).toBeDefined();
expect(actx.curr).toBeDefined();
expect(actx.unit).toBeDefined();
// Configuración de numr
const numrResult = actx.numr.f(1234);
expect(numrResult).toBeTruthy();
expect(numrResult).toContain('1234');
// Configuración de dati
expect(actx.dati.getTimeFormat()).toBe('24h');
// Configuración de curr
expect(actx.curr.getCurrency()).toBe('EUR');
expect(actx.curr.get('EUR')).toEqual(EUR);
// Configuración de unit
expect(actx.unit.getSystem()).toBe('metric');
// Configuración de logr
expect(() => actx.logr.warn('test', 'msg')).not.toThrow();
});
});
describe('actx como punto único de entrada', () => {
it('proporciona acceso unificado a toda la funcionalidad', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: {},
dati: {},
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
unit: {},
logr: { level: LogLevel.NONE },
});
// Traducción directa
expect(actx.t('common.ok')).toBe('Aceptar');
// Traducción de string
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
// Locale
expect(actx.locale).toBe('es');
// Números
const numResult = actx.numr.f(1234.5);
expect(numResult).toBeTruthy();
expect(numResult).toContain('5');
// Fechas
const date = new Date('2024-03-15');
expect(actx.dati.f(date)).toBeTruthy();
// Monedas
expect(actx.curr.f(100)).toBe('100,00 €');
// Unidades
expect(actx.unit.weight.f(75)).toContain('75');
// Cambio de locale
actx.ling.setLocale('en');
expect(actx.locale).toBe('en');
expect(actx.t('common.ok')).toBe('OK');
});
});
describe('uso típico en aplicación', () => {
it('simula el flujo de una aplicación multilingüe', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
logr: { level: LogLevel.INFO },
});
// Usuario en español
expect(actx.t('messages.welcome')).toBe('Bienvenido');
const esNum = actx.numr.f(1000);
expect(esNum).toBeTruthy();
expect(esNum).toContain('1');
expect(actx.curr.f(50)).toContain('50');
// Usuario cambia a inglés
actx.ling.setLocale('en');
expect(actx.t('messages.welcome')).toBe('Welcome');
const enNum = actx.numr.f(1000);
expect(enNum).toBeTruthy();
expect(enNum).toContain('1');
expect(actx.curr.f(50)).toContain('50');
// Conversión de moneda
actx.curr.setCurrency('USD');
const converted = actx.curr.convertTo(100, 'EUR');
expect(converted).toBeCloseTo(90.91);
});
});
it('lanza error al acceder a dati si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.dati).toThrow("[actx] 'dati' no está configurado");
});
it('lanza error al acceder a curr si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.curr).toThrow("[actx] 'curr' no está configurado");
});
it('lanza error al acceder a unit si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.unit).toThrow("[actx] 'unit' no está configurado");
});
});
describe('lazy initialization', () => {
it('logr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, logr: { level: LogLevel.INFO } });
expect(actx.logr).toBeDefined();
expect(actx.logr.info).toBeInstanceOf(Function);
});
it('numr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: { maxDecimals: 3 } });
expect(actx.numr).toBeDefined();
expect(actx.numr.f).toBeInstanceOf(Function);
});
it('dati se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, dati: { selectedTimeFormat: '12h' } });
expect(actx.dati).toBeDefined();
expect(actx.dati.f).toBeInstanceOf(Function);
});
it('curr se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' }
});
expect(actx.curr).toBeDefined();
expect(actx.curr.f).toBeInstanceOf(Function);
});
it('unit se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, unit: { selectedSystem: 'metric' } });
expect(actx.unit).toBeDefined();
expect(actx.unit.weight).toBeDefined();
});
it('múltiples accesos devuelven la misma instancia (singleton)', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const numr1 = actx.numr;
const numr2 = actx.numr;
expect(numr1).toBe(numr2);
});
});
describe('integración de módulos con ling', () => {
it('numr lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const esResult = actx.numr.f(1234.5);
expect(esResult).toBeTruthy();
expect(esResult).toContain('5');
actx.ling.setLocale('en');
const enResult = actx.numr.f(1234.5);
expect(enResult).toBeTruthy();
expect(enResult).toContain('5');
});
it('dati lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, dati: {} });
const testDate = new Date('2024-03-15T14:30:00Z');
const esResult = actx.dati.f(testDate, 'short');
expect(esResult).toBeTruthy();
actx.ling.setLocale('en');
const enResult = actx.dati.f(testDate, 'short');
expect(enResult).toBeTruthy();
});
it('curr lee el locale de ling automáticamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' }
});
const esResult = actx.curr.f(99.99, 'name');
expect(esResult).toBeTruthy();
expect(esResult).toContain('99');
actx.ling.setLocale('en');
const enResult = actx.curr.f(99.99, 'name');
expect(enResult).toBeTruthy();
expect(enResult).toContain('99');
});
it('curr usa ling para pluralizar nombres', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' }
});
expect(actx.curr.f(1, 'name')).toBe('1,00 euro');
expect(actx.curr.f(2, 'name')).toBe('2,00 euros');
});
it('logr usa ling para resolver mensajes localizados', () => {
const ling = createLing(translations, 'es');
const captured: string[] = [];
const actx = createACtx({
ling,
logr: {
level: LogLevel.INFO,
transports: [
{
write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage)
}
]
}
});
actx.logr.info('test', '#?common.ok');
expect(captured[0]).toBe('Aceptar');
actx.ling.setLocale('en');
actx.logr.info('test', '#?common.ok');
expect(captured[1]).toBe('OK');
});
});
describe('cambio de locale', () => {
it('ling.setLocale() cambia el locale para todos los módulos', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
dati: {},
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
unit: { selectedSystem: 'metric' }
});
// Todos los módulos en español
const esNum = actx.numr.f(1234.5);
const esDate = actx.dati.f(new Date());
const esCurr = actx.curr.f(100);
const esUnit = actx.unit.weight.f(75);
expect(esNum).toBeTruthy();
expect(esDate).toBeTruthy();
expect(esCurr).toBeTruthy();
expect(esUnit).toBeTruthy();
actx.ling.setLocale('en');
// Todos los módulos en inglés
const enNum = actx.numr.f(1234.5);
const enDate = actx.dati.f(new Date());
const enCurr = actx.curr.f(100);
const enUnit = actx.unit.weight.f(75);
expect(enNum).toBeTruthy();
expect(enDate).toBeTruthy();
expect(enCurr).toBeTruthy();
expect(enUnit).toBeTruthy();
});
it('no es necesario reconectar módulos tras cambiar locale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
const numrRef = actx.numr;
actx.ling.setLocale('en');
// La misma instancia ahora usa el nuevo locale
expect(actx.numr).toBe(numrRef);
const result = actx.numr.f(1234.5);
expect(result).toBeTruthy();
expect(result).toContain('5');
});
});
describe('onLocaleChange', () => {
it('notifica cuando cambia el locale a través de ling.setLocale', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const locales: string[] = [];
actx.ling.onLocaleChange((loc) => locales.push(loc));
actx.ling.setLocale('en');
actx.ling.setLocale('fr');
expect(locales).toEqual(['en', 'fr']);
});
it('el unsubscribe deja de notificar', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const locales: string[] = [];
const unsub = actx.ling.onLocaleChange((loc) => locales.push(loc));
unsub();
actx.ling.setLocale('en');
expect(locales).toEqual([]);
});
it('los callbacks se ejecutan después de que el locale cambió', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
const capturedLocales: string[] = [];
actx.ling.onLocaleChange(() => {
capturedLocales.push(actx.locale);
});
actx.ling.setLocale('en');
expect(capturedLocales[0]).toBe('en');
});
});
describe('configuración completa de todos los módulos', () => {
it('crea un contexto completo con todos los módulos configurados', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
logr: { level: LogLevel.WARN },
numr: { minDecimals: 0, maxDecimals: 2 },
dati: { selectedTimeFormat: '24h', selectedDateOrder: 'DMY' },
curr: {
definitions: { EUR, USD },
selectedCurrency: 'EUR',
display: 'symbol',
decimals: 2
},
unit: { selectedSystem: 'metric' }
});
// Todos los módulos están disponibles
expect(actx.ling).toBeDefined();
expect(actx.logr).toBeDefined();
expect(actx.numr).toBeDefined();
expect(actx.dati).toBeDefined();
expect(actx.curr).toBeDefined();
expect(actx.unit).toBeDefined();
// Configuración de numr
const numrResult = actx.numr.f(1234);
expect(numrResult).toBeTruthy();
expect(numrResult).toContain('1234');
// Configuración de dati
expect(actx.dati.getTimeFormat()).toBe('24h');
// Configuración de curr
expect(actx.curr.getCurrency()).toBe('EUR');
expect(actx.curr.get('EUR')).toEqual(EUR);
// Configuración de unit
expect(actx.unit.getSystem()).toBe('metric');
// Configuración de logr
expect(() => actx.logr.warn('test', 'msg')).not.toThrow();
});
});
describe('actx como punto único de entrada', () => {
it('proporciona acceso unificado a toda la funcionalidad', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: {},
dati: {},
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
unit: {},
logr: { level: LogLevel.NONE }
});
// Traducción directa
expect(actx.t('common.ok')).toBe('Aceptar');
// Traducción de string
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
// Locale
expect(actx.locale).toBe('es');
// Números
const numResult = actx.numr.f(1234.5);
expect(numResult).toBeTruthy();
expect(numResult).toContain('5');
// Fechas
const date = new Date('2024-03-15');
expect(actx.dati.f(date)).toBeTruthy();
// Monedas
expect(actx.curr.f(100)).toBe('100,00 €');
// Unidades
expect(actx.unit.weight.f(75)).toContain('75');
// Cambio de locale
actx.ling.setLocale('en');
expect(actx.locale).toBe('en');
expect(actx.t('common.ok')).toBe('OK');
});
});
describe('uso típico en aplicación', () => {
it('simula el flujo de una aplicación multilingüe', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
logr: { level: LogLevel.INFO }
});
// Usuario en español
expect(actx.t('messages.welcome')).toBe('Bienvenido');
const esNum = actx.numr.f(1000);
expect(esNum).toBeTruthy();
expect(esNum).toContain('1');
expect(actx.curr.f(50)).toContain('50');
// Usuario cambia a inglés
actx.ling.setLocale('en');
expect(actx.t('messages.welcome')).toBe('Welcome');
const enNum = actx.numr.f(1000);
expect(enNum).toBeTruthy();
expect(enNum).toContain('1');
expect(actx.curr.f(50)).toContain('50');
// Conversión de moneda
actx.curr.setCurrency('USD');
const converted = actx.curr.convertTo(100, 'EUR');
expect(converted).toBeCloseTo(90.91);
});
});
});

@ -0,0 +1,878 @@
# App Layer — Architecture & Design Document
## Context
The current service architecture has soma owning application-level services (translator, logger, formatters, dir). This creates coupling: soma dictates what services exist, requires adapter functions, and creates phantom instances when no context exists (F12). The app layer extracts service ownership to where it belongs — the application.
### New layer hierarchy
```
app → service provider (translator, format, logger, dir, portalTo, user, ...)
soma → headless behavior (components, ARIA, keyboard, state)
sema → semantic intent (motion meaning, sound meaning, interaction semantics)
eidos → visual form (tokens, themes, CSS recipes, styled components)
```
Dependencies flow one way: `app → soma → sema → eidos`. Each layer reads from the one above, never imports from below.
### What changes
- `app` is NEW — lives at `src/lib/ext/app/`
- `soma` is MODIFIED — receives services from app instead of owning them
- `sema` is NEW — lives at `src/uix/sema/` (semantic layer extracted from air/semantics)
- `eidos` is FUTURE — eventually replaces `air/` (not in this phase)
- `air` stays for now — gradually delegates to sema
### Naming
| Layer | Greek | English | Responsibility |
| ----- | ------------ | ----------- | ------------------------------------------------ |
| app | — | application | Services, config, domain |
| soma | σῶμα (body) | behavior | Components, ARIA, keyboard, state |
| sema | σῆμα (sign) | semantics | Motion intent, sound intent, interaction meaning |
| eidos | εἶδος (form) | appearance | Tokens, themes, CSS, visual styling |
---
## Phase 1: App Layer (`src/lib/ext/app/`)
### 1.1 Directory structure
```
src/lib/ext/app/
├── index.ts ← barrel exports
├── types.ts ← AppServices, AppLangs, AppLogger, AppFormat interfaces
├── app.svelte.ts ← App class (reactive service holder)
├── context.ts ← createApp, getApp, useApp
└── defaults.ts ← fallback services (mock langs, console logger)
```
### 1.2 Core interface: `AppServices`
```ts
// src/lib/ext/app/types.ts
export interface AppServices {
/** i18n runtime — locale, translation, interpolation, module extension, reactive subscriptions */
readonly langs: AppLangs;
/** Text direction */
readonly dir: Direction;
/** Logging */
readonly logger?: AppLogger;
/** Number/currency/unit/date formatting */
readonly format?: AppFormat;
/** Default portal target for overlays */
readonly portalTo?: string | HTMLElement;
}
```
### 1.2.1 `AppLangs` — i18n contract
The interface mirrors lang's `LangInstance` API exactly. Lang fulfills it directly (no adapter). If the consumer doesn't use lang, they implement the interface or provide a mock.
```ts
export interface AppLangs {
/** Resolve a dot-path key to a localized string */
t(path: string, params?: Record<string, unknown>): string;
/** Resolve a LangRecord, LangRef, or descriptor to a string */
ts(value: unknown, locale?: string): string;
/** Get current locale */
getLocale(): string;
/** Set active locale — triggers onLocaleChange subscribers */
setLocale(locale: string): void;
/** Extend with a translation module at runtime (lazy loading) */
extend(namespace: string, module: unknown): void;
/** Subscribe to locale changes. Returns unsubscribe function. */
onLocaleChange(fn: (locale: string) => void): () => void;
/** Subscribe to schema changes (extend calls). Returns unsubscribe function. */
onSchemaChange(fn: () => void): () => void;
}
```
**Key points:**
- `locale` is NOT a separate field on `AppServices` — it lives in `langs.getLocale()`. Single source of truth.
- `setLocale()` is on `langs`, not on `App`. The i18n runtime owns locale state.
- `onLocaleChange` / `onSchemaChange` provide the reactivity hooks. Components subscribe to know when to re-render.
- lang implements this interface natively. Assignment is direct: `langs: myLangInstance`.
- For testing or SSR without lang, a mock implements `AppLangs` returning paths as-is.
### 1.2.2 Domain service interfaces
Each domain owns its data, its formatting, and its state. No central `fmts` library — each domain formats itself because it knows its own rules.
#### `AppNums` — number formatting & parsing
```ts
export interface AppNums {
/** Format a number with locale-aware separators */
format(value: number, options?: Intl.NumberFormatOptions): string;
/** Parse a locale-formatted string back to a number */
parse(value: string): number | undefined;
// Preferences (derived from locale, overridable by user)
getDecimalSeparator(): string;
getGroupSeparator(): string;
getGrouping(): boolean;
setDecimalSeparator(sep: string | 'auto'): void;
setGroupSeparator(sep: string | 'auto'): void;
setGrouping(enabled: boolean | 'auto'): void;
/** Subscribe to preference changes. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
```
Refactored from the existing `createNumr` in `@/glob/lib`. Lives at `src/lib/ext/nums/`. Owns numeric formatting preferences (separators, grouping) — derived from locale, overridable by user. The base building block — `money` and `units` use `nums` internally for the numeric part of their formatting.
#### `AppMoney` — currency & monetary values
```ts
export interface AppMoney {
/** Active currency code (e.g., 'EUR', 'USD') */
getCurrency(): string;
/** Set active currency */
setCurrency(currency: string): void;
/** Create an exact monetary value (integer minor units internally) */
create(amount: number, currency?: string): MoneyValue;
/** Format a monetary value in the active currency */
format(value: number | MoneyValue, options?: MoneyFormatOptions): string;
/** Format in a specific currency */
formatAs(value: number | MoneyValue, currency: string, options?: MoneyFormatOptions): string;
/** Subscribe to currency changes. Returns unsubscribe. */
onCurrencyChange(fn: (currency: string) => void): () => void;
}
```
Lives at `src/lib/ext/money/`. Uses integer/bigint representation internally (like dinero.js) — never stores `10.99` as float. Owns currency state like `langs` owns locale.
#### `AppDates` — date & time
```ts
export interface AppDates {
/** Format a date */
format(value: Date, options?: Intl.DateTimeFormatOptions): string;
/** Format time only */
formatTime(value: Date, options?: Intl.DateTimeFormatOptions): string;
/** Get date field order */
getDateOrder(): DateOrder;
/** Set date field order preference */
setDateOrder(order: DateOrder): void;
/** Get hour cycle */
getHourCycle(): HourCycle;
/** Set hour cycle preference */
setHourCycle(cycle: HourCycle): void;
/** Subscribe to preference changes. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
```
Lives at `src/lib/ext/dates/`. Owns date/time preferences (dateOrder, hourCycle) like `money` owns currency and `langs` owns locale. Defaults derived from locale, overridable by user.
#### `AppUnits` — units & measurement
```ts
export interface AppUnits {
/** Format a value with a unit */
format(value: number, unit: string, options?: Intl.NumberFormatOptions): string;
/** Get the active measurement system */
getSystem(): 'metric' | 'imperial';
/** Set measurement system preference */
setSystem(system: 'metric' | 'imperial'): void;
/** Convert between units */
convert(value: number, from: string, to: string): number;
/** Subscribe to preference changes. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
```
Lives at `src/lib/ext/units/`. Owns measurement system preference like `money` owns currency.
#### `AppPresentation` — visual & sensorial preferences
```ts
export interface AppPresentation {
/** Text direction (derived from locale, overridable) */
getDir(): Direction;
setDir(dir: Direction | 'auto'): void;
/** Color theme */
getTheme(): string;
setTheme(theme: string): void;
/** Color mode — light, dark, or system-derived */
getMode(): 'light' | 'dark';
setMode(mode: 'light' | 'dark' | 'auto'): void;
/** User prefers reduced motion (respects OS setting, overridable) */
getReducedMotion(): boolean;
setReducedMotion(reduced: boolean | 'auto'): void;
/** User prefers reduced sound */
getReducedSound(): boolean;
setReducedSound(reduced: boolean): void;
/** UI density */
getDensity(): 'compact' | 'normal' | 'comfortable';
setDensity(density: 'compact' | 'normal' | 'comfortable'): void;
/** Subscribe to any presentation change. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
```
Lives at `src/lib/ext/presentation/`. Owns ALL visual/sensorial preferences. Follows the same override/derive pattern:
- `dir` → derived from locale unless overridden
- `mode` → derived from `prefers-color-scheme` media query unless overridden ('auto')
- `reducedMotion` → derived from `prefers-reduced-motion` media query unless overridden ('auto')
- `reducedSound` → explicit only (no OS signal)
- `theme` → explicit (e.g., 'base', 'brand', 'midnight')
- `density` → explicit, defaults to 'normal'
This is the service that sema and eidos will read from. sema checks `reducedMotion`/`reducedSound` before playing motion/sound. eidos checks `mode`/`theme`/`density` for CSS token resolution.
#### `AppLogger`
```ts
export interface AppLogger {
debug(message: string, context?: unknown): void;
info(message: string, context?: unknown): void;
warn(message: string, context?: unknown): void;
error(message: string, context?: unknown): void;
}
```
### 1.2.3 Updated `AppServices`
```ts
export interface AppServices {
/** i18n — locale, translation, interpolation, module extension */
readonly langs: AppLangs;
/** Numbers — pure numeric formatting/parsing */
readonly nums?: AppNums;
/** Money — currency, exact decimal values, monetary formatting */
readonly money?: AppMoney;
/** Dates — date/time formatting, dateOrder, hourCycle */
readonly dates?: AppDates;
/** Units — measurement formatting, conversion, system */
readonly units?: AppUnits;
/** Presentation — dir, theme, mode, reducedMotion, reducedSound, density */
readonly presentation: AppPresentation;
/** Logging */
readonly logger?: AppLogger;
/** Default portal target for overlays */
readonly portalTo?: string | HTMLElement;
}
```
**Service ownership — each domain owns its state and formatting:**
| Service | Name | Owns (state + preferences) | Formats | Library |
| -------------- | ---------------------- | ------------------------------------------------------ | ------------------------ | --------------------------- |
| `langs` | plural | locale, translations, schema | — | `src/lib/ext/lang/` |
| `nums` | plural | decimal/group separators, grouping | `1234.5` → `'1.234,5'` | `src/lib/ext/nums/` |
| `money` | singular (uncountable) | currency, exact values | `1099` → `'10,99 €'` | `src/lib/ext/money/` |
| `dates` | plural | dateOrder, hourCycle | `Date` → `'14 abr 2026'` | `src/lib/ext/dates/` |
| `units` | plural | measurement system | `5, 'km'` → `'5 km'` | `src/lib/ext/units/` |
| `presentation` | singular | dir, theme, mode, reducedMotion, reducedSound, density | — | `src/lib/ext/presentation/` |
| `app` | — | nothing — composes services | — | `src/lib/ext/app/` |
**Key design principles:**
- **Each domain owns its state AND formats itself.** No central `fmts`. Each service has `get*`/`set*` for its preferences and `format()` for display.
- **`locale` lives in `langs`** — single source of truth
- **`currency` lives in `money`** — single source of truth
- **`dateOrder`/`hourCycle` live in `dates`** — single source of truth
- **`measurement system` lives in `units`** — single source of truth
- **`dir`/`theme`/`mode`/`reducedMotion`/`reducedSound`/`density` live in `presentation`** — visual/sensorial preferences
- **App owns NO preferences directly** — it composes services and provides convenience getters/setters
- **Each library implements its `App*` interface directly** — no adapters
- **Money uses integer representation** — never `10.99` as float
- **Subscription pattern** — each stateful service exposes `on*Change()` for reactivity
- **ALL services are stateful** — each owns preferences derived from locale, overridable by user
### 1.3 Reactive App class — override/derive pattern
Every preference follows the same pattern: **`userOverride ?? deriveFromLocale(locale)`**. When the user changes locale, all non-overridden preferences recalculate. When the user sets an explicit preference, it sticks regardless of locale changes.
```ts
// src/lib/ext/app/app.svelte.ts
export class App {
// ── Services (stable references) ────────────────────────────────────
readonly langs: AppLangs;
readonly nums: AppNums | undefined;
readonly money: AppMoney | undefined;
readonly dates: AppDates | undefined;
readonly units: AppUnits | undefined;
readonly presentation: AppPresentation;
readonly logger: AppLogger;
readonly portalTo: string | HTMLElement | undefined;
// App holds NO preferences directly. Each service owns its own state:
// - locale → langs
// - currency → money
// - dateOrder, hourCycle → dates
// - unit system → units
// - dir, theme, mode, reducedMotion, etc. → presentation
constructor(opts: AppOptions) {
this.langs = opts.langs;
this.nums = opts.nums;
this.money = opts.money;
this.dates = opts.dates;
this.units = opts.units;
this.presentation = opts.presentation;
this.logger = opts.logger ?? consoleLogger;
this.portalTo = opts.portalTo;
}
// ── Convenience getters (delegate to services) ──────────────────────
get locale(): string {
return this.langs.getLocale();
}
get dir(): Direction {
return this.presentation.getDir();
}
get currency(): string | undefined {
return this.money?.getCurrency();
}
get dateOrder(): DateOrder | undefined {
return this.dates?.getDateOrder();
}
get hourCycle(): HourCycle | undefined {
return this.dates?.getHourCycle();
}
get theme(): string {
return this.presentation.getTheme();
}
get mode(): 'light' | 'dark' {
return this.presentation.getMode();
}
// ── Convenience setters (delegate to services) ──────────────────────
setLocale(locale: string) {
this.langs.setLocale(locale);
}
setCurrency(currency: string) {
this.money?.setCurrency(currency);
}
setDir(dir: Direction | 'auto') {
this.presentation.setDir(dir);
}
setTheme(theme: string) {
this.presentation.setTheme(theme);
}
setMode(mode: 'light' | 'dark' | 'auto') {
this.presentation.setMode(mode);
}
// ── Convenience ─────────────────────────────────────────────────────
/** Translate with fallback */
translate(path: string, fallback: string): string {
return this.langs.t(path) || fallback;
}
}
```
**Reactivity model — locale change propagation:**
Every service receives `langs` at construction and subscribes to `onLocaleChange` internally. When locale changes, each service recalculates its defaults — unless the user has set an explicit override.
```ts
// Internal pattern inside each service (e.g., createPresentation)
function createPresentation(langs: AppLangs): AppPresentation {
let _dir: Direction | null = null; // null = derive from locale
// Subscribe to locale changes — recalculate derived defaults
langs.onLocaleChange((locale) => {
// Only recalculates if no user override (_dir === null)
// Getters read: _dir ?? resolveDir(locale)
});
return {
getDir: () => _dir ?? resolveDir(langs.getLocale()),
setDir: (dir) => {
_dir = dir === 'auto' ? null : dir;
}
// ...
};
}
```
**Key rules:**
- `override ?? deriveFromLocale(locale)` — every preference follows this pattern
- `'auto'` clears the override, returning to locale-derived default
- Each service manages its own subscription — App does NOT orchestrate
- Locale change → all non-overridden preferences recalculate automatically
- User-set overrides are preserved across locale changes
### 1.4 Context distribution
```ts
// src/lib/ext/app/context.ts
import { Context } from 'runed';
const AppContext = new Context<App>('App');
export function createApp(opts: AppOptions): App {
const app = new App(opts);
return AppContext.set(app);
}
export function getApp(): App {
return AppContext.getOr(fallbackApp);
}
export function useApp(): App {
return AppContext.get(); // throws if no App ancestor
}
```
### 1.5 Supporting libraries
#### `lang` (`src/lib/ext/lang/`) — i18n runtime
Already exists. `LangInstance` implements `AppLangs` directly. No adapter.
#### Domain libraries
Each domain is an independent library in `src/lib/ext/`. Each implements its `App*` interface directly — no adapters.
```
src/lib/ext/
├── lang/ ← exists, implements AppLangs
├── nums/ ← NEW, refactored from @/glob createNumr. Implements AppNums
├── money/ ← NEW, integer-based money values. Implements AppMoney
├── dates/ ← NEW, refactored from @/glob date utils. Implements AppDates
├── units/ ← NEW, measurement formatting + conversion. Implements AppUnits
├── presentation/ ← NEW, visual/sensorial preferences. Implements AppPresentation
├── app/ ← NEW, composes all services
└── table/ ← exists (Table createTable utility)
```
**Locale → preference derivation** lives in each service:
- `presentation` derives `dir` from locale internally
- `dates` derives `dateOrder`/`hourCycle` from locale internally
- `units` derives `system` from locale internally
- `money` derives default currency from locale internally
Each service knows how to derive its defaults from locale. The derivation logic is NOT centralized in app.
**Independence from glob:** Each library in `src/lib/ext/` is self-contained. No imports from `@/glob`. Code that currently lives in `glob` (createNumr, date utils, number formatting) is refactored into the corresponding `ext/` library. `glob` continues to exist — terra and air still use it — but soma, sema, eidos, and all `ext/` libraries do NOT import from glob.
### 1.6 Integration
lang's `LangInstance` already implements `AppLangs` directly. No adapter.
```ts
import { createLang } from '$lib/ext/lang';
const lang = createLang(translations, 'es');
const app = createApp({
langs: lang, // direct — lang IS AppLangs
dir: 'ltr'
});
```
For testing without lang, use the fallback mock from `defaults.ts`:
```ts
// src/lib/ext/app/defaults.ts
export const fallbackLangs: AppLangs = {
t: (path) => path,
ts: (value) => String(value ?? ''),
getLocale: () => '',
setLocale: () => {},
extend: () => {},
onLocaleChange: () => () => {},
onSchemaChange: () => () => {}
};
```
---
## Phase 2: Soma consumes App services
### 2.1 New `<Soma>` wrapper
```svelte
<!-- src/uix/soma/components/internal/soma.svelte -->
<script lang="ts">
import { getApp } from '$lib/ext/app';
import { Soma } from '$soma/core/soma.svelte';
let { children } = $props();
// Read services from App context (set by consumer above)
const app = getApp();
// Create Soma context, delegates to App for services
Soma.create(app);
</script>
{@render children?.()}
```
### 2.2 Soma simplification
The `Soma` class stops holding services. Instead:
```ts
// src/uix/soma/core/soma.svelte.ts
export class Soma {
static readonly ctx = new Context<Soma>('Soma');
readonly app: App;
static create(app: App): Soma {
return Soma.ctx.set(new Soma(app));
}
static get(): Soma {
return Soma.ctx.get();
}
static getOr(): Soma | undefined {
return Soma.ctx.getOr(undefined);
}
private constructor(app: App) {
this.app = app;
}
// Convenience — components call soma.translate() directly
translate(path: string, fallback: string): string {
return this.app.translator.t(path) || fallback;
}
}
```
### 2.3 Component migration
Before:
```ts
readonly config = getSoma();
const translator = this.config.translator.current;
resolveSomaTranslationPath('soma.dialog.close', translator, 'Close');
```
After:
```ts
readonly app = getApp();
this.app.langs.t('soma.dialog.close') || 'Close';
// or via convenience:
this.app.translate('soma.dialog.close', 'Close');
```
**What changes per component:**
- `getSoma()` → `getApp()`
- `config.translator.current` → `app.langs`
- `resolveSomaTranslationPath(path, translator, fallback)` → `app.langs.t(path) || fallback`
- `config.presentation.current?.dir` → `app.dir`
- `Soma.resolve()` → `prop ?? app.dir ?? 'ltr'` (plain nullish coalescing)
- Prop resolvers (`resolveLocaleProp`, `resolveDirectionProp`) → eliminated, replaced by direct reads from `app`
### 2.4 What gets deleted from soma
- `src/uix/soma/core/translator.svelte.ts` — replaced by app adapter
- `src/uix/soma/core/prop-resolvers.ts` — simplified, reads from App
- `src/uix/soma/core/number-formatter.svelte.ts` — moved to app format
- `src/uix/soma/core/currency-formatter.svelte.ts` — moved to app format
- `src/uix/soma/core/unit-formatter.svelte.ts` — moved to app format
- `src/uix/soma/core/date-time-formatter.svelte.ts` — moved to app format
- `createSomaTranslator()`, `extendSomaTranslationModule()` — no longer needed
- `SomaTranslator`, `SomaLogger`, `SomaFormatter` interfaces — replaced by `AppLangs`, `AppLogger`, `AppFormat`
- `SomaPresentationConfig` — replaced by `AppServices.dir` + `AppServices.format`
- `Soma.resolve()` — eliminated, replaced by plain `??` coalescing from `app`
### 2.5 Backward compatibility
During transition:
- `getSoma()` still works but reads from `App` under the hood
- `<Soma>` wrapper still exists but delegates to `<App>`
- Components gradually migrate from `getSoma()` to `getApp()`
---
## Phase 3: sema layer (`src/uix/sema/`) — future
The sema layer extracts semantic intent from `air/semantics/`:
```
src/uix/sema/
├── index.ts ← public API
├── types.ts ← Semantic (12 types), SemanticPhase
├── props.ts ← SemanticBehaviorProps
├── intents/
│ ├── feedback.ts ← action semantic
│ ├── selection.ts
│ ├── revelation.ts ← transition semantic
│ ├── context.ts
│ ├── expansion.ts
│ ├── attention.ts
│ ├── emphasis.ts
│ ├── completion.ts
│ ├── destruction.ts
│ ├── persistence.ts
│ └── navigation.ts
├── motion/
│ ├── presets.ts ← default motion presets per semantic
│ └── types.ts ← MotionPresets, MotionConfig
└── sound/
├── presets.ts ← default sound presets per semantic
├── engine.ts ← Web Audio synthesis engine
└── types.ts ← SoundPresets, SoundDefinition
```
**sema reads from App:**
- `app.dir` for RTL-aware motion
- `app.locale` for locale-aware sounds (future)
**sema does NOT depend on soma or air.** It's a pure semantic layer.
**eidos (air) reads from sema** for motion/sound presets and from soma for data-\* attrs.
---
## Consumer usage (final state)
```svelte
<!-- +layout.svelte -->
<script>
import { createApp } from '$lib/ext/app';
import { createLang } from '$lib/ext/lang';
import { createNums } from '$lib/ext/nums';
import { createMoney } from '$lib/ext/money';
import { createDates } from '$lib/ext/dates';
import { createPresentation } from '$lib/ext/presentation';
import { Soma } from '$soma/components/internal';
// langs is the root — all other services depend on it for locale
const langs = createLang(translations, 'es');
const app = createApp({
langs, // root service — owns locale
nums: createNums(langs), // subscribes to locale changes
money: createMoney(langs), // subscribes to locale changes, owns currency
dates: createDates(langs), // subscribes to locale changes, owns dateOrder/hourCycle
presentation: createPresentation(langs) // subscribes to locale changes, owns dir/theme/mode
});
</script>
<Soma>
<slot />
</Soma>
```
```svelte
<!-- Any component -->
<script>
import { getApp } from '$lib/ext/app';
const app = getApp();
// Translation
const label = app.langs.t('soma.dialog.close') || 'Close';
// Numbers
app.nums?.format(1234.5); // '1.234,5'
// Money (owns currency)
app.money?.format(1099); // '10,99 €'
app.money?.getCurrency(); // 'EUR'
// Dates (owns dateOrder, hourCycle)
app.dates?.format(new Date()); // '14 abr 2026'
app.dates?.getDateOrder(); // 'DMY'
app.dates?.getHourCycle(); // '24h'
// Presentation (owns dir, theme, mode, motion/sound prefs)
app.presentation.getDir(); // 'ltr'
app.presentation.getMode(); // 'light' (from OS)
app.presentation.getReducedMotion(); // false (from OS)
app.presentation.getTheme(); // 'base'
// Convenience via App
app.dir; // 'ltr'
app.mode; // 'light'
app.theme; // 'base'
</script>
```
```ts
// Locale switching — each service derives its defaults
app.setLocale('ar');
app.dir; // 'rtl' (presentation derived from 'ar')
app.dates?.getDateOrder(); // recalculated for 'ar'
app.money?.getCurrency(); // unchanged (money owns currency independently)
// Presentation overrides
app.presentation.setDir('ltr'); // force LTR despite 'ar' locale
app.presentation.setMode('dark'); // force dark mode
app.presentation.setReducedMotion(true); // disable motion
// Reset presentation to derived defaults
app.presentation.setDir('auto'); // back to 'rtl' (from 'ar')
app.presentation.setMode('auto'); // back to OS preference
```
---
## Implementation order
1. **Create `src/lib/ext/app/`** — types, App class, context, defaults
2. **Create lang adapter** — `langAsTranslator()` (trivial if interfaces match)
3. **Update `<Soma>`** — accept App from context instead of raw props
4. **Update `getSoma()`** — delegate to `getApp()`
5. **Migrate test layout** — use `createApp()` + `<Soma>`
6. **Migrate components** (25) — `getSoma().translator.current` → `getApp().translator`
7. **Delete soma service code** — translator.svelte.ts, formatters, prop-resolvers
8. **Create `src/uix/sema/`** — extract from air/semantics (separate PR)
---
## Verification
1. `npm run check` — 0 errors after each phase
2. Test pages still work (all 25 components)
3. Locale switching works reactively
4. Dir switching works reactively
5. Translation fallbacks work without App context (fallbackApp)
6. No phantom Soma instances (F12 resolved)
---
## Files affected
### New files
| File | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `src/lib/ext/app/index.ts` | Barrel |
| `src/lib/ext/app/types.ts` | AppServices, AppTranslator, AppLogger, AppFormat |
| `src/lib/ext/app/app.svelte.ts` | App reactive class |
| `src/lib/ext/app/context.ts` | createApp, getApp, useApp |
| `src/lib/ext/app/defaults.ts` | Fallback services |
| `src/lib/ext/app/defaults.ts` | Fallback langs mock + console logger |
### Modified files
| File | Change |
| ---------------------------------------------- | ------------------------------------------- |
| `src/uix/soma/core/soma.svelte.ts` | Simplified — delegates to App |
| `src/uix/soma/components/internal/soma.svelte` | Reads App from context |
| `src/routes/test/soma/+layout.svelte` | Uses createApp() |
| 25 provider files | `getSoma()` → `getApp()` for service access |
### Deleted files (after migration)
| File | Reason |
| ------------------------------------------------- | -------------------------- |
| `src/uix/soma/core/translator.svelte.ts` | Replaced by app translator |
| `src/uix/soma/core/prop-resolvers.ts` | Simplified into App reads |
| `src/uix/soma/core/number-formatter.svelte.ts` | Moved to app format |
| `src/uix/soma/core/currency-formatter.svelte.ts` | Moved to app format |
| `src/uix/soma/core/unit-formatter.svelte.ts` | Moved to app format |
| `src/uix/soma/core/date-time-formatter.svelte.ts` | Moved to app format |
---
## Auditoría y opinión
### A favor
1. **Separación de responsabilidades correcta.** Soma no debería poseer servicios de aplicación. Las auditorías de los 26 componentes revelaron que muchos (accordion, tabs, stepper, switch, slider, toolbar, splitter) tienen `config = getSoma()` que nunca usan — símbolo de que la clase Soma hace demasiado.
2. **`AppServices` bien diseñada.** Interfaces limpias, `readonly`, sin sobre-ingeniería. El adapter `langAsTranslator` es trivial (casi innecesario si lang ya implementa la interface).
3. **El modelo reactivo es correcto.** `$state` en `locale`/`dir` + servicios estables como `readonly` — las piezas que cambian son reactivas, las que no cambian son estables.
4. **Migration path sensata.** Fases incrementales, backward compat, deletions al final.
### Concerns
**1. `Soma` se vuelve redundante**
Después de Phase 2, `Soma` es un proxy de una línea a `App`:
```ts
class Soma {
readonly app: App;
translate(path, fallback) {
return this.app.translator.t(path) || fallback;
}
}
```
Dos contextos (App + Soma) significan dos caminos al mismo lugar: `Soma.get().app.translator.t()` o `getApp().translator.t()`. Se recomienda eliminar `Soma` como clase y que los componentes llamen `getApp()` directamente. Si se necesita el convenience method `translate()`, moverlo a `App`:
```ts
class App {
translate(path: string, fallback: string): string {
return this.translator.t(path) || fallback;
}
}
```
**2. `Soma.resolve()` queda huérfano**
El pattern `Soma.resolve(prop, somaConfig, fallback)` se usa en varios componentes para resolver props por capas. El doc no menciona qué pasa con este mecanismo. ¿Se migra a `App.resolve()`? ¿Se elimina? Necesita decisión explícita.
**3. Fallback chain sin App context**
El doc menciona `fallbackApp` pero no lo define. Actualmente `Soma.getOr()` retorna `undefined` y los componentes manejan el caso. Con `getApp()` retornando un fallback, los tests unitarios que no montan contexto necesitan poder funcionar sin App. El `defaults.ts` (no-op translator, console logger) es la respuesta correcta pero debería detallarse más.
**4. `setLocale` es incompleto**
```ts
setLocale(locale: string) {
this.locale = locale;
// If translator supports locale switching
}
```
El comentario suelto indica que `translator` y `locale` pueden desincronizarse. Si lang maneja su propio locale internamente, `app.locale` es redundante o contradictorio. Debería ser:
```ts
setLocale(locale: string) {
this.locale = locale;
this.translator.setLocale?.(locale);
}
```
O mejor: `locale` derivado de `translator.getLocale()`, no estado independiente.
**5. Phase 3 (sema) está muy verde**
No hay suficiente detalle para evaluar. La estructura de directorios parece razonable pero los 12 intents (`feedback`, `selection`, `revelation`, etc.) son especulativos. No se comenta más hasta que haya una implementación concreta.
### Resoluciones
**Concern 1 — Soma redundante:** Agreed. Soma as a class is eliminated. `<Soma>` wrapper remains as a scope boundary (verifies App context exists) but holds no services. Components call `getApp()` directly.
**Concern 2 — `Soma.resolve()` orphaned:** Eliminated. Replaced by plain `prop ?? app.dir ?? 'ltr'` coalescing. No generic resolver needed.
**Concern 3 — Fallback chain:** `defaults.ts` defines `fallbackLangs` (returns path as-is, no-op setLocale) and `consoleLogger`. `getApp()` returns a fallback `App` with these defaults.
**Concern 4 — `setLocale` incomplete:** Resolved. `locale` is derived from `langs.getLocale()`, not independent state. `App.setLocale()` delegates to `langs.setLocale()`. Single source of truth.
**Concern 5 — sema is green:** Acknowledged. sema design deferred to implementation phase.
### Veredicto
Phase 1 y 2: Adelante. Concerns resolved.

@ -0,0 +1,144 @@
import { Context } from 'runed';
import { createActiveDom } from '$adom';
import { EngineSemantic } from '$uix/sema';
import type {
AppOptions,
AppDom,
AppSemantic,
AppLangs,
AppNums,
AppMoney,
AppDates,
AppUnits,
AppPresentation,
AppLogger,
Direction,
DateOrder,
HourCycle
} from './types';
import {
fallbackDom,
fallbackLangs,
fallbackPresentation,
fallbackSemantic,
consoleLogger
} from './defaults';
/**
* App — root service compositor.
*
* Holds references to all domain services. Owns no preferences directly —
* each service owns its own state. App provides convenience getters/setters
* that delegate to the appropriate service.
*
* All services that depend on locale receive `langs` at construction and
* subscribe to `onLocaleChange` internally. When locale changes, each
* recalculates its defaults unless the user has set an explicit override.
*/
export class App {
// ── Services ────────────────────────────────────────────────────────
readonly dom: AppDom;
readonly semantic: AppSemantic;
readonly langs: AppLangs;
readonly nums: AppNums | undefined;
readonly money: AppMoney | undefined;
readonly dates: AppDates | undefined;
readonly units: AppUnits | undefined;
readonly presentation: AppPresentation;
readonly logger: AppLogger;
constructor(opts: AppOptions) {
this.dom = opts.dom ?? createActiveDom();
this.semantic = opts.semantic ?? new EngineSemantic();
this.langs = opts.langs;
this.nums = opts.nums;
this.money = opts.money;
this.dates = opts.dates;
this.units = opts.units;
this.presentation = opts.presentation;
this.logger = opts.logger ?? consoleLogger;
}
// ── Convenience getters (delegate to services) ──────────────────────
get currency(): string | undefined {
return this.money?.getCurrency();
}
get dateOrder(): DateOrder | undefined {
return this.dates?.getDateOrder();
}
get hourCycle(): HourCycle | undefined {
return this.dates?.getHourCycle();
}
get theme(): string {
return this.presentation.getTheme();
}
get mode(): 'light' | 'dark' {
return this.presentation.getMode();
}
// ── Convenience setters (delegate to services) ──────────────────────
setLocale(locale: string) {
this.langs.setLocale(locale);
}
setCurrency(currency: string) {
this.money?.setCurrency(currency);
}
setDir(dir: Direction | 'auto') {
this.presentation.setDir(dir);
}
setTheme(theme: string) {
this.presentation.setTheme(theme);
}
setMode(mode: 'light' | 'dark' | 'auto') {
this.presentation.setMode(mode);
}
// ── Static distribution methods ─────────────────────────────────────
static create(opts: AppOptions): App {
const app = new App(opts);
return _ctx.set(app);
}
static get(): App {
return _ctx.getOr(_fallback);
}
static require(): App {
const app = _ctx.getOr(null as unknown as App);
if (!app) {
throw new Error(
'[app] No App found. Make sure this component is rendered inside a App.create() scope.'
);
}
return app;
}
}
// ── Module-level context ────────────────────────────────────────────────────
const _ctx = new Context<App>('App');
/**
* Fallback App used when no <App> ancestor exists.
* Only provides core services (langs, presentation, logger).
* Optional services (nums, money, dates, units) are undefined —
* components must handle their absence gracefully.
*/
const _fallback = new App({
dom: fallbackDom,
semantic: fallbackSemantic,
langs: fallbackLangs,
presentation: fallbackPresentation,
logger: consoleLogger
});

@ -0,0 +1,45 @@
import { createActiveDom } from '$adom';
import { EngineSemantic } from '$uix/sema';
import type { AppLangs, AppPresentation, AppLogger, AppSemantic } from './types';
/** No-op langs: returns path as-is, no locale switching */
export const fallbackLangs: AppLangs = {
t: (path) => path,
ts: (value) => String(value ?? ''),
getLocale: () => '',
setLocale: () => {},
extend: () => {},
onLocaleChange: () => () => {},
onSchemaChange: () => () => {}
};
/** Console-based logger (matches logr's API: category + message + context) */
export const consoleLogger: AppLogger = {
debug: (cat, msg, ctx) => console.debug(`[${cat}]`, msg, ctx ?? ''),
info: (cat, msg, ctx) => console.info(`[${cat}]`, msg, ctx ?? ''),
warn: (cat, msg, ctx) => console.warn(`[${cat}]`, msg, ctx ?? ''),
error: (cat, msg, ctx) => console.error(`[${cat}]`, msg, ctx ?? '')
};
/** Minimal presentation: all defaults, no OS queries */
export const fallbackPresentation: AppPresentation = {
getDir: () => 'ltr',
setDir: () => {},
getTheme: () => 'base',
setTheme: () => {},
getMode: () => 'light',
setMode: () => {},
getReducedMotion: () => false,
setReducedMotion: () => {},
getReducedSound: () => false,
setReducedSound: () => {},
getDensity: () => 'normal',
setDensity: () => {},
onPreferenceChange: () => () => {}
};
/** Minimal dom runtime: default breakpoints + responsive helpers. */
export const fallbackDom = createActiveDom({});
/** Default semantic engine wired to the fallback dom. */
export const fallbackSemantic: AppSemantic = new EngineSemantic();

@ -0,0 +1,31 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export { App } from './app.svelte';
export {
fallbackDom,
fallbackLangs,
fallbackPresentation,
fallbackSemantic,
consoleLogger
} from './defaults';
export type {
AppServices,
AppOptions,
AppDom,
AppSemantic,
AppLangs,
AppNums,
AppMoney,
AppDates,
AppUnits,
AppPresentation,
AppLogger,
MoneyFormatOptions,
Direction,
DateOrder,
HourCycle
} from './types';

@ -0,0 +1,292 @@
/**
* App layer — test suite
*
* Covers:
* - types (AppServices, AppLangs, AppNums, AppMoney, AppDates, AppUnits, AppPresentation, AppLogger)
* - defaults (fallbackLangs, consoleLogger, fallbackPresentation)
* - app (App class — getters, setters, delegation)
* - context (createApp, getApp — requires Svelte context, tested separately)
* - nums (createNums — format, parse, preference overrides, locale reactivity)
* - money (createMoney — format, currency state, locale reactivity)
* - dates (createDates — format, dateOrder, hourCycle, locale reactivity)
* - units (createUnits — format, convert, system preference, locale reactivity)
* - presentation (createPresentation — dir, theme, mode, reducedMotion, locale reactivity)
* - conversions (convert — pure unit conversion)
*/
import { describe, it, expect, vi } from 'vitest';
import type { AppLangs } from '../types';
import { fallbackLangs, consoleLogger, fallbackPresentation } from '../defaults';
import { App } from '../app.svelte';
import { convert } from '../../units/conversions';
// ── Test helpers ─────────────────────────────────────────────────────────────
/** Creates a mock AppLangs with controllable locale and subscriptions */
function mockLangs(initialLocale = 'es'): AppLangs & { _setLocale: (l: string) => void } {
let locale = initialLocale;
const localeListeners = new Set<(l: string) => void>();
const schemaListeners = new Set<() => void>();
return {
t: (path) => `[${locale}:${path}]`,
ts: (value) => String(value ?? ''),
getLocale: () => locale,
setLocale: (l) => {
locale = l;
for (const fn of localeListeners) fn(l);
},
extend: () => {},
onLocaleChange: (fn) => {
localeListeners.add(fn);
return () => localeListeners.delete(fn);
},
onSchemaChange: (fn) => {
schemaListeners.add(fn);
return () => schemaListeners.delete(fn);
},
_setLocale: (l) => {
locale = l;
for (const fn of localeListeners) fn(l);
}
};
}
// ============================================================================
// DEFAULTS
// ============================================================================
describe('fallbackLangs', () => {
it('t returns path as-is', () => {
expect(fallbackLangs.t('some.path')).toBe('some.path');
});
it('ts returns stringified value', () => {
expect(fallbackLangs.ts('hello')).toBe('hello');
expect(fallbackLangs.ts(null)).toBe('');
expect(fallbackLangs.ts(undefined)).toBe('');
});
it('getLocale returns empty string', () => {
expect(fallbackLangs.getLocale()).toBe('');
});
it('setLocale is no-op', () => {
expect(() => fallbackLangs.setLocale('en')).not.toThrow();
});
it('onLocaleChange returns unsubscribe', () => {
const unsub = fallbackLangs.onLocaleChange(() => {});
expect(typeof unsub).toBe('function');
unsub();
});
});
describe('consoleLogger', () => {
it('has all four log methods', () => {
expect(typeof consoleLogger.debug).toBe('function');
expect(typeof consoleLogger.info).toBe('function');
expect(typeof consoleLogger.warn).toBe('function');
expect(typeof consoleLogger.error).toBe('function');
});
it('logs with category prefix', () => {
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
consoleLogger.info('auth', 'user logged in');
expect(spy).toHaveBeenCalledWith('[auth]', 'user logged in', '');
spy.mockRestore();
});
it('logs with context', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleLogger.warn('db', 'slow query', { ms: 2000 });
expect(spy).toHaveBeenCalledWith('[db]', 'slow query', { ms: 2000 });
spy.mockRestore();
});
});
describe('fallbackPresentation', () => {
it('returns safe defaults', () => {
expect(fallbackPresentation.getDir()).toBe('ltr');
expect(fallbackPresentation.getTheme()).toBe('base');
expect(fallbackPresentation.getMode()).toBe('light');
expect(fallbackPresentation.getReducedMotion()).toBe(false);
expect(fallbackPresentation.getReducedSound()).toBe(false);
expect(fallbackPresentation.getDensity()).toBe('normal');
});
it('setters are no-ops', () => {
expect(() => fallbackPresentation.setDir('rtl')).not.toThrow();
expect(fallbackPresentation.getDir()).toBe('ltr');
});
});
// ============================================================================
// APP CLASS
// ============================================================================
describe('App', () => {
it('delegates locale to langs', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
expect(app.langs.getLocale()).toBe('es');
});
it('setLocale delegates to langs', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
app.setLocale('en');
expect(langs.getLocale()).toBe('en');
expect(app.langs.getLocale()).toBe('en');
});
it('delegates dir to presentation', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
expect(app.presentation.getDir()).toBe('ltr');
});
it('currency delegates to money', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
expect(app.currency).toBeUndefined();
});
it('optional services are undefined when not provided', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
expect(app.nums).toBeUndefined();
expect(app.money).toBeUndefined();
expect(app.dates).toBeUndefined();
expect(app.units).toBeUndefined();
});
it('uses consoleLogger when no logger provided', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
expect(app.logger).toBe(consoleLogger);
});
it('provides a dom runtime even when none is injected', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
expect(app.dom.currentBreakpoint.current).toBe('base');
expect(app.dom.resolve({ base: 'stack', lg: 'inline' })).toBe('stack');
});
it('uses provided logger', () => {
const langs = mockLangs('es');
const customLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
};
const app = new App({ langs, presentation: fallbackPresentation, logger: customLogger });
expect(app.logger).toBe(customLogger);
});
});
// ============================================================================
// UNIT CONVERSIONS (pure, no Svelte runes)
// ============================================================================
describe('convert', () => {
it('converts km to miles', () => {
const result = convert(1, 'kilometer', 'mile');
expect(result).toBeCloseTo(0.621371, 4);
});
it('converts miles to km', () => {
const result = convert(1, 'mile', 'kilometer');
expect(result).toBeCloseTo(1.60934, 4);
});
it('converts celsius to fahrenheit', () => {
expect(convert(0, 'celsius', 'fahrenheit')).toBeCloseTo(32, 1);
expect(convert(100, 'celsius', 'fahrenheit')).toBeCloseTo(212, 1);
});
it('converts fahrenheit to celsius', () => {
expect(convert(32, 'fahrenheit', 'celsius')).toBeCloseTo(0, 1);
expect(convert(212, 'fahrenheit', 'celsius')).toBeCloseTo(100, 1);
});
it('converts kg to pounds', () => {
expect(convert(1, 'kilogram', 'pound')).toBeCloseTo(2.20462, 4);
});
it('converts liters to gallons', () => {
expect(convert(1, 'liter', 'gallon')).toBeCloseTo(0.264172, 4);
});
it('identity conversion returns same value', () => {
expect(convert(5, 'meter', 'meter')).toBeCloseTo(5, 10);
});
it('throws for incompatible units', () => {
expect(() => convert(1, 'meter', 'kilogram')).toThrow('Incompatible units');
});
it('throws for unknown unit', () => {
expect(() => convert(1, 'parsec', 'meter')).toThrow('Unknown unit');
});
it('converts meters to feet', () => {
expect(convert(1, 'meter', 'foot')).toBeCloseTo(3.28084, 4);
});
it('converts speed units', () => {
expect(convert(100, 'kilometer-per-hour', 'mile-per-hour')).toBeCloseTo(62.1371, 3);
});
it('celsius to kelvin', () => {
expect(convert(0, 'celsius', 'kelvin')).toBeCloseTo(273.15, 2);
});
});
// ============================================================================
// LOCALE DEFAULTS
// ============================================================================
describe('locale-defaults', () => {
it('resolveCurrency maps locales to currencies', async () => {
const { resolveCurrency } = await import('../../money/locale-defaults');
expect(resolveCurrency('es')).toBe('EUR');
expect(resolveCurrency('en-US')).toBe('USD');
expect(resolveCurrency('en-GB')).toBe('GBP');
expect(resolveCurrency('ja')).toBe('JPY');
expect(resolveCurrency('unknown')).toBeUndefined();
});
it('resolveDir maps locales to direction', async () => {
const { resolveDir } = await import('../../presentation/locale-defaults');
expect(resolveDir('es')).toBe('ltr');
expect(resolveDir('en')).toBe('ltr');
expect(resolveDir('ar')).toBe('rtl');
expect(resolveDir('he')).toBe('rtl');
expect(resolveDir('fa')).toBe('rtl');
});
it('resolveDateOrder maps locales', async () => {
const { resolveDateOrder } = await import('../../dates/locale-defaults');
expect(resolveDateOrder('es')).toBe('DMY');
expect(resolveDateOrder('en-US')).toBe('MDY');
expect(resolveDateOrder('ja')).toBe('YMD');
expect(resolveDateOrder('zh')).toBe('YMD');
});
it('resolveHourCycle maps locales', async () => {
const { resolveHourCycle } = await import('../../dates/locale-defaults');
expect(resolveHourCycle('es')).toBe(24);
expect(resolveHourCycle('en')).toBe(12);
expect(resolveHourCycle('en-GB')).toBe(24);
});
it('resolveUnitSystem maps locales', async () => {
const { resolveUnitSystem } = await import('../../units/locale-defaults');
expect(resolveUnitSystem('es')).toBe('metric');
expect(resolveUnitSystem('en-US')).toBe('imperial');
expect(resolveUnitSystem('en-GB')).toBe('metric');
});
});

@ -0,0 +1,670 @@
/**
* Service factories — test suite
*
* Tests createNums, createMoney, createDates, createUnits, createPresentation
* These use $state internally (Svelte runes) but the public API is plain JS.
*/
import { describe, it, expect, vi } from 'vitest';
import type { AppLangs } from '../types';
// ── Test helpers ─────────────────────────────────────────────────────────────
function mockLangs(initialLocale = 'es'): AppLangs & { _fire: () => void } {
let locale = initialLocale;
const listeners = new Set<(l: string) => void>();
return {
t: (path) => `[${locale}:${path}]`,
ts: (value) => String(value ?? ''),
getLocale: () => locale,
setLocale: (l) => {
locale = l;
for (const fn of listeners) fn(l);
},
extend: () => {},
onLocaleChange: (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
onSchemaChange: () => () => {},
_fire: () => {
for (const fn of listeners) fn(locale);
}
};
}
// ============================================================================
// NUMS
// ============================================================================
describe('createNums', () => {
it('formats numbers with locale', async () => {
const { createNums } = await import('../../nums/nums.svelte');
const langs = mockLangs('en');
const nums = createNums(langs);
const result = nums.format(1234.5);
expect(result).toContain('1');
expect(result).toContain('234');
});
it('parses locale-formatted numbers', async () => {
const { createNums } = await import('../../nums/nums.svelte');
const langs = mockLangs('en');
const nums = createNums(langs);
expect(nums.parse('1,234.5')).toBeCloseTo(1234.5);
});
it('returns undefined for invalid parse input', async () => {
const { createNums } = await import('../../nums/nums.svelte');
const langs = mockLangs('en');
const nums = createNums(langs);
expect(nums.parse('')).toBeUndefined();
});
it('handles NaN/Infinity gracefully', async () => {
const { createNums } = await import('../../nums/nums.svelte');
const langs = mockLangs('en');
const nums = createNums(langs);
expect(nums.format(NaN)).toBe('NaN');
expect(nums.format(Infinity)).toBe('Infinity');
});
it('notifies on preference change', async () => {
const { createNums } = await import('../../nums/nums.svelte');
const langs = mockLangs('en');
const nums = createNums(langs);
const spy = vi.fn();
nums.onPreferenceChange(spy);
nums.setDecimalSeparator(',');
expect(spy).toHaveBeenCalledTimes(1);
});
it('notifies on locale change', async () => {
const { createNums } = await import('../../nums/nums.svelte');
const langs = mockLangs('en');
const nums = createNums(langs);
const spy = vi.fn();
nums.onPreferenceChange(spy);
langs.setLocale('es');
expect(spy).toHaveBeenCalledTimes(1);
});
it('unsubscribe works', async () => {
const { createNums } = await import('../../nums/nums.svelte');
const langs = mockLangs('en');
const nums = createNums(langs);
const spy = vi.fn();
const unsub = nums.onPreferenceChange(spy);
unsub();
nums.setDecimalSeparator(',');
expect(spy).not.toHaveBeenCalled();
});
});
// ============================================================================
// MONEY
// ============================================================================
describe('createMoney', () => {
it('derives currency from locale', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs);
expect(money.getCurrency()).toBe('EUR');
});
it('accepts currency override', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs, { currency: 'USD' });
expect(money.getCurrency()).toBe('USD');
});
it('setCurrency changes active currency', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs);
money.setCurrency('GBP');
expect(money.getCurrency()).toBe('GBP');
});
it('formats monetary values', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('en-US');
const money = createMoney(langs, { currency: 'USD' });
const result = money.format(10.99);
expect(result).toContain('10');
expect(result).toContain('99');
});
it('formatAs uses specified currency', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('en-US');
const money = createMoney(langs, { currency: 'USD' });
const result = money.formatAs(10.99, 'EUR');
expect(result).toContain('10');
});
it('notifies on currency change', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs);
const spy = vi.fn();
money.onCurrencyChange(spy);
money.setCurrency('GBP');
expect(spy).toHaveBeenCalledWith('GBP');
});
it('recalculates currency on locale change when no override', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs);
const spy = vi.fn();
expect(money.getCurrency()).toBe('EUR');
money.onCurrencyChange(spy);
langs.setLocale('en-US');
// Notifies because currency would change (EUR → USD)
expect(spy).toHaveBeenCalled();
});
it('preserves override across locale changes', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs, { currency: 'GBP' });
langs.setLocale('en-US');
expect(money.getCurrency()).toBe('GBP'); // override sticks
});
it('ratesSupported is false when no provider', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs);
expect(money.ratesSupported).toBe(false);
});
it('convert returns undefined when no rates provider', async () => {
const { createMoney } = await import('../../money/money.svelte');
const langs = mockLangs('es');
const money = createMoney(langs);
expect(await money.convert(100, 'USD')).toBeUndefined();
});
it('converts with static rates', async () => {
const { createMoney } = await import('../../money/money.svelte');
const { createRates } = await import('../../money/rates');
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } }
});
const money = createMoney(langs, { currency: 'EUR', rates });
expect(money.ratesSupported).toBe(true);
// EUR → USD: rate = 1/0.92 ≈ 1.087
const result = await money.convert(100, 'USD');
expect(result).toBeDefined();
expect(result!).toBeCloseTo(108.7, 0);
});
it('convertAs converts between any two currencies', async () => {
const { createMoney } = await import('../../money/money.svelte');
const { createRates } = await import('../../money/rates');
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } }
});
const money = createMoney(langs, { rates });
const result = await money.convertAs(100, 'EUR', 'GBP');
expect(result).toBeDefined();
expect(result!).toBeCloseTo(85.87, 0);
});
it('convert same currency returns same value', async () => {
const { createMoney } = await import('../../money/money.svelte');
const { createRates } = await import('../../money/rates');
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92 } }
});
const money = createMoney(langs, { currency: 'EUR', rates });
expect(await money.convert(100, 'EUR')).toBe(100);
});
it('convert returns undefined for unknown pair without fetcher', async () => {
const { createMoney } = await import('../../money/money.svelte');
const { createRates } = await import('../../money/rates');
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92 } }
});
const money = createMoney(langs, { currency: 'EUR', rates });
expect(await money.convert(100, 'JPY')).toBeUndefined();
});
it('fetches rate lazily when expired', async () => {
const { createRates } = await import('../../money/rates');
let fetchCount = 0;
const provider = createRates({
fetchRate: async (from, to) => {
fetchCount++;
if (from === 'USD' && to === 'EUR') return { rate: 0.92, expiresAt: Infinity };
return undefined;
}
});
// No initial rates — fetches on demand
const rate = await provider.getRate('USD', 'EUR');
expect(fetchCount).toBe(1);
expect(rate).toBeCloseTo(0.92);
// Second call uses cache (not expired)
const rate2 = await provider.getRate('USD', 'EUR');
expect(fetchCount).toBe(1); // no new fetch
expect(rate2).toBeCloseTo(0.92);
});
it('setRate populates cache directly', async () => {
const { createRates } = await import('../../money/rates');
const provider = createRates();
provider.setRate('USD', 'EUR', 0.92);
expect(await provider.getRate('USD', 'EUR')).toBeCloseTo(0.92);
expect(await provider.getRate('EUR', 'USD')).toBeCloseTo(1 / 0.92, 4);
expect(provider.hasRates()).toBe(true);
});
it('initial rates available immediately', async () => {
const { createRates } = await import('../../money/rates');
const provider = createRates({
initial: { base: 'USD', rates: { EUR: 0.92 } }
});
expect(await provider.getRate('USD', 'EUR')).toBeCloseTo(0.92);
expect(provider.hasRates()).toBe(true);
});
});
// ============================================================================
// DATES
// ============================================================================
describe('createDates', () => {
it('derives dateOrder from locale', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('es');
const dates = createDates(langs);
expect(dates.getDateOrder()).toBe('DMY');
});
it('derives hourCycle from locale', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('es');
const dates = createDates(langs);
expect(dates.getHourCycle()).toBe(24);
});
it('formats dates', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('en');
const dates = createDates(langs);
const result = dates.format(new Date(2026, 3, 14));
expect(result).toContain('2026');
});
it('formats time', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('en');
const dates = createDates(langs);
const result = dates.formatTime(new Date(2026, 3, 14, 15, 30));
expect(result).toBeTruthy();
});
it('setDateOrder overrides', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('es');
const dates = createDates(langs);
dates.setDateOrder('YMD');
expect(dates.getDateOrder()).toBe('YMD');
});
it('setDateOrder auto resets to locale default', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('es');
const dates = createDates(langs);
dates.setDateOrder('YMD');
dates.setDateOrder('auto');
expect(dates.getDateOrder()).toBe('DMY');
});
it('setHourCycle overrides', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('es');
const dates = createDates(langs);
dates.setHourCycle(12);
expect(dates.getHourCycle()).toBe(12);
});
it('notifies on preference change', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('es');
const dates = createDates(langs);
const spy = vi.fn();
dates.onPreferenceChange(spy);
dates.setDateOrder('MDY');
expect(spy).toHaveBeenCalledTimes(1);
});
it('notifies on locale change', async () => {
const { createDates } = await import('../../dates/dates.svelte');
const langs = mockLangs('es');
const dates = createDates(langs);
const spy = vi.fn();
dates.onPreferenceChange(spy);
langs.setLocale('en-US');
expect(spy).toHaveBeenCalledTimes(1);
});
});
// ============================================================================
// UNITS
// ============================================================================
describe('createUnits', () => {
it('derives system from locale', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('es');
const units = createUnits(langs);
expect(units.getSystem()).toBe('metric');
});
it('en-US defaults to imperial', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('en-US');
const units = createUnits(langs);
expect(units.getSystem()).toBe('imperial');
});
it('formats units', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('en');
const units = createUnits(langs);
const result = units.format(5, 'kilometer');
expect(result).toContain('5');
expect(result).toContain('km');
});
it('converts between units', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('en');
const units = createUnits(langs);
expect(units.convert(1, 'kilometer', 'meter')).toBeCloseTo(1000);
});
it('setSystem overrides', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('es');
const units = createUnits(langs);
units.setSystem('imperial');
expect(units.getSystem()).toBe('imperial');
});
it('setSystem auto resets', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('es');
const units = createUnits(langs);
units.setSystem('imperial');
units.setSystem('auto');
expect(units.getSystem()).toBe('metric');
});
it('notifies on locale change when no override', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('es');
const units = createUnits(langs);
const spy = vi.fn();
units.onPreferenceChange(spy);
langs.setLocale('en-US');
expect(spy).toHaveBeenCalledTimes(1);
});
it('does not notify on locale change when overridden', async () => {
const { createUnits } = await import('../../units/units.svelte');
const langs = mockLangs('es');
const units = createUnits(langs, { system: 'imperial' });
const spy = vi.fn();
units.onPreferenceChange(spy);
langs.setLocale('en-US');
// Should NOT notify because system is explicitly overridden
expect(spy).not.toHaveBeenCalled();
});
});
// ============================================================================
// PRESENTATION
// ============================================================================
describe('createPresentation', () => {
it('derives dir from locale', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
expect(pres.getDir()).toBe('ltr');
});
it('derives rtl for arabic', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('ar');
const pres = createPresentation(langs);
expect(pres.getDir()).toBe('rtl');
});
it('setDir overrides', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setDir('rtl');
expect(pres.getDir()).toBe('rtl');
});
it('setDir auto resets to locale-derived', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setDir('rtl');
pres.setDir('auto');
expect(pres.getDir()).toBe('ltr');
});
it('defaults theme to base', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
expect(pres.getTheme()).toBe('base');
});
it('setTheme changes theme', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setTheme('midnight');
expect(pres.getTheme()).toBe('midnight');
});
it('defaults mode to light (no window in Node)', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
expect(pres.getMode()).toBe('light');
});
it('setMode overrides', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setMode('dark');
expect(pres.getMode()).toBe('dark');
});
it('setMode auto resets to OS default', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setMode('dark');
pres.setMode('auto');
// In Node, OS dark mode is false → light
expect(pres.getMode()).toBe('light');
});
it('defaults reducedMotion to false', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
expect(pres.getReducedMotion()).toBe(false);
});
it('defaults reducedSound to false', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
expect(pres.getReducedSound()).toBe(false);
});
it('setReducedSound changes preference', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setReducedSound(true);
expect(pres.getReducedSound()).toBe(true);
});
it('defaults density to normal', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
expect(pres.getDensity()).toBe('normal');
});
it('setDensity changes density', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setDensity('compact');
expect(pres.getDensity()).toBe('compact');
});
it('notifies on preference change', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
const spy = vi.fn();
pres.onPreferenceChange(spy);
pres.setDir('rtl');
expect(spy).toHaveBeenCalledTimes(1);
});
it('notifies on locale change', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
const spy = vi.fn();
pres.onPreferenceChange(spy);
langs.setLocale('ar');
expect(spy).toHaveBeenCalledTimes(1);
});
it('dir recalculates on locale change', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
expect(pres.getDir()).toBe('ltr');
langs.setLocale('ar');
expect(pres.getDir()).toBe('rtl');
});
it('dir override preserved across locale change', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs);
pres.setDir('rtl');
langs.setLocale('en');
expect(pres.getDir()).toBe('rtl'); // override sticks
});
it('accepts initial options', async () => {
const { createPresentation } = await import('../../presentation/presentation.svelte');
const langs = mockLangs('es');
const pres = createPresentation(langs, {
dir: 'rtl',
theme: 'midnight',
mode: 'dark',
density: 'compact',
reducedSound: true
});
expect(pres.getDir()).toBe('rtl');
expect(pres.getTheme()).toBe('midnight');
expect(pres.getMode()).toBe('dark');
expect(pres.getDensity()).toBe('compact');
expect(pres.getReducedSound()).toBe(true);
});
});

@ -0,0 +1,239 @@
import type { ActiveDom } from '$adom';
import type { SemanticSignal } from '$uix/sema';
// ── Direction ────────────────────────────────────────────────────────────────
export type Direction = 'ltr' | 'rtl';
// ── Dates ────────────────────────────────────────────────────────────────────
//
// `DateOrder` and `HourCycle` are canonical across the framework. Defined in
// `$lib/util/dias`, re-exported here as convenience for App-layer consumers.
// `HourCycle` uses the numeric form `12 | 24` — same as `Intl.DateTimeFormat`'s
// resolved `hour12` option (12 → true, 24 → false).
import type { DateOrder, HourCycle } from '$lib/util/dias';
export type { DateOrder, HourCycle };
// ── AppLangs ─────────────────────────────────────────────────────────────────
/** i18n runtime contract. lang implements this directly. */
export interface AppLangs {
/** Resolve a dot-path key to a localized string */
t(path: string, params?: Record<string, unknown>): string;
/** Resolve a LangRecord, LangRef, or descriptor to a string */
ts(value: unknown, locale?: string): string;
/** Get current locale */
getLocale(): string;
/** Set active locale — triggers onLocaleChange subscribers */
setLocale(locale: string): void;
/** Extend with a translation module at runtime */
extend(namespace: string, module: unknown): void;
/** Subscribe to locale changes. Returns unsubscribe. */
onLocaleChange(fn: (locale: string) => void): () => void;
/** Subscribe to schema changes (extend calls). Returns unsubscribe. */
onSchemaChange(fn: () => void): () => void;
}
// ── AppNums ──────────────────────────────────────────────────────────────────
/** Number formatting/parsing contract. nums implements this directly. */
export interface AppNums {
/** Format a number with locale-aware separators */
format(value: number, options?: Intl.NumberFormatOptions): string;
/** Parse a locale-formatted string back to a number */
parse(value: string): number | undefined;
/** Get decimal separator */
getDecimalSeparator(): string;
/** Get group (thousands) separator */
getGroupSeparator(): string;
/** Whether grouping is enabled */
getGrouping(): boolean;
/** Override decimal separator ('auto' resets to locale default) */
setDecimalSeparator(sep: string | 'auto'): void;
/** Override group separator ('auto' resets to locale default) */
setGroupSeparator(sep: string | 'auto'): void;
/** Override grouping ('auto' resets to locale default) */
setGrouping(enabled: boolean | 'auto'): void;
/** Subscribe to preference changes. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
// ── AppMoney ─────────────────────────────────────────────────────────────────
/** Currency & monetary value contract. money implements this directly. */
export interface AppMoney {
/** Get active currency code (e.g., 'EUR', 'USD') */
getCurrency(): string;
/** Set active currency */
setCurrency(currency: string): void;
/** Format a monetary amount in the active currency */
format(value: number, options?: MoneyFormatOptions): string;
/** Format in a specific currency (ignoring active) */
formatAs(value: number, currency: string, options?: MoneyFormatOptions): string;
/**
* Convert an amount from active currency to target currency.
* Async — may fetch rate if cached entry is expired.
* Returns undefined if rates provider is not configured or pair unavailable.
*/
convert(value: number, to: string): Promise<number | undefined>;
/**
* Convert between any two currencies.
* Async — may fetch rate if cached entry is expired.
* Returns undefined if rates provider is not configured or pair unavailable.
*/
convertAs(value: number, from: string, to: string): Promise<number | undefined>;
/** Whether this money instance supports currency conversion */
readonly ratesSupported: boolean;
/** Subscribe to currency changes. Returns unsubscribe. */
onCurrencyChange(fn: (currency: string) => void): () => void;
}
export interface MoneyFormatOptions {
/** How to display the currency: symbol, narrowSymbol, code, name */
currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
/** Sign convention for negative amounts */
currencySign?: 'standard' | 'accounting';
/** Minimum fraction digits */
minDecimals?: number;
/** Maximum fraction digits */
maxDecimals?: number;
}
// ── AppDates ─────────────────────────────────────────────────────────────────
/** Date/time formatting contract. dates implements this directly. */
export interface AppDates {
/** Format a date */
format(value: Date, options?: Intl.DateTimeFormatOptions): string;
/** Format time only */
formatTime(value: Date, options?: Intl.DateTimeFormatOptions): string;
/** Get date field order */
getDateOrder(): DateOrder;
/** Set date field order preference */
setDateOrder(order: DateOrder | 'auto'): void;
/** Get hour cycle */
getHourCycle(): HourCycle;
/** Set hour cycle preference */
setHourCycle(cycle: HourCycle | 'auto'): void;
/** Subscribe to preference changes. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
// ── AppUnits ─────────────────────────────────────────────────────────────────
/** Unit formatting & conversion contract. units implements this directly. */
export interface AppUnits {
/** Format a value with a unit */
format(value: number, unit: string, options?: Intl.NumberFormatOptions): string;
/** Get the active measurement system */
getSystem(): 'metric' | 'imperial';
/** Set measurement system preference */
setSystem(system: 'metric' | 'imperial' | 'auto'): void;
/** Convert between units */
convert(value: number, from: string, to: string): number;
/** Subscribe to preference changes. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
// ── AppPresentation ──────────────────────────────────────────────────────────
/** Visual & sensorial preferences contract. presentation implements this directly. */
export interface AppPresentation {
/** Text direction (derived from locale, overridable) */
getDir(): Direction;
setDir(dir: Direction | 'auto'): void;
/** Color theme name */
getTheme(): string;
setTheme(theme: string): void;
/** Color mode — light, dark, or auto (OS preference) */
getMode(): 'light' | 'dark';
setMode(mode: 'light' | 'dark' | 'auto'): void;
/** User prefers reduced motion (auto = respect OS prefers-reduced-motion) */
getReducedMotion(): boolean;
setReducedMotion(reduced: boolean | 'auto'): void;
/** User prefers reduced/no sound */
getReducedSound(): boolean;
setReducedSound(reduced: boolean): void;
/** UI density */
getDensity(): 'compact' | 'normal' | 'comfortable';
setDensity(density: 'compact' | 'normal' | 'comfortable'): void;
/** Subscribe to any presentation change. Returns unsubscribe. */
onPreferenceChange(fn: () => void): () => void;
}
// ── AppDom ───────────────────────────────────────────────────────────────────
/** DOM runtime contract. Implements viewport + breakpoints + responsive helpers. */
export type AppDom = ActiveDom;
// ── AppSemantic ──────────────────────────────────────────────────────────────
/**
* Semantic engine contract. Responsible for orchestrating perceptual signals
* (`data-event*`) on a target with the agreed lifecycle: write the signal,
* await one rAF so CSS / Eidos can react, resolve the Promise, hold N frames,
* then clean up. The provider/Soma decides when to emit; the engine never
* decides what occurred.
*
* Implementations may also publish to other channels (sound, vibra) by
* subscribing to their own internal bus — that's an engine detail, not the
* contract.
*/
export interface AppSemantic {
emit(signal: SemanticSignal): Promise<void>;
}
// ── AppLogger ────────────────────────────────────────────────────────────────
/** Logging contract. logr implements this directly. */
export interface AppLogger {
/** Log a debug message */
debug(category: string, message: unknown, context?: Record<string, unknown>): void;
/** Log an info message */
info(category: string, message: unknown, context?: Record<string, unknown>): void;
/** Log a warning */
warn(category: string, message: unknown, context?: Record<string, unknown>): void;
/** Log an error */
error(category: string, message: unknown, context?: Record<string, unknown>): void;
}
// ── AppServices ──────────────────────────────────────────────────────────────
export interface AppServices {
/** DOM runtime — viewport, breakpoints, responsive helpers */
readonly dom: AppDom;
/** Semantic engine — emits perceptual signals as `data-event*` on a target */
readonly semantic: AppSemantic;
/** i18n — locale, translation, interpolation, module extension */
readonly langs: AppLangs;
/** Numbers — formatting, parsing, separator preferences */
readonly nums?: AppNums;
/** Money — currency, monetary formatting */
readonly money?: AppMoney;
/** Dates — date/time formatting, dateOrder, hourCycle */
readonly dates?: AppDates;
/** Units — measurement formatting, conversion, system */
readonly units?: AppUnits;
/** Presentation — dir, theme, mode, reducedMotion, reducedSound, density */
readonly presentation: AppPresentation;
/** Logging */
readonly logger?: AppLogger;
/** Default portal target for overlays */
readonly portalTo?: string | HTMLElement;
}
// ── AppOptions (constructor input) ───────────────────────────────────────────
export interface AppOptions {
dom?: AppDom;
semantic?: AppSemantic;
langs: AppLangs;
nums?: AppNums;
money?: AppMoney;
dates?: AppDates;
units?: AppUnits;
presentation: AppPresentation;
logger?: AppLogger;
portalTo?: string | HTMLElement;
}

@ -0,0 +1,74 @@
import type { AppLangs, AppDates, DateOrder, HourCycle } from '../app/types';
import { resolveDateOrder, resolveHourCycle } from './locale-defaults';
import { getCachedDateFormat } from '$lib/util/dias';
// ── Factory ─────────────────────────────────────────────────────────────────
export interface DatesOptions {
/** Initial dateOrder override (null = derive from locale) */
dateOrder?: DateOrder;
/** Initial hourCycle override (null = derive from locale) */
hourCycle?: HourCycle;
}
/**
* Create a reactive dates service.
* Subscribes to locale changes from langs.
* Owns dateOrder and hourCycle preferences.
*
* Formatting itself is delegated to `$lib/util/dias`: this module adds
* nothing but reactive preference state on top of it.
*/
export function createDates(langs: AppLangs, opts: DatesOptions = {}): AppDates {
let _dateOrder = $state<DateOrder | null>(opts.dateOrder ?? null);
let _hourCycle = $state<HourCycle | null>(opts.hourCycle ?? null);
const listeners = new Set<() => void>();
function notify() {
for (const fn of listeners) fn();
}
langs.onLocaleChange(() => notify());
function locale(): string {
return langs.getLocale() || 'en';
}
return {
format(value: Date, options?: Intl.DateTimeFormatOptions): string {
const mergedOpts: Intl.DateTimeFormatOptions = {
...(options ?? { dateStyle: 'medium' })
};
return getCachedDateFormat(locale(), mergedOpts).format(value);
},
formatTime(value: Date, options?: Intl.DateTimeFormatOptions): string {
const cycle = _hourCycle ?? resolveHourCycle(locale());
const mergedOpts: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: 'numeric',
hourCycle: cycle === 12 ? 'h12' : 'h23',
...options
};
return getCachedDateFormat(locale(), mergedOpts).format(value);
},
getDateOrder: () => _dateOrder ?? resolveDateOrder(locale()),
setDateOrder(order) {
_dateOrder = order === 'auto' ? null : order;
notify();
},
getHourCycle: () => _hourCycle ?? resolveHourCycle(locale()),
setHourCycle(cycle) {
_hourCycle = cycle === 'auto' ? null : cycle;
notify();
},
onPreferenceChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
};
}

@ -0,0 +1,7 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export { createDates, type DatesOptions } from './dates.svelte';
export { resolveDateOrder, resolveHourCycle } from './locale-defaults';

@ -0,0 +1,9 @@
/**
* Thin re-export of locale-default helpers from `$lib/util/dias`. Exists for
* historical reasons — `ext/dates` used to convert between App-layer string
* hour-cycle (`'12h'|'24h'`) and the canonical numeric form. Since both
* layers now use the same numeric form, this file is a passthrough and can
* be removed; consumers should import directly from `$lib/util/dias`.
*/
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';

@ -0,0 +1,381 @@
# lang
Libreria i18n type-safe para SvelteKit. Zero dependencias externas. Resolucion reactiva de traducciones con cadena de fallback, pluralizacion via `Intl.PluralRules`, referencias entre claves, y serializacion JSON.
## Arquitectura
```
lang/
├── lib/
│ ├── index.ts Barrel de exports
│ ├── types.ts Tipos: LangRecord, LangNode, LangInstance, etc.
│ ├── engine.ts Factory createLang() — nucleo de la libreria
│ ├── plural.ts Helper p() con WeakMap para serializacion
│ ├── plural_rules.ts Wrapper sobre Intl.PluralRules
│ ├── guards.ts Type guards: isLangRef, isLangRecord, isLangString
│ ├── helpers.ts Utilidades: interpolateTemplate, resolveRecordFallback, deepMerge
│ ├── json.ts Serializacion/deserializacion: langNodeToJSON, JSONToLangNode
│ ├── consts.ts Constantes: ID_PREFIX (#?), separadores, limits
│ └── errors.ts Mensajes de error centralizados
└── test/
└── lang.test.ts 136 tests
```
### Principios de diseno
1. **Factory pattern** — `createLang()` devuelve una instancia con estado cerrado sobre el schema y locale. No hay singletons globales.
2. **Resolucion pura** — `tsRecord` y `resolveValue` reciben locale como parametro. Sin mutacion de estado para resolver en otro locale.
3. **Cadena de fallback** — `[currentLocale, ...fallbackChain, defaultLocale]`. Configurable en `createLang()`.
4. **Type-safe paths** — `t('common.ok')` autocompleta y valida tipos. Overload `string` para paths dinamicos.
5. **Datos desacoplados** — `p()` vive en `plural.ts`, no en el engine. Los datos de traducciones no importan del engine.
---
## Schema de traducciones
Un schema es un arbol de `LangNode` con 4 tipos de hoja:
### LangRecord — traduccion estatica
```ts
{ es: 'Aceptar', en: 'OK' }
```
Todos los locales son opcionales. No hay locale obligatorio — ningun `LangRecord` te exige tener `es`, `en`, o cualquier otro.
### LangFn — interpolacion con parametros
```ts
(params: { name: string }) => ({
es: `Hola, {{name}}`,
en: `Hello, {{name}}`
});
```
Los parametros se interpolan con `{{key}}`. La funcion recibe un objeto y devuelve un `LangRecord`.
### LangPluralFn — pluralizacion con `p()`
```ts
import { p } from '@/ext/lang';
p({
es: { one: '{{count}} mensaje', other: '{{count}} mensajes' },
en: { one: '{{count}} message', other: '{{count}} messages' },
ar: { zero: '...', one: '...', two: '...', few: '...', many: '...', other: '...' }
});
```
`p()` usa `Intl.PluralRules` internamente. Formas: `zero`, `one`, `two`, `few`, `many`, `other`. Solo `other` es obligatoria. Si una forma no existe, cae a `other`.
### LangRef — referencia a otra clave
```ts
'#?common.ok'; // alias → resuelve el valor de common.ok
'#?common.ok|fallback'; // alias con fallback literal si la clave no existe
```
Prefijo `#?`, separador `|` para fallback. Maximo 3 niveles de indireccion. Si se detecta un ciclo, lanza error.
### Ejemplo completo de schema
```ts
import { p } from '@/ext/lang';
import type { LangNode } from '@/ext/lang';
export const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
greet: (params: { name: string }) => ({
es: `Hola, {{name}}`,
en: `Hello, {{name}}`
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
})
},
ref: '#?common.ok'
} satisfies LangNode;
export type TranslationSchema = typeof translations;
```
---
## Uso
### Crear una instancia
```ts
import { createLang } from '@/ext/lang';
import { translations } from './translations';
const lang = createLang(translations, 'es');
```
Parametros de `createLang`:
```ts
createLang(
schema: LangNode, // arbol de traducciones
defaultLocale: SupportedLocale = 'es', // locale por defecto
fallbackChain?: SupportedLocale[] // cadena intermedia
)
```
Con cadena de fallback:
```ts
// Resolucion: currentLocale → en → es(default)
const lang = createLang(translations, 'es', ['en']);
lang.setLocale('fr');
lang.t('common.ok'); // fr no existe → busca en → 'OK'
```
Con cualquier locale como default:
```ts
const langEN = createLang(translations, 'en');
const langFR = createLang(translations, 'fr', ['en', 'es']);
```
### Traducir por path — `t()`
```ts
const lang = createLang(translations, 'es');
// Path tipado (autocompleta)
lang.t('common.ok'); // → 'Aceptar'
lang.t('common.ok', undefined, 'en'); // → 'OK' (sin cambiar locale activo)
// Con parametros de interpolacion
lang.t('greet', { name: 'Ana' }); // → 'Hola, Ana'
lang.t('greet', { name: 'Ana' }, 'en'); // → 'Hello, Ana'
// Pluralizacion
lang.t('messages.unread', { count: 1 }); // → '1 mensaje sin leer'
lang.t('messages.unread', { count: 5 }, 'en'); // → '5 unread messages'
// Referencia
lang.t('ref'); // → 'Aceptar' (resuelve #?common.ok)
```
### Traducir un valor directo — `ts()`
```ts
const lang = createLang(translations, 'es');
// LangRecord
lang.ts({ es: 'Hola', en: 'Hello' }); // → 'Hola'
lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // → 'Hello'
// LangRef
lang.ts('#?common.ok'); // → 'Aceptar'
lang.ts('#?common.ok|fallback'); // → 'Aceptar' (clave existe, ignora fallback)
lang.ts('#?missing.key|valor por defecto'); // → 'valor por defecto'
// String plano — no hace lookup
lang.ts('texto plano'); // → 'texto plano'
```
### Cambiar locale
```ts
const lang = createLang(translations, 'es');
lang.setLocale('en');
lang.getLocale(); // → 'en'
lang.t('common.ok'); // → 'OK'
// Suscribirse a cambios
const unsubscribe = lang.onLocaleChange((locale) => {
console.log('Locale cambiado a', locale);
});
lang.setLocale('es'); // dispara el callback
unsubscribe(); // dejar de escuchar
```
### Modulos lazy — `extend()`
Anade traducciones al schema en runtime. Soporta namespaces con puntos y deep-merge:
```ts
const lang = createLang(translations, 'es');
lang.extend('shop', {
product: { es: 'Producto', en: 'Product' },
cart: { es: 'Carrito', en: 'Cart' }
});
lang.t('shop.product'); // → 'Producto'
// Namespace con puntos — crea la jerarquia anidada
lang.extend('app.settings', {
title: { es: 'Ajustes', en: 'Settings' }
});
lang.t('app.settings.title'); // → 'Ajustes'
// Deep-merge — no borra claves existentes
lang.extend('common', {
extra: { es: 'Extra', en: 'Extra' }
});
lang.t('common.ok'); // → 'Aceptar' (sigue existiendo)
// Suscribirse a cambios de schema
const unsub = lang.onSchemaChange(() => {
// Re-render cuando se anade un modulo
});
```
### Instancias hijas — `register()`
Crea una nueva instancia con el modulo anadido. La hija sincroniza locale con la padre. Llamar `dispose()` para desconectar:
```ts
const lang = createLang(translations, 'es');
const child = lang.register('admin', {
dashboard: { es: 'Panel', en: 'Dashboard' }
});
child.t('admin.dashboard'); // → 'Panel'
child.t('common.ok'); // → 'Aceptar' (hereda el schema padre)
lang.setLocale('en'); // hijo se sincroniza automaticamente
child.t('admin.dashboard'); // → 'Dashboard'
child.dispose(); // desconecta del padre
lang.setLocale('es'); // hijo ya no recibe el cambio
child.getLocale(); // → 'en' (se quedo en el ultimo)
```
La hija hereda el `defaultLocale` y la `fallbackChain` del padre.
### Logger personalizado
```ts
const lang = createLang(translations, 'es');
lang.setLogger({
warn: (category, message) => myLogger.warn(message),
error: (category, message) => myLogger.error(message)
});
// Solo se puede llamar una vez — posteriores llamadas se ignoran
```
---
## Cadena de fallback
Cuando se traduce una clave, se busca en este orden:
```
1. currentLocale
2. fallbackChain[0]
3. fallbackChain[1]
4. ...
5. defaultLocale
```
Ejemplo con `createLang(schema, 'es', ['en'])` y `lang.setLocale('fr')`:
```
clave: common.ok → { es: 'Aceptar', en: 'OK' }
1. fr → no existe
2. en → 'OK' ✓
```
Si ningun locale tiene la traduccion, `t()` devuelve el path y `ts()` devuelve string vacio. En DEV se emite un warning cuando se usa un fallback.
---
## API — LangInstance
| Metodo | Descripcion |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `t(path, params?, locale?)` | Traduce por path del schema. `params` para interpolacion/plural. `locale` para resolver en un locale concreto sin cambiar el activo. |
| `ts(value, locale?)` | Traduce un `LangString` directo (record, string, ref). `locale` opcional. |
| `setLocale(locale)` | Cambia el locale activo y notifica listeners. |
| `getLocale()` | Devuelve el locale activo. |
| `onLocaleChange(fn)` | Subscribe a cambios de locale. Devuelve funcion de unsubscribe. |
| `onSchemaChange(fn)` | Subscribe a cambios de schema (`extend`). Devuelve funcion de unsubscribe. |
| `extend(namespace, module)` | Anade un modulo al schema en runtime via deep-merge. Soporta namespaces con puntos. |
| `register(namespace, module)` | Crea una instancia hija con el modulo anadido y locale sincronizado. |
| `setLogger(logger)` | Inyecta un logger personalizado. Solo puede llamarse una vez. |
| `dispose()` | Desconecta una instancia hija de su padre. |
---
## Tipos principales
| Tipo | Descripcion |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `SupportedLocale` | Union de locales: `es \| en \| ar \| de \| fr \| it \| pt \| ca \| eu \| gl` |
| `LangRecord` | `{ [K in SupportedLocale]?: string }` — todos opcionales |
| `LangString` | `string \| LangRecord \| LangRef` |
| `LangNode` | Arbol recursivo: `LangRecord \| LangFn \| LangPluralFn \| LangRef \| { [key: string]: LangNode }` |
| `LangRef` | Template literal: `` `#?${string}` `` |
| `LangFn<P>` | `(params: P) => LangRecord` |
| `LangPluralFn<P>` | `(params: { count: number } & P) => LangRecord` |
| `PluralConfig` | `{ [K in SupportedLocale]?: PluralForms }` |
| `PluralForms` | `{ other: string } & Partial<Record<PluralCategory, string>>` |
| `LangInstance<S>` | Interface completa de la instancia devuelta por `createLang()` |
| `LangLogger` | `{ warn, error }` — interface del logger inyectable |
---
## Serializacion JSON
```ts
import { langNodeToJSON, JSONToLangNode } from '@/ext/lang';
import { translations } from './translations';
const json = langNodeToJSON(translations);
// LangFn → { es: 'Hola {{name}}', en: 'Hello {{name}}' }
// LangPluralFn → { __type: 'plural', config: {...} }
// LangRef → '#?common.ok' (string plano)
// LangRecord → { es: 'Aceptar', en: 'OK' } (sin cambios)
const restored = JSONToLangNode(json);
// Reconstruye funciones de interpolacion y pluralizacion
// Las referencias #? se conservan como strings — isLangRef() las reconoce
```
---
## Helpers publicos
Disponibles para adapters y consumidores externos:
```ts
import {
interpolateTemplate,
resolveRecordFallback,
resolvePath,
parseLangRef,
makeLangRecord,
asLangString,
deepMerge
} from '@/ext/lang';
interpolateTemplate('Hola {{name}}', { name: 'Ana' }); // → 'Hola Ana'
resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'en', 'es']); // → 'Hello'
makeLangRecord('Hello', 'en'); // → { en: 'Hello' }
parseLangRef('#?common.ok|fallback'); // → { path: 'common.ok', fallback: 'fallback' }
```
---
## Tests
```bash
npx vitest run src/lib/ext/lang/test/lang.test.ts
```
136 tests cubriendo: guards, helpers, plural rules, `p()` con WeakMap, engine completo (t, ts, locale param, fallback chain, extend, register), JSON round-trip, y verificacion de que no hay locale hardcoded.

@ -0,0 +1,77 @@
import { createLang, type LangNode, type SupportedLocale, type LangInstance } from './lib';
export type { LangNode, SupportedLocale, LangInstance };
/**
* Reactive wrapper around lang for Svelte 5.
*
* Mirrors the lang instance but overrides `getLocale` and `t` to touch
* a `$state` variable, so any `$derived` / `$effect` that calls them
* re-evaluates when the locale changes.
*/
export function createLangs<S extends LangNode>(
schema: S,
defaultLocale?: SupportedLocale,
fallbackChain?: SupportedLocale[]
) {
const lang = createLang(schema, defaultLocale, fallbackChain);
let _locale = $state<SupportedLocale>(lang.getLocale());
let _schemaVersion = $state(0);
lang.onLocaleChange((locale) => {
_locale = locale;
});
lang.onSchemaChange(() => {
_schemaVersion++;
});
return {
t(path: string, params?: Record<string, any>, locale?: SupportedLocale): string {
void _locale;
void _schemaVersion;
return lang.t(path, params, locale);
},
ts(value: any, locale?: SupportedLocale): string {
void _locale;
void _schemaVersion;
return lang.ts(value, locale);
},
getLocale(): SupportedLocale {
return _locale;
},
setLocale(locale: SupportedLocale): void {
lang.setLocale(locale);
},
extend(namespace: string, module: LangNode): void {
lang.extend(namespace, module);
},
register<NS extends string, M extends LangNode>(namespace: NS, module: M) {
return lang.register(namespace, module);
},
onLocaleChange(fn: (locale: SupportedLocale) => void): () => void {
return lang.onLocaleChange(fn);
},
onSchemaChange(fn: () => void): () => void {
return lang.onSchemaChange(fn);
},
setLogger(logger: any): void {
lang.setLogger(logger);
},
dispose(): void {
lang.dispose();
},
__dispose: lang.__dispose
} as unknown as LangInstance<S>;
}

@ -0,0 +1,5 @@
export const ID_PREFIX = '#?';
export const ID_FALLBACK_SEPARATOR = '|';
export const MAX_RESOLVE_DEEP = 3;
export const LOGGER_CATEGORY = 'lang';

@ -0,0 +1,265 @@
import type {
SupportedLocale,
LangRecord,
LangString,
LangInstance,
LangLogger,
LangNode
} from './types.ts';
import { LANG_ERRORS } from './errors.ts';
import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP } from './consts.ts';
import { isLangRef, isLangRecord } from './guards.ts';
import {
interpolateTemplate,
resolvePath,
parseLangRef,
parsePathFallback,
deepMerge
} from './helpers.ts';
const DEV: boolean =
typeof import.meta !== 'undefined' && import.meta.env != null
? import.meta.env.DEV === true
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
export function createLang<S extends LangNode>(
schema: S,
defaultLocale: SupportedLocale = 'es',
fallbackChain?: SupportedLocale[]
): LangInstance<S> {
let currentSchema: LangNode = schema;
let currentLocale: SupportedLocale = defaultLocale;
let logger: LangLogger = consoleLogger;
let loggerSet: boolean = false;
const listeners = new Set<(locale: SupportedLocale) => void>();
const schemaListeners = new Set<() => void>();
let __dispose: (() => void) | undefined;
function setLogger(external: LangLogger): void {
if (loggerSet) {
if (DEV) console.warn(LANG_ERRORS.LOGGER_ALREADY_SET);
return;
}
logger = external;
loggerSet = true;
}
function setLocale(locale: SupportedLocale): void {
currentLocale = locale;
listeners.forEach((fn) => fn(locale));
}
function getLocale(): SupportedLocale {
return currentLocale;
}
function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
function buildChain(locale: SupportedLocale): SupportedLocale[] {
if (locale === defaultLocale) return [defaultLocale];
const chain: SupportedLocale[] = [locale];
for (const loc of fallbackChain ?? []) {
if (loc !== locale && loc !== defaultLocale) chain.push(loc);
}
chain.push(defaultLocale);
return chain;
}
function tsRecord(
record: LangRecord,
locale: SupportedLocale,
path?: string,
params?: any
): string {
const chain = buildChain(locale);
let translation: string | undefined;
let usedLocale: SupportedLocale | undefined;
for (const loc of chain) {
const candidate = (record as any)[loc];
if (candidate !== undefined) {
translation = candidate;
usedLocale = loc;
break;
}
}
if (translation === undefined) translation = path ?? '';
translation = interpolateTemplate(translation, params);
if (DEV && usedLocale !== undefined && usedLocale !== locale) {
const msg = path
? LANG_ERRORS.MISSING_TRANSLATION(path, locale, usedLocale)
: LANG_ERRORS.MISSING_TRANSLATION_RECORD(locale, usedLocale);
logger.warn(LOGGER_CATEGORY, msg);
}
return translation;
}
function resolveValue(
value: any,
args: any[],
locale: SupportedLocale,
depth: number,
visited?: Set<string>
): any {
if (depth > MAX_RESOLVE_DEEP) {
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error('Circular reference in lang');
}
if (isLangRef(value)) {
const parsed = parseLangRef(value);
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
const seen = visited ?? new Set<string>();
if (seen.has(path)) {
logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(path));
throw new Error('Circular reference in lang');
}
seen.add(path);
const resolved = resolvePath(currentSchema, path);
if (resolved === undefined) {
if (parsed?.fallback !== undefined) {
if (DEV)
logger.warn(
LOGGER_CATEGORY,
`${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`
);
return parsed.fallback;
}
return undefined;
}
return resolveValue(resolved, args, locale, depth + 1, seen);
}
if (typeof value === 'function') {
return value(args[0]);
}
return value;
}
function extractArgs(args: any[]): { params: any; locale: SupportedLocale | undefined } {
if (args.length === 0) return { params: undefined, locale: undefined };
const last = args[args.length - 1];
if (typeof last === 'string' && args.length > 1) {
return { params: args[0], locale: last as SupportedLocale };
}
if (typeof last === 'string' && args.length === 1) {
return { params: undefined, locale: last as SupportedLocale };
}
if (args.length === 2 && typeof args[1] === 'string') {
return { params: args[0], locale: args[1] as SupportedLocale };
}
return { params: args[0], locale: undefined };
}
const t: LangInstance<S>['t'] = (path: string, ...args: any[]): string => {
const { params, locale: argLocale } = extractArgs(args);
const locale = argLocale ?? currentLocale;
const { path: cleanPath, fallback: pathFallback } = parsePathFallback(path);
const rawValue = resolvePath(currentSchema, cleanPath);
if (rawValue === undefined) {
if (pathFallback !== undefined) {
return interpolateTemplate(pathFallback, params);
}
if (DEV) logger.error(LOGGER_CATEGORY, LANG_ERRORS.KEY_NOT_FOUND(cleanPath));
return cleanPath;
}
const finalValue = resolveValue(rawValue, [params], locale, 0);
if (isLangRecord(finalValue)) {
return tsRecord(finalValue, locale, cleanPath, params);
}
return String(finalValue);
};
function ts(value: LangString, locale?: SupportedLocale): string {
const loc = locale ?? currentLocale;
if (value == null) return '';
const finalValue = resolveValue(value, [], loc, 0);
if (isLangRecord(finalValue)) return tsRecord(finalValue, loc, undefined, undefined);
return typeof finalValue === 'string' ? finalValue : String(finalValue);
}
function extend(namespace: string, module: LangNode): void {
const parts = namespace.split('.');
let nested: any = module;
for (let i = parts.length - 1; i > 0; i--) {
nested = { [parts[i]]: nested };
}
const root = parts[0];
currentSchema = {
...(currentSchema as Record<string, any>),
[root]: deepMerge((currentSchema as any)[root] ?? {}, nested)
};
schemaListeners.forEach((fn) => fn());
}
function register<NS extends string, M extends LangNode>(
namespace: NS,
module: M
): LangInstance<S & { [K in NS]: M }> {
const newSchema = {
...(currentSchema as Record<string, any>),
[namespace]: module
} as S & { [K in NS]: M };
const extended = createLang(newSchema, defaultLocale, fallbackChain);
extended.setLocale(currentLocale);
const unsubscribe = onLocaleChange((locale) => extended.setLocale(locale));
extended.__dispose = unsubscribe;
return extended;
}
function onSchemaChange(fn: () => void): () => void {
schemaListeners.add(fn);
return () => schemaListeners.delete(fn);
}
function dispose(): void {
__dispose?.();
}
return {
t,
ts,
setLocale,
getLocale,
onLocaleChange,
onSchemaChange,
extend,
register,
setLogger,
dispose,
get __dispose() {
return __dispose;
},
set __dispose(fn) {
__dispose = fn;
}
};
}
const consoleLogger: LangLogger = {
warn: (_category: string, message: string) => DEV && console.warn(message),
error: (_category: string, message: string) => DEV && console.error(message)
};

@ -0,0 +1,15 @@
import type { SupportedLocale } from './types.ts';
export const LANG_ERRORS = {
KEY_NOT_FOUND: (path: string): string => `[lang] Translation key not found: "${path}"`,
CIRCULAR_REFERENCE: (path: string): string => `[lang] Circular reference in "${path}".`,
MISSING_TRANSLATION: (path: string, locale: string, usedLocale: string): string =>
`[lang] Missing translation for "${path}" in "${locale}". Falling back to "${usedLocale}".`,
MISSING_TRANSLATION_RECORD: (locale: string, usedLocale: string): string =>
`[lang] Missing translation in "${locale}". Falling back to "${usedLocale}".`,
LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.'
} as const;

@ -0,0 +1,19 @@
import type { LangRef, LangRecord, LangString } from './types.ts';
import { ID_PREFIX } from './consts.ts';
export function isLangRef(value: unknown): value is LangRef {
return typeof value === 'string' && value.startsWith(ID_PREFIX) && value.length > 2;
}
export function isLangRecord(value: unknown): value is LangRecord {
if (typeof value !== 'object' || value === null) return false;
if (Array.isArray(value)) return false;
if (typeof value === 'function') return false;
const vals = Object.values(value as Record<string, unknown>);
if (vals.length === 0) return false;
return vals.every((v) => typeof v === 'string' || v === undefined);
}
export function isLangString(value: unknown): value is LangString {
return isLangRef(value) || isLangRecord(value) || typeof value === 'string';
}

@ -0,0 +1,89 @@
import type { LangRecord, LangString, SupportedLocale } from './types.ts';
import { ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts';
import { isLangString } from './guards.ts';
export function resolvePath(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
export function parseLangRef(value: string): { path: string; fallback?: string } | null {
if (!value.startsWith(ID_PREFIX) || value.length <= ID_PREFIX.length) return null;
const raw = value.slice(ID_PREFIX.length);
const separatorIndex = raw.indexOf(ID_FALLBACK_SEPARATOR);
if (separatorIndex === -1) {
return { path: raw };
}
return {
path: raw.slice(0, separatorIndex),
fallback: raw.slice(separatorIndex + 1)
};
}
export function parsePathFallback(path: string): { path: string; fallback?: string } {
const separatorIndex = path.indexOf(ID_FALLBACK_SEPARATOR);
if (separatorIndex === -1) {
return { path };
}
return {
path: path.slice(0, separatorIndex),
fallback: path.slice(separatorIndex + 1)
};
}
export function makeLangRecord(text: string, locale: SupportedLocale): LangRecord {
return { [locale]: text } as LangRecord;
}
export function asLangString(value: unknown): LangString {
if (value == null) return '';
if (isLangString(value)) return value;
return String(value);
}
export function interpolateTemplate(text: string, params?: Record<string, any>): string {
if (!params) return text;
let result = text;
for (const [key, val] of Object.entries(params)) {
result = result.replaceAll(`{{${key}}}`, String(val));
}
return result;
}
export function resolveRecordFallback(
record: Partial<Record<SupportedLocale, string>>,
chain: SupportedLocale[]
): string | undefined {
for (const locale of chain) {
const val = record[locale];
if (val !== undefined) return val;
}
return Object.values(record).find((v) => typeof v === 'string');
}
export function deepMerge(target: any, source: any): any {
const result = { ...target };
for (const key of Object.keys(source)) {
const srcVal = source[key];
const tgtVal = result[key];
if (
typeof srcVal === 'object' &&
srcVal !== null &&
!Array.isArray(srcVal) &&
typeof srcVal !== 'function' &&
typeof tgtVal === 'object' &&
tgtVal !== null &&
!Array.isArray(tgtVal) &&
typeof tgtVal !== 'function'
) {
result[key] = deepMerge(tgtVal, srcVal);
} else {
result[key] = srcVal;
}
}
return result;
}

@ -0,0 +1,14 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export * from './consts.ts';
export * from './guards.ts';
export * from './types.ts';
export * from './plural_rules.ts';
export * from './plural.ts';
export * from './helpers.ts';
export * from './engine.ts';
export * from './json.ts';
export * from './errors.ts';

@ -0,0 +1,58 @@
import type { LangNode } from './types.ts';
import { p } from './plural.ts';
import { getPluralConfig } from './plural.ts';
import { isLangRecord } from './guards.ts';
export function langNodeToJSON(node: LangNode | any): any {
if (typeof node === 'string') return node;
if (typeof node === 'function') {
const config = getPluralConfig(node);
if (config) {
return {
__type: 'plural',
config: config
};
}
try {
const proxyParams = new Proxy(
{},
{
get: (_, prop) => `{{${String(prop)}}}`
}
);
return langNodeToJSON(node(proxyParams));
} catch {
return {};
}
}
if (typeof node === 'object' && node !== null) {
const result: Record<string, any> = {};
for (const key in node) {
result[key] = langNodeToJSON(node[key]);
}
return result;
}
return node;
}
export function JSONToLangNode(json: any): LangNode {
if (typeof json === 'string') return json as LangNode;
if (typeof json === 'object' && json !== null) {
if (json.__type === 'plural' && json.config) {
return p(json.config) as unknown as LangNode;
}
const result: any = {};
for (const key in json) {
result[key] = JSONToLangNode(json[key]);
}
return result;
}
return json as LangNode;
}

@ -0,0 +1,34 @@
import type { PluralConfig, LangRecord, SupportedLocale } from './types.ts';
import type { PluralCategory } from './plural_rules.ts';
import { pluralRule } from './plural_rules.ts';
import type { PluralForms } from './types.ts';
const pluralConfigs = new WeakMap<Function, PluralConfig>();
export const p = (config: PluralConfig) => {
const cache = new Map<string, LangRecord>();
const fn = (params: { count: number } & Record<string, any>): LangRecord => {
const key = String(params.count);
const cached = cache.get(key);
if (cached) return cached;
const result: any = {};
for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue;
const rule: PluralCategory = pluralRule(locale, params.count);
const typedForms = forms as PluralForms;
result[locale] = typedForms[rule] || typedForms.other;
}
cache.set(key, result as LangRecord);
return result as LangRecord;
};
pluralConfigs.set(fn, config);
return fn;
};
export function getPluralConfig(fn: Function): PluralConfig | undefined {
return pluralConfigs.get(fn);
}

@ -0,0 +1,12 @@
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
export function pluralRule(locale: string, n: number): PluralCategory {
if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
try {
return new Intl.PluralRules(locale).select(n) as PluralCategory;
} catch {
return 'other';
}
}
return 'other';
}

@ -0,0 +1,85 @@
import type { ID_PREFIX } from './consts.ts';
import type { PluralCategory } from './plural_rules.ts';
export type SupportedLocale = 'es' | 'en' | 'ar' | 'de' | 'fr' | 'it' | 'pt' | 'ca' | 'eu' | 'gl';
export type LangRef = `${typeof ID_PREFIX}${string}`;
export type LangRecord = {
[K in SupportedLocale]?: string;
};
export type LangFn<P = any> = (params: P) => LangRecord;
export type LangPluralFn<P = any> = (params: { count: number } & P) => LangRecord;
export type LangValue<P = any> = LangRecord | LangFn<P> | LangPluralFn<P> | LangRef;
export type LangNode = LangValue | { [key: string]: LangNode };
export type LangString = string | LangRecord | LangRef;
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
export type LeafPaths<T, D extends number = 6> = [D] extends [never]
? never
: T extends LangValue
? ''
: T extends object
? {
[K in keyof T & string]: T[K] extends LangValue
? K
: `${K}.${LeafPaths<T[K], Prev[D]> & string}`;
}[keyof T & string]
: never;
export type GetTypeAtPath<Root, Current, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof Current
? GetTypeAtPath<Root, Current[K], Rest>
: never
: P extends keyof Current
? Current[P] extends `#?${infer AliasPath}`
? GetTypeAtPath<Root, Root, AliasPath>
: Current[P]
: never;
export type ParamsFor<T> = T extends (params: infer P) => any ? P : never;
export type HasParams<T> = T extends (params: any) => any ? true : false;
export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
export type PluralConfig = {
[K in SupportedLocale]?: PluralForms;
};
export type LangInstance<S extends LangNode = LangNode> = {
t: {
<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
params?: HasParams<TType> extends true ? ParamsFor<TType> : Record<string, any>,
locale?: SupportedLocale
): string;
(path: string, params?: Record<string, any>, locale?: SupportedLocale): string;
};
ts: (value: LangString, locale?: SupportedLocale) => string;
setLocale: (locale: SupportedLocale) => void;
getLocale: () => SupportedLocale;
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
onSchemaChange: (fn: () => void) => () => void;
extend: (namespace: string, module: LangNode) => void;
register: <NS extends string, M extends LangNode>(
namespace: NS,
module: M
) => LangInstance<S & { [K in NS]: M }>;
setLogger: (logger: LangLogger) => void;
dispose: () => void;
__dispose: (() => void) | undefined;
};
export interface LangLogger {
warn: (category: string, message: string) => void;
error: (category: string, message: string) => void;
}

@ -0,0 +1,880 @@
/**
* lang — test suite
*
* Covers:
* - guards (isLangRef, isLangRecord, isLangString)
* - helpers (resolvePath, parseLangRef, makeLangRecord, interpolateTemplate, resolveRecordFallback, deepMerge)
* - plural-rules (pluralRule via Intl.PluralRules)
* - plural (p, getPluralConfig)
* - engine (createLang: t, ts, fallback chain, locale param, extend, register, setLocale)
* - json (langNodeToJSON, JSONToLangNode)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isLangRef, isLangRecord, isLangString } from '../lib/guards';
import {
parseLangRef,
parsePathFallback,
resolvePath,
makeLangRecord,
interpolateTemplate,
resolveRecordFallback,
deepMerge
} from '../lib/helpers';
import { pluralRule } from '../lib/plural_rules';
import { p, getPluralConfig } from '../lib/plural';
import { createLang } from '../lib/engine';
import { langNodeToJSON, JSONToLangNode } from '../lib/json';
// ============================================================================
// GUARDS
// ============================================================================
describe('isLangRef', () => {
it('returns true for a valid reference', () => {
expect(isLangRef('#?common.ok')).toBe(true);
});
it('returns false for prefix only without path', () => {
expect(isLangRef('#?')).toBe(false);
});
it('returns false for normal strings', () => {
expect(isLangRef('hola')).toBe(false);
expect(isLangRef('')).toBe(false);
});
it('returns false for non-strings', () => {
expect(isLangRef(null)).toBe(false);
expect(isLangRef(42)).toBe(false);
expect(isLangRef({})).toBe(false);
expect(isLangRef(undefined)).toBe(false);
});
it('returns true for references with nested paths', () => {
expect(isLangRef('#?a.b.c')).toBe(true);
});
});
describe('isLangRecord', () => {
it('returns true for a record with es and en', () => {
expect(isLangRecord({ es: 'Aceptar', en: 'OK' })).toBe(true);
});
it('returns true with only en — no hardcoded locale required', () => {
expect(isLangRecord({ en: 'Hello' })).toBe(true);
});
it('returns true with only fr', () => {
expect(isLangRecord({ fr: 'Bonjour' })).toBe(true);
});
it('returns true with only es', () => {
expect(isLangRecord({ es: 'Hola' })).toBe(true);
});
it('returns false for empty objects', () => {
expect(isLangRecord({})).toBe(false);
});
it('returns false if values are not strings', () => {
expect(isLangRecord({ es: 42 })).toBe(false);
expect(isLangRecord({ es: null })).toBe(false);
});
it('returns false for arrays', () => {
expect(isLangRecord(['es', 'Hola'])).toBe(false);
});
it('returns false for functions', () => {
expect(isLangRecord(() => ({ es: 'x' }))).toBe(false);
});
it('returns false for primitives', () => {
expect(isLangRecord(null)).toBe(false);
expect(isLangRecord('hola')).toBe(false);
expect(isLangRecord(42)).toBe(false);
});
});
describe('isLangString', () => {
it('accepts plain strings', () => {
expect(isLangString('hola')).toBe(true);
expect(isLangString('')).toBe(true);
});
it('accepts LangRecord', () => {
expect(isLangString({ en: 'Hello' })).toBe(true);
});
it('accepts LangRef', () => {
expect(isLangString('#?common.ok')).toBe(true);
});
it('rejects null and undefined', () => {
expect(isLangString(null)).toBe(false);
expect(isLangString(undefined)).toBe(false);
});
it('rejects numbers', () => {
expect(isLangString(42)).toBe(false);
});
});
// ============================================================================
// HELPERS
// ============================================================================
describe('parseLangRef', () => {
it('extracts path without fallback', () => {
expect(parseLangRef('#?common.ok')).toEqual({ path: 'common.ok' });
});
it('extracts path and fallback literal', () => {
expect(parseLangRef('#?terra.calendar.month|month')).toEqual({
path: 'terra.calendar.month',
fallback: 'month'
});
});
it('returns null for non-ref strings', () => {
expect(parseLangRef('terra.calendar.month')).toBeNull();
expect(parseLangRef('#?')).toBeNull();
});
});
describe('parsePathFallback', () => {
it('extracts path without fallback', () => {
expect(parsePathFallback('common.ok')).toEqual({ path: 'common.ok' });
});
it('extracts path and fallback', () => {
expect(parsePathFallback('home.options|Opciones')).toEqual({
path: 'home.options',
fallback: 'Opciones'
});
});
it('returns full string as path when no separator', () => {
expect(parsePathFallback('noseparator')).toEqual({ path: 'noseparator' });
});
it('handles empty fallback', () => {
expect(parsePathFallback('path|')).toEqual({ path: 'path', fallback: '' });
});
});
describe('resolvePath', () => {
const obj = { a: { b: { c: 'valor' } } };
it('resolves nested paths', () => {
expect(resolvePath(obj, 'a.b.c')).toBe('valor');
});
it('resolves one level', () => {
expect(resolvePath(obj, 'a')).toEqual({ b: { c: 'valor' } });
});
it('returns undefined for non-existent paths', () => {
expect(resolvePath(obj, 'a.x.y')).toBeUndefined();
});
it('returns undefined for empty path', () => {
expect(resolvePath(obj, '')).toBeUndefined();
});
it('does not throw with null intermediate objects', () => {
expect(resolvePath({ a: null }, 'a.b')).toBeUndefined();
});
});
describe('makeLangRecord', () => {
it('creates a record with specified locale', () => {
const record = makeLangRecord('Hola', 'es');
expect(record.es).toBe('Hola');
});
it('creates a record with en locale', () => {
const record = makeLangRecord('Hello', 'en');
expect(record.en).toBe('Hello');
expect((record as any).es).toBeUndefined();
});
});
describe('interpolateTemplate', () => {
it('replaces {{key}} with value', () => {
expect(interpolateTemplate('Hello {{name}}', { name: 'World' })).toBe('Hello World');
});
it('replaces multiple keys', () => {
expect(interpolateTemplate('{{greeting}} {{name}}', { greeting: 'Hi', name: 'Ana' })).toBe(
'Hi Ana'
);
});
it('returns text unchanged if no params', () => {
expect(interpolateTemplate('Hello')).toBe('Hello');
});
it('handles special regex chars in keys', () => {
expect(interpolateTemplate('Code: {{code}}', { code: 'A+B' })).toBe('Code: A+B');
});
});
describe('resolveRecordFallback', () => {
it('returns value for first locale in chain', () => {
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['en', 'es'])).toBe('Hello');
});
it('falls back through chain', () => {
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'en', 'es'])).toBe('Hello');
});
it('falls back to last resort', () => {
expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'de', 'es'])).toBe('Hola');
});
it('returns undefined for empty record', () => {
expect(resolveRecordFallback({}, ['es'])).toBeUndefined();
});
it('uses Object.values fallback when chain misses', () => {
expect(resolveRecordFallback({ fr: 'Bonjour' }, ['de'])).toBe('Bonjour');
});
});
describe('deepMerge', () => {
it('merges flat objects', () => {
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
});
it('deep merges nested objects', () => {
expect(deepMerge({ a: { x: 1, y: 2 } }, { a: { y: 3, z: 4 } })).toEqual({
a: { x: 1, y: 3, z: 4 }
});
});
it('overwrites primitives with source', () => {
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
});
});
// ============================================================================
// PLURAL RULES (Intl.PluralRules)
// ============================================================================
describe('pluralRule', () => {
describe('español (es) — one/other', () => {
it('1 → one', () => expect(pluralRule('es', 1)).toBe('one'));
it('0 → other', () => expect(pluralRule('es', 0)).toBe('other'));
it('2 → other', () => expect(pluralRule('es', 2)).toBe('other'));
});
describe('inglés (en) — one/other', () => {
it('1 → one', () => expect(pluralRule('en', 1)).toBe('one'));
it('2 → other', () => expect(pluralRule('en', 2)).toBe('other'));
});
describe('árabe (ar) — 6 forms', () => {
it('0 → zero', () => expect(pluralRule('ar', 0)).toBe('zero'));
it('1 → one', () => expect(pluralRule('ar', 1)).toBe('one'));
it('2 → two', () => expect(pluralRule('ar', 2)).toBe('two'));
it('5 → few', () => expect(pluralRule('ar', 5)).toBe('few'));
it('15 → many', () => expect(pluralRule('ar', 15)).toBe('many'));
it('100 → other', () => expect(pluralRule('ar', 100)).toBe('other'));
});
describe('ruso (ru) — one/few/many/other', () => {
it('1 → one', () => expect(pluralRule('ru', 1)).toBe('one'));
it('21 → one', () => expect(pluralRule('ru', 21)).toBe('one'));
it('2 → few', () => expect(pluralRule('ru', 2)).toBe('few'));
it('5 → many', () => expect(pluralRule('ru', 5)).toBe('many'));
it('0 → many', () => expect(pluralRule('ru', 0)).toBe('many'));
it('11 → many', () => expect(pluralRule('ru', 11)).toBe('many'));
});
describe('locale desconocido — Intl.PluralRules uses root rules', () => {
it('xx, 1 → one (Intl falls back to root)', () => {
const result = pluralRule('xx', 1);
expect(['one', 'other']).toContain(result);
});
it('xx, 5 → other', () => expect(pluralRule('xx', 5)).toBe('other'));
});
describe('normalización con región', () => {
it('es-ES → one', () => expect(pluralRule('es-ES', 1)).toBe('one'));
it('en-US → one', () => expect(pluralRule('en-US', 1)).toBe('one'));
});
});
// ============================================================================
// PLURAL — p() with WeakMap
// ============================================================================
describe('p()', () => {
const plural = p({
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
en: { one: '{{count}} item', other: '{{count}} items' }
});
it('selects singular form for 1', () => {
const record = plural({ count: 1 });
expect(record.es).toBe('{{count}} elemento');
});
it('selects plural form for 2', () => {
const record = plural({ count: 2 });
expect(record.es).toBe('{{count}} elementos');
});
it('caches by count', () => {
const r1 = plural({ count: 5 });
const r2 = plural({ count: 5 });
expect(r1).toBe(r2);
});
it('caches floats correctly', () => {
const r1 = plural({ count: 1.5 });
const r2 = plural({ count: 1.5 });
expect(r1).toBe(r2);
});
it('uses other as fallback when form missing', () => {
const fallback = p({ es: { other: 'varios' } });
const record = fallback({ count: 1 });
expect(record.es).toBe('varios');
});
it('getPluralConfig retrieves config via WeakMap', () => {
expect(getPluralConfig(plural)).toBeDefined();
expect(getPluralConfig(plural)!.es).toBeDefined();
});
it('getPluralConfig returns undefined for non-plural functions', () => {
const regular = () => ({ es: 'x' });
expect(getPluralConfig(regular)).toBeUndefined();
});
it('no __pluralConfig on the function — uses WeakMap instead', () => {
expect((plural as any).__pluralConfig).toBeUndefined();
});
});
// ============================================================================
// ENGINE — createLang
// ============================================================================
const schema = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' }
},
greet: (params: { name: string }) => ({
es: `Hola, {{name}}`,
en: `Hello, {{name}}`
}),
messages: {
unread: p({
es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' },
en: { one: '{{count}} unread message', other: '{{count}} unread messages' }
})
},
ref: '#?common.ok',
nested: {
deep: {
value: { es: 'Profundo', en: 'Deep' }
}
},
onlyEn: { en: 'English only' },
onlyFr: { fr: 'Francais seulement' }
} as const;
describe('createLang — t()', () => {
let lang: ReturnType<typeof createLang<typeof schema>>;
beforeEach(() => {
lang = createLang(schema, 'es');
});
it('translates a simple key to default locale', () => {
expect(lang.t('common.ok')).toBe('Aceptar');
});
it('translates to active locale after setLocale', () => {
lang.setLocale('en');
expect(lang.t('common.ok')).toBe('OK');
});
it('returns path if key does not exist', () => {
expect((lang.t as any)('no.existe')).toBe('no.existe');
});
it('translates with interpolation', () => {
expect((lang.t as any)('greet', { name: 'Ana' })).toBe('Hola, Ana');
});
it('translates with interpolation in alternative locale', () => {
lang.setLocale('en');
expect((lang.t as any)('greet', { name: 'Ana' })).toBe('Hello, Ana');
});
it('resolves LangRef references (#?)', () => {
expect((lang.t as any)('ref')).toBe('Aceptar');
});
it('translates deep nested paths', () => {
expect(lang.t('nested.deep.value')).toBe('Profundo');
});
it('translates plurals — one', () => {
expect((lang.t as any)('messages.unread', { count: 1 })).toBe('1 mensaje sin leer');
});
it('translates plurals — other', () => {
expect((lang.t as any)('messages.unread', { count: 5 })).toBe('5 mensajes sin leer');
});
it('translates plurals in alternative locale', () => {
lang.setLocale('en');
expect((lang.t as any)('messages.unread', { count: 1 })).toBe('1 unread message');
expect((lang.t as any)('messages.unread', { count: 3 })).toBe('3 unread messages');
});
it('falls back to default locale when translation missing', () => {
lang.setLocale('en');
expect(lang.t('common.ok')).toBe('OK');
});
it('returns fallback text when path has | and key not found', () => {
expect((lang.t as any)('missing.key|Texto alternativo')).toBe('Texto alternativo');
});
it('returns resolved value when path has | and key exists', () => {
expect((lang.t as any)('common.ok|Fallback')).toBe('Aceptar');
});
it('interpolates params in fallback text', () => {
expect((lang.t as any)('missing.greet|Hola {{name}}', { name: 'Ana' })).toBe('Hola Ana');
});
});
// ============================================================================
// ENGINE — t() with locale parameter (replaces tForLocale)
// ============================================================================
describe('createLang — t() with explicit locale parameter', () => {
let lang: ReturnType<typeof createLang<typeof schema>>;
beforeEach(() => {
lang = createLang(schema, 'es');
});
it('translates to explicit locale without changing active locale', () => {
lang.setLocale('es');
expect((lang.t as any)('common.ok', undefined, 'en')).toBe('OK');
expect(lang.getLocale()).toBe('es');
});
it('translates with params and explicit locale', () => {
expect((lang.t as any)('greet', { name: 'Ana' }, 'en')).toBe('Hello, Ana');
expect(lang.getLocale()).toBe('es');
});
it('is reentrant — does not corrupt state on nested calls', () => {
lang.setLocale('es');
expect((lang.t as any)('common.ok', undefined, 'en')).toBe('OK');
expect((lang.t as any)('common.ok')).toBe('Aceptar');
expect(lang.getLocale()).toBe('es');
});
it('restores state after circular reference throws', () => {
const circular = { a: '#?a' } as any;
const l = createLang(circular, 'es');
expect(() => (l.t as any)('a', undefined, 'en')).toThrow();
expect(l.getLocale()).toBe('es');
});
});
// ============================================================================
// ENGINE — ts() with locale parameter
// ============================================================================
describe('createLang — ts()', () => {
let lang: ReturnType<typeof createLang<typeof schema>>;
beforeEach(() => {
lang = createLang(schema, 'es');
});
it('translates a LangRecord directly', () => {
expect(lang.ts({ es: 'Directo', en: 'Direct' })).toBe('Directo');
});
it('translates a LangRecord with explicit locale', () => {
expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'en')).toBe('Direct');
});
it('translates with explicit locale without changing active locale', () => {
lang.setLocale('es');
expect(lang.ts({ es: 'Hola', en: 'Hello' }, 'en')).toBe('Hello');
expect(lang.getLocale()).toBe('es');
});
it('returns plain strings as-is', () => {
expect(lang.ts('texto plano')).toBe('texto plano');
});
it('returns empty string for null', () => {
expect(lang.ts(null as any)).toBe('');
});
it('resolves LangRef references', () => {
expect(lang.ts('#?common.ok')).toBe('Aceptar');
});
it('uses fallback literal when ref key does not exist', () => {
expect(lang.ts('#?terra.calendar.month|month')).toBe('month');
});
it('prioritizes schema key over fallback literal', () => {
expect(lang.ts('#?common.ok|fallback')).toBe('Aceptar');
});
it('keeps normal strings as literals — no implicit lookup', () => {
expect(lang.ts('terra.calendar.month')).toBe('terra.calendar.month');
});
});
// ============================================================================
// ENGINE — FALLBACK CHAIN
// ============================================================================
describe('createLang — fallback chain', () => {
it('falls back through chain: current → fallback → default', () => {
const lang = createLang(schema, 'es', ['en']);
lang.setLocale('fr');
expect(lang.t('common.ok')).toBe('OK');
});
it('uses current locale when available', () => {
const lang = createLang(schema, 'es', ['en']);
lang.setLocale('en');
expect(lang.t('common.ok')).toBe('OK');
});
it('skips chain locales already equal to current or default', () => {
const lang = createLang(schema, 'es', ['es']);
lang.setLocale('en');
expect(lang.t('common.ok')).toBe('OK');
});
it('falls back to default when chain and current miss', () => {
const lang = createLang(schema, 'es', ['de']);
lang.setLocale('fr');
expect(lang.t('common.ok')).toBe('Aceptar');
});
it('key with only en: fr → en(chain) → es(default)', () => {
const lang = createLang(schema, 'es', ['en']);
lang.setLocale('fr');
expect((lang.t as any)('onlyEn')).toBe('English only');
});
it('key with only fr: en → es(default) — fr not in chain', () => {
const lang = createLang(schema, 'es');
lang.setLocale('en');
expect((lang.t as any)('onlyFr')).toBe('onlyFr');
});
it('explicit locale parameter uses chain', () => {
const lang = createLang(schema, 'es', ['en']);
expect((lang.t as any)('common.ok', undefined, 'fr')).toBe('OK');
});
});
// ============================================================================
// ENGINE — setLocale / getLocale / onLocaleChange
// ============================================================================
describe('createLang — setLocale / getLocale / onLocaleChange', () => {
it('getLocale returns initial locale', () => {
const lang = createLang(schema, 'es');
expect(lang.getLocale()).toBe('es');
});
it('setLocale notifies listeners', () => {
const lang = createLang(schema, 'es');
const spy = vi.fn();
lang.onLocaleChange(spy);
lang.setLocale('en');
expect(spy).toHaveBeenCalledWith('en');
expect(spy).toHaveBeenCalledTimes(1);
});
it('unsubscribe stops notifications', () => {
const lang = createLang(schema, 'es');
const spy = vi.fn();
const unsub = lang.onLocaleChange(spy);
unsub();
lang.setLocale('en');
expect(spy).not.toHaveBeenCalled();
});
});
// ============================================================================
// ENGINE — extend()
// ============================================================================
describe('createLang — extend()', () => {
it('adds a simple namespace', () => {
const lang = createLang(schema, 'es');
lang.extend('shop', { product: { es: 'Producto', en: 'Product' } });
expect((lang.t as any)('shop.product')).toBe('Producto');
});
it('adds with dotted namespace', () => {
const lang = createLang(schema, 'es');
lang.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } });
expect((lang.t as any)('app.settings.title')).toBe('Ajustes');
});
it('deep-merges without overwriting existing keys', () => {
const lang = createLang(schema, 'es');
lang.extend('common', { extra: { es: 'Extra', en: 'Extra' } });
expect(lang.t('common.ok')).toBe('Aceptar');
expect((lang.t as any)('common.extra')).toBe('Extra');
});
it('notifies schema listeners', () => {
const lang = createLang(schema, 'es');
const spy = vi.fn();
lang.onSchemaChange(spy);
lang.extend('shop', { product: { es: 'Producto' } });
expect(spy).toHaveBeenCalledTimes(1);
});
it('unsubscribe stops schema notifications', () => {
const lang = createLang(schema, 'es');
const spy = vi.fn();
const unsub = lang.onSchemaChange(spy);
unsub();
lang.extend('shop', { product: { es: 'Producto' } });
expect(spy).not.toHaveBeenCalled();
});
});
// ============================================================================
// ENGINE — register()
// ============================================================================
describe('createLang — register()', () => {
it('returns a new instance with namespace', () => {
const lang = createLang(schema, 'es');
const extended = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
expect((extended.t as any)('shop.product')).toBe('Producto');
});
it('child syncs locale with parent', () => {
const lang = createLang(schema, 'es');
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
lang.setLocale('en');
expect((child.t as any)('shop.product')).toBe('Product');
});
it('dispose() removes parent listener', () => {
const lang = createLang(schema, 'es');
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
child.dispose();
lang.setLocale('en');
expect(child.getLocale()).toBe('es');
});
it('original instance is not affected by register', () => {
const lang = createLang(schema, 'es');
lang.register('shop', { product: { es: 'Producto' } });
expect((lang.t as any)('shop.product')).toBe('shop.product');
});
it('child inherits fallback chain from parent', () => {
const lang = createLang(schema, 'es', ['en']);
const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } });
lang.setLocale('fr');
expect((child.t as any)('shop.product')).toBe('Product');
});
});
// ============================================================================
// ENGINE — setLogger()
// ============================================================================
describe('createLang — setLogger()', () => {
it('replaces default logger', () => {
const lang = createLang(schema, 'es');
const warn = vi.fn();
const error = vi.fn();
lang.setLogger({ warn, error });
expect(lang.getLocale()).toBe('es');
});
it('setLogger() can only be called once', () => {
const lang = createLang(schema, 'es');
const firstWarn = vi.fn();
const secondWarn = vi.fn();
lang.setLogger({ warn: firstWarn, error: vi.fn() });
lang.setLogger({ warn: secondWarn, error: vi.fn() });
expect(lang.getLocale()).toBe('es');
});
});
// ============================================================================
// ENGINE — NO RIGIDEZ: records sin locale obligatorio
// ============================================================================
describe('createLang — no hardcoded default locale', () => {
it('works with en as default locale', () => {
const enSchema = {
greeting: { en: 'Hello' }
} as const;
const lang = createLang(enSchema, 'en');
expect(lang.t('greeting')).toBe('Hello');
});
it('works with fr as default locale', () => {
const frSchema = {
greeting: { fr: 'Bonjour' }
} as const;
const lang = createLang(frSchema, 'fr');
expect(lang.t('greeting')).toBe('Bonjour');
});
it('PluralConfig without es — only en', () => {
const plural = p({
en: { one: '{{count}} item', other: '{{count}} items' }
});
const record = plural({ count: 1 });
expect(record.en).toBe('{{count}} item');
});
it('createLang with en default and fallback chain to fr', () => {
const s = {
msg: { en: 'Hello', fr: 'Bonjour' }
} as const;
const lang = createLang(s, 'en', ['fr']);
expect(lang.t('msg')).toBe('Hello');
lang.setLocale('fr');
expect(lang.t('msg')).toBe('Bonjour');
});
});
// ============================================================================
// JSON — langNodeToJSON / JSONToLangNode
// ============================================================================
describe('langNodeToJSON', () => {
it('serializes plain strings', () => {
expect(langNodeToJSON('hola')).toBe('hola');
});
it('serializes LangRef as strings', () => {
expect(langNodeToJSON('#?common.ok')).toBe('#?common.ok');
});
it('serializes LangRecord as plain object', () => {
const result = langNodeToJSON({ en: 'Hello' });
expect(result).toEqual({ en: 'Hello' });
});
it('serializes namespaces recursively', () => {
const node = { common: { ok: { es: 'Aceptar', en: 'OK' } } };
const result = langNodeToJSON(node);
expect(result.common.ok).toEqual({ es: 'Aceptar', en: 'OK' });
});
it('serializes interpolation functions with {{tokens}}', () => {
const fn = (p: any) => ({ es: `Hola ${p.name}`, en: `Hello ${p.name}` });
const result = langNodeToJSON(fn);
expect(result.es).toBe('Hola {{name}}');
expect(result.en).toBe('Hello {{name}}');
});
it('serializes plurals with __type: plural via WeakMap', () => {
const plural = p({
en: { one: '1 item', other: '{{count}} items' }
});
const result = langNodeToJSON(plural);
expect(result.__type).toBe('plural');
expect(result.config).toBeDefined();
});
it('returns {} if interpolation function throws', () => {
const broken = () => {
throw new Error('broken');
};
expect(langNodeToJSON(broken)).toEqual({});
});
});
describe('JSONToLangNode', () => {
it('reconstructs plain strings', () => {
expect(JSONToLangNode('hola')).toBe('hola');
});
it('reconstructs LangRecord', () => {
const result = JSONToLangNode({ en: 'Hello' });
expect(result).toEqual({ en: 'Hello' });
});
it('reconstructs plurals as functions', () => {
const config = {
en: { one: '{{count}} item', other: '{{count}} items' }
};
const result = JSONToLangNode({ __type: 'plural', config }) as any;
expect(typeof result).toBe('function');
expect(result({ count: 1 }).en).toBe('{{count}} item');
});
it('reconstructs namespaces recursively', () => {
const json = { common: { ok: { en: 'OK' } } };
const result = JSONToLangNode(json) as any;
expect(result.common.ok.en).toBe('OK');
});
});
describe('round-trip langNodeToJSON → JSONToLangNode', () => {
it('LangRecord survives round-trip', () => {
const original = { en: 'Hello' };
const json = langNodeToJSON(original);
const restored = JSONToLangNode(json) as any;
expect(restored.en).toBe('Hello');
});
it('plural survives round-trip and works', () => {
const original = p({ en: { one: '1 item', other: '{{count}} items' } });
const json = langNodeToJSON(original);
const restored = JSONToLangNode(json) as any;
expect(typeof restored).toBe('function');
expect(restored({ count: 1 }).en).toBe('1 item');
expect(restored({ count: 5 }).en).toBe('{{count}} items');
});
it('LangRef survives round-trip', () => {
const json = langNodeToJSON('#?common.ok');
const restored = JSONToLangNode(json);
expect(restored).toBe('#?common.ok');
expect(isLangRef(restored)).toBe(true);
});
it('full tree survives round-trip', () => {
const node = {
common: { ok: { es: 'Aceptar', en: 'OK' } },
ref: '#?common.ok',
items: p({ en: { one: '1 item', other: '{{count}} items' } })
};
const json = langNodeToJSON(node);
const restored = JSONToLangNode(json) as any;
expect(restored.common.ok.en).toBe('OK');
expect(restored.ref).toBe('#?common.ok');
expect(typeof restored.items).toBe('function');
});
});

@ -0,0 +1,9 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export { createMoney, type MoneyOptions } from './money.svelte';
export { createRates, type RateFetcher, type RatesOptions } from './rates';
export { resolveCurrency } from './locale-defaults';
export type { MoneyRatesProvider, RateEntry } from './types';

@ -0,0 +1,28 @@
const LOCALE_CURRENCY: Record<string, string> = {
es: 'EUR',
en: 'USD',
'en-US': 'USD',
'en-GB': 'GBP',
'en-AU': 'AUD',
'en-CA': 'CAD',
de: 'EUR',
fr: 'EUR',
it: 'EUR',
pt: 'EUR',
'pt-BR': 'BRL',
ar: 'SAR',
'ar-AE': 'AED',
'ar-EG': 'EGP',
ja: 'JPY',
ko: 'KRW',
zh: 'CNY',
'zh-TW': 'TWD',
ca: 'EUR',
eu: 'EUR',
gl: 'EUR'
};
/** Resolve default currency from locale. Returns undefined if locale is not in the table. */
export function resolveCurrency(locale: string): string | undefined {
return LOCALE_CURRENCY[locale] ?? LOCALE_CURRENCY[locale.split('-')[0]];
}

@ -0,0 +1,111 @@
import type { AppLangs, AppMoney, MoneyFormatOptions } from '../app/types';
import type { MoneyRatesProvider } from './types';
import { resolveCurrency } from './locale-defaults';
// ── Intl cache ──────────────────────────────────────────────────────────────
const formatCache = new Map<string, Intl.NumberFormat>();
function getCachedCurrencyFormat(
locale: string,
currency: string,
opts?: MoneyFormatOptions
): Intl.NumberFormat {
const key = `${locale}:${currency}:${JSON.stringify(opts ?? {})}`;
let fmt = formatCache.get(key);
if (!fmt) {
fmt = new Intl.NumberFormat(locale || undefined, {
style: 'currency',
currency,
currencyDisplay: opts?.currencyDisplay ?? 'symbol',
currencySign: opts?.currencySign ?? 'standard',
minimumFractionDigits: opts?.minDecimals,
maximumFractionDigits: opts?.maxDecimals
});
formatCache.set(key, fmt);
}
return fmt;
}
// ── Factory ─────────────────────────────────────────────────────────────────
export interface MoneyOptions {
/** Initial/default currency. Used when locale has no mapping. */
currency?: string;
/** Exchange rate provider for currency conversion */
rates?: MoneyRatesProvider;
}
/**
* Create a reactive money service.
* Subscribes to locale changes from langs.
* Owns currency state. Conversion requires a rates provider.
*/
export function createMoney(langs: AppLangs, opts: MoneyOptions = {}): AppMoney {
let _currency = $state<string | null>(opts.currency ?? null);
const _rates = opts.rates;
const listeners = new Set<(currency: string) => void>();
function notify() {
const cur = getCurrency();
for (const fn of listeners) fn(cur);
}
langs.onLocaleChange(() => {
if (_currency === null) notify();
});
function locale(): string {
return langs.getLocale() || 'en';
}
const defaultCurrency = opts.currency ?? 'USD';
function getCurrency(): string {
return _currency ?? resolveCurrency(locale()) ?? defaultCurrency;
}
return {
getCurrency,
setCurrency(currency: string) {
_currency = currency;
notify();
},
format(value: number, options?: MoneyFormatOptions): string {
if (!Number.isFinite(value)) return String(value);
return getCachedCurrencyFormat(locale(), getCurrency(), options).format(value);
},
formatAs(value: number, currency: string, options?: MoneyFormatOptions): string {
if (!Number.isFinite(value)) return String(value);
return getCachedCurrencyFormat(locale(), currency, options).format(value);
},
async convert(value: number, to: string): Promise<number | undefined> {
if (!_rates) return undefined;
const from = getCurrency();
if (from === to) return value;
const rate = await _rates.getRate(from, to);
return rate !== undefined ? value * rate : undefined;
},
async convertAs(value: number, from: string, to: string): Promise<number | undefined> {
if (!_rates) return undefined;
if (from === to) return value;
const rate = await _rates.getRate(from, to);
return rate !== undefined ? value * rate : undefined;
},
get ratesSupported() {
return _rates !== undefined;
},
onCurrencyChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
};
}

@ -0,0 +1,121 @@
import type { MoneyRatesProvider, RateEntry } from './types';
/**
* Function that fetches a fresh rate for a currency pair.
* Called lazily when a cached rate has expired.
*/
export type RateFetcher = (
from: string,
to: string
) => Promise<{ rate: number; expiresAt: number } | undefined>;
export interface RatesOptions {
/** Initial rates: base currency + rate map. Optional. */
initial?: {
base: string;
rates: Record<string, number>;
/** When these initial rates expire. @default Infinity */
expiresAt?: number;
};
/** Function to fetch a fresh rate when a cached entry expires. */
fetchRate?: RateFetcher;
}
/**
* Create a rates provider backed by a cache table.
*
* Each rate entry stores its own expiresAt. When getRate() is called
* and the entry is expired, fetchRate is called to get a fresh one.
* No timers. No proactive refresh. Lazy only.
*/
export function createRates(opts: RatesOptions = {}): MoneyRatesProvider {
const cache = new Map<string, RateEntry>();
const pendingFetches = new Map<string, Promise<number | undefined>>();
// Populate initial rates
if (opts.initial) {
const { base, rates, expiresAt = Infinity } = opts.initial;
for (const [currency, rate] of Object.entries(rates)) {
cache.set(`${base}→${currency}`, { rate, expiresAt });
if (rate !== 0) {
cache.set(`${currency}→${base}`, { rate: 1 / rate, expiresAt });
}
}
}
function getCachedRate(from: string, to: string): number | undefined {
const entry = cache.get(`${from}→${to}`);
if (entry && (entry.expiresAt === Infinity || Date.now() < entry.expiresAt)) {
return entry.rate;
}
return undefined;
}
return {
async getRate(from: string, to: string): Promise<number | undefined> {
if (from === to) return 1;
// Try cache (direct)
const cached = getCachedRate(from, to);
if (cached !== undefined) return cached;
// Try computing cross rate via any shared base
// (skip if no fetcher — only use what's in cache)
if (!opts.fetchRate) {
// Try cross rate from existing entries
for (const [key, entry] of cache) {
if (entry.expiresAt !== Infinity && Date.now() >= entry.expiresAt) continue;
const [kFrom, kTo] = key.split('→');
if (kFrom === from) {
const second = getCachedRate(kTo, to);
if (second !== undefined) return entry.rate * second;
}
}
return undefined;
}
// Fetch — deduplicate concurrent requests for the same pair
const fetchKey = `${from}→${to}`;
if (pendingFetches.has(fetchKey)) {
return pendingFetches.get(fetchKey);
}
const promise = opts
.fetchRate(from, to)
.then((result) => {
pendingFetches.delete(fetchKey);
if (!result) return undefined;
cache.set(fetchKey, { rate: result.rate, expiresAt: result.expiresAt });
cache.set(`${to}→${from}`, { rate: 1 / result.rate, expiresAt: result.expiresAt });
return result.rate;
})
.catch(() => {
pendingFetches.delete(fetchKey);
return undefined;
});
pendingFetches.set(fetchKey, promise);
return promise;
},
setRate(from: string, to: string, rate: number, expiresAt = Infinity) {
cache.set(`${from}→${to}`, { rate, expiresAt });
if (rate !== 0) {
cache.set(`${to}→${from}`, { rate: 1 / rate, expiresAt });
}
},
setRates(base: string, rates: Record<string, number>, expiresAt = Infinity) {
for (const [currency, rate] of Object.entries(rates)) {
cache.set(`${base}→${currency}`, { rate, expiresAt });
if (rate !== 0) {
cache.set(`${currency}→${base}`, { rate: 1 / rate, expiresAt });
}
}
},
hasRates(): boolean {
return cache.size > 0;
}
};
}

@ -0,0 +1,37 @@
/** A single cached exchange rate with expiration */
export interface RateEntry {
/** Exchange rate multiplier (from → to) */
rate: number;
/** Timestamp when this rate expires. Infinity = never expires. */
expiresAt: number;
}
/**
* Exchange rate provider — injected into money at construction.
*
* Rates are stored in a table with per-entry expiration.
* When a rate is requested and its entry has expired, the fetch function
* is called to get a fresh rate. No timers, no proactive refresh.
*/
export interface MoneyRatesProvider {
/**
* Get the exchange rate from one currency to another.
* If the cached entry is expired, calls the fetch function to refresh it.
* Returns undefined if the pair is not available and cannot be fetched.
*/
getRate(from: string, to: string): Promise<number | undefined>;
/**
* Set a rate directly (e.g., from initial data or backend push).
* expiresAt is the timestamp when this rate becomes stale.
* Use Infinity for rates that never expire.
*/
setRate(from: string, to: string, rate: number, expiresAt?: number): void;
/**
* Set multiple rates at once relative to a base currency.
*/
setRates(base: string, rates: Record<string, number>, expiresAt?: number): void;
/**
* Whether any rates are available (even if some may be expired).
*/
hasRates(): boolean;
}

@ -0,0 +1,6 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export { createNums, type NumsOptions } from './nums.svelte';

@ -0,0 +1,105 @@
import type { AppLangs, AppNums } from '../app/types';
// ── Intl cache ──────────────────────────────────────────────────────────────
const formatCache = new Map<string, Intl.NumberFormat>();
function getCachedFormat(locale: string, opts?: Intl.NumberFormatOptions): Intl.NumberFormat {
const key = `${locale}:${JSON.stringify(opts ?? {})}`;
let fmt = formatCache.get(key);
if (!fmt) {
fmt = new Intl.NumberFormat(locale || undefined, opts);
formatCache.set(key, fmt);
}
return fmt;
}
// ── Locale defaults ─────────────────────────────────────────────────────────
function resolveDecimalSeparator(locale: string): string {
const parts = getCachedFormat(locale).formatToParts(1.1);
return parts.find((p) => p.type === 'decimal')?.value ?? '.';
}
function resolveGroupSeparator(locale: string): string {
const parts = getCachedFormat(locale).formatToParts(1000);
return parts.find((p) => p.type === 'group')?.value ?? ',';
}
// ── Factory ─────────────────────────────────────────────────────────────────
export interface NumsOptions {
/** Initial decimal separator override */
decimalSeparator?: string;
/** Initial group separator override */
groupSeparator?: string;
/** Initial grouping override */
grouping?: boolean;
}
/**
* Create a reactive number formatting service.
* Subscribes to locale changes from langs.
*/
export function createNums(langs: AppLangs, opts: NumsOptions = {}): AppNums {
let _decimalSep = $state<string | null>(opts.decimalSeparator ?? null);
let _groupSep = $state<string | null>(opts.groupSeparator ?? null);
let _grouping = $state<boolean | null>(opts.grouping ?? null);
const listeners = new Set<() => void>();
function notify() {
for (const fn of listeners) fn();
}
langs.onLocaleChange(() => notify());
function locale(): string {
return langs.getLocale() || 'en';
}
return {
format(value: number, options?: Intl.NumberFormatOptions): string {
if (!Number.isFinite(value)) return String(value);
return getCachedFormat(locale(), options).format(value);
},
parse(value: string): number | undefined {
if (!value) return undefined;
const loc = locale();
const decSep = _decimalSep ?? resolveDecimalSeparator(loc);
const grpSep = _groupSep ?? resolveGroupSeparator(loc);
// Normalize to parseable format
let normalized = value;
if (grpSep) normalized = normalized.replaceAll(grpSep, '');
if (decSep !== '.') normalized = normalized.replace(decSep, '.');
normalized = normalized.replace(/[^\d.\-+eE]/g, '');
const n = Number(normalized);
return Number.isNaN(n) ? undefined : n;
},
getDecimalSeparator: () => _decimalSep ?? resolveDecimalSeparator(locale()),
getGroupSeparator: () => _groupSep ?? resolveGroupSeparator(locale()),
getGrouping: () => _grouping ?? true,
setDecimalSeparator(sep) {
_decimalSep = sep === 'auto' ? null : sep;
notify();
},
setGroupSeparator(sep) {
_groupSep = sep === 'auto' ? null : sep;
notify();
},
setGrouping(enabled) {
_grouping = enabled === 'auto' ? null : enabled;
notify();
},
onPreferenceChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
};
}

@ -0,0 +1,7 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export { createPresentation } from './presentation.svelte';
export type { PresentationOptions } from './presentation.svelte';

@ -0,0 +1,18 @@
import type { Direction } from '../app/types';
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'ku', 'yi', 'dv']);
/**
* Best-effort direction inferred from a BCP 47 locale.
*/
export function resolveDir(locale: string): Direction {
const base = locale.split('-')[0].toLowerCase();
return RTL_LOCALES.has(base) ? 'rtl' : 'ltr';
}
/**
* Legacy re-exports. The date-related defaults live in `$lib/util/dias`;
* they were duplicated here in an earlier iteration and are kept as
* passthroughs to preserve any lingering imports.
*/
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';

@ -0,0 +1,113 @@
import type { AppLangs, AppPresentation, Direction } from '../app/types';
import { resolveDir } from './locale-defaults';
export interface PresentationOptions {
/** Initial dir override (null = derive from locale) */
dir?: Direction;
/** Initial theme name */
theme?: string;
/** Initial color mode */
mode?: 'light' | 'dark' | 'auto';
/** Initial reduced motion preference */
reducedMotion?: boolean | 'auto';
/** Initial reduced sound preference */
reducedSound?: boolean;
/** Initial density */
density?: 'compact' | 'normal' | 'comfortable';
}
/**
* Create a reactive presentation service that subscribes to locale changes.
*
* Preferences follow the override/derive pattern:
* - null override → derive from locale or OS
* - explicit override → sticks regardless of locale changes
* - 'auto' resets to derived
*/
export function createPresentation(
langs: AppLangs,
opts: PresentationOptions = {}
): AppPresentation {
// ── Overrides (null = derive) ────────────────────────────────────────
let _dir = $state<Direction | null>(opts.dir ?? null);
let _theme = $state(opts.theme ?? 'base');
let _modeOverride = $state<'light' | 'dark' | null>(
opts.mode === 'auto' || opts.mode === undefined ? null : opts.mode
);
let _reducedMotionOverride = $state<boolean | null>(
opts.reducedMotion === 'auto' || opts.reducedMotion === undefined ? null : opts.reducedMotion
);
let _reducedSound = $state(opts.reducedSound ?? false);
let _density = $state<'compact' | 'normal' | 'comfortable'>(opts.density ?? 'normal');
// ── OS media queries (SSR-safe) ─────────────────────────────────────
let osDarkMode = $state(false);
let osReducedMotion = $state(false);
if (typeof window !== 'undefined') {
const darkMq = window.matchMedia('(prefers-color-scheme: dark)');
const motionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
osDarkMode = darkMq.matches;
osReducedMotion = motionMq.matches;
darkMq.addEventListener('change', (e) => (osDarkMode = e.matches));
motionMq.addEventListener('change', (e) => (osReducedMotion = e.matches));
}
// ── Subscribers ──────────────────────────────────────────────────────
const listeners = new Set<() => void>();
function notify() {
for (const fn of listeners) fn();
}
// Subscribe to locale changes — recalculate derived defaults
langs.onLocaleChange(() => {
// Derived values recalculate automatically via getters
// But we need to notify subscribers for non-Svelte consumers
notify();
});
// ── Interface ────────────────────────────────────────────────────────
return {
getDir: () => _dir ?? resolveDir(langs.getLocale()),
setDir: (dir) => {
_dir = dir === 'auto' ? null : dir;
notify();
},
getTheme: () => _theme,
setTheme: (theme) => {
_theme = theme;
notify();
},
getMode: () => _modeOverride ?? (osDarkMode ? 'dark' : 'light'),
setMode: (mode) => {
_modeOverride = mode === 'auto' ? null : mode;
notify();
},
getReducedMotion: () => _reducedMotionOverride ?? osReducedMotion,
setReducedMotion: (reduced) => {
_reducedMotionOverride = reduced === 'auto' ? null : reduced;
notify();
},
getReducedSound: () => _reducedSound,
setReducedSound: (reduced) => {
_reducedSound = reduced;
notify();
},
getDensity: () => _density,
setDensity: (density) => {
_density = density;
notify();
},
onPreferenceChange: (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
}
};
}

@ -0,0 +1,59 @@
/** Unit conversion factors to SI base units */
const TO_SI: Record<string, { base: string; factor: number }> = {
// Length
meter: { base: 'meter', factor: 1 },
kilometer: { base: 'meter', factor: 1000 },
centimeter: { base: 'meter', factor: 0.01 },
millimeter: { base: 'meter', factor: 0.001 },
mile: { base: 'meter', factor: 1609.344 },
yard: { base: 'meter', factor: 0.9144 },
foot: { base: 'meter', factor: 0.3048 },
inch: { base: 'meter', factor: 0.0254 },
// Mass
kilogram: { base: 'kilogram', factor: 1 },
gram: { base: 'kilogram', factor: 0.001 },
milligram: { base: 'kilogram', factor: 0.000001 },
pound: { base: 'kilogram', factor: 0.45359237 },
ounce: { base: 'kilogram', factor: 0.028349523 },
// Volume
liter: { base: 'liter', factor: 1 },
milliliter: { base: 'liter', factor: 0.001 },
gallon: { base: 'liter', factor: 3.785411784 },
quart: { base: 'liter', factor: 0.946352946 },
pint: { base: 'liter', factor: 0.473176473 },
'fluid-ounce': { base: 'liter', factor: 0.029573529 },
// Temperature handled separately
// Speed
'kilometer-per-hour': { base: 'meter-per-second', factor: 0.277778 },
'mile-per-hour': { base: 'meter-per-second', factor: 0.44704 },
'meter-per-second': { base: 'meter-per-second', factor: 1 }
};
/**
* Convert a value between two compatible units.
* Throws if units are incompatible (different base).
*/
export function convert(value: number, from: string, to: string): number {
// Temperature special case
if (from === 'celsius' && to === 'fahrenheit') return (value * 9) / 5 + 32;
if (from === 'fahrenheit' && to === 'celsius') return ((value - 32) * 5) / 9;
if (from === 'celsius' && to === 'kelvin') return value + 273.15;
if (from === 'kelvin' && to === 'celsius') return value - 273.15;
if (from === 'fahrenheit' && to === 'kelvin') return ((value - 32) * 5) / 9 + 273.15;
if (from === 'kelvin' && to === 'fahrenheit') return ((value - 273.15) * 9) / 5 + 32;
const fromDef = TO_SI[from];
const toDef = TO_SI[to];
if (!fromDef) throw new Error(`[units] Unknown unit: ${from}`);
if (!toDef) throw new Error(`[units] Unknown unit: ${to}`);
if (fromDef.base !== toDef.base) {
throw new Error(
`[units] Incompatible units: ${from} (${fromDef.base}) and ${to} (${toDef.base})`
);
}
// Convert: from → SI base → to
const siValue = value * fromDef.factor;
return siValue / toDef.factor;
}

@ -0,0 +1,8 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export { createUnits, type UnitsOptions } from './units.svelte';
export { resolveUnitSystem } from './locale-defaults';
export { convert } from './conversions';

@ -0,0 +1,6 @@
/** Locales that use the imperial measurement system */
const IMPERIAL_LOCALES = new Set(['en-US', 'en-LR', 'en-MM']);
export function resolveUnitSystem(locale: string): 'metric' | 'imperial' {
return IMPERIAL_LOCALES.has(locale) ? 'imperial' : 'metric';
}

@ -0,0 +1,77 @@
import type { AppLangs, AppUnits } from '../app/types';
import { resolveUnitSystem } from './locale-defaults';
import { convert as convertUnits } from './conversions';
// ── Intl cache ──────────────────────────────────────────────────────────────
const formatCache = new Map<string, Intl.NumberFormat>();
function getCachedUnitFormat(
locale: string,
unit: string,
opts?: Intl.NumberFormatOptions
): Intl.NumberFormat {
const key = `${locale}:${unit}:${JSON.stringify(opts ?? {})}`;
let fmt = formatCache.get(key);
if (!fmt) {
fmt = new Intl.NumberFormat(locale || undefined, {
style: 'unit',
unit,
unitDisplay: 'short',
...opts
});
formatCache.set(key, fmt);
}
return fmt;
}
// ── Factory ─────────────────────────────────────────────────────────────────
export interface UnitsOptions {
/** Initial measurement system override (null = derive from locale) */
system?: 'metric' | 'imperial';
}
/**
* Create a reactive units service.
* Subscribes to locale changes from langs.
* Owns measurement system preference.
*/
export function createUnits(langs: AppLangs, opts: UnitsOptions = {}): AppUnits {
let _system = $state<'metric' | 'imperial' | null>(opts.system ?? null);
const listeners = new Set<() => void>();
function notify() {
for (const fn of listeners) fn();
}
langs.onLocaleChange(() => {
if (_system === null) notify();
});
function locale(): string {
return langs.getLocale() || 'en';
}
return {
format(value: number, unit: string, options?: Intl.NumberFormatOptions): string {
if (!Number.isFinite(value)) return String(value);
return getCachedUnitFormat(locale(), unit, options).format(value);
},
getSystem: () => _system ?? resolveUnitSystem(locale()),
setSystem(system) {
_system = system === 'auto' ? null : system;
notify();
},
convert: convertUnits,
onPreferenceChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
};
}

@ -2,12 +2,12 @@
// HELPERS
// ==============================
import type { GlobLocale, LocaleResolver } from "./comn_types.ts";
import type { GlobLocale, LocaleResolver } from './comn_types.ts';
import { NumberFormatter, NumberParser, DateFormatter } from './comn_utils.ts';
/** Normaliza un `LocaleResolver` a un string de locale listo para `Intl.*`. */
export function resolveLocale(l: LocaleResolver): GlobLocale {
return typeof l === 'function' ? l() : l;
return typeof l === 'function' ? l() : l;
}
// ==============================
@ -16,7 +16,7 @@ export function resolveLocale(l: LocaleResolver): GlobLocale {
/** Devuelve true si `n` es un número finito válido para formateo. */
export function isValidNumber(n: number): boolean {
return typeof n === 'number' && Number.isFinite(n);
return typeof n === 'number' && Number.isFinite(n);
}
// ==============================
@ -36,16 +36,22 @@ export function isValidNumber(n: number): boolean {
* Devuelve un `NumberFormatter` (wrapper de `Intl.NumberFormat`) con cache interno
* y polyfills para `signDisplay` y `unit` en Safari.
*/
export function cachedNumberFormat(locale: GlobLocale, opts: Intl.NumberFormatOptions): NumberFormatter {
return new NumberFormatter(locale, opts);
export function cachedNumberFormat(
locale: GlobLocale,
opts: Intl.NumberFormatOptions
): NumberFormatter {
return new NumberFormatter(locale, opts);
}
/**
* Devuelve un `DateFormatter` (wrapper de `Intl.DateTimeFormat`) con cache interno
* y correcciones para los bugs de `hour12`/`hourCycle` en Chrome y Safari.
*/
export function cachedDateTimeFormat(locale: GlobLocale, opts: Intl.DateTimeFormatOptions): DateFormatter {
return new DateFormatter(locale, opts);
export function cachedDateTimeFormat(
locale: GlobLocale,
opts: Intl.DateTimeFormatOptions
): DateFormatter {
return new DateFormatter(locale, opts);
}
/**
@ -53,10 +59,9 @@ export function cachedDateTimeFormat(locale: GlobLocale, opts: Intl.DateTimeForm
* Detecta automáticamente el sistema numérico (latn, arab, hanidec, deva…)
* y valida input parcial mientras el usuario escribe.
*/
export function getNumberParser(locale: GlobLocale, opts: Intl.NumberFormatOptions = {}): NumberParser {
return new NumberParser(locale, opts);
export function getNumberParser(
locale: GlobLocale,
opts: Intl.NumberFormatOptions = {}
): NumberParser {
return new NumberParser(locale, opts);
}

@ -13,135 +13,147 @@ let _nfFormatterCache = new Map<string, Intl.NumberFormat>();
let supportsSignDisplay = false;
try {
supportsSignDisplay = (new Intl.NumberFormat('de-DE', { signDisplay: 'exceptZero' })).resolvedOptions().signDisplay === 'exceptZero';
supportsSignDisplay =
new Intl.NumberFormat('de-DE', { signDisplay: 'exceptZero' }).resolvedOptions().signDisplay ===
'exceptZero';
} catch {}
let supportsUnit = false;
try {
supportsUnit = (new Intl.NumberFormat('de-DE', { style: 'unit', unit: 'degree' })).resolvedOptions().style === 'unit';
supportsUnit =
new Intl.NumberFormat('de-DE', { style: 'unit', unit: 'degree' }).resolvedOptions().style ===
'unit';
} catch {}
// Polyfill para la unidad `degree` en Safari (formato narrow)
const DEGREE_NARROW: Record<string, string> = {
default : '°',
'ja-JP' : ' 度',
'zh-TW' : '度',
'sl-SI' : ' °',
default: '°',
'ja-JP': ' 度',
'zh-TW': '度',
'sl-SI': ' °'
};
export interface NumberFormatOptions extends Intl.NumberFormatOptions {
numberingSystem?: string;
numberingSystem?: string;
}
export class NumberFormatter implements Intl.NumberFormat {
private numberFormatter: Intl.NumberFormat;
private options: NumberFormatOptions;
constructor(locale: string, options: NumberFormatOptions = {}) {
this.numberFormatter = getCachedNF(locale, options);
this.options = options;
}
format(value: number): string {
let res = '';
if (!supportsSignDisplay && this.options.signDisplay != null) {
res = nfSignDisplayPolyfill(this.numberFormatter, this.options.signDisplay, value);
} else {
res = this.numberFormatter.format(value);
}
if (this.options.style === 'unit' && !supportsUnit) {
const { unit, unitDisplay = 'short', locale } = this.resolvedOptions();
if (unit === 'degree' && unitDisplay === 'narrow') {
res += DEGREE_NARROW[locale] ?? DEGREE_NARROW.default;
}
}
return res;
}
formatToParts(value: number): Intl.NumberFormatPart[] {
return this.numberFormatter.formatToParts(value);
}
formatRange(start: number, end: number): string {
if (typeof (this.numberFormatter as any).formatRange === 'function') {
return (this.numberFormatter as any).formatRange(start, end);
}
if (end < start) throw new RangeError('End value must be >= start value');
return `${this.format(start)} – ${this.format(end)}`;
}
formatRangeToParts(start: number, end: number): any[] {
if (typeof (this.numberFormatter as any).formatRangeToParts === 'function') {
return (this.numberFormatter as any).formatRangeToParts(start, end);
}
if (end < start) throw new RangeError('End value must be >= start value');
return [
...this.numberFormatter.formatToParts(start).map(p => ({ ...p, source: 'startRange' })),
{ type: 'literal', value: ' – ', source: 'shared' },
...this.numberFormatter.formatToParts(end).map(p => ({ ...p, source: 'endRange' })),
];
}
resolvedOptions(): Intl.ResolvedNumberFormatOptions {
let opts = this.numberFormatter.resolvedOptions();
if (!supportsSignDisplay && this.options.signDisplay != null) {
opts = { ...opts, signDisplay: this.options.signDisplay };
}
if (!supportsUnit && this.options.style === 'unit') {
opts = { ...opts, style: 'unit', unit: this.options.unit, unitDisplay: this.options.unitDisplay };
}
return opts;
}
private numberFormatter: Intl.NumberFormat;
private options: NumberFormatOptions;
constructor(locale: string, options: NumberFormatOptions = {}) {
this.numberFormatter = getCachedNF(locale, options);
this.options = options;
}
format(value: number): string {
let res = '';
if (!supportsSignDisplay && this.options.signDisplay != null) {
res = nfSignDisplayPolyfill(this.numberFormatter, this.options.signDisplay, value);
} else {
res = this.numberFormatter.format(value);
}
if (this.options.style === 'unit' && !supportsUnit) {
const { unit, unitDisplay = 'short', locale } = this.resolvedOptions();
if (unit === 'degree' && unitDisplay === 'narrow') {
res += DEGREE_NARROW[locale] ?? DEGREE_NARROW.default;
}
}
return res;
}
formatToParts(value: number): Intl.NumberFormatPart[] {
return this.numberFormatter.formatToParts(value);
}
formatRange(start: number, end: number): string {
if (typeof (this.numberFormatter as any).formatRange === 'function') {
return (this.numberFormatter as any).formatRange(start, end);
}
if (end < start) throw new RangeError('End value must be >= start value');
return `${this.format(start)} – ${this.format(end)}`;
}
formatRangeToParts(start: number, end: number): any[] {
if (typeof (this.numberFormatter as any).formatRangeToParts === 'function') {
return (this.numberFormatter as any).formatRangeToParts(start, end);
}
if (end < start) throw new RangeError('End value must be >= start value');
return [
...this.numberFormatter.formatToParts(start).map((p) => ({ ...p, source: 'startRange' })),
{ type: 'literal', value: ' – ', source: 'shared' },
...this.numberFormatter.formatToParts(end).map((p) => ({ ...p, source: 'endRange' }))
];
}
resolvedOptions(): Intl.ResolvedNumberFormatOptions {
let opts = this.numberFormatter.resolvedOptions();
if (!supportsSignDisplay && this.options.signDisplay != null) {
opts = { ...opts, signDisplay: this.options.signDisplay };
}
if (!supportsUnit && this.options.style === 'unit') {
opts = {
...opts,
style: 'unit',
unit: this.options.unit,
unitDisplay: this.options.unitDisplay
};
}
return opts;
}
}
function getCachedNF(locale: string, options: NumberFormatOptions = {}): Intl.NumberFormat {
let { numberingSystem } = options;
if (numberingSystem && !locale.includes('-nu-')) {
locale += (locale.includes('-u-') ? '' : '-u') + `-nu-${numberingSystem}`;
}
if (options.style === 'unit' && !supportsUnit) {
const { unit, unitDisplay = 'short' } = options;
if (!unit) throw new Error('unit option must be provided with style: "unit"');
options = { ...options, style: 'decimal' };
}
const cacheKey = locale + (options
? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join()
: '');
if (_nfFormatterCache.has(cacheKey)) return _nfFormatterCache.get(cacheKey)!;
const fmt = new Intl.NumberFormat(locale, options);
_nfFormatterCache.set(cacheKey, fmt);
return fmt;
let { numberingSystem } = options;
if (numberingSystem && !locale.includes('-nu-')) {
locale += (locale.includes('-u-') ? '' : '-u') + `-nu-${numberingSystem}`;
}
if (options.style === 'unit' && !supportsUnit) {
const { unit, unitDisplay = 'short' } = options;
if (!unit) throw new Error('unit option must be provided with style: "unit"');
options = { ...options, style: 'decimal' };
}
const cacheKey =
locale +
(options
? Object.entries(options)
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.join()
: '');
if (_nfFormatterCache.has(cacheKey)) return _nfFormatterCache.get(cacheKey)!;
const fmt = new Intl.NumberFormat(locale, options);
_nfFormatterCache.set(cacheKey, fmt);
return fmt;
}
function nfSignDisplayPolyfill(fmt: Intl.NumberFormat, signDisplay: string, num: number): string {
if (signDisplay === 'auto') return fmt.format(num);
if (signDisplay === 'never') return fmt.format(Math.abs(num));
let needsPositiveSign = false;
if (signDisplay === 'always') {
needsPositiveSign = num > 0 || Object.is(num, 0);
} else if (signDisplay === 'exceptZero') {
if (Object.is(num, -0) || Object.is(num, 0)) {
num = Math.abs(num);
} else {
needsPositiveSign = num > 0;
}
}
if (needsPositiveSign) {
const negative = fmt.format(-num);
const noSign = fmt.format(num);
const minus = negative.replace(noSign, '').replace(/\u200e|\u061C/, '');
return negative.replace(noSign, '!!!').replace(minus, '+').replace('!!!', noSign);
}
return fmt.format(num);
if (signDisplay === 'auto') return fmt.format(num);
if (signDisplay === 'never') return fmt.format(Math.abs(num));
let needsPositiveSign = false;
if (signDisplay === 'always') {
needsPositiveSign = num > 0 || Object.is(num, 0);
} else if (signDisplay === 'exceptZero') {
if (Object.is(num, -0) || Object.is(num, 0)) {
num = Math.abs(num);
} else {
needsPositiveSign = num > 0;
}
}
if (needsPositiveSign) {
const negative = fmt.format(-num);
const noSign = fmt.format(num);
const minus = negative.replace(noSign, '').replace(/\u200e|\u061C/, '');
return negative.replace(noSign, '!!!').replace(minus, '+').replace('!!!', noSign);
}
return fmt.format(num);
}
// ==============================
// NUMBER PARSER
// ==============================
@ -152,240 +164,290 @@ function nfSignDisplayPolyfill(fmt: Intl.NumberFormat, signDisplay: string, num:
// Adaptado de @internationalized/number (Adobe React Spectrum, Apache-2.0)
interface NpSymbols {
minusSign? : string;
plusSign? : string;
decimal? : string;
group? : string;
literals : RegExp;
numeral : RegExp;
numerals : string[];
index : (v: string) => string;
noNumeralUnits: Array<{ unit: string; value: number }>;
minusSign?: string;
plusSign?: string;
decimal?: string;
group?: string;
literals: RegExp;
numeral: RegExp;
numerals: string[];
index: (v: string) => string;
noNumeralUnits: Array<{ unit: string; value: number }>;
}
const CURRENCY_SIGN_REGEX = /^.*\(.*\).*$/;
const NUMBERING_SYSTEMS = ['latn', 'arab', 'hanidec', 'deva', 'beng', 'fullwide'];
const NUMBERING_SYSTEMS = ['latn', 'arab', 'hanidec', 'deva', 'beng', 'fullwide'];
const _npCache = new Map<string, NumberParserImpl>();
export class NumberParser {
private locale : string;
private options: Intl.NumberFormatOptions;
constructor(locale: string, options: Intl.NumberFormatOptions = {}) {
this.locale = locale;
this.options = options;
}
parse(value: string): number {
return getNPImpl(this.locale, this.options, value).parse(value);
}
isValidPartialNumber(value: string, minValue?: number, maxValue?: number): boolean {
return getNPImpl(this.locale, this.options, value).isValidPartialNumber(value, minValue, maxValue);
}
getNumberingSystem(value: string): string {
return getNPImpl(this.locale, this.options, value).options.numberingSystem;
}
private locale: string;
private options: Intl.NumberFormatOptions;
constructor(locale: string, options: Intl.NumberFormatOptions = {}) {
this.locale = locale;
this.options = options;
}
parse(value: string): number {
return getNPImpl(this.locale, this.options, value).parse(value);
}
isValidPartialNumber(value: string, minValue?: number, maxValue?: number): boolean {
return getNPImpl(this.locale, this.options, value).isValidPartialNumber(
value,
minValue,
maxValue
);
}
getNumberingSystem(value: string): string {
return getNPImpl(this.locale, this.options, value).options.numberingSystem;
}
}
function getNPImpl(locale: string, options: Intl.NumberFormatOptions, value: string): NumberParserImpl {
const defaultParser = getCachedNP(locale, options);
if (!locale.includes('-nu-') && !defaultParser.isValidPartialNumber(value)) {
for (const ns of NUMBERING_SYSTEMS) {
if (ns !== defaultParser.options.numberingSystem) {
const parser = getCachedNP(
locale + (locale.includes('-u-') ? '-nu-' : '-u-nu-') + ns,
options
);
if (parser.isValidPartialNumber(value)) return parser;
}
}
}
return defaultParser;
function getNPImpl(
locale: string,
options: Intl.NumberFormatOptions,
value: string
): NumberParserImpl {
const defaultParser = getCachedNP(locale, options);
if (!locale.includes('-nu-') && !defaultParser.isValidPartialNumber(value)) {
for (const ns of NUMBERING_SYSTEMS) {
if (ns !== defaultParser.options.numberingSystem) {
const parser = getCachedNP(
locale + (locale.includes('-u-') ? '-nu-' : '-u-nu-') + ns,
options
);
if (parser.isValidPartialNumber(value)) return parser;
}
}
}
return defaultParser;
}
function getCachedNP(locale: string, options: Intl.NumberFormatOptions): NumberParserImpl {
const key = locale + (options ? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join() : '');
let p = _npCache.get(key);
if (!p) { p = new NumberParserImpl(locale, options); _npCache.set(key, p); }
return p;
const key =
locale +
(options
? Object.entries(options)
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.join()
: '');
let p = _npCache.get(key);
if (!p) {
p = new NumberParserImpl(locale, options);
_npCache.set(key, p);
}
return p;
}
const NP_PLURAL_NUMBERS = [0, 4, 2, 1, 11, 20, 3, 7, 100, 21, 0.1, 1.1];
const NP_NON_LITERAL = new Set(['decimal', 'fraction', 'integer', 'minusSign', 'plusSign', 'group']);
const NP_NON_LITERAL = new Set([
'decimal',
'fraction',
'integer',
'minusSign',
'plusSign',
'group'
]);
class NumberParserImpl {
formatter: Intl.NumberFormat;
options : Intl.ResolvedNumberFormatOptions;
symbols : NpSymbols;
locale : string;
constructor(locale: string, options: Intl.NumberFormatOptions = {}) {
this.locale = locale;
if (options.roundingIncrement !== 1 && options.roundingIncrement != null) {
if (options.maximumFractionDigits == null && options.minimumFractionDigits == null) {
options = { ...options, maximumFractionDigits: 0, minimumFractionDigits: 0 };
} else if (options.maximumFractionDigits == null) {
options = { ...options, maximumFractionDigits: options.minimumFractionDigits };
} else if (options.minimumFractionDigits == null) {
options = { ...options, minimumFractionDigits: options.maximumFractionDigits };
}
}
this.formatter = new Intl.NumberFormat(locale, options);
this.options = this.formatter.resolvedOptions();
this.symbols = buildNpSymbols(locale, this.formatter, this.options, options);
}
parse(value: string): number {
const useGrouping = this.formatter.resolvedOptions().useGrouping;
let v = this.sanitize(value);
if (!useGrouping && this.symbols.group && v.includes(this.symbols.group)) return NaN;
if (this.symbols.group) v = v.replaceAll(this.symbols.group, '');
if (this.symbols.decimal) v = v.replace(this.symbols.decimal, '.');
if (this.symbols.minusSign) v = v.replace(this.symbols.minusSign, '-');
v = v.replace(this.symbols.numeral, this.symbols.index);
if (this.options.style === 'percent') {
const isNeg = v.indexOf('-');
v = v.replace('-', '').replace('+', '');
let idx = v.indexOf('.');
if (idx === -1) idx = v.length;
v = v.replace('.', '');
if (idx - 2 === 0) v = `0.${v}`;
else if (idx - 2 === -1) v = `0.0${v}`;
else if (idx - 2 === -2) v = '0.00';
else v = `${v.slice(0, idx - 2)}.${v.slice(idx - 2)}`;
if (isNeg > -1) v = `-${v}`;
}
const num = v ? +v : NaN;
if (isNaN(num)) return NaN;
if (this.options.style === 'percent') {
const pOpts = {
...this.options, style: 'decimal' as const,
minimumFractionDigits: Math.min((this.options.minimumFractionDigits ?? 0) + 2, 20),
maximumFractionDigits: Math.min((this.options.maximumFractionDigits ?? 0) + 2, 20),
};
return new NumberParser(this.locale, pOpts).parse(
new NumberFormatter(this.locale, pOpts).format(num)
);
}
if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) {
return -1 * num;
}
return num;
}
sanitize(value: string): string {
const useGrouping = this.formatter.resolvedOptions().useGrouping;
if (this.symbols.noNumeralUnits.length > 0) {
const match = this.symbols.noNumeralUnits.find(o => o.unit === value);
if (match) return String(match.value);
}
value = value.replace(this.symbols.literals, '');
if (this.symbols.minusSign) value = value.replace('-', this.symbols.minusSign);
if (this.options.numberingSystem === 'arab') {
if (this.symbols.decimal) {
value = value.replaceAll(',', this.symbols.decimal);
value = value.replaceAll(String.fromCharCode(1548), this.symbols.decimal);
}
if (this.symbols.group && useGrouping) {
value = value.replaceAll('.', this.symbols.group);
}
}
if (this.symbols.group === '\u2019' && value.includes("'") && useGrouping) {
value = value.replaceAll("'", this.symbols.group);
}
if (this.options.locale === 'fr-FR' && this.symbols.group && useGrouping) {
value = value.replaceAll(' ', this.symbols.group);
value = value.replaceAll('\u00A0', this.symbols.group);
}
return value;
}
isValidPartialNumber(value: string, minValue = -Infinity, maxValue = Infinity): boolean {
const useGrouping = this.formatter.resolvedOptions().useGrouping;
value = this.sanitize(value);
if (this.symbols.minusSign && value.startsWith(this.symbols.minusSign) && minValue < 0) {
value = value.slice(this.symbols.minusSign.length);
} else if (this.symbols.plusSign && value.startsWith(this.symbols.plusSign) && maxValue > 0) {
value = value.slice(this.symbols.plusSign.length);
}
if (this.symbols.decimal && value.indexOf(this.symbols.decimal) > -1 && this.options.maximumFractionDigits === 0) {
return false;
}
if (this.symbols.group && useGrouping) value = value.replaceAll(this.symbols.group, '');
value = value.replace(this.symbols.numeral, '');
if (this.symbols.decimal) value = value.replace(this.symbols.decimal, '');
return value.length === 0;
}
formatter: Intl.NumberFormat;
options: Intl.ResolvedNumberFormatOptions;
symbols: NpSymbols;
locale: string;
constructor(locale: string, options: Intl.NumberFormatOptions = {}) {
this.locale = locale;
if (options.roundingIncrement !== 1 && options.roundingIncrement != null) {
if (options.maximumFractionDigits == null && options.minimumFractionDigits == null) {
options = { ...options, maximumFractionDigits: 0, minimumFractionDigits: 0 };
} else if (options.maximumFractionDigits == null) {
options = { ...options, maximumFractionDigits: options.minimumFractionDigits };
} else if (options.minimumFractionDigits == null) {
options = { ...options, minimumFractionDigits: options.maximumFractionDigits };
}
}
this.formatter = new Intl.NumberFormat(locale, options);
this.options = this.formatter.resolvedOptions();
this.symbols = buildNpSymbols(locale, this.formatter, this.options, options);
}
parse(value: string): number {
const useGrouping = this.formatter.resolvedOptions().useGrouping;
let v = this.sanitize(value);
if (!useGrouping && this.symbols.group && v.includes(this.symbols.group)) return NaN;
if (this.symbols.group) v = v.replaceAll(this.symbols.group, '');
if (this.symbols.decimal) v = v.replace(this.symbols.decimal, '.');
if (this.symbols.minusSign) v = v.replace(this.symbols.minusSign, '-');
v = v.replace(this.symbols.numeral, this.symbols.index);
if (this.options.style === 'percent') {
const isNeg = v.indexOf('-');
v = v.replace('-', '').replace('+', '');
let idx = v.indexOf('.');
if (idx === -1) idx = v.length;
v = v.replace('.', '');
if (idx - 2 === 0) v = `0.${v}`;
else if (idx - 2 === -1) v = `0.0${v}`;
else if (idx - 2 === -2) v = '0.00';
else v = `${v.slice(0, idx - 2)}.${v.slice(idx - 2)}`;
if (isNeg > -1) v = `-${v}`;
}
const num = v ? +v : NaN;
if (isNaN(num)) return NaN;
if (this.options.style === 'percent') {
const pOpts = {
...this.options,
style: 'decimal' as const,
minimumFractionDigits: Math.min((this.options.minimumFractionDigits ?? 0) + 2, 20),
maximumFractionDigits: Math.min((this.options.maximumFractionDigits ?? 0) + 2, 20)
};
return new NumberParser(this.locale, pOpts).parse(
new NumberFormatter(this.locale, pOpts).format(num)
);
}
if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) {
return -1 * num;
}
return num;
}
sanitize(value: string): string {
const useGrouping = this.formatter.resolvedOptions().useGrouping;
if (this.symbols.noNumeralUnits.length > 0) {
const match = this.symbols.noNumeralUnits.find((o) => o.unit === value);
if (match) return String(match.value);
}
value = value.replace(this.symbols.literals, '');
if (this.symbols.minusSign) value = value.replace('-', this.symbols.minusSign);
if (this.options.numberingSystem === 'arab') {
if (this.symbols.decimal) {
value = value.replaceAll(',', this.symbols.decimal);
value = value.replaceAll(String.fromCharCode(1548), this.symbols.decimal);
}
if (this.symbols.group && useGrouping) {
value = value.replaceAll('.', this.symbols.group);
}
}
if (this.symbols.group === '\u2019' && value.includes("'") && useGrouping) {
value = value.replaceAll("'", this.symbols.group);
}
if (this.options.locale === 'fr-FR' && this.symbols.group && useGrouping) {
value = value.replaceAll(' ', this.symbols.group);
value = value.replaceAll('\u00A0', this.symbols.group);
}
return value;
}
isValidPartialNumber(value: string, minValue = -Infinity, maxValue = Infinity): boolean {
const useGrouping = this.formatter.resolvedOptions().useGrouping;
value = this.sanitize(value);
if (this.symbols.minusSign && value.startsWith(this.symbols.minusSign) && minValue < 0) {
value = value.slice(this.symbols.minusSign.length);
} else if (this.symbols.plusSign && value.startsWith(this.symbols.plusSign) && maxValue > 0) {
value = value.slice(this.symbols.plusSign.length);
}
if (
this.symbols.decimal &&
value.indexOf(this.symbols.decimal) > -1 &&
this.options.maximumFractionDigits === 0
) {
return false;
}
if (this.symbols.group && useGrouping) value = value.replaceAll(this.symbols.group, '');
value = value.replace(this.symbols.numeral, '');
if (this.symbols.decimal) value = value.replace(this.symbols.decimal, '');
return value.length === 0;
}
}
function buildNpSymbols(
locale : string,
formatter : Intl.NumberFormat,
intlOptions : Intl.ResolvedNumberFormatOptions,
origOptions : Intl.NumberFormatOptions
locale: string,
formatter: Intl.NumberFormat,
intlOptions: Intl.ResolvedNumberFormatOptions,
origOptions: Intl.NumberFormatOptions
): NpSymbols {
const symFmt = new Intl.NumberFormat(locale, {
...intlOptions,
minimumSignificantDigits: 1,
maximumSignificantDigits: 21,
roundingIncrement: 1,
roundingPriority : 'auto',
roundingMode : 'halfExpand',
useGrouping : true,
});
const allParts = symFmt.formatToParts(-10000.111);
const posAllParts = symFmt.formatToParts(10000.111);
const pluralParts = NP_PLURAL_NUMBERS.map(n => symFmt.formatToParts(n));
const noNumeralUnits = pluralParts.map((p, i) => {
const unit = p.find(x => x.type === 'unit');
if (unit && !p.some(x => x.type === 'integer' || x.type === 'fraction')) {
return { unit: unit.value, value: NP_PLURAL_NUMBERS[i] };
}
return null;
}).filter(Boolean) as Array<{ unit: string; value: number }>;
let minusSign: string = allParts.find(p => p.type === 'minusSign')?.value ?? '-';
let plusSign : string | undefined = posAllParts.find(p => p.type === 'plusSign')?.value;
if (!plusSign && (origOptions?.signDisplay === 'exceptZero' || origOptions?.signDisplay === 'always')) {
plusSign = '+';
}
const decimalParts = new Intl.NumberFormat(locale, {
...intlOptions, minimumFractionDigits: 2, maximumFractionDigits: 2,
}).formatToParts(0.001);
const decimal = decimalParts.find(p => p.type === 'decimal')?.value;
const group = allParts.find(p => p.type === 'group')?.value;
const allLits = allParts.filter(p => !NP_NON_LITERAL.has(p.type)).map(p => escapeRe(p.value));
const pluralLits = pluralParts.flatMap(p => p.filter(x => !NP_NON_LITERAL.has(x.type)).map(x => escapeRe(x.value)));
const sorted = [...new Set([...allLits, ...pluralLits])].sort((a, b) => b.length - a.length);
const literals = sorted.length === 0
? new RegExp('\\p{White_Space}|\\p{Cf}', 'gu')
: new RegExp(`${sorted.join('|')}|\\p{White_Space}|\\p{Cf}`, 'gu');
const numerals = [...new Intl.NumberFormat(intlOptions.locale, { useGrouping: false }).format(9876543210)].reverse();
const idxMap = new Map(numerals.map((d, i) => [d, i]));
const numeral = new RegExp(`[${numerals.join('')}]`, 'g');
const index = (d: string) => String(idxMap.get(d));
return { minusSign, plusSign, decimal, group, literals, numeral, numerals, index, noNumeralUnits };
const symFmt = new Intl.NumberFormat(locale, {
...intlOptions,
minimumSignificantDigits: 1,
maximumSignificantDigits: 21,
roundingIncrement: 1,
roundingPriority: 'auto',
roundingMode: 'halfExpand',
useGrouping: true
});
const allParts = symFmt.formatToParts(-10000.111);
const posAllParts = symFmt.formatToParts(10000.111);
const pluralParts = NP_PLURAL_NUMBERS.map((n) => symFmt.formatToParts(n));
const noNumeralUnits = pluralParts
.map((p, i) => {
const unit = p.find((x) => x.type === 'unit');
if (unit && !p.some((x) => x.type === 'integer' || x.type === 'fraction')) {
return { unit: unit.value, value: NP_PLURAL_NUMBERS[i] };
}
return null;
})
.filter(Boolean) as Array<{ unit: string; value: number }>;
let minusSign: string = allParts.find((p) => p.type === 'minusSign')?.value ?? '-';
let plusSign: string | undefined = posAllParts.find((p) => p.type === 'plusSign')?.value;
if (
!plusSign &&
(origOptions?.signDisplay === 'exceptZero' || origOptions?.signDisplay === 'always')
) {
plusSign = '+';
}
const decimalParts = new Intl.NumberFormat(locale, {
...intlOptions,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).formatToParts(0.001);
const decimal = decimalParts.find((p) => p.type === 'decimal')?.value;
const group = allParts.find((p) => p.type === 'group')?.value;
const allLits = allParts.filter((p) => !NP_NON_LITERAL.has(p.type)).map((p) => escapeRe(p.value));
const pluralLits = pluralParts.flatMap((p) =>
p.filter((x) => !NP_NON_LITERAL.has(x.type)).map((x) => escapeRe(x.value))
);
const sorted = [...new Set([...allLits, ...pluralLits])].sort((a, b) => b.length - a.length);
const literals =
sorted.length === 0
? new RegExp('\\p{White_Space}|\\p{Cf}', 'gu')
: new RegExp(`${sorted.join('|')}|\\p{White_Space}|\\p{Cf}`, 'gu');
const numerals = [
...new Intl.NumberFormat(intlOptions.locale, { useGrouping: false }).format(9876543210)
].reverse();
const idxMap = new Map(numerals.map((d, i) => [d, i]));
const numeral = new RegExp(`[${numerals.join('')}]`, 'g');
const index = (d: string) => String(idxMap.get(d));
return {
minusSign,
plusSign,
decimal,
group,
literals,
numeral,
numerals,
index,
noNumeralUnits
};
}
function escapeRe(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ==============================
// DATE FORMATTER
// ==============================
@ -402,120 +464,143 @@ let _dtFormatterCache = new Map<string, Intl.DateTimeFormat>();
// https://github.com/unicode-org/cldr/blob/...supplementalData.xml#L4774-L4802
const hour12Prefs: Record<string, Record<string, string>> = {
true : { ja: 'h11' },
false: {},
true: { ja: 'h11' },
false: {}
};
export class DateFormatter implements Intl.DateTimeFormat {
private formatter : Intl.DateTimeFormat;
private options : Intl.DateTimeFormatOptions;
private resolvedHourCycle ?: Intl.DateTimeFormatOptions['hourCycle'];
constructor(locale: string, options: Intl.DateTimeFormatOptions = {}) {
this.formatter = getCachedDTF(locale, options);
this.options = options;
}
format(value: Date): string {
return this.formatter.format(value);
}
formatToParts(value: Date): Intl.DateTimeFormatPart[] {
return this.formatter.formatToParts(value);
}
formatRange(start: Date, end: Date): string {
if (typeof (this.formatter as any).formatRange === 'function') {
return (this.formatter as any).formatRange(start, end);
}
if (end < start) throw new RangeError('End date must be >= start date');
return `${this.formatter.format(start)} – ${this.formatter.format(end)}`;
}
formatRangeToParts(start: Date, end: Date): any[] {
if (typeof (this.formatter as any).formatRangeToParts === 'function') {
return (this.formatter as any).formatRangeToParts(start, end);
}
if (end < start) throw new RangeError('End date must be >= start date');
return [
...this.formatter.formatToParts(start).map(p => ({ ...p, source: 'startRange' })),
{ type: 'literal', value: ' – ', source: 'shared' },
...this.formatter.formatToParts(end).map(p => ({ ...p, source: 'endRange' })),
];
}
resolvedOptions(): Intl.ResolvedDateTimeFormatOptions {
let opts = this.formatter.resolvedOptions();
if (hasBuggyResolvedHourCycle()) {
if (!this.resolvedHourCycle) {
this.resolvedHourCycle = getResolvedHourCycle(opts.locale, this.options);
}
if (this.resolvedHourCycle) {
opts = { ...opts, hourCycle: this.resolvedHourCycle };
opts = { ...opts, hour12: this.resolvedHourCycle === 'h11' || this.resolvedHourCycle === 'h12' };
}
}
// Safari usa un nombre diferente para el calendario Ethiopic Amete Alem
if (opts.calendar === 'ethiopic-amete-alem') {
opts = { ...opts, calendar: 'ethioaa' };
}
return opts;
}
private formatter: Intl.DateTimeFormat;
private options: Intl.DateTimeFormatOptions;
private resolvedHourCycle?: Intl.DateTimeFormatOptions['hourCycle'];
constructor(locale: string, options: Intl.DateTimeFormatOptions = {}) {
this.formatter = getCachedDTF(locale, options);
this.options = options;
}
format(value: Date): string {
return this.formatter.format(value);
}
formatToParts(value: Date): Intl.DateTimeFormatPart[] {
return this.formatter.formatToParts(value);
}
formatRange(start: Date, end: Date): string {
if (typeof (this.formatter as any).formatRange === 'function') {
return (this.formatter as any).formatRange(start, end);
}
if (end < start) throw new RangeError('End date must be >= start date');
return `${this.formatter.format(start)} – ${this.formatter.format(end)}`;
}
formatRangeToParts(start: Date, end: Date): any[] {
if (typeof (this.formatter as any).formatRangeToParts === 'function') {
return (this.formatter as any).formatRangeToParts(start, end);
}
if (end < start) throw new RangeError('End date must be >= start date');
return [
...this.formatter.formatToParts(start).map((p) => ({ ...p, source: 'startRange' })),
{ type: 'literal', value: ' – ', source: 'shared' },
...this.formatter.formatToParts(end).map((p) => ({ ...p, source: 'endRange' }))
];
}
resolvedOptions(): Intl.ResolvedDateTimeFormatOptions {
let opts = this.formatter.resolvedOptions();
if (hasBuggyResolvedHourCycle()) {
if (!this.resolvedHourCycle) {
this.resolvedHourCycle = getResolvedHourCycle(opts.locale, this.options);
}
if (this.resolvedHourCycle) {
opts = { ...opts, hourCycle: this.resolvedHourCycle };
opts = {
...opts,
hour12: this.resolvedHourCycle === 'h11' || this.resolvedHourCycle === 'h12'
};
}
}
// Safari usa un nombre diferente para el calendario Ethiopic Amete Alem
if (opts.calendar === 'ethiopic-amete-alem') {
opts = { ...opts, calendar: 'ethioaa' };
}
return opts;
}
}
function getCachedDTF(locale: string, options: Intl.DateTimeFormatOptions = {}): Intl.DateTimeFormat {
// Fix Chrome: hour12:false resuelve a h24 (medianoche = "24:00") en vez de h23 ("0:00")
if (typeof options.hour12 === 'boolean' && hasBuggyHour12Behavior()) {
options = { ...options };
const pref = hour12Prefs[String(options.hour12)]?.[locale.split('-')[0]];
options.hourCycle = (pref ?? (options.hour12 ? 'h12' : 'h23')) as Intl.DateTimeFormatOptions['hourCycle'];
delete options.hour12;
}
const cacheKey = locale + (options
? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join()
: '');
if (_dtFormatterCache.has(cacheKey)) return _dtFormatterCache.get(cacheKey)!;
const fmt = new Intl.DateTimeFormat(locale, options);
_dtFormatterCache.set(cacheKey, fmt);
return fmt;
function getCachedDTF(
locale: string,
options: Intl.DateTimeFormatOptions = {}
): Intl.DateTimeFormat {
// Fix Chrome: hour12:false resuelve a h24 (medianoche = "24:00") en vez de h23 ("0:00")
if (typeof options.hour12 === 'boolean' && hasBuggyHour12Behavior()) {
options = { ...options };
const pref = hour12Prefs[String(options.hour12)]?.[locale.split('-')[0]];
options.hourCycle = (pref ??
(options.hour12 ? 'h12' : 'h23')) as Intl.DateTimeFormatOptions['hourCycle'];
delete options.hour12;
}
const cacheKey =
locale +
(options
? Object.entries(options)
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.join()
: '');
if (_dtFormatterCache.has(cacheKey)) return _dtFormatterCache.get(cacheKey)!;
const fmt = new Intl.DateTimeFormat(locale, options);
_dtFormatterCache.set(cacheKey, fmt);
return fmt;
}
let _hasBuggyHour12: boolean | null = null;
function hasBuggyHour12Behavior(): boolean {
if (_hasBuggyHour12 == null) {
_hasBuggyHour12 = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false })
.format(new Date(2020, 2, 3, 0)) === '24';
}
return _hasBuggyHour12;
if (_hasBuggyHour12 == null) {
_hasBuggyHour12 =
new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false }).format(
new Date(2020, 2, 3, 0)
) === '24';
}
return _hasBuggyHour12;
}
let _hasBuggyHourCycle: boolean | null = null;
function hasBuggyResolvedHourCycle(): boolean {
if (_hasBuggyHourCycle == null) {
_hasBuggyHourCycle = new Intl.DateTimeFormat('fr', { hour: 'numeric', hour12: false })
.resolvedOptions().hourCycle === 'h12';
}
return _hasBuggyHourCycle;
if (_hasBuggyHourCycle == null) {
_hasBuggyHourCycle =
new Intl.DateTimeFormat('fr', { hour: 'numeric', hour12: false }).resolvedOptions()
.hourCycle === 'h12';
}
return _hasBuggyHourCycle;
}
function getResolvedHourCycle(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions['hourCycle'] | undefined {
if (!options.timeStyle && !options.hour) return undefined;
locale = locale.replace(/(-u-)?-nu-[a-zA-Z0-9]+/, '');
locale += (locale.includes('-u-') ? '' : '-u') + '-nu-latn';
const fmt = getCachedDTF(locale, { ...options, timeZone: undefined });
const min = parseInt(fmt.formatToParts(new Date(2020, 2, 3, 0)).find(p => p.type === 'hour')!.value, 10);
const max = parseInt(fmt.formatToParts(new Date(2020, 2, 3, 23)).find(p => p.type === 'hour')!.value, 10);
if (min === 0 && max === 23) return 'h23';
if (min === 24 && max === 23) return 'h24';
if (min === 0 && max === 11) return 'h11';
if (min === 12 && max === 11) return 'h12';
throw new Error('Unexpected hour cycle result');
function getResolvedHourCycle(
locale: string,
options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormatOptions['hourCycle'] | undefined {
if (!options.timeStyle && !options.hour) return undefined;
locale = locale.replace(/(-u-)?-nu-[a-zA-Z0-9]+/, '');
locale += (locale.includes('-u-') ? '' : '-u') + '-nu-latn';
const fmt = getCachedDTF(locale, { ...options, timeZone: undefined });
const min = parseInt(
fmt.formatToParts(new Date(2020, 2, 3, 0)).find((p) => p.type === 'hour')!.value,
10
);
const max = parseInt(
fmt.formatToParts(new Date(2020, 2, 3, 23)).find((p) => p.type === 'hour')!.value,
10
);
if (min === 0 && max === 23) return 'h23';
if (min === 24 && max === 23) return 'h24';
if (min === 0 && max === 11) return 'h11';
if (min === 12 && max === 11) return 'h12';
throw new Error('Unexpected hour cycle result');
}

@ -12,36 +12,33 @@ import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
import { createCurr } from './curr_engine.ts';
export function createCurrState<S extends LingNode>(
definitions : Record<string, CurrDefinition>,
ling : LingInstance<S> | null = null,
opts : CurrConfig = {},
defaultLocale : LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale : () => unknown = () => null,
definitions: Record<string, CurrDefinition>,
ling: LingInstance<S> | null = null,
opts: CurrConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale: () => unknown = () => null
): CurrInstance {
const _curr = createCurr(definitions, ling, opts, defaultLocale);
// $state propio — moneda activa
let currency = $state<string>(_curr.getCurrency());
return {
f: (amount, display) => {
getReactiveLocale(); // dependencia reactiva de locale
currency; // dependencia reactiva de moneda
return _curr.f(amount, display);
},
convert : _curr.convert.bind(_curr),
convertTo : _curr.convertTo.bind(_curr),
get : _curr.get.bind(_curr),
getCurrencies: _curr.getCurrencies.bind(_curr),
getCurrency: () => currency,
setCurrency: (c) => {
_curr.setCurrency(c);
currency = _curr.getCurrency(); // normaliza a mayúsculas
},
};
const _curr = createCurr(definitions, ling, opts, defaultLocale);
// $state propio — moneda activa
let currency = $state<string>(_curr.getCurrency());
return {
f: (amount, display) => {
getReactiveLocale(); // dependencia reactiva de locale
currency; // dependencia reactiva de moneda
return _curr.f(amount, display);
},
convert: _curr.convert.bind(_curr),
convertTo: _curr.convertTo.bind(_curr),
get: _curr.get.bind(_curr),
getCurrencies: _curr.getCurrencies.bind(_curr),
getCurrency: () => currency,
setCurrency: (c) => {
_curr.setCurrency(c);
currency = _curr.getCurrency(); // normaliza a mayúsculas
}
};
}

@ -1,26 +1,19 @@
import type {
CurrInstance,
CurrDefinition,
CurrDisplay,
CurrConfig,
ConversionValue
CurrInstance,
CurrDefinition,
CurrDisplay,
CurrConfig,
ConversionValue
} from './curr_types.ts';
import type {
LingInstance,
LingNode
} from '../../ling/lib/types';
import type {
LocaleResolver,
GlobLocale
} from './comn_types.ts';
import type { LingInstance, LingNode } from '../../ling/lib/types';
import type { LocaleResolver, GlobLocale } from './comn_types.ts';
import { resolveLocale, cachedNumberFormat, isValidNumber } from './comn_engine.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib';
import { currLingNode } from './curr_langs.ts';
// Flag de módulo — registra traducciones en ling solo una vez.
let currExtended = false;
/**
* Crea un módulo de moneda con formateo y conversión.
*
@ -35,107 +28,114 @@ let currExtended = false;
* a la misma moneda base. La fórmula es `(amount / fromRate) * toRate`.
*/
export function createCurr<S extends LingNode>(
definitions : Record<string, CurrDefinition>,
ling : LingInstance<S> | null = null,
opts : CurrConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
definitions: Record<string, CurrDefinition>,
ling: LingInstance<S> | null = null,
opts: CurrConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): CurrInstance {
// Registra traducciones predefinidas de monedas en ling solo una vez
if (ling && !currExtended) {
ling.extend('curr', currLingNode);
currExtended = true;
}
let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? '';
const defaults = {
display : opts.display ?? 'symbol' as CurrDisplay,
decimals : opts.decimals ?? 2,
};
function getCurrency(): string { return selectedCurrency; }
function setCurrency(currency: string): void { selectedCurrency = currency.toUpperCase(); }
function get(currency: string): CurrDefinition | undefined {
return definitions[currency.toUpperCase()];
}
function formatAmount(amount: number, decimals: number, locale: GlobLocale): string {
return cachedNumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount);
}
/**
* Resuelve el nombre plural de la moneda.
* Requiere `ling` — sin él devuelve el código ISO como fallback.
*/
/**
* Resuelve el nombre plural de la moneda.
* Lee de ling via 'curr.{CODE}.name' — registrado en extend().
* Fallback: def.name() si existe, luego el código ISO.
*/
function resolveName(def: CurrDefinition, amount: number, locale: GlobLocale): string {
if (!ling) return def.code;
// Intenta leer desde el nodo 'curr' registrado en ling
const path = `curr.${def.code}.name`;
const fromLing = (ling.t as any)(path, { count: Math.abs(amount) });
if (fromLing && fromLing !== path) return fromLing;
// Fallback: función name() definida en CurrDefinition
if (def.name) {
const record = def.name({ count: Math.abs(amount) });
const baseLocale = locale.split('-')[0] as keyof typeof record;
const fallbackLocale = resolveLocale(defaultLocale).split('-')[0] as keyof typeof record;
return record[baseLocale] ?? record[fallbackLocale] ?? record['es'] ?? def.code;
}
return def.code;
}
function resolveConversionValue(value: ConversionValue): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
return value();
}
function f(
amount : number,
display : CurrDisplay = defaults.display,
locale : LocaleResolver = defaultLocale
): string {
if (!isValidNumber(amount)) return String(amount);
const loc = resolveLocale(locale);
const formatted = formatAmount(amount, defaults.decimals, loc);
const def = get(selectedCurrency);
if (!def) return `${formatted} ${selectedCurrency}`;
switch (display) {
case 'symbol': return `${formatted} ${def.symbol}`;
case 'code': return `${formatted} ${def.code}`;
case 'name': return `${formatted} ${resolveName(def, amount, loc)}`;
}
}
function convert(amount: number, from: string, to: string): number | null {
if (from.toUpperCase() === to.toUpperCase()) return amount;
const fromDef = get(from);
const toDef = get(to);
if (!fromDef || !toDef) return null;
const fromRate = resolveConversionValue(fromDef.conversionValue ?? null);
const toRate = resolveConversionValue(toDef.conversionValue ?? null);
if (fromRate === null || toRate === null) return null;
return (amount / fromRate) * toRate;
}
function convertTo(amount: number, to: string): number | null {
return convert(amount, selectedCurrency, to);
}
function getCurrencies(): string[] { return Object.keys(definitions); }
return { f, convert, convertTo, setCurrency, getCurrency, get, getCurrencies };
}
// Registra traducciones predefinidas de monedas en ling solo una vez
if (ling && !currExtended) {
ling.extend('curr', currLingNode);
currExtended = true;
}
let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? '';
const defaults = {
display: opts.display ?? ('symbol' as CurrDisplay),
decimals: opts.decimals ?? 2
};
function getCurrency(): string {
return selectedCurrency;
}
function setCurrency(currency: string): void {
selectedCurrency = currency.toUpperCase();
}
function get(currency: string): CurrDefinition | undefined {
return definitions[currency.toUpperCase()];
}
function formatAmount(amount: number, decimals: number, locale: GlobLocale): string {
return cachedNumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
}).format(amount);
}
/**
* Resuelve el nombre plural de la moneda.
* Requiere `ling` — sin él devuelve el código ISO como fallback.
*/
/**
* Resuelve el nombre plural de la moneda.
* Lee de ling via 'curr.{CODE}.name' — registrado en extend().
* Fallback: def.name() si existe, luego el código ISO.
*/
function resolveName(def: CurrDefinition, amount: number, locale: GlobLocale): string {
if (!ling) return def.code;
// Intenta leer desde el nodo 'curr' registrado en ling
const path = `curr.${def.code}.name`;
const fromLing = (ling.t as any)(path, { count: Math.abs(amount) });
if (fromLing && fromLing !== path) return fromLing;
// Fallback: función name() definida en CurrDefinition
if (def.name) {
const record = def.name({ count: Math.abs(amount) });
const baseLocale = locale.split('-')[0] as keyof typeof record;
const fallbackLocale = resolveLocale(defaultLocale).split('-')[0] as keyof typeof record;
return record[baseLocale] ?? record[fallbackLocale] ?? record['es'] ?? def.code;
}
return def.code;
}
function resolveConversionValue(value: ConversionValue): number | null {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
return value();
}
function f(
amount: number,
display: CurrDisplay = defaults.display,
locale: LocaleResolver = defaultLocale
): string {
if (!isValidNumber(amount)) return String(amount);
const loc = resolveLocale(locale);
const formatted = formatAmount(amount, defaults.decimals, loc);
const def = get(selectedCurrency);
if (!def) return `${formatted} ${selectedCurrency}`;
switch (display) {
case 'symbol':
return `${formatted} ${def.symbol}`;
case 'code':
return `${formatted} ${def.code}`;
case 'name':
return `${formatted} ${resolveName(def, amount, loc)}`;
}
}
function convert(amount: number, from: string, to: string): number | null {
if (from.toUpperCase() === to.toUpperCase()) return amount;
const fromDef = get(from);
const toDef = get(to);
if (!fromDef || !toDef) return null;
const fromRate = resolveConversionValue(fromDef.conversionValue ?? null);
const toRate = resolveConversionValue(toDef.conversionValue ?? null);
if (fromRate === null || toRate === null) return null;
return (amount / fromRate) * toRate;
}
function convertTo(amount: number, to: string): number | null {
return convert(amount, selectedCurrency, to);
}
function getCurrencies(): string[] {
return Object.keys(definitions);
}
return { f, convert, convertTo, setCurrency, getCurrency, get, getCurrencies };
}

@ -8,106 +8,106 @@ import type { LingNode } from '../../ling/lib/types';
* las aporta el usuario en ACtxConfig.curr.definitions.
*/
export const currLingNode = {
EUR: {
name: p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' },
de: { one: 'Euro', other: 'Euro' },
fr: { one: 'euro', other: 'euros' },
it: { one: 'euro', other: 'euro' },
pt: { one: 'euro', other: 'euros' },
}),
},
USD: {
name: p({
es: { one: 'dólar', other: 'dólares' },
en: { one: 'dollar', other: 'dollars' },
de: { one: 'Dollar', other: 'Dollar' },
fr: { one: 'dollar', other: 'dollars' },
it: { one: 'dollaro', other: 'dollari' },
pt: { one: 'dólar', other: 'dólares' },
}),
},
GBP: {
name: p({
es: { one: 'libra', other: 'libras' },
en: { one: 'pound', other: 'pounds' },
de: { one: 'Pfund', other: 'Pfund' },
fr: { one: 'livre', other: 'livres' },
it: { one: 'sterlina', other: 'sterline' },
pt: { one: 'libra', other: 'libras' },
}),
},
JPY: {
name: p({
es: { one: 'yen', other: 'yenes' },
en: { one: 'yen', other: 'yen' },
de: { one: 'Yen', other: 'Yen' },
fr: { one: 'yen', other: 'yens' },
it: { one: 'yen', other: 'yen' },
pt: { one: 'iene', other: 'ienes' },
}),
},
CHF: {
name: p({
es: { one: 'franco suizo', other: 'francos suizos' },
en: { one: 'Swiss franc', other: 'Swiss francs' },
de: { one: 'Schweizer Franken', other: 'Schweizer Franken' },
fr: { one: 'franc suisse', other: 'francs suisses' },
it: { one: 'franco svizzero', other: 'franchi svizzeri' },
pt: { one: 'franco suíço', other: 'francos suíços' },
}),
},
CAD: {
name: p({
es: { one: 'dólar canadiense', other: 'dólares canadienses' },
en: { one: 'Canadian dollar', other: 'Canadian dollars' },
de: { one: 'Kanadischer Dollar', other: 'Kanadische Dollar' },
fr: { one: 'dollar canadien', other: 'dollars canadiens' },
it: { one: 'dollaro canadese', other: 'dollari canadesi' },
pt: { one: 'dólar canadiano', other: 'dólares canadianos' },
}),
},
AUD: {
name: p({
es: { one: 'dólar australiano', other: 'dólares australianos' },
en: { one: 'Australian dollar', other: 'Australian dollars' },
de: { one: 'Australischer Dollar', other: 'Australische Dollar' },
fr: { one: 'dollar australien', other: 'dollars australiens' },
it: { one: 'dollaro australiano', other: 'dollari australiani' },
pt: { one: 'dólar australiano', other: 'dólares australianos' },
}),
},
MXN: {
name: p({
es: { one: 'peso mexicano', other: 'pesos mexicanos' },
en: { one: 'Mexican peso', other: 'Mexican pesos' },
de: { one: 'Mexikanischer Peso', other: 'Mexikanische Pesos' },
fr: { one: 'peso mexicain', other: 'pesos mexicains' },
it: { one: 'peso messicano', other: 'pesos messicani' },
pt: { one: 'peso mexicano', other: 'pesos mexicanos' },
}),
},
BRL: {
name: p({
es: { one: 'real brasileño', other: 'reales brasileños' },
en: { one: 'Brazilian real', other: 'Brazilian reais' },
de: { one: 'Brasilianischer Real', other: 'Brasilianische Real' },
fr: { one: 'réal brésilien', other: 'réals brésiliens' },
it: { one: 'real brasiliano', other: 'real brasiliani' },
pt: { one: 'real brasileiro', other: 'reais brasileiros' },
}),
},
CNY: {
name: p({
es: { one: 'yuan chino', other: 'yuanes chinos' },
en: { one: 'Chinese yuan', other: 'Chinese yuan' },
de: { one: 'Chinesischer Yuan', other: 'Chinesische Yuan' },
fr: { one: 'yuan chinois', other: 'yuans chinois' },
it: { one: 'yuan cinese', other: 'yuan cinesi' },
pt: { one: 'yuan chinês', other: 'yuans chineses' },
}),
},
EUR: {
name: p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' },
de: { one: 'Euro', other: 'Euro' },
fr: { one: 'euro', other: 'euros' },
it: { one: 'euro', other: 'euro' },
pt: { one: 'euro', other: 'euros' }
})
},
USD: {
name: p({
es: { one: 'dólar', other: 'dólares' },
en: { one: 'dollar', other: 'dollars' },
de: { one: 'Dollar', other: 'Dollar' },
fr: { one: 'dollar', other: 'dollars' },
it: { one: 'dollaro', other: 'dollari' },
pt: { one: 'dólar', other: 'dólares' }
})
},
GBP: {
name: p({
es: { one: 'libra', other: 'libras' },
en: { one: 'pound', other: 'pounds' },
de: { one: 'Pfund', other: 'Pfund' },
fr: { one: 'livre', other: 'livres' },
it: { one: 'sterlina', other: 'sterline' },
pt: { one: 'libra', other: 'libras' }
})
},
JPY: {
name: p({
es: { one: 'yen', other: 'yenes' },
en: { one: 'yen', other: 'yen' },
de: { one: 'Yen', other: 'Yen' },
fr: { one: 'yen', other: 'yens' },
it: { one: 'yen', other: 'yen' },
pt: { one: 'iene', other: 'ienes' }
})
},
CHF: {
name: p({
es: { one: 'franco suizo', other: 'francos suizos' },
en: { one: 'Swiss franc', other: 'Swiss francs' },
de: { one: 'Schweizer Franken', other: 'Schweizer Franken' },
fr: { one: 'franc suisse', other: 'francs suisses' },
it: { one: 'franco svizzero', other: 'franchi svizzeri' },
pt: { one: 'franco suíço', other: 'francos suíços' }
})
},
CAD: {
name: p({
es: { one: 'dólar canadiense', other: 'dólares canadienses' },
en: { one: 'Canadian dollar', other: 'Canadian dollars' },
de: { one: 'Kanadischer Dollar', other: 'Kanadische Dollar' },
fr: { one: 'dollar canadien', other: 'dollars canadiens' },
it: { one: 'dollaro canadese', other: 'dollari canadesi' },
pt: { one: 'dólar canadiano', other: 'dólares canadianos' }
})
},
AUD: {
name: p({
es: { one: 'dólar australiano', other: 'dólares australianos' },
en: { one: 'Australian dollar', other: 'Australian dollars' },
de: { one: 'Australischer Dollar', other: 'Australische Dollar' },
fr: { one: 'dollar australien', other: 'dollars australiens' },
it: { one: 'dollaro australiano', other: 'dollari australiani' },
pt: { one: 'dólar australiano', other: 'dólares australianos' }
})
},
MXN: {
name: p({
es: { one: 'peso mexicano', other: 'pesos mexicanos' },
en: { one: 'Mexican peso', other: 'Mexican pesos' },
de: { one: 'Mexikanischer Peso', other: 'Mexikanische Pesos' },
fr: { one: 'peso mexicain', other: 'pesos mexicains' },
it: { one: 'peso messicano', other: 'pesos messicani' },
pt: { one: 'peso mexicano', other: 'pesos mexicanos' }
})
},
BRL: {
name: p({
es: { one: 'real brasileño', other: 'reales brasileños' },
en: { one: 'Brazilian real', other: 'Brazilian reais' },
de: { one: 'Brasilianischer Real', other: 'Brasilianische Real' },
fr: { one: 'réal brésilien', other: 'réals brésiliens' },
it: { one: 'real brasiliano', other: 'real brasiliani' },
pt: { one: 'real brasileiro', other: 'reais brasileiros' }
})
},
CNY: {
name: p({
es: { one: 'yuan chino', other: 'yuanes chinos' },
en: { one: 'Chinese yuan', other: 'Chinese yuan' },
de: { one: 'Chinesischer Yuan', other: 'Chinesische Yuan' },
fr: { one: 'yuan chinois', other: 'yuans chinois' },
it: { one: 'yuan cinese', other: 'yuan cinesi' },
pt: { one: 'yuan chinês', other: 'yuans chineses' }
})
}
} satisfies LingNode;
export type CurrLingNode = typeof currLingNode;
export type CurrLingNode = typeof currLingNode;

@ -2,9 +2,8 @@
// CURR TYPES
// ==============================
import type { LingPluralFn } from "../../ling/lib";
import type { LocaleResolver } from "./comn_types.ts";
import type { LingPluralFn } from '../../ling/lib';
import type { LocaleResolver } from './comn_types.ts';
/**
* Tasa de conversión de una moneda respecto a la moneda base.
@ -24,47 +23,45 @@ export type CurrDisplay = 'symbol' | 'code' | 'name';
/** Definición completa de una moneda. */
export interface CurrDefinition {
/** Símbolo gráfico, ej. `'€'`, `'$'`. */
symbol : string;
/** Función plural para el nombre de la moneda (ej. "euro" / "euros"). */
name : LingPluralFn<Record<never, never>>;
/** Código ISO 4217, ej. `'EUR'`, `'USD'`. */
code : string;
/** Tasa respecto a la moneda base. Omitir o `null` deshabilita la conversión. */
conversionValue?: ConversionValue;
/** Símbolo gráfico, ej. `'€'`, `'$'`. */
symbol: string;
/** Función plural para el nombre de la moneda (ej. "euro" / "euros"). */
name: LingPluralFn<Record<never, never>>;
/** Código ISO 4217, ej. `'EUR'`, `'USD'`. */
code: string;
/** Tasa respecto a la moneda base. Omitir o `null` deshabilita la conversión. */
conversionValue?: ConversionValue;
}
export interface CurrInstance {
/** Formatea `amount` en la moneda activa según `display` y `locale`. */
f : (amount: number, display?: CurrDisplay, locale?: LocaleResolver) => string;
/**
* Convierte `amount` de la moneda `from` a la moneda `to`.
* Devuelve `null` si alguna de las monedas no tiene `conversionValue`.
* Fórmula: `(amount / fromRate) * toRate`
*/
convert : (amount: number, from: string, to: string) => number | null;
/** Convierte `amount` desde la moneda activa (`selectedCurrency`) hacia `to`. */
convertTo : (amount: number, to: string) => number | null;
/** Cambia la moneda activa. Acepta mayúsculas y minúsculas. */
setCurrency: (currency: string) => void;
/** Devuelve el código ISO de la moneda activa. */
getCurrency: () => string;
/** Busca la definición de una moneda por código ISO (insensible a mayúsculas). */
get : (currency: string) => CurrDefinition | undefined;
/** Devuelve todos los códigos ISO disponibles en las definiciones. */
getCurrencies: () => string[];
/** Formatea `amount` en la moneda activa según `display` y `locale`. */
f: (amount: number, display?: CurrDisplay, locale?: LocaleResolver) => string;
/**
* Convierte `amount` de la moneda `from` a la moneda `to`.
* Devuelve `null` si alguna de las monedas no tiene `conversionValue`.
* Fórmula: `(amount / fromRate) * toRate`
*/
convert: (amount: number, from: string, to: string) => number | null;
/** Convierte `amount` desde la moneda activa (`selectedCurrency`) hacia `to`. */
convertTo: (amount: number, to: string) => number | null;
/** Cambia la moneda activa. Acepta mayúsculas y minúsculas. */
setCurrency: (currency: string) => void;
/** Devuelve el código ISO de la moneda activa. */
getCurrency: () => string;
/** Busca la definición de una moneda por código ISO (insensible a mayúsculas). */
get: (currency: string) => CurrDefinition | undefined;
/** Devuelve todos los códigos ISO disponibles en las definiciones. */
getCurrencies: () => string[];
}
/** Configuración de `createCurr`. */
export interface CurrConfig {
/** Moneda base para los cálculos de conversión. Default: `selectedCurrency`. */
baseCurrency? : string;
/** Moneda activa al iniciar. Default: primera moneda de `definitions`. */
selectedCurrency?: string;
/** Modo de display por defecto. Default: `'symbol'`. */
display? : CurrDisplay;
/** Decimales fijos para el formateo. Default: `2`. */
decimals? : number;
/** Moneda base para los cálculos de conversión. Default: `selectedCurrency`. */
baseCurrency?: string;
/** Moneda activa al iniciar. Default: primera moneda de `definitions`. */
selectedCurrency?: string;
/** Modo de display por defecto. Default: `'symbol'`. */
display?: CurrDisplay;
/** Decimales fijos para el formateo. Default: `2`. */
decimals?: number;
}

@ -12,44 +12,45 @@ import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
import { createDati } from './dati_engine.ts';
export function createDatiState(
opts : DatiConfig = {},
defaultLocale : LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale : () => unknown = () => null,
opts: DatiConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale: () => unknown = () => null
): DatiInstance {
const _dati = createDati(opts, defaultLocale);
// $state propios — timeFormat y dateOrder son estado interno de dati
let timeFormat = $state<DatiTimeFormat>(_dati.getTimeFormat());
let dateOrder = $state<DateOrder>(_dati.getDateOrder());
return {
f: (date, callOpts) => {
getReactiveLocale(); // dependencia reactiva de locale
timeFormat; // dependencia reactiva de timeFormat
return _dati.f(date, callOpts);
},
fRange: (start, end, callOpts) => {
getReactiveLocale();
timeFormat;
return _dati.fRange(start, end, callOpts);
},
get timeFormat() { return timeFormat; }, // $state — reactivo
getTimeFormat: () => timeFormat,
setTimeFormat: (fmt) => {
_dati.setTimeFormat(fmt);
timeFormat = fmt; // actualiza $state → Svelte re-renderiza
},
get dateOrder() { return dateOrder; }, // $state — reactivo
getDateOrder: () => dateOrder,
setDateOrder: (order) => {
_dati.setDateOrder(order);
dateOrder = order; // actualiza $state → Svelte re-renderiza
},
};
const _dati = createDati(opts, defaultLocale);
// $state propios — timeFormat y dateOrder son estado interno de dati
let timeFormat = $state<DatiTimeFormat>(_dati.getTimeFormat());
let dateOrder = $state<DateOrder>(_dati.getDateOrder());
return {
f: (date, callOpts) => {
getReactiveLocale(); // dependencia reactiva de locale
timeFormat; // dependencia reactiva de timeFormat
return _dati.f(date, callOpts);
},
fRange: (start, end, callOpts) => {
getReactiveLocale();
timeFormat;
return _dati.fRange(start, end, callOpts);
},
get timeFormat() {
return timeFormat;
}, // $state — reactivo
getTimeFormat: () => timeFormat,
setTimeFormat: (fmt) => {
_dati.setTimeFormat(fmt);
timeFormat = fmt; // actualiza $state → Svelte re-renderiza
},
get dateOrder() {
return dateOrder;
}, // $state — reactivo
getDateOrder: () => dateOrder,
setDateOrder: (order) => {
_dati.setDateOrder(order);
dateOrder = order; // actualiza $state → Svelte re-renderiza
}
};
}

@ -1,10 +1,9 @@
import type { DatiInstance, DatiOptions, DatiConfig, DatiTimeFormat } from './dati_types.ts';
import type { LocaleResolver } from './comn_types.ts';
import type { DatiInstance, DatiOptions, DatiConfig, DatiTimeFormat } from './dati_types.ts';
import type { LocaleResolver } from './comn_types.ts';
import { resolveLocale, cachedDateTimeFormat } from './comn_engine.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
import type { DateOrder, DateStyleType } from '$lib/util/dates/core/types';
// ==============================
// HELPERS
// ==============================
@ -16,14 +15,14 @@ import type { DateOrder, DateStyleType } from '$lib/util/dates/core/types';
* - Objeto → combina `date` y `time` según lo indicado.
*/
function resolveOpts(opts?: DatiOptions): {
dateStyle?: DateStyleType;
timeStyle?: DateStyleType;
dateStyle?: DateStyleType;
timeStyle?: DateStyleType;
} {
if (!opts) return { dateStyle: 'long' };
if (typeof opts === 'string') return { dateStyle: opts };
const { date, time } = opts;
if (!date && !time) return { dateStyle: 'long' };
return { dateStyle: date, timeStyle: time };
if (!opts) return { dateStyle: 'long' };
if (typeof opts === 'string') return { dateStyle: opts };
const { date, time } = opts;
if (!date && !time) return { dateStyle: 'long' };
return { dateStyle: date, timeStyle: time };
}
// ==============================
@ -37,65 +36,82 @@ function resolveOpts(opts?: DatiOptions): {
* @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito.
*/
export function createDati(
opts : DatiConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
opts: DatiConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): DatiInstance {
let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h';
let dateOrder : DateOrder = opts.selectedDateOrder ?? 'DMY';
// ── Preferencias ──────────────────────────────────────────────────────────
function getTimeFormat(): DatiTimeFormat { return timeFormat; }
function setTimeFormat(format: DatiTimeFormat): void { timeFormat = format; }
function getDateOrder(): DateOrder { return dateOrder; }
function setDateOrder(order: DateOrder): void { dateOrder = order; }
// ── Format ────────────────────────────────────────────────────────────────
function buildIntlOpts(callOpts: DatiOptions): Intl.DateTimeFormatOptions {
const { dateStyle, timeStyle } = resolveOpts(callOpts);
const intlOpts: Intl.DateTimeFormatOptions = {};
if (dateStyle) intlOpts.dateStyle = dateStyle;
if (timeStyle) {
intlOpts.timeStyle = timeStyle;
intlOpts.hour12 = timeFormat === '12h';
}
return intlOpts;
}
/**
* Formatea `date` usando `Intl.DateTimeFormat`.
* Aplica correcciones automáticas de bugs en Chrome (hour12→h24) y Safari
* (hourCycle en francés, calendario ethiopic) mediante `DateFormatter` de extra.
*/
function f(
date : Date,
callOpts: DatiOptions = 'long',
locale : LocaleResolver = defaultLocale
): string {
return cachedDateTimeFormat(resolveLocale(locale), buildIntlOpts(callOpts)).format(date);
}
/**
* Formatea un rango de fechas (ej. "1–15 de enero de 2024").
* Usa `Intl.DateTimeFormat.formatRange()` con fallback para navegadores sin soporte.
*/
function fRange(
start : Date,
end : Date,
callOpts: DatiOptions = 'long',
locale : LocaleResolver = defaultLocale
): string {
return cachedDateTimeFormat(resolveLocale(locale), buildIntlOpts(callOpts)).formatRange(start, end);
}
return {
f, fRange, getTimeFormat, setTimeFormat, getDateOrder, setDateOrder,
get timeFormat() { return timeFormat; },
get dateOrder() { return dateOrder; },
};
let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h';
let dateOrder: DateOrder = opts.selectedDateOrder ?? 'DMY';
// ── Preferencias ──────────────────────────────────────────────────────────
function getTimeFormat(): DatiTimeFormat {
return timeFormat;
}
function setTimeFormat(format: DatiTimeFormat): void {
timeFormat = format;
}
function getDateOrder(): DateOrder {
return dateOrder;
}
function setDateOrder(order: DateOrder): void {
dateOrder = order;
}
// ── Format ────────────────────────────────────────────────────────────────
function buildIntlOpts(callOpts: DatiOptions): Intl.DateTimeFormatOptions {
const { dateStyle, timeStyle } = resolveOpts(callOpts);
const intlOpts: Intl.DateTimeFormatOptions = {};
if (dateStyle) intlOpts.dateStyle = dateStyle;
if (timeStyle) {
intlOpts.timeStyle = timeStyle;
intlOpts.hour12 = timeFormat === '12h';
}
return intlOpts;
}
/**
* Formatea `date` usando `Intl.DateTimeFormat`.
* Aplica correcciones automáticas de bugs en Chrome (hour12→h24) y Safari
* (hourCycle en francés, calendario ethiopic) mediante `DateFormatter` de extra.
*/
function f(
date: Date,
callOpts: DatiOptions = 'long',
locale: LocaleResolver = defaultLocale
): string {
return cachedDateTimeFormat(resolveLocale(locale), buildIntlOpts(callOpts)).format(date);
}
/**
* Formatea un rango de fechas (ej. "1–15 de enero de 2024").
* Usa `Intl.DateTimeFormat.formatRange()` con fallback para navegadores sin soporte.
*/
function fRange(
start: Date,
end: Date,
callOpts: DatiOptions = 'long',
locale: LocaleResolver = defaultLocale
): string {
return cachedDateTimeFormat(resolveLocale(locale), buildIntlOpts(callOpts)).formatRange(
start,
end
);
}
return {
f,
fRange,
getTimeFormat,
setTimeFormat,
getDateOrder,
setDateOrder,
get timeFormat() {
return timeFormat;
},
get dateOrder() {
return dateOrder;
}
};
}

@ -1,4 +1,4 @@
import type { LocaleResolver } from "./comn_types.ts";
import type { LocaleResolver } from './comn_types.ts';
import type { DateOrder, DateStyleType } from '$lib/util/dates/core/types';
/**
@ -6,9 +6,7 @@ import type { DateOrder, DateStyleType } from '$lib/util/dates/core/types';
* - `'short'` / `'medium'` / `'long'` / `'full'` : solo fecha en ese estilo.
* - `{ date?, time? }` : control independiente de fecha y hora.
*/
export type DatiOptions =
| DateStyleType
| { date?: DateStyleType; time?: DateStyleType };
export type DatiOptions = DateStyleType | { date?: DateStyleType; time?: DateStyleType };
/**
* Formato de hora usado en el API de dati.
@ -17,37 +15,29 @@ export type DatiOptions =
*/
export type DatiTimeFormat = '12h' | '24h';
/** Configuración inicial de `createDati`. */
export interface DatiConfig {
/** Formato de horas. Default: `'24h'`. */
selectedTimeFormat?: DatiTimeFormat;
/** Orden de los componentes de fecha. Default: `'DMY'`. */
selectedDateOrder?: DateOrder;
/** Formato de horas. Default: `'24h'`. */
selectedTimeFormat?: DatiTimeFormat;
/** Orden de los componentes de fecha. Default: `'DMY'`. */
selectedDateOrder?: DateOrder;
}
export interface DatiInstance {
/** Formatea `date` según las opciones y el locale indicados. */
f : (date: Date, opts?: DatiOptions, locale?: LocaleResolver) => string;
/**
* Formatea un rango de fechas como string (ej. "1–15 de enero de 2024").
* Usa `Intl.DateTimeFormat.formatRange()` con fallback para navegadores antiguos.
*/
fRange : (start: Date, end: Date, opts?: DatiOptions, locale?: LocaleResolver) => string;
setTimeFormat: (format: DatiTimeFormat) => void;
getTimeFormat: () => DatiTimeFormat;
/** Formato de hora actual. Reactivo en la versión svelte. */
readonly timeFormat : DatiTimeFormat;
/** Guarda la preferencia de orden de fecha para uso de la UI. No afecta a `Intl`. */
setDateOrder : (order: DateOrder) => void;
getDateOrder : () => DateOrder;
/** Orden de fecha actual. Reactivo en la versión svelte. */
readonly dateOrder : DateOrder;
/** Formatea `date` según las opciones y el locale indicados. */
f: (date: Date, opts?: DatiOptions, locale?: LocaleResolver) => string;
/**
* Formatea un rango de fechas como string (ej. "1–15 de enero de 2024").
* Usa `Intl.DateTimeFormat.formatRange()` con fallback para navegadores antiguos.
*/
fRange: (start: Date, end: Date, opts?: DatiOptions, locale?: LocaleResolver) => string;
setTimeFormat: (format: DatiTimeFormat) => void;
getTimeFormat: () => DatiTimeFormat;
/** Formato de hora actual. Reactivo en la versión svelte. */
readonly timeFormat: DatiTimeFormat;
/** Guarda la preferencia de orden de fecha para uso de la UI. No afecta a `Intl`. */
setDateOrder: (order: DateOrder) => void;
getDateOrder: () => DateOrder;
/** Orden de fecha actual. Reactivo en la versión svelte. */
readonly dateOrder: DateOrder;
}

@ -1,3 +1,8 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export * from './comn_types.ts';
export * from './curr_types.ts';
export * from './dati_types.ts';
@ -9,6 +14,3 @@ export * from './curr_engine.ts';
export * from './dati_engine.ts';
export * from './numr_engine.ts';
export * from './unit_engine.ts';

@ -17,11 +17,10 @@ import { createNumr } from './numr_engine.ts';
* Al leerlo aquí, Svelte establece la dependencia.
*/
export function createNumrState(
format : NumrFormat = {},
defaultLocale : LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale : () => unknown = () => null,
format: NumrFormat = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale: () => unknown = () => null
): NumrInstance {
const _numr = createNumr(format, defaultLocale);
return {
@ -32,6 +31,6 @@ export function createNumrState(
parse: (value, fmt) => {
getReactiveLocale();
return _numr.parse(value, fmt);
},
}
};
}

@ -1,17 +1,21 @@
import { DEFAULT_ISO_LOCALE } from '../../ling/lib';
import { resolveLocale, cachedNumberFormat, getNumberParser, isValidNumber } from './comn_engine.ts';
import {
resolveLocale,
cachedNumberFormat,
getNumberParser,
isValidNumber
} from './comn_engine.ts';
import type { LocaleResolver } from './comn_types.ts';
import type { NumrFormat, NumrGrouping, NumrInstance } from './numr_types.ts';
// ─── Conversión interna ───────────────────────────────────────────────────────
//
// La traducción de NumrFormat → Intl.NumberFormatOptions queda confinada aquí.
// Los consumidores nunca ven tipos Intl.
function groupingToIntl(g: NumrGrouping): Intl.NumberFormatOptions['useGrouping'] {
if (g === true) return 'auto';
if (g === false) return false;
if (g === true) return 'auto';
if (g === false) return false;
if (g === 'never') return false;
return g as 'min2' | 'always' | 'auto';
}
@ -19,21 +23,23 @@ function groupingToIntl(g: NumrGrouping): Intl.NumberFormatOptions['useGrouping'
function numrFormatToIntlOpts(fmt: NumrFormat): Intl.NumberFormatOptions {
const opts: Intl.NumberFormatOptions = {};
if (fmt.style !== undefined) opts.style = fmt.style;
if (fmt.currency !== undefined) opts.currency = fmt.currency;
if (fmt.currencyDisplay !== undefined) opts.currencyDisplay = fmt.currencyDisplay;
if (fmt.currencySign !== undefined) opts.currencySign = fmt.currencySign;
if (fmt.unit !== undefined) opts.unit = fmt.unit;
if (fmt.unitDisplay !== undefined) opts.unitDisplay = fmt.unitDisplay;
if (fmt.notation !== undefined) opts.notation = fmt.notation;
if (fmt.compactDisplay !== undefined) opts.compactDisplay = fmt.compactDisplay;
if (fmt.signDisplay !== undefined) opts.signDisplay = fmt.signDisplay;
if (fmt.minIntegerDigits !== undefined) opts.minimumIntegerDigits = fmt.minIntegerDigits;
if (fmt.minDecimals !== undefined) opts.minimumFractionDigits = fmt.minDecimals;
if (fmt.maxDecimals !== undefined) opts.maximumFractionDigits = fmt.maxDecimals;
if (fmt.minSignificantDigits !== undefined) opts.minimumSignificantDigits = fmt.minSignificantDigits;
if (fmt.maxSignificantDigits !== undefined) opts.maximumSignificantDigits = fmt.maxSignificantDigits;
if (fmt.grouping !== undefined) opts.useGrouping = groupingToIntl(fmt.grouping);
if (fmt.style !== undefined) opts.style = fmt.style;
if (fmt.currency !== undefined) opts.currency = fmt.currency;
if (fmt.currencyDisplay !== undefined) opts.currencyDisplay = fmt.currencyDisplay;
if (fmt.currencySign !== undefined) opts.currencySign = fmt.currencySign;
if (fmt.unit !== undefined) opts.unit = fmt.unit;
if (fmt.unitDisplay !== undefined) opts.unitDisplay = fmt.unitDisplay;
if (fmt.notation !== undefined) opts.notation = fmt.notation;
if (fmt.compactDisplay !== undefined) opts.compactDisplay = fmt.compactDisplay;
if (fmt.signDisplay !== undefined) opts.signDisplay = fmt.signDisplay;
if (fmt.minIntegerDigits !== undefined) opts.minimumIntegerDigits = fmt.minIntegerDigits;
if (fmt.minDecimals !== undefined) opts.minimumFractionDigits = fmt.minDecimals;
if (fmt.maxDecimals !== undefined) opts.maximumFractionDigits = fmt.maxDecimals;
if (fmt.minSignificantDigits !== undefined)
opts.minimumSignificantDigits = fmt.minSignificantDigits;
if (fmt.maxSignificantDigits !== undefined)
opts.maximumSignificantDigits = fmt.maxSignificantDigits;
if (fmt.grouping !== undefined) opts.useGrouping = groupingToIntl(fmt.grouping);
// dir no se pasa a Intl — lo usa el consumidor para el atributo HTML `dir`.
@ -45,9 +51,10 @@ function numrFormatToIntlOpts(fmt: NumrFormat): Intl.NumberFormatOptions {
/** Fusiona el formato de instancia con el override de llamada. Solo sobreescribe los campos definidos. */
function mergeFormats(base: NumrFormat, override: NumrFormat | undefined): NumrFormat {
if (!override) return base;
return { ...base, ...Object.fromEntries(
Object.entries(override).filter(([, v]) => v !== undefined)
)};
return {
...base,
...Object.fromEntries(Object.entries(override).filter(([, v]) => v !== undefined))
};
}
/** Garantiza que maxDecimals >= minDecimals para evitar RangeError en Intl. */
@ -60,7 +67,6 @@ function clampDecimals(fmt: NumrFormat): NumrFormat {
return fmt;
}
// ─── Engine ───────────────────────────────────────────────────────────────────
/**
@ -70,10 +76,9 @@ function clampDecimals(fmt: NumrFormat): NumrFormat {
* @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito.
*/
export function createNumr(
format : NumrFormat = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE,
format: NumrFormat = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): NumrInstance {
function resolvedFormat(callFmt: NumrFormat | undefined): Intl.NumberFormatOptions {
return numrFormatToIntlOpts(clampDecimals(mergeFormats(format, callFmt)));
}
@ -82,11 +87,7 @@ export function createNumr(
* Formatea `n` con el locale y el formato indicados.
* El `fmt` de la llamada se fusiona sobre los defaults de la instancia.
*/
function f(
n : number,
fmt? : NumrFormat,
locale : LocaleResolver = defaultLocale,
): string {
function f(n: number, fmt?: NumrFormat, locale: LocaleResolver = defaultLocale): string {
if (!isValidNumber(n)) return String(n);
return cachedNumberFormat(resolveLocale(locale), resolvedFormat(fmt)).format(n);
}
@ -95,11 +96,7 @@ export function createNumr(
* Parsea un string numérico localizado y devuelve el número.
* Devuelve `NaN` si el string no es un número válido en ese locale.
*/
function parse(
value : string,
fmt? : NumrFormat,
locale : LocaleResolver = defaultLocale,
): number {
function parse(value: string, fmt?: NumrFormat, locale: LocaleResolver = defaultLocale): number {
return getNumberParser(resolveLocale(locale), resolvedFormat(fmt)).parse(value);
}

@ -5,67 +5,66 @@ import type { UnitCategoryKey, UnitCategoryData } from './unit_types.ts';
* Las traducciones (symbol, name) viven en units_lang.ts → ling.
*/
export const unitData: Record<UnitCategoryKey, UnitCategoryData> = {
weight: {
base: 'kg',
baseImperial: 'lb',
units: {
kg: { factor: 1, system: 'metric' },
g: { factor: 0.001, system: 'metric' },
lb: { factor: 0.453592, system: 'imperial' },
oz: { factor: 0.0283495, system: 'imperial' },
t: { factor: 1000, system: 'metric' }
}
},
weight: {
base : 'kg',
baseImperial: 'lb',
units: {
kg: { factor: 1, system: 'metric' },
g: { factor: 0.001, system: 'metric' },
lb: { factor: 0.453592, system: 'imperial' },
oz: { factor: 0.0283495, system: 'imperial' },
t: { factor: 1000, system: 'metric' },
},
},
length: {
base: 'm',
baseImperial: 'mi',
units: {
km: { factor: 1000, system: 'metric' },
m: { factor: 1, system: 'metric' },
cm: { factor: 0.01, system: 'metric' },
mm: { factor: 0.001, system: 'metric' },
mi: { factor: 1609.344, system: 'imperial' },
ft: { factor: 0.3048, system: 'imperial' },
in: { factor: 0.0254, system: 'imperial' },
yd: { factor: 0.9144, system: 'imperial' }
}
},
length: {
base : 'm',
baseImperial: 'mi',
units: {
km: { factor: 1000, system: 'metric' },
m: { factor: 1, system: 'metric' },
cm: { factor: 0.01, system: 'metric' },
mm: { factor: 0.001, system: 'metric' },
mi: { factor: 1609.344, system: 'imperial' },
ft: { factor: 0.3048, system: 'imperial' },
in: { factor: 0.0254, system: 'imperial' },
yd: { factor: 0.9144, system: 'imperial' },
},
},
temp: {
base: 'c',
baseImperial: 'f',
units: {
c: { factor: 1, system: 'metric' },
f: { factor: 1, system: 'imperial' },
k: { factor: 1, system: 'metric' }
}
},
temp: {
base : 'c',
baseImperial: 'f',
units: {
c: { factor: 1, system: 'metric' },
f: { factor: 1, system: 'imperial' },
k: { factor: 1, system: 'metric' },
},
},
volume: {
base: 'l',
baseImperial: 'gal',
units: {
l: { factor: 1, system: 'metric' },
ml: { factor: 0.001, system: 'metric' },
m3: { factor: 1000, system: 'metric' },
gal: { factor: 3.78541, system: 'imperial' },
floz: { factor: 0.029574, system: 'imperial' },
pt: { factor: 0.473176, system: 'imperial' }
}
},
volume: {
base : 'l',
baseImperial: 'gal',
units: {
l: { factor: 1, system: 'metric' },
ml: { factor: 0.001, system: 'metric' },
m3: { factor: 1000, system: 'metric' },
gal: { factor: 3.78541, system: 'imperial' },
floz: { factor: 0.029574, system: 'imperial' },
pt: { factor: 0.473176, system: 'imperial' },
},
},
area: {
base : 'm2',
baseImperial: 'ft2',
units: {
m2: { factor: 1, system: 'metric' },
km2: { factor: 1000000, system: 'metric' },
ha: { factor: 10000, system: 'metric' },
ft2: { factor: 0.092903, system: 'imperial' },
mi2: { factor: 2589988.11, system: 'imperial' },
ac: { factor: 4046.856, system: 'imperial' },
},
},
};
area: {
base: 'm2',
baseImperial: 'ft2',
units: {
m2: { factor: 1, system: 'metric' },
km2: { factor: 1000000, system: 'metric' },
ha: { factor: 10000, system: 'metric' },
ft2: { factor: 0.092903, system: 'imperial' },
mi2: { factor: 2589988.11, system: 'imperial' },
ac: { factor: 4046.856, system: 'imperial' }
}
}
};

@ -7,51 +7,55 @@
*/
import type { LingNode, LingInstance } from '../../ling/lib/types.ts';
import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitInstance, UnitSystem } from './unit_types.ts';
import type {
UnitCategoryData,
UnitCategoryInstance,
UnitCategoryKey,
UnitInstance,
UnitSystem
} from './unit_types.ts';
import type { LocaleResolver } from './comn_types.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
import { createUnit } from './unit_engine.ts';
export function createUnitState<S extends LingNode = LingNode>(
lingInst : LingInstance<S> | null = null,
categories : Partial<Record<UnitCategoryKey, UnitCategoryData>> = {},
opts : { selectedSystem?: UnitSystem } = {},
defaultLocale : LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale : () => unknown = () => null,
lingInst: LingInstance<S> | null = null,
categories: Partial<Record<UnitCategoryKey, UnitCategoryData>> = {},
opts: { selectedSystem?: UnitSystem } = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale: () => unknown = () => null
): UnitInstance {
const _unit = createUnit(lingInst as any, categories, opts, defaultLocale);
const _unit = createUnit(lingInst as any, categories, opts, defaultLocale);
// $state propio — sistema activo
let system = $state<UnitSystem>(_unit.getSystem());
// $state propio — sistema activo
let system = $state<UnitSystem>(_unit.getSystem());
// Wrapper reactivo — resuelve la categoría en cada llamada
// para que el cambio de sistema se propague correctamente.
const reactCat = (key: UnitCategoryKey): UnitCategoryInstance => ({
f: (value, display) => {
getReactiveLocale();
system; // dependencia reactiva — re-ejecuta al cambiar sistema
return _unit[key].f(value, display);
},
fTo: (value, from, to, display) => {
getReactiveLocale();
system;
return _unit[key].fTo(value, from, to, display);
},
convert: (value, from, to) => _unit[key].convert(value, from, to)
});
// Wrapper reactivo — resuelve la categoría en cada llamada
// para que el cambio de sistema se propague correctamente.
const reactCat = (key: UnitCategoryKey): UnitCategoryInstance => ({
f: (value, display) => {
getReactiveLocale();
system; // dependencia reactiva — re-ejecuta al cambiar sistema
return _unit[key].f(value, display);
},
fTo: (value, from, to, display) => {
getReactiveLocale();
system;
return _unit[key].fTo(value, from, to, display);
},
convert: (value, from, to) => _unit[key].convert(value, from, to),
});
return {
getSystem: () => system,
setSystem: (s) => {
_unit.setSystem(s);
system = s; // actualiza $state → Svelte re-renderiza
},
weight : reactCat('weight'),
length : reactCat('length'),
temp : reactCat('temp'),
volume : reactCat('volume'),
area : reactCat('area'),
};
return {
getSystem: () => system,
setSystem: (s) => {
_unit.setSystem(s);
system = s; // actualiza $state → Svelte re-renderiza
},
weight: reactCat('weight'),
length: reactCat('length'),
temp: reactCat('temp'),
volume: reactCat('volume'),
area: reactCat('area')
};
}

@ -1,30 +1,48 @@
import type { LocaleResolver, GlobLocale } from "./comn_types.ts";
import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts";
import { resolveLocale, cachedNumberFormat, isValidNumber } from "./comn_engine.ts";
import { DEFAULT_ISO_LOCALE } from "../../ling/lib/consts.ts";
import { type LingNode, type LingInstance } from "../../ling/lib";
import { unitData } from "./unit_data.ts";
import { unitsLingNode } from "./unit_langs.ts";
import type { LocaleResolver, GlobLocale } from './comn_types.ts';
import type {
UnitCategoryData,
UnitCategoryInstance,
UnitCategoryKey,
UnitDisplay,
UnitInstance,
UnitSystem
} from './unit_types.ts';
import { resolveLocale, cachedNumberFormat, isValidNumber } from './comn_engine.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
import { type LingNode, type LingInstance } from '../../ling/lib';
import { unitData } from './unit_data.ts';
import { unitsLingNode } from './unit_langs.ts';
// ==============================
// CONVERSIÓN DE TEMPERATURA
// ==============================
function convertTemp(value: number, from: string, to: string): number {
if (from === to) return value;
let c: number;
switch (from) {
case 'c': c = value; break;
case 'f': c = (value - 32) * 5 / 9; break;
case 'k': c = value - 273.15; break;
default: c = value;
}
switch (to) {
case 'c': return c;
case 'f': return c * 9 / 5 + 32;
case 'k': return c + 273.15;
default: return c;
}
if (from === to) return value;
let c: number;
switch (from) {
case 'c':
c = value;
break;
case 'f':
c = ((value - 32) * 5) / 9;
break;
case 'k':
c = value - 273.15;
break;
default:
c = value;
}
switch (to) {
case 'c':
return c;
case 'f':
return (c * 9) / 5 + 32;
case 'k':
return c + 273.15;
default:
return c;
}
}
// ==============================
@ -32,71 +50,79 @@ function convertTemp(value: number, from: string, to: string): number {
// ==============================
function createCategory<S extends LingNode>(
categoryKey : UnitCategoryKey,
category : UnitCategoryData,
getSystem : () => UnitSystem,
defaultLocale: LocaleResolver,
lingInst : LingInstance<S>,
categoryKey: UnitCategoryKey,
category: UnitCategoryData,
getSystem: () => UnitSystem,
defaultLocale: LocaleResolver,
lingInst: LingInstance<S>
): UnitCategoryInstance {
function resolveTargetUnit(): string {
return getSystem() === 'imperial' ? category.baseImperial : category.base;
}
function formatValue(value: number, unitKey: string, display: UnitDisplay, locale: GlobLocale): string {
if (!isValidNumber(value)) return String(value);
const formatted = cachedNumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
if (display === 'abbr') {
const abbr = lingInst.t(`units.${categoryKey}.${unitKey}.abbr` as any);
return `${formatted} ${abbr}`;
}
const name = lingInst.t(`units.${categoryKey}.${unitKey}.name` as any, { count: value });
return `${formatted} ${name}`;
}
function convert(value: number, from: string, to: string): number | null {
if (from === to) return value;
if (categoryKey === 'temp') return convertTemp(value, from, to);
const fromData = category.units[from];
const toData = category.units[to];
if (!fromData || !toData) return null;
return (value * fromData.factor) / toData.factor;
}
/**
* Formatea `value` en la unidad base del sistema activo.
* Asume que `value` ya está expresado en esa unidad.
* Para convertir desde otra unidad primero, usa `fTo()`.
*/
function f(value: number, display: UnitDisplay = 'abbr', locale: LocaleResolver = defaultLocale): string {
const loc = resolveLocale(locale);
const unit = resolveTargetUnit();
return formatValue(value, unit, display, loc);
}
/**
* Convierte `value` desde `fromUnit` a `targetUnit` y lo formatea.
* Si se omite `fromUnit`, usa la unidad base del sistema activo.
* Si se omite `targetUnit`, usa la unidad base del sistema activo.
*/
function fTo(
value : number,
fromUnit? : string,
targetUnit?: string,
display : UnitDisplay = 'abbr',
locale : LocaleResolver = defaultLocale
): string {
const loc = resolveLocale(locale);
const from = fromUnit ?? resolveTargetUnit();
const to = targetUnit ?? resolveTargetUnit();
const converted = convert(value, from, to);
if (converted === null) return String(value);
return formatValue(converted, to, display, loc);
}
return { f, fTo, convert };
function resolveTargetUnit(): string {
return getSystem() === 'imperial' ? category.baseImperial : category.base;
}
function formatValue(
value: number,
unitKey: string,
display: UnitDisplay,
locale: GlobLocale
): string {
if (!isValidNumber(value)) return String(value);
const formatted = cachedNumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
if (display === 'abbr') {
const abbr = lingInst.t(`units.${categoryKey}.${unitKey}.abbr` as any);
return `${formatted} ${abbr}`;
}
const name = lingInst.t(`units.${categoryKey}.${unitKey}.name` as any, { count: value });
return `${formatted} ${name}`;
}
function convert(value: number, from: string, to: string): number | null {
if (from === to) return value;
if (categoryKey === 'temp') return convertTemp(value, from, to);
const fromData = category.units[from];
const toData = category.units[to];
if (!fromData || !toData) return null;
return (value * fromData.factor) / toData.factor;
}
/**
* Formatea `value` en la unidad base del sistema activo.
* Asume que `value` ya está expresado en esa unidad.
* Para convertir desde otra unidad primero, usa `fTo()`.
*/
function f(
value: number,
display: UnitDisplay = 'abbr',
locale: LocaleResolver = defaultLocale
): string {
const loc = resolveLocale(locale);
const unit = resolveTargetUnit();
return formatValue(value, unit, display, loc);
}
/**
* Convierte `value` desde `fromUnit` a `targetUnit` y lo formatea.
* Si se omite `fromUnit`, usa la unidad base del sistema activo.
* Si se omite `targetUnit`, usa la unidad base del sistema activo.
*/
function fTo(
value: number,
fromUnit?: string,
targetUnit?: string,
display: UnitDisplay = 'abbr',
locale: LocaleResolver = defaultLocale
): string {
const loc = resolveLocale(locale);
const from = fromUnit ?? resolveTargetUnit();
const to = targetUnit ?? resolveTargetUnit();
const converted = convert(value, from, to);
if (converted === null) return String(value);
return formatValue(converted, to, display, loc);
}
return { f, fTo, convert };
}
// ==============================
@ -108,40 +134,41 @@ function createCategory<S extends LingNode>(
let unitsExtended = false;
export function createUnit<S extends LingNode>(
lingInst : LingInstance<S>,
categories : Partial<Record<UnitCategoryKey, UnitCategoryData>> = {},
opts : { selectedSystem?: UnitSystem } = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
lingInst: LingInstance<S>,
categories: Partial<Record<UnitCategoryKey, UnitCategoryData>> = {},
opts: { selectedSystem?: UnitSystem } = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): UnitInstance {
// Registra traducciones solo en la primera llamada — idempotente y eficiente
if (!unitsExtended) {
lingInst.extend('units', unitsLingNode);
unitsExtended = true;
}
let currentSystem: UnitSystem = opts.selectedSystem ?? 'metric';
function getSystem(): UnitSystem { return currentSystem; }
function setSystem(s: UnitSystem) { currentSystem = s; }
const merged: Record<UnitCategoryKey, UnitCategoryData> = {
...unitData,
...categories,
};
const makeCategory = (key: UnitCategoryKey) =>
createCategory(key, merged[key], getSystem, defaultLocale, lingInst);
return {
getSystem,
setSystem,
weight : makeCategory('weight'),
length : makeCategory('length'),
temp : makeCategory('temp'),
volume : makeCategory('volume'),
area : makeCategory('area'),
};
// Registra traducciones solo en la primera llamada — idempotente y eficiente
if (!unitsExtended) {
lingInst.extend('units', unitsLingNode);
unitsExtended = true;
}
let currentSystem: UnitSystem = opts.selectedSystem ?? 'metric';
function getSystem(): UnitSystem {
return currentSystem;
}
function setSystem(s: UnitSystem) {
currentSystem = s;
}
const merged: Record<UnitCategoryKey, UnitCategoryData> = {
...unitData,
...categories
};
const makeCategory = (key: UnitCategoryKey) =>
createCategory(key, merged[key], getSystem, defaultLocale, lingInst);
return {
getSystem,
setSystem,
weight: makeCategory('weight'),
length: makeCategory('length'),
temp: makeCategory('temp'),
volume: makeCategory('volume'),
area: makeCategory('area')
};
}

@ -7,44 +7,173 @@ import type { LingNode } from '../../ling/lib/types.ts';
* Los datos numéricos (factor, system, base) viven en units_data.ts.
*/
export const unitsLingNode = {
weight: {
kg: { abbr: { es: 'kg', en: 'kg' }, name: p({ es: { one: 'kilogramo', other: 'kilogramos' }, en: { one: 'kilogram', other: 'kilograms' } }) },
g: { abbr: { es: 'g', en: 'g' }, name: p({ es: { one: 'gramo', other: 'gramos' }, en: { one: 'gram', other: 'grams' } }) },
lb: { abbr: { es: 'lb', en: 'lb' }, name: p({ es: { one: 'libra', other: 'libras' }, en: { one: 'pound', other: 'pounds' } }) },
oz: { abbr: { es: 'oz', en: 'oz' }, name: p({ es: { one: 'onza', other: 'onzas' }, en: { one: 'ounce', other: 'ounces' } }) },
t: { abbr: { es: 't', en: 't' }, name: p({ es: { one: 'tonelada', other: 'toneladas' }, en: { one: 'tonne', other: 'tonnes' } }) },
},
length: {
km: { abbr: { es: 'km', en: 'km' }, name: p({ es: { one: 'kilómetro', other: 'kilómetros' }, en: { one: 'kilometer', other: 'kilometers' } }) },
m: { abbr: { es: 'm', en: 'm' }, name: p({ es: { one: 'metro', other: 'metros' }, en: { one: 'meter', other: 'meters' } }) },
cm: { abbr: { es: 'cm', en: 'cm' }, name: p({ es: { one: 'centímetro', other: 'centímetros' }, en: { one: 'centimeter', other: 'centimeters' } }) },
mm: { abbr: { es: 'mm', en: 'mm' }, name: p({ es: { one: 'milímetro', other: 'milímetros' }, en: { one: 'millimeter', other: 'millimeters' } }) },
mi: { abbr: { es: 'mi', en: 'mi' }, name: p({ es: { one: 'milla', other: 'millas' }, en: { one: 'mile', other: 'miles' } }) },
ft: { abbr: { es: 'ft', en: 'ft' }, name: p({ es: { one: 'pie', other: 'pies' }, en: { one: 'foot', other: 'feet' } }) },
in: { abbr: { es: 'in', en: 'in' }, name: p({ es: { one: 'pulgada', other: 'pulgadas' }, en: { one: 'inch', other: 'inches' } }) },
yd: { abbr: { es: 'yd', en: 'yd' }, name: p({ es: { one: 'yarda', other: 'yardas' }, en: { one: 'yard', other: 'yards' } }) },
},
temp: {
c: { abbr: { es: '°C', en: '°C' }, name: p({ es: { one: 'grado Celsius', other: 'grados Celsius' }, en: { one: 'degree Celsius', other: 'degrees Celsius' } }) },
f: { abbr: { es: '°F', en: '°F' }, name: p({ es: { one: 'grado Fahrenheit', other: 'grados Fahrenheit' }, en: { one: 'degree Fahrenheit', other: 'degrees Fahrenheit' } }) },
k: { abbr: { es: 'K', en: 'K' }, name: p({ es: { one: 'kelvin', other: 'kelvins' }, en: { one: 'kelvin', other: 'kelvins' } }) },
},
volume: {
l: { abbr: { es: 'l', en: 'l' }, name: p({ es: { one: 'litro', other: 'litros' }, en: { one: 'liter', other: 'liters' } }) },
ml: { abbr: { es: 'ml', en: 'ml' }, name: p({ es: { one: 'mililitro', other: 'mililitros' }, en: { one: 'milliliter', other: 'milliliters' } }) },
m3: { abbr: { es: 'm³', en: 'm³' }, name: p({ es: { one: 'metro cúbico', other: 'metros cúbicos'}, en: { one: 'cubic meter', other: 'cubic meters' } }) },
gal: { abbr: { es: 'gal', en: 'gal' }, name: p({ es: { one: 'galón', other: 'galones' }, en: { one: 'gallon', other: 'gallons' } }) },
floz: { abbr: { es: 'fl oz', en: 'fl oz' }, name: p({ es: { one: 'onza líquida', other: 'onzas líquidas'}, en: { one: 'fluid ounce', other: 'fluid ounces' } }) },
pt: { abbr: { es: 'pt', en: 'pt' }, name: p({ es: { one: 'pinta', other: 'pintas' }, en: { one: 'pint', other: 'pints' } }) },
},
area: {
m2: { abbr: { es: 'm²', en: 'm²' }, name: p({ es: { one: 'metro cuadrado', other: 'metros cuadrados' }, en: { one: 'square meter', other: 'square meters' } }) },
km2: { abbr: { es: 'km²', en: 'km²' }, name: p({ es: { one: 'kilómetro cuadrado', other: 'kilómetros cuadrados' }, en: { one: 'square kilometer', other: 'square kilometers' } }) },
ha: { abbr: { es: 'ha', en: 'ha' }, name: p({ es: { one: 'hectárea', other: 'hectáreas' }, en: { one: 'hectare', other: 'hectares' } }) },
ft2: { abbr: { es: 'ft²', en: 'ft²' }, name: p({ es: { one: 'pie cuadrado', other: 'pies cuadrados' }, en: { one: 'square foot', other: 'square feet' } }) },
mi2: { abbr: { es: 'mi²', en: 'mi²' }, name: p({ es: { one: 'milla cuadrada', other: 'millas cuadradas' }, en: { one: 'square mile', other: 'square miles' } }) },
ac: { abbr: { es: 'ac', en: 'ac' }, name: p({ es: { one: 'acre', other: 'acres' }, en: { one: 'acre', other: 'acres' } }) },
},
weight: {
kg: {
abbr: { es: 'kg', en: 'kg' },
name: p({
es: { one: 'kilogramo', other: 'kilogramos' },
en: { one: 'kilogram', other: 'kilograms' }
})
},
g: {
abbr: { es: 'g', en: 'g' },
name: p({ es: { one: 'gramo', other: 'gramos' }, en: { one: 'gram', other: 'grams' } })
},
lb: {
abbr: { es: 'lb', en: 'lb' },
name: p({ es: { one: 'libra', other: 'libras' }, en: { one: 'pound', other: 'pounds' } })
},
oz: {
abbr: { es: 'oz', en: 'oz' },
name: p({ es: { one: 'onza', other: 'onzas' }, en: { one: 'ounce', other: 'ounces' } })
},
t: {
abbr: { es: 't', en: 't' },
name: p({
es: { one: 'tonelada', other: 'toneladas' },
en: { one: 'tonne', other: 'tonnes' }
})
}
},
length: {
km: {
abbr: { es: 'km', en: 'km' },
name: p({
es: { one: 'kilómetro', other: 'kilómetros' },
en: { one: 'kilometer', other: 'kilometers' }
})
},
m: {
abbr: { es: 'm', en: 'm' },
name: p({ es: { one: 'metro', other: 'metros' }, en: { one: 'meter', other: 'meters' } })
},
cm: {
abbr: { es: 'cm', en: 'cm' },
name: p({
es: { one: 'centímetro', other: 'centímetros' },
en: { one: 'centimeter', other: 'centimeters' }
})
},
mm: {
abbr: { es: 'mm', en: 'mm' },
name: p({
es: { one: 'milímetro', other: 'milímetros' },
en: { one: 'millimeter', other: 'millimeters' }
})
},
mi: {
abbr: { es: 'mi', en: 'mi' },
name: p({ es: { one: 'milla', other: 'millas' }, en: { one: 'mile', other: 'miles' } })
},
ft: {
abbr: { es: 'ft', en: 'ft' },
name: p({ es: { one: 'pie', other: 'pies' }, en: { one: 'foot', other: 'feet' } })
},
in: {
abbr: { es: 'in', en: 'in' },
name: p({ es: { one: 'pulgada', other: 'pulgadas' }, en: { one: 'inch', other: 'inches' } })
},
yd: {
abbr: { es: 'yd', en: 'yd' },
name: p({ es: { one: 'yarda', other: 'yardas' }, en: { one: 'yard', other: 'yards' } })
}
},
temp: {
c: {
abbr: { es: '°C', en: '°C' },
name: p({
es: { one: 'grado Celsius', other: 'grados Celsius' },
en: { one: 'degree Celsius', other: 'degrees Celsius' }
})
},
f: {
abbr: { es: '°F', en: '°F' },
name: p({
es: { one: 'grado Fahrenheit', other: 'grados Fahrenheit' },
en: { one: 'degree Fahrenheit', other: 'degrees Fahrenheit' }
})
},
k: {
abbr: { es: 'K', en: 'K' },
name: p({ es: { one: 'kelvin', other: 'kelvins' }, en: { one: 'kelvin', other: 'kelvins' } })
}
},
volume: {
l: {
abbr: { es: 'l', en: 'l' },
name: p({ es: { one: 'litro', other: 'litros' }, en: { one: 'liter', other: 'liters' } })
},
ml: {
abbr: { es: 'ml', en: 'ml' },
name: p({
es: { one: 'mililitro', other: 'mililitros' },
en: { one: 'milliliter', other: 'milliliters' }
})
},
m3: {
abbr: { es: 'm³', en: 'm³' },
name: p({
es: { one: 'metro cúbico', other: 'metros cúbicos' },
en: { one: 'cubic meter', other: 'cubic meters' }
})
},
gal: {
abbr: { es: 'gal', en: 'gal' },
name: p({ es: { one: 'galón', other: 'galones' }, en: { one: 'gallon', other: 'gallons' } })
},
floz: {
abbr: { es: 'fl oz', en: 'fl oz' },
name: p({
es: { one: 'onza líquida', other: 'onzas líquidas' },
en: { one: 'fluid ounce', other: 'fluid ounces' }
})
},
pt: {
abbr: { es: 'pt', en: 'pt' },
name: p({ es: { one: 'pinta', other: 'pintas' }, en: { one: 'pint', other: 'pints' } })
}
},
area: {
m2: {
abbr: { es: 'm²', en: 'm²' },
name: p({
es: { one: 'metro cuadrado', other: 'metros cuadrados' },
en: { one: 'square meter', other: 'square meters' }
})
},
km2: {
abbr: { es: 'km²', en: 'km²' },
name: p({
es: { one: 'kilómetro cuadrado', other: 'kilómetros cuadrados' },
en: { one: 'square kilometer', other: 'square kilometers' }
})
},
ha: {
abbr: { es: 'ha', en: 'ha' },
name: p({
es: { one: 'hectárea', other: 'hectáreas' },
en: { one: 'hectare', other: 'hectares' }
})
},
ft2: {
abbr: { es: 'ft²', en: 'ft²' },
name: p({
es: { one: 'pie cuadrado', other: 'pies cuadrados' },
en: { one: 'square foot', other: 'square feet' }
})
},
mi2: {
abbr: { es: 'mi²', en: 'mi²' },
name: p({
es: { one: 'milla cuadrada', other: 'millas cuadradas' },
en: { one: 'square mile', other: 'square miles' }
})
},
ac: {
abbr: { es: 'ac', en: 'ac' },
name: p({ es: { one: 'acre', other: 'acres' }, en: { one: 'acre', other: 'acres' } })
}
}
} satisfies LingNode;
export type UnitsLingNode = typeof unitsLingNode;
export type UnitsLingNode = typeof unitsLingNode;

@ -1,55 +1,59 @@
// ==============================
// UNIT TYPES
// ==============================
import type { LocaleResolver } from "./comn_types.ts";
import type { LocaleResolver } from './comn_types.ts';
export type UnitDisplay = 'abbr' | 'name';
export type UnitSystem = 'metric' | 'imperial';
export type UnitSystem = 'metric' | 'imperial';
export type UnitCategoryKey = 'weight' | 'length' | 'temp' | 'volume' | 'area';
/** Solo datos numéricos — sin texto, sin traducciones */
export interface UnitData {
factor : number;
system : UnitSystem;
factor: number;
system: UnitSystem;
}
/** Datos de categoría — base y factores de conversión */
export interface UnitCategoryData {
base : string;
baseImperial : string;
units : Record<string, UnitData>;
base: string;
baseImperial: string;
units: Record<string, UnitData>;
}
export interface UnitCategoryInstance {
/**
* Formatea `value` en la unidad activa del sistema actual (métrico o imperial).
* Asume que `value` ya está expresado en esa unidad base.
* Para convertir desde otra unidad, usa `fTo()`.
*/
f : (value: number, display?: UnitDisplay, locale?: LocaleResolver) => string;
/**
* Convierte `value` desde `fromUnit` (o la unidad base del sistema actual si se omite)
* a `targetUnit` (o la unidad base del sistema actual si se omite) y lo formatea.
*/
fTo : (value: number, fromUnit?: string, targetUnit?: string, display?: UnitDisplay, locale?: LocaleResolver) => string;
/** Convierte `value` de la unidad `from` a la unidad `to`. Devuelve null si alguna unidad no existe. */
convert: (value: number, from: string, to: string) => number | null;
/**
* Formatea `value` en la unidad activa del sistema actual (métrico o imperial).
* Asume que `value` ya está expresado en esa unidad base.
* Para convertir desde otra unidad, usa `fTo()`.
*/
f: (value: number, display?: UnitDisplay, locale?: LocaleResolver) => string;
/**
* Convierte `value` desde `fromUnit` (o la unidad base del sistema actual si se omite)
* a `targetUnit` (o la unidad base del sistema actual si se omite) y lo formatea.
*/
fTo: (
value: number,
fromUnit?: string,
targetUnit?: string,
display?: UnitDisplay,
locale?: LocaleResolver
) => string;
/** Convierte `value` de la unidad `from` a la unidad `to`. Devuelve null si alguna unidad no existe. */
convert: (value: number, from: string, to: string) => number | null;
}
export interface UnitConfig {
selectedSystem?: UnitSystem;
categories?: Partial<Record<UnitCategoryKey, UnitCategoryData>>;
selectedSystem?: UnitSystem;
categories?: Partial<Record<UnitCategoryKey, UnitCategoryData>>;
}
export interface UnitInstance {
weight : UnitCategoryInstance;
length : UnitCategoryInstance;
temp : UnitCategoryInstance;
volume : UnitCategoryInstance;
area : UnitCategoryInstance;
setSystem: (system: UnitSystem) => void;
getSystem: () => UnitSystem;
weight: UnitCategoryInstance;
length: UnitCategoryInstance;
temp: UnitCategoryInstance;
volume: UnitCategoryInstance;
area: UnitCategoryInstance;
setSystem: (system: UnitSystem) => void;
getSystem: () => UnitSystem;
}

File diff suppressed because it is too large Load Diff

@ -1,6 +1,9 @@
/**
* @deprecated `src/lib` is the legacy ecosystem and is slated for removal.
* Migrate to the new ecosystem under `src/arts` + `src/libs` + `src/svrs`.
* See `src/lib/README.md` for the migration map.
*/
export * from './actx/lib';
export * from './glob/lib';
export * from './ling/lib';
export * from './logr/lib';

@ -1,9 +1,8 @@
export const ID_PREFIX = '#?';
export const ID_FALLBACK_SEPARATOR = '|';
export const DEFAULT_ISO_LOCALE = 'es' as const;
export const MAX_RESOLVE_DEEP = 3;
export const LOGGER_CATEGORY = 'ling';

@ -1,326 +1,359 @@
import type {
SupportedLocale,
LeafPaths,
GetTypeAtPath,
ParamsFor,
HasParams,
PluralForms,
LingRecord,
LingString, LingInstance, LingLogger, LingNode, PluralConfig,
SupportedLocale,
LeafPaths,
GetTypeAtPath,
ParamsFor,
HasParams,
PluralForms,
LingRecord,
LingString,
LingInstance,
LingLogger,
LingNode,
PluralConfig
} from './types.ts';
import {LING_ERRORS} from "./errors.ts";
import {ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP} from "./consts.ts";
import {isIDLing, isLingRecord} from "./guards.ts";
import {pluralRule} from "./plural_rules.ts";
import {resolvePath} from "./helpers.ts";
import { LING_ERRORS } from './errors.ts';
import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP } from './consts.ts';
import { isIDLing, isLingRecord } from './guards.ts';
import { pluralRule } from './plural_rules.ts';
import { parseIDLing, resolvePath } from './helpers.ts';
// Memoizado — evaluado una sola vez en módulo load.
// Usa import.meta.env.DEV (Vite/SvelteKit) con fallback a process.env para Node.
const DEV: boolean =
typeof import.meta !== 'undefined' && import.meta.env != null
? import.meta.env.DEV === true
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
typeof import.meta !== 'undefined' && import.meta.env != null
? import.meta.env.DEV === true
: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
/** Deep-merge recursivo de objetos planos. Las funciones y primitivos de `source` sobreescriben `target`. */
function deepMerge(target: any, source: any): any {
const result = { ...target };
for (const key of Object.keys(source)) {
const srcVal = source[key];
const tgtVal = result[key];
if (
typeof srcVal === 'object' && srcVal !== null && !Array.isArray(srcVal) && typeof srcVal !== 'function' &&
typeof tgtVal === 'object' && tgtVal !== null && !Array.isArray(tgtVal) && typeof tgtVal !== 'function'
) {
result[key] = deepMerge(tgtVal, srcVal);
} else {
result[key] = srcVal;
}
}
return result;
const result = { ...target };
for (const key of Object.keys(source)) {
const srcVal = source[key];
const tgtVal = result[key];
if (
typeof srcVal === 'object' &&
srcVal !== null &&
!Array.isArray(srcVal) &&
typeof srcVal !== 'function' &&
typeof tgtVal === 'object' &&
tgtVal !== null &&
!Array.isArray(tgtVal) &&
typeof tgtVal !== 'function'
) {
result[key] = deepMerge(tgtVal, srcVal);
} else {
result[key] = srcVal;
}
}
return result;
}
// ==============================
// ENGINE
// ==============================
export function createLing<S extends LingNode>(
schema: S,
defaultLocale: SupportedLocale
schema: S,
defaultLocale: SupportedLocale
): LingInstance<S> {
let currentSchema : LingNode = schema;
let currentLocale : SupportedLocale = defaultLocale;
let logger : LingLogger = consoleLogger;
let loggerSet : boolean = false;
const listeners = new Set<(locale: SupportedLocale) => void>();
const schemaListeners = new Set<() => void>();
// Expuesto para que instancias hijas (register) puedan ser destruidas.
let __dispose: (() => void) | undefined;
// -------------------------------------------------------------------------
// Logger
// -------------------------------------------------------------------------
function setLogger(external: LingLogger): void {
if (loggerSet) {
if (DEV) console.warn(LING_ERRORS.LOGGER_ALREADY_SET);
return;
}
logger = external;
loggerSet = true;
}
// -------------------------------------------------------------------------
// Locale
// -------------------------------------------------------------------------
function setLocale(locale: SupportedLocale): void {
currentLocale = locale;
listeners.forEach(fn => fn(locale));
}
function getLocale(): SupportedLocale {
return currentLocale;
}
function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// -------------------------------------------------------------------------
// Resolución interna
// -------------------------------------------------------------------------
function tsRecord(record: LingRecord, path?: string, params?: any): string {
const translationInLocale = record[currentLocale];
const isMissing = translationInLocale === undefined;
let translation = translationInLocale ?? record[defaultLocale] ?? path ?? '';
if (params) {
// replaceAll con string literal es más rápido que new RegExp y evita
// inyección de caracteres especiales de regex en las claves de los params.
for (const [key, val] of Object.entries(params)) {
translation = translation.replaceAll(`{{${key}}}`, String(val));
}
}
if (DEV && isMissing) {
const msg = path
? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale)
: LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale);
logger.warn(LOGGER_CATEGORY, msg);
}
return translation;
}
/**
* Resuelve un valor del schema siguiendo referencias (#?) y funciones
* de forma recursiva. Lanza si detecta un ciclo o se supera la profundidad máxima.
*/
function resolveValue(value: any, args: any[], depth: number, visited?: Set<string>): any {
if (depth > MAX_RESOLVE_DEEP) {
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error("Circular reference in ling");
}
if (isIDLing(value)) {
const path = value.substring(ID_PREFIX.length);
const seen = visited ?? new Set<string>();
if (seen.has(path)) {
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(path));
throw new Error("Circular reference in ling");
}
seen.add(path);
const resolved = resolvePath(currentSchema, path);
return resolveValue(resolved, args, depth + 1, seen);
}
if (typeof value === 'function') {
return value(args[0]);
}
return value; // LingRecord, string, etc.
}
// -------------------------------------------------------------------------
// API pública
// -------------------------------------------------------------------------
const t: LingInstance<S>['t'] = (path: string, ...args: any[]): string => {
const rawValue = resolvePath(currentSchema, path);
if (rawValue === undefined) {
if (DEV) logger.error(LOGGER_CATEGORY, LING_ERRORS.KEY_NOT_FOUND(path));
return path;
}
// Toda la resolución de referencias y funciones vive en resolveValue.
// t() solo orquesta: busca → resuelve → traduce.
const finalValue = resolveValue(rawValue, args, 0);
if (isLingRecord(finalValue)) {
return tsRecord(finalValue, path, args[0]);
}
return String(finalValue);
};
function ts(value: LingString): string {
if (value == null) return '';
const finalValue = resolveValue(value, [], 0);
if (isLingRecord(finalValue)) return tsRecord(finalValue);
return typeof finalValue === 'string' ? finalValue : String(finalValue);
}
/**
* Similar a ts() pero permite especificar un locale específico.
* Útil para serializar mensajes al locale en que se registraron.
*/
function tsForLocale(value: LingString, locale: SupportedLocale): string {
if (value == null) return '';
const prev = currentLocale;
try {
currentLocale = locale;
return ts(value);
} finally {
currentLocale = prev;
}
}
function tForLocale<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
locale: SupportedLocale,
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
) : string {
const prev = currentLocale;
try {
currentLocale = locale;
return t(path, ...(args as any));
} finally {
// Garantiza la restauración del locale aunque t() lance (ej. referencia circular).
currentLocale = prev;
}
}
/**
* Añade un módulo lazy al schema en runtime mutando la instancia actual.
* Soporta namespaces con puntos: 'vcen.editor.mtas' crea la jerarquía anidada correcta
* y hace deep-merge con lo que ya existiera en ese path.
*
* Nota: los componentes suscritos a onLocaleChange no se notifican automáticamente
* de cambios de schema. Si necesitas reactividad ante extend(), usa onSchemaChange().
*
* @example
* ling.extend('shop', shopTranslations);
* ling.extend('vcen.editor.mtas', mtasTranslations);
*/
function extend(namespace: string, module: LingNode): void {
const parts = namespace.split('.');
let nested: any = module;
for (let i = parts.length - 1; i > 0; i--) {
nested = { [parts[i]]: nested };
}
const root = parts[0];
currentSchema = {
...(currentSchema as Record<string, any>),
[root]: deepMerge((currentSchema as any)[root] ?? {}, nested),
};
schemaListeners.forEach(fn => fn());
}
/**
* Registra un módulo y devuelve una nueva instancia con el tipo actualizado.
* Útil para contextos aislados, tests, o cuando necesitas el tipo inferido
* sin declarar FullSchema de antemano.
*
* Llama a `dispose()` en la instancia devuelta cuando ya no la necesites
* para evitar memory leaks (elimina el listener de locale del padre).
*
* @example
* const { instance: lingTest, dispose } = ling.register('shop', shopTranslations);
* lingTest.t('shop.product'); // ✅ tipado
* dispose(); // limpia el listener cuando el contexto se destruye
*/
function register<NS extends string, M extends LingNode>(
namespace: NS,
module: M
): LingInstance<S & { [K in NS]: M }> {
const newSchema = {
...(currentSchema as Record<string, any>),
[namespace]: module,
} as S & { [K in NS]: M };
const extended = createLing(newSchema, defaultLocale);
extended.setLocale(currentLocale);
// Sincroniza el locale del hijo cuando cambie el padre.
// El unsubscribe se expone en la instancia hija como dispose().
const unsubscribe = onLocaleChange(locale => extended.setLocale(locale));
extended.__dispose = unsubscribe;
return extended;
}
function onSchemaChange(fn: () => void): () => void {
schemaListeners.add(fn);
return () => schemaListeners.delete(fn);
}
function dispose(): void {
__dispose?.();
}
return { t, tForLocale, ts, tsForLocale, setLocale, getLocale, onLocaleChange, onSchemaChange, extend, register, setLogger, dispose, get __dispose() { return __dispose; }, set __dispose(fn) { __dispose = fn; } };
let currentSchema: LingNode = schema;
let currentLocale: SupportedLocale = defaultLocale;
let logger: LingLogger = consoleLogger;
let loggerSet: boolean = false;
const listeners = new Set<(locale: SupportedLocale) => void>();
const schemaListeners = new Set<() => void>();
// Expuesto para que instancias hijas (register) puedan ser destruidas.
let __dispose: (() => void) | undefined;
// -------------------------------------------------------------------------
// Logger
// -------------------------------------------------------------------------
function setLogger(external: LingLogger): void {
if (loggerSet) {
if (DEV) console.warn(LING_ERRORS.LOGGER_ALREADY_SET);
return;
}
logger = external;
loggerSet = true;
}
// -------------------------------------------------------------------------
// Locale
// -------------------------------------------------------------------------
function setLocale(locale: SupportedLocale): void {
currentLocale = locale;
listeners.forEach((fn) => fn(locale));
}
function getLocale(): SupportedLocale {
return currentLocale;
}
function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// -------------------------------------------------------------------------
// Resolución interna
// -------------------------------------------------------------------------
function tsRecord(record: LingRecord, path?: string, params?: any): string {
const translationInLocale = record[currentLocale];
const isMissing = translationInLocale === undefined;
let translation = translationInLocale ?? record[defaultLocale] ?? path ?? '';
if (params) {
// replaceAll con string literal es más rápido que new RegExp y evita
// inyección de caracteres especiales de regex en las claves de los params.
for (const [key, val] of Object.entries(params)) {
translation = translation.replaceAll(`{{${key}}}`, String(val));
}
}
if (DEV && isMissing) {
const msg = path
? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale)
: LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale);
logger.warn(LOGGER_CATEGORY, msg);
}
return translation;
}
/**
* Resuelve un valor del schema siguiendo referencias (#?) y funciones
* de forma recursiva. Lanza si detecta un ciclo o se supera la profundidad máxima.
*/
function resolveValue(value: any, args: any[], depth: number, visited?: Set<string>): any {
if (depth > MAX_RESOLVE_DEEP) {
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error('Circular reference in ling');
}
if (isIDLing(value)) {
const parsed = parseIDLing(value);
const path = parsed?.path ?? value.substring(ID_PREFIX.length);
const seen = visited ?? new Set<string>();
if (seen.has(path)) {
logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(path));
throw new Error('Circular reference in ling');
}
seen.add(path);
const resolved = resolvePath(currentSchema, path);
if (resolved === undefined) {
if (parsed?.fallback !== undefined) {
if (DEV)
logger.warn(
LOGGER_CATEGORY,
`${LING_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".`
);
return parsed.fallback;
}
return undefined;
}
return resolveValue(resolved, args, depth + 1, seen);
}
if (typeof value === 'function') {
return value(args[0]);
}
return value; // LingRecord, string, etc.
}
// -------------------------------------------------------------------------
// API pública
// -------------------------------------------------------------------------
const t: LingInstance<S>['t'] = (path: string, ...args: any[]): string => {
const rawValue = resolvePath(currentSchema, path);
if (rawValue === undefined) {
if (DEV) logger.error(LOGGER_CATEGORY, LING_ERRORS.KEY_NOT_FOUND(path));
return path;
}
// Toda la resolución de referencias y funciones vive en resolveValue.
// t() solo orquesta: busca → resuelve → traduce.
const finalValue = resolveValue(rawValue, args, 0);
if (isLingRecord(finalValue)) {
return tsRecord(finalValue, path, args[0]);
}
return String(finalValue);
};
function ts(value: LingString): string {
if (value == null) return '';
const finalValue = resolveValue(value, [], 0);
if (isLingRecord(finalValue)) return tsRecord(finalValue);
return typeof finalValue === 'string' ? finalValue : String(finalValue);
}
/**
* Similar a ts() pero permite especificar un locale específico.
* Útil para serializar mensajes al locale en que se registraron.
*/
function tsForLocale(value: LingString, locale: SupportedLocale): string {
if (value == null) return '';
const prev = currentLocale;
try {
currentLocale = locale;
return ts(value);
} finally {
currentLocale = prev;
}
}
function tForLocale<P extends LeafPaths<S>, TType = GetTypeAtPath<S, S, P>>(
path: P,
locale: SupportedLocale,
...args: HasParams<TType> extends true ? [params: ParamsFor<TType>] : []
): string {
const prev = currentLocale;
try {
currentLocale = locale;
return t(path, ...(args as any));
} finally {
// Garantiza la restauración del locale aunque t() lance (ej. referencia circular).
currentLocale = prev;
}
}
/**
* Añade un módulo lazy al schema en runtime mutando la instancia actual.
* Soporta namespaces con puntos: 'vcen.editor.mtas' crea la jerarquía anidada correcta
* y hace deep-merge con lo que ya existiera en ese path.
*
* Nota: los componentes suscritos a onLocaleChange no se notifican automáticamente
* de cambios de schema. Si necesitas reactividad ante extend(), usa onSchemaChange().
*
* @example
* ling.extend('shop', shopTranslations);
* ling.extend('vcen.editor.mtas', mtasTranslations);
*/
function extend(namespace: string, module: LingNode): void {
const parts = namespace.split('.');
let nested: any = module;
for (let i = parts.length - 1; i > 0; i--) {
nested = { [parts[i]]: nested };
}
const root = parts[0];
currentSchema = {
...(currentSchema as Record<string, any>),
[root]: deepMerge((currentSchema as any)[root] ?? {}, nested)
};
schemaListeners.forEach((fn) => fn());
}
/**
* Registra un módulo y devuelve una nueva instancia con el tipo actualizado.
* Útil para contextos aislados, tests, o cuando necesitas el tipo inferido
* sin declarar FullSchema de antemano.
*
* Llama a `dispose()` en la instancia devuelta cuando ya no la necesites
* para evitar memory leaks (elimina el listener de locale del padre).
*
* @example
* const { instance: lingTest, dispose } = ling.register('shop', shopTranslations);
* lingTest.t('shop.product'); // ✅ tipado
* dispose(); // limpia el listener cuando el contexto se destruye
*/
function register<NS extends string, M extends LingNode>(
namespace: NS,
module: M
): LingInstance<S & { [K in NS]: M }> {
const newSchema = {
...(currentSchema as Record<string, any>),
[namespace]: module
} as S & { [K in NS]: M };
const extended = createLing(newSchema, defaultLocale);
extended.setLocale(currentLocale);
// Sincroniza el locale del hijo cuando cambie el padre.
// El unsubscribe se expone en la instancia hija como dispose().
const unsubscribe = onLocaleChange((locale) => extended.setLocale(locale));
extended.__dispose = unsubscribe;
return extended;
}
function onSchemaChange(fn: () => void): () => void {
schemaListeners.add(fn);
return () => schemaListeners.delete(fn);
}
function dispose(): void {
__dispose?.();
}
return {
t,
tForLocale,
ts,
tsForLocale,
setLocale,
getLocale,
onLocaleChange,
onSchemaChange,
extend,
register,
setLogger,
dispose,
get __dispose() {
return __dispose;
},
set __dispose(fn) {
__dispose = fn;
}
};
}
/**
* Helper de pluralización.
* El tipo de retorno incluye `& Record<string, any>` para permitir
* parámetros adicionales de interpolación (como {{name}}).
*/
export const p = (config: PluralConfig) => {
// Caché por count: la selección de forma plural solo depende de count.
// Usamos String(count) como clave para evitar inestabilidad de floats en Map.
const cache = new Map<string, LingRecord>();
const fn = (params: { count: number } & Record<string, any>): LingRecord => {
const key = String(params.count);
const cached = cache.get(key);
if (cached) return cached;
const result: any = {};
for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue;
const rule = pluralRule(locale, params.count);
const typedForms = forms as PluralForms;
result[locale] = typedForms[rule] || typedForms.other;
}
cache.set(key, result as LingRecord);
return result as LingRecord;
};
(fn as any).__pluralConfig = config;
return fn;
}
// Caché por count: la selección de forma plural solo depende de count.
// Usamos String(count) como clave para evitar inestabilidad de floats en Map.
const cache = new Map<string, LingRecord>();
const fn = (params: { count: number } & Record<string, any>): LingRecord => {
const key = String(params.count);
const cached = cache.get(key);
if (cached) return cached;
const result: any = {};
for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue;
const rule = pluralRule(locale, params.count);
const typedForms = forms as PluralForms;
result[locale] = typedForms[rule] || typedForms.other;
}
cache.set(key, result as LingRecord);
return result as LingRecord;
};
(fn as any).__pluralConfig = config;
return fn;
};
// Logger por defecto — console puro, sin dependencias externas.
// Se reemplaza con setLogger() una vez logr está inicializado.
const consoleLogger: LingLogger = {
warn : (_category: string, message: string) => DEV && console.warn (message),
error: (_category: string, message: string) => DEV && console.error(message),
};
warn: (_category: string, message: string) => DEV && console.warn(message),
error: (_category: string, message: string) => DEV && console.error(message)
};

@ -14,49 +14,44 @@ import type { SupportedLocale } from './types.ts';
* (`NODE_ENV === 'development'`). En producción la degradación es silenciosa.
*/
export const LING_ERRORS = {
/**
* La clave de traducción no existe en el schema.
* Se emite como `console.error` — indica un error de programación,
* no una traducción faltante.
*
* @example
* LING_ERRORS.KEY_NOT_FOUND('checkout.total')
* // → '[ling] Translation key not found: "checkout.total"'
*/
KEY_NOT_FOUND: (path: string): string =>
`[ling] Translation key not found: "${path}"`,
CIRCULAR_REFERENCE: (path: string): string =>
`[ling] Circular reference in "${path}".`,
/**
* La clave existe pero no tiene traducción para el locale solicitado.
* Se emite como `console.warn` — degradación controlada con fallback.
*
* @example
* LING_ERRORS.MISSING_TRANSLATION('common.ok', 'de', 'es')
* // → '[ling] Missing translation for "common.ok" in "de". Falling back to "es".'
*/
MISSING_TRANSLATION: (path: string, locale: SupportedLocale, fallback: SupportedLocale): string =>
`[ling] Missing translation for "${path}" in "${locale}". Falling back to "${fallback}".`,
/**
* Variante de MISSING_TRANSLATION para cuando no hay path disponible
* — usado en `ts()` al resolver un `LocaleRecord` sin contexto de clave.
*
* @example
* LING_ERRORS.MISSING_TRANSLATION_RECORD('de', 'es')
* // → '[ling] Missing translation in "de". Falling back to "es".'
*/
MISSING_TRANSLATION_RECORD: (locale: SupportedLocale, fallback: SupportedLocale): string =>
`[ling] Missing translation in "${locale}". Falling back to "${fallback}".`,
/**
* Se intentó llamar a setLogger() más de una vez.
* El logger solo puede inyectarse una vez — post-init es inmutable.
*/
LOGGER_ALREADY_SET: '[ling] Logger already set. setLogger() can only be called once.',
} as const;
/**
* La clave de traducción no existe en el schema.
* Se emite como `console.error` — indica un error de programación,
* no una traducción faltante.
*
* @example
* LING_ERRORS.KEY_NOT_FOUND('checkout.total')
* // → '[ling] Translation key not found: "checkout.total"'
*/
KEY_NOT_FOUND: (path: string): string => `[ling] Translation key not found: "${path}"`,
CIRCULAR_REFERENCE: (path: string): string => `[ling] Circular reference in "${path}".`,
/**
* La clave existe pero no tiene traducción para el locale solicitado.
* Se emite como `console.warn` — degradación controlada con fallback.
*
* @example
* LING_ERRORS.MISSING_TRANSLATION('common.ok', 'de', 'es')
* // → '[ling] Missing translation for "common.ok" in "de". Falling back to "es".'
*/
MISSING_TRANSLATION: (path: string, locale: SupportedLocale, fallback: SupportedLocale): string =>
`[ling] Missing translation for "${path}" in "${locale}". Falling back to "${fallback}".`,
/**
* Variante de MISSING_TRANSLATION para cuando no hay path disponible
* — usado en `ts()` al resolver un `LocaleRecord` sin contexto de clave.
*
* @example
* LING_ERRORS.MISSING_TRANSLATION_RECORD('de', 'es')
* // → '[ling] Missing translation in "de". Falling back to "es".'
*/
MISSING_TRANSLATION_RECORD: (locale: SupportedLocale, fallback: SupportedLocale): string =>
`[ling] Missing translation in "${locale}". Falling back to "${fallback}".`,
/**
* Se intentó llamar a setLogger() más de una vez.
* El logger solo puede inyectarse una vez — post-init es inmutable.
*/
LOGGER_ALREADY_SET: '[ling] Logger already set. setLogger() can only be called once.'
} as const;

@ -1,38 +1,31 @@
import type {IDLing, LingRecord, LingString} from "./types.ts";
import {DEFAULT_ISO_LOCALE, ID_PREFIX} from "./consts.ts";
import type { IDLing, LingRecord, LingString } from './types.ts';
import { DEFAULT_ISO_LOCALE, ID_PREFIX } from './consts.ts';
/**
* Guard para identificar referencias.
* Usamos un chequeo de longitud para evitar que "#?" vacío sea válido.
* Guard para identificar referencias `IDLing`.
* Acepta tanto `#?path` como `#?path|fallback`.
* Usamos un chequeo de longitud para evitar que `#?` vacío sea válido.
*/
export function isIDLing(value: unknown): value is IDLing {
return (
typeof value === 'string' &&
value.startsWith(ID_PREFIX) &&
value.length > 2
);
return typeof value === 'string' && value.startsWith(ID_PREFIX) && value.length > 2;
}
/**
* Guard para registros de idioma.
* Valida que el objeto tenga la DefaultLocale y que su valor sea string,
* evitando falsos positivos con objetos de dominio que casualmente tengan esa clave.
*/
export function isLingRecord(value: unknown): value is LingRecord {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
typeof value !== 'function' &&
DEFAULT_ISO_LOCALE in value &&
typeof (value as Record<string, unknown>)[DEFAULT_ISO_LOCALE] === 'string'
);
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
typeof value !== 'function' &&
DEFAULT_ISO_LOCALE in value &&
typeof (value as Record<string, unknown>)[DEFAULT_ISO_LOCALE] === 'string'
);
}
/**
* Comprueba si un valor es un LingString.
* Un LingString puede ser:
@ -41,5 +34,5 @@ export function isLingRecord(value: unknown): value is LingRecord {
* - IDLing → referencia con prefijo '#?...'
*/
export function isLingString(value: unknown): value is LingString {
return isIDLing(value) || isLingRecord(value) || typeof value === 'string';
}
return isIDLing(value) || isLingRecord(value) || typeof value === 'string';
}

@ -1,24 +1,35 @@
// ==============================
// HELPERS
// ==============================
import type {LingRecord, LingString} from "./types.ts";
import {DEFAULT_ISO_LOCALE} from "./consts.ts";
import { isLingString } from "./guards.ts";
import type { LingRecord, LingString } from './types.ts';
import { DEFAULT_ISO_LOCALE, ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts';
import { isLingString } from './guards.ts';
export function resolvePath(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
export function parseIDLing(value: string): { path: string; fallback?: string } | null {
if (!value.startsWith(ID_PREFIX) || value.length <= ID_PREFIX.length) return null;
const raw = value.slice(ID_PREFIX.length);
const separatorIndex = raw.indexOf(ID_FALLBACK_SEPARATOR);
if (separatorIndex === -1) {
return { path: raw };
}
export function makeLingRecord (text?: string) : LingRecord {
return {
[DEFAULT_ISO_LOCALE]: text ?? ''
} as LingRecord;
return {
path: raw.slice(0, separatorIndex),
fallback: raw.slice(separatorIndex + 1)
};
}
export function makeLingRecord(text?: string): LingRecord {
return {
[DEFAULT_ISO_LOCALE]: text ?? ''
} as LingRecord;
}
// src/lib/ling/lib/helpers.ts — añadir esto
@ -31,7 +42,7 @@ export function makeLingRecord (text?: string) : LingRecord {
* - Cualquier otra cosa → lo convierte a string con String()
*/
export function asLingString(value: unknown): LingString {
if (value == null) return '';
if (isLingString(value)) return value;
return String(value);
}
if (value == null) return '';
if (isLingString(value)) return value;
return String(value);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.