morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired

Morfo (src/uix/morfo/): machine-readable contract of a component's public
DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across
three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment.
Types, sium-based validator with cross-field invariants, dialog.ts as the
first canonical morfo with data-last-action for Sema-causal exits, 7 passing
unit tests.

createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy
inline signature removed. Dialog provider refactored to derive parts +
data contract from dialogMorfo. The other 65 soma components need porting
(svelte-check lists them) — tracked work.

Tooling:
  scripts/smoke-check.mjs      — Playwright smoke over 65 soma demo routes
  scripts/morfo-check.ts       — validates emitted DOM vs morfo
  scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency
  npm run smoke / morfo:check / morfo:vocabulary

WAI-ARIA APG components added (100% APG coverage now):
  Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress,
  SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel
  pattern; hierarchical Table rows deprecated — use TreeGrid instead).
  air: Link, Banner.

Sema prep:
  src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements.
  Morfo already provides everything Sema needs (data-last-action patterns,
  transition markers, enumerable state values, cross-component consistency).
  Sema implementation deferred.

COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM
topology vs .require() audit, npm run smoke is part of done) + A34 rule
with incident log. WAI-ARIA pattern listed as soma membership criterion.

Pagination translation bug fixed (soma.pagination.page -> idlangref
PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.*
-> components.table.*). Avatar demo now offline-safe (SVG data URL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
semantuix
dev 6 months ago
parent 3b704286ba
commit 8c16ffac81

@ -1,84 +1,91 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npx svelte-check:*)",
"Bash(npx tsc:*)",
"Bash(grep -v \"^$\")",
"Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)",
"Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)",
"Bash(wc -l \"g:/dev/svelte/vicen/src/uix/uisv/lib/bits/calendar/components/\"*.svelte)",
"Bash(grep -n \"from \"\"@internationalized\\\\|from ''.*shared/date\\\\|from \"\".*shared/date\\\\|getDocument\\\\|isBrowser\\\\|styleToString\\\\|afterTick\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/utils.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/formatter.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/announcer.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts\")",
"Bash(grep -r from.*[''].*internal/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
"Bash(grep -r from.*[''].*shared/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
"Bash(grep -rh \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
"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(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)"
]
}
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npx svelte-check:*)",
"Bash(npx tsc:*)",
"Bash(grep -v \"^$\")",
"Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)",
"Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)",
"Bash(wc -l \"g:/dev/svelte/vicen/src/uix/uisv/lib/bits/calendar/components/\"*.svelte)",
"Bash(grep -n \"from \"\"@internationalized\\\\|from ''.*shared/date\\\\|from \"\".*shared/date\\\\|getDocument\\\\|isBrowser\\\\|styleToString\\\\|afterTick\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/utils.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/formatter.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/announcer.ts\" \"g:/dev/svelte/vicen/src/uix/acel/internal/date-time/calendar-helpers.svelte.ts\")",
"Bash(grep -r from.*[''].*internal/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
"Bash(grep -r from.*[''].*shared/ g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte -l)",
"Bash(grep -rh \"from.*[''''].*\\\\\\(internal\\\\|shared\\\\\\)/\" g:/dev/svelte/vicen/src/uix/acel --include=*.ts --include=*.svelte)",
"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(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"
]
}
}

@ -159,6 +159,9 @@
"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,6 +7,8 @@
"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",
@ -14,6 +16,9 @@
"format": "prettier --write .",
"test:unit": "vitest",
"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",
"generate:contracts-docs": "node --import tsx/esm scripts/generate-contracts-docs.ts"
},
"devDependencies": {

@ -0,0 +1,219 @@
/**
* 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 === 'root' ? `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');
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
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,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);

@ -10,6 +10,7 @@
<h2>Feedback</h2>
<ul>
<li><a href="/test/soma/toast">Toast</a></li>
<li><a href="/test/soma/announce">Announce</a></li>
</ul>
<h2>Overlay</h2>
@ -40,9 +41,16 @@
<h2>Data Display</h2>
<ul>
<li><a href="/test/soma/tree-view">TreeView</a></li>
<li><a href="/test/soma/tree-grid">TreeGrid</a></li>
<li><a href="/test/soma/table">Table</a></li>
<li><a href="/test/soma/virtual-list">VirtualList</a></li>
<li><a href="/test/soma/virtual-grid">VirtualGrid</a></li>
<li><a href="/test/soma/avatar">Avatar</a></li>
<li><a href="/test/soma/progress">Progress</a></li>
<li><a href="/test/soma/meter">Meter</a></li>
<li><a href="/test/soma/grid-list">GridList</a></li>
<li><a href="/test/soma/tag-group">TagGroup</a></li>
<li><a href="/test/soma/feed">Feed</a></li>
</ul>
<h2>Dates</h2>
@ -68,7 +76,9 @@
<li><a href="/test/soma/toolbar">Toolbar</a></li>
<li><a href="/test/soma/toggle">Toggle</a></li>
<li><a href="/test/soma/editable">Editable</a></li>
<li><a href="/test/soma/drag-drop">DragDrop</a></li>
<li><a href="/test/soma/tags-input">TagsInput</a></li>
<li><a href="/test/soma/clipboard">Clipboard</a></li>
</ul>
<h2>Layout</h2>
@ -98,6 +108,7 @@
<li><a href="/test/soma/pin-input">PinInput</a></li>
<li><a href="/test/soma/rating-group">RatingGroup</a></li>
<li><a href="/test/soma/listbox">Listbox</a></li>
<li><a href="/test/soma/search-field">SearchField</a></li>
</ul>
</nav>
</div>

@ -0,0 +1,326 @@
<script lang="ts">
import * as Announce from '$soma/components/announce';
import { createAnnouncer } from '$soma/components/announce';
let defaultTimeout = $state(1000);
let message = $state('File saved successfully.');
let priority = $state<'polite' | 'assertive'>('polite');
let customRegionMessage = $state('Ready');
let log = $state<string[]>([]);
let globalLog = $state<string[]>([]);
function logAnnounce(m: string, p: 'polite' | 'assertive') {
log = [...log, `[${p}] ${m}`].slice(-8);
}
// Global announcer — lives outside any Provider; creates DOM nodes lazily.
const global = createAnnouncer(1200);
function fireGlobal(m: string, p: 'polite' | 'assertive') {
global.announce(m, p);
globalLog = [...globalLog, `[${p}] ${m}`].slice(-5);
}
</script>
<svelte:head>
<title>Announce · Soma</title>
</svelte:head>
<div class="page">
<h1>Announce</h1>
<p>
Primitivo de live region — <code>role=status</code> / <code>role=alert</code> +
<code>aria-live</code> para anuncios a lectores de pantalla. API imperativa
<code>announce(msg, priority, timeout)</code> y regiones declarativas.
</p>
<section>
<h2>1. Imperative API</h2>
<p class="desc">
Provider monta 2 regiones internas ocultas (polite + assertive) y expone
<code>announce()</code> via snippet. Abre DevTools accessibility tree para ver las
regiones.
</p>
<div class="controls">
<label class="full">
<span>message</span>
<input type="text" bind:value={message} />
</label>
<label>
<span>priority</span>
<select bind:value={priority}>
<option value="polite">polite</option>
<option value="assertive">assertive</option>
</select>
</label>
<label>
<span>defaultTimeout ({defaultTimeout}ms)</span>
<input type="range" min="300" max="3000" step="100" bind:value={defaultTimeout} />
</label>
</div>
<Announce.Provider {defaultTimeout}>
{#snippet children({ announce, clear })}
<div class="actions">
<button
type="button"
onclick={() => {
announce(message, priority);
logAnnounce(message, priority);
}}>Announce</button
>
<button
type="button"
onclick={() => {
clear();
log = [];
}}>Clear</button
>
<button
type="button"
onclick={() => {
announce('Saved!', 'polite');
logAnnounce('Saved!', 'polite');
setTimeout(() => {
announce('Saved!', 'polite');
logAnnounce('Saved! (repeat)', 'polite');
}, 100);
}}>Announce same twice</button
>
<button
type="button"
onclick={() => {
announce('Error: connection lost', 'assertive');
logAnnounce('Error: connection lost', 'assertive');
}}>Fire error</button
>
</div>
{/snippet}
</Announce.Provider>
<dl class="state">
<dt>history (last 8)</dt>
<dd>
{#if log.length === 0}
<code>—</code>
{:else}
<ul class="log">
{#each log as entry (entry + Math.random())}
<li><code>{entry}</code></li>
{/each}
</ul>
{/if}
</dd>
</dl>
</section>
<section>
<h2>2. Declarative Region (<code>bind:message</code>)</h2>
<p class="desc">
Region visible — útil cuando el anuncio también es UI. Cambia el texto y escucha con un
lector de pantalla.
</p>
<div class="controls">
<label class="full">
<span>current status</span>
<input type="text" bind:value={customRegionMessage} />
</label>
</div>
<Announce.Region
role="status"
message={customRegionMessage}
visuallyHidden={false}
class="status-pill"
/>
</section>
<section>
<h2>3. Global API (<code>createAnnouncer()</code>)</h2>
<p class="desc">
Sin Provider — monta regiones en <code>document.body</code> la primera vez. Úsalo desde
módulos utilitarios o cuando no quieras cargar Provider en la raíz.
</p>
<div class="actions">
<button type="button" onclick={() => fireGlobal('Global polite announcement', 'polite')}>
Polite (global)
</button>
<button type="button" onclick={() => fireGlobal('Global assertive alert', 'assertive')}>
Assertive (global)
</button>
<button
type="button"
onclick={() => {
global.clear();
globalLog = [];
}}>Clear global</button
>
</div>
<dl class="state">
<dt>global history</dt>
<dd>
{#if globalLog.length === 0}
<code>—</code>
{:else}
<ul class="log">
{#each globalLog as entry, i (entry + i)}
<li><code>{entry}</code></li>
{/each}
</ul>
{/if}
</dd>
</dl>
</section>
<section>
<h2>4. Role variants</h2>
<p class="desc">
Cada <code>role</code> tiene semántica distinta. Abre el accessibility tree:
</p>
<div class="grid">
<Announce.Region role="status" message="Idle" visuallyHidden={false} class="demo-region">
</Announce.Region>
<Announce.Region
role="alert"
message="Critical!"
visuallyHidden={false}
class="demo-region alert"
/>
<Announce.Region
role="log"
message="Turn 4: moved 3"
visuallyHidden={false}
class="demo-region log"
/>
<Announce.Region
role="timer"
message="02:30"
visuallyHidden={false}
class="demo-region timer"
/>
</div>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
.desc {
font-size: 0.9rem;
color: #555;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem;
padding-block: 0.5rem 0.75rem;
}
.controls label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
.controls .full {
grid-column: 1 / -1;
}
.controls input,
.controls select {
padding: 0.3rem 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-family: inherit;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.actions button {
padding: 0.35rem 0.8rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
cursor: pointer;
font-size: 0.85rem;
}
.actions button:hover {
background: #e2e8f0;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
.log {
margin: 0;
padding: 0;
list-style: none;
}
.log li {
margin: 0.1rem 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.5rem;
}
:global(.status-pill) {
display: inline-block;
padding: 0.35rem 0.8rem;
border-radius: 999px;
background: #dcfce7;
color: #166534;
font-size: 0.85rem;
}
:global(.demo-region) {
padding: 0.4rem 0.7rem;
border-radius: 4px;
background: #f1f5f9;
font-size: 0.85rem;
}
:global(.demo-region.alert) {
background: #fee2e2;
color: #991b1b;
}
:global(.demo-region.log) {
background: #e0f2fe;
color: #075985;
}
:global(.demo-region.timer) {
background: #fef3c7;
color: #92400e;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>

@ -0,0 +1,240 @@
<script lang="ts">
import * as Avatar from '$soma/components/avatar';
import type { AvatarLoadingStatus } from '$soma/components/avatar';
// Inline SVG data URLs — work offline, no external dependency.
const SAMPLE_SRC =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%230ea5e9"/><stop offset="1" stop-color="%238b5cf6"/></linearGradient></defs><rect width="96" height="96" fill="url(%23g)"/><circle cx="48" cy="40" r="16" fill="white" opacity=".9"/><path d="M16 96c0-18 14-32 32-32s32 14 32 32" fill="white" opacity=".9"/></svg>`
);
const BROKEN_SRC = 'data:image/png;base64,INVALID';
let src = $state<string>(SAMPLE_SRC);
let delayMs = $state(0);
let status = $state<AvatarLoadingStatus>('idle');
let statusLog = $state<AvatarLoadingStatus[]>([]);
function resetSample() {
src = SAMPLE_SRC;
}
function triggerError() {
src = BROKEN_SRC;
}
function clearSrc() {
src = '';
}
</script>
<svelte:head>
<title>Avatar · Soma</title>
</svelte:head>
<div class="page">
<h1>Avatar</h1>
<p>
Preload <code>&lt;img&gt;</code> en background; muestra fallback hasta que el estado sea
<code>loaded</code>. <code>delayMs</code> evita flash en cargas rápidas.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label>
<span>src</span>
<input type="text" bind:value={src} placeholder="Image URL…" />
</label>
<label>
<span>delayMs ({delayMs})</span>
<input type="range" min="0" max="1500" step="50" bind:value={delayMs} />
</label>
</div>
<div class="actions">
<button type="button" onclick={resetSample}>Reset to sample</button>
<button type="button" onclick={triggerError}>Force error</button>
<button type="button" onclick={clearSrc}>Clear src</button>
</div>
<Avatar.Provider
{delayMs}
onStatusChange={(s) => {
status = s;
statusLog = [...statusLog, s].slice(-8);
}}
class="avatar"
>
<Avatar.Image {src} alt="Demo avatar" class="avatar-img" />
<Avatar.Fallback class="avatar-fallback">JP</Avatar.Fallback>
</Avatar.Provider>
<dl class="state">
<dt>status</dt>
<dd><code data-status={status}>{status}</code></dd>
<dt>transitions</dt>
<dd><code>{statusLog.join(' → ') || '—'}</code></dd>
</dl>
</section>
<section>
<h2>2. Grid (variations)</h2>
<div class="grid">
<Avatar.Provider class="avatar">
<Avatar.Image src={SAMPLE_SRC} alt="Ok" class="avatar-img" />
<Avatar.Fallback class="avatar-fallback">JP</Avatar.Fallback>
</Avatar.Provider>
<Avatar.Provider class="avatar">
<Avatar.Image src={BROKEN_SRC} alt="Broken" class="avatar-img" />
<Avatar.Fallback class="avatar-fallback">MX</Avatar.Fallback>
</Avatar.Provider>
<Avatar.Provider class="avatar">
<Avatar.Image src="" alt="Empty" class="avatar-img" />
<Avatar.Fallback class="avatar-fallback">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">
<path
d="M12 12c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5Zm0 2c-3.31 0-10 1.67-10 5v3h20v-3c0-3.33-6.69-5-10-5Z"
/>
</svg>
</Avatar.Fallback>
</Avatar.Provider>
<Avatar.Provider delayMs={800} class="avatar">
<Avatar.Image
src={SAMPLE_SRC + '&bust=' + Math.random()}
alt="Delayed"
class="avatar-img"
/>
<Avatar.Fallback class="avatar-fallback">800</Avatar.Fallback>
</Avatar.Provider>
</div>
<p class="desc">
De izquierda a derecha: carga OK, error, <code>src=""</code> (fuerza fallback), y
<code>delayMs=800</code>.
</p>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
.desc {
font-size: 0.9rem;
color: #555;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
code[data-status='loaded'] {
background: #dcfce7;
color: #166534;
}
code[data-status='loading'] {
background: #fef9c3;
color: #854d0e;
}
code[data-status='error'] {
background: #fee2e2;
color: #991b1b;
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding-block: 0.5rem 0.75rem;
}
.controls label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
.controls input[type='text'] {
padding: 0.3rem 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-family: inherit;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.actions button {
padding: 0.3rem 0.7rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
cursor: pointer;
font-size: 0.85rem;
}
.actions button:hover {
background: #e2e8f0;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
.grid {
display: flex;
gap: 1rem;
flex-wrap: wrap;
padding-block: 0.5rem 1rem;
}
:global(.avatar) {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
background: #e2e8f0;
color: #475569;
font-weight: 600;
font-size: 0.85rem;
}
:global(.avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
:global(.avatar-fallback) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

@ -0,0 +1,198 @@
<script lang="ts">
import * as Clipboard from '$soma/components/clipboard';
let value = $state('https://soma.dev/clipboard-demo');
let timeout = $state(2000);
let copyCount = $state(0);
let lastError = $state<string | null>(null);
</script>
<svelte:head>
<title>Clipboard · Soma</title>
</svelte:head>
<div class="page">
<h1>Clipboard</h1>
<p>
Wrapper alrededor de <code>navigator.clipboard.writeText</code>. Flag
<code>copied</code> se auto-resetea en <code>timeout</code> ms. <code>data-copied</code> lo
expone en root/trigger/indicator.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label class="full">
<span>value</span>
<input type="text" bind:value />
</label>
<label>
<span>timeout ({timeout}ms)</span>
<input type="range" min="500" max="5000" step="100" bind:value={timeout} />
</label>
</div>
<Clipboard.Provider
{value}
{timeout}
onCopy={() => (copyCount += 1)}
onError={(err) => (lastError = String(err))}
class="cb"
>
{#snippet children({ copied })}
<Clipboard.Trigger class="cb-trigger">
{copied ? '✓ Copied!' : 'Copy'}
</Clipboard.Trigger>
<Clipboard.Indicator class="cb-indicator">Copied to clipboard</Clipboard.Indicator>
{/snippet}
</Clipboard.Provider>
<dl class="state">
<dt>copies fired</dt>
<dd><code>{copyCount}x</code></dd>
<dt>last error</dt>
<dd><code>{lastError ?? '—'}</code></dd>
</dl>
<p class="desc">
<kbd>Enter</kbd> / <kbd>Space</kbd> sobre el botón copia.
<code>data-copied</code> persiste durante <code>{timeout}ms</code>.
</p>
</section>
<section>
<h2>2. Minimal (children as snippet)</h2>
<p class="desc">El mismo valor, pero sin Indicator separado — consumer lee <code>copied</code> del snippet.</p>
<Clipboard.Provider value="npm install soma" class="cb">
{#snippet children({ copied, copy })}
<code class="inline-code">npm install soma</code>
<button type="button" onclick={() => copy()} class="cb-trigger">
{copied ? '✓' : 'Copy'}
</button>
{/snippet}
</Clipboard.Provider>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
.desc {
font-size: 0.9rem;
color: #555;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
kbd {
padding: 0 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
font: inherit;
font-size: 0.8em;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
.controls .full {
grid-column: 1 / -1;
}
.controls input {
padding: 0.3rem 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-family: inherit;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
:global(.cb) {
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.inline-code {
padding: 0.3rem 0.6rem;
border-radius: 4px;
background: #0f172a;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
:global(.cb-trigger) {
padding: 0.35rem 0.8rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
transition: background 150ms;
}
:global(.cb-trigger:hover) {
background: #e2e8f0;
}
:global(.cb-trigger[data-copied]) {
background: #dcfce7;
border-color: #16a34a;
color: #166534;
}
:global(.cb-indicator) {
padding: 0.2rem 0.5rem;
border-radius: 3px;
background: #dcfce7;
color: #166534;
font-size: 0.8rem;
animation: cb-pop 200ms ease-out;
}
@keyframes cb-pop {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

@ -0,0 +1,300 @@
<script lang="ts">
import * as DragDrop from '$soma/components/drag-drop';
type Task = { id: string; title: string; kind: 'task' | 'doc' };
type Column = { id: string; title: string; accepts: Array<'task' | 'doc'>; tasks: Task[] };
let columns = $state<Column[]>([
{
id: 'todo',
title: 'To Do',
accepts: ['task'],
tasks: [
{ id: 't1', title: 'Design mockups', kind: 'task' },
{ id: 't2', title: 'Review PR #42', kind: 'task' }
]
},
{
id: 'doing',
title: 'In Progress',
accepts: ['task'],
tasks: [{ id: 't3', title: 'Implement DnD', kind: 'task' }]
},
{
id: 'done',
title: 'Done',
accepts: ['task', 'doc'],
tasks: [
{ id: 't4', title: 'Spec signed', kind: 'task' },
{ id: 'd1', title: 'ADR: treegrid', kind: 'doc' }
]
},
{
id: 'trash',
title: 'Trash (accepts nothing)',
accepts: [],
tasks: []
}
]);
let dropLog = $state<string[]>([]);
function handleDrop(e: import('$soma/components/drag-drop').DropEvent) {
// Find source column + task.
let srcCol: Column | undefined;
let task: Task | undefined;
for (const col of columns) {
const found = col.tasks.find((t) => t.id === e.value);
if (found) {
srcCol = col;
task = found;
break;
}
}
if (!srcCol || !task) return;
// Find target column.
const targetCol = columns.find((c) => c.title === e.targetLabel || c.id === e.targetLabel);
if (!targetCol || targetCol === srcCol) return;
srcCol.tasks = srcCol.tasks.filter((t) => t.id !== task!.id);
targetCol.tasks = [...targetCol.tasks, task];
columns = [...columns];
dropLog = [`${task.title} → ${targetCol.title}`, ...dropLog].slice(0, 6);
}
</script>
<svelte:head>
<title>DragDrop · Soma</title>
</svelte:head>
<div class="page">
<h1>DragDrop</h1>
<p>
Sistema headless de drag & drop — pointer + teclado + screen reader. <kbd>Space</kbd> /
<kbd>Enter</kbd> sobre un <code>Draggable</code> inicia el drag por teclado, <kbd>Tab</kbd>
/ flechas navegan entre <code>Droppable</code>s, <kbd>Space</kbd>/<kbd>Enter</kbd> suelta,
<kbd>Esc</kbd> cancela. Los anuncios de screen reader se emiten vía el live region global
de Announce.
</p>
<section>
<h2>1. Kanban — drag tasks between columns</h2>
<p class="desc">
El "Trash" column rechaza todo (<code>accept=() =&gt; false</code>) — no se resalta al
hacer drag. La columna "Done" acepta <code>task</code> y <code>doc</code>; las demás solo
<code>task</code>.
</p>
<DragDrop.Provider onDrop={handleDrop} class="board">
{#snippet children()}
{#each columns as col (col.id)}
<DragDrop.Droppable
accept={(data) => col.accepts.includes((data as { kind: 'task' | 'doc' }).kind)}
textValue={col.title}
class="col"
>
<header class="col-head">
<strong>{col.title}</strong>
<span class="col-count">{col.tasks.length}</span>
</header>
<div class="col-body">
{#each col.tasks as task (task.id)}
<DragDrop.Draggable
value={task.id}
data={{ kind: task.kind }}
textValue={task.title}
class="task {task.kind}"
>
<span class="task-kind">{task.kind === 'doc' ? '📄' : '•'}</span>
{task.title}
</DragDrop.Draggable>
{/each}
{#if col.tasks.length === 0}
<span class="col-empty">—</span>
{/if}
</div>
</DragDrop.Droppable>
{/each}
<DragDrop.Preview class="preview">
{#snippet children({ active })}
<div class="preview-chip">{active.label}</div>
{/snippet}
</DragDrop.Preview>
{/snippet}
</DragDrop.Provider>
<dl class="state">
<dt>recent drops</dt>
<dd>
{#if dropLog.length === 0}
<code>—</code>
{:else}
<ul class="log">
{#each dropLog as entry, i (entry + i)}
<li><code>{entry}</code></li>
{/each}
</ul>
{/if}
</dd>
</dl>
<p class="desc">
Con el teclado: enfoca cualquier task (<kbd>Tab</kbd>), pulsa <kbd>Space</kbd> para
agarrarla, flechas/<kbd>Tab</kbd> para moverte entre columnas que aceptan,
<kbd>Space</kbd>/<kbd>Enter</kbd> para soltar. El <code>Trash</code> es saltado durante
navegación por teclado.
</p>
</section>
</div>
<style>
.page {
max-width: 56rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
.desc {
font-size: 0.9rem;
color: #555;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
kbd {
padding: 0 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
font: inherit;
font-size: 0.8em;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
.log {
margin: 0;
padding: 0;
list-style: none;
}
.log li {
margin: 0.1rem 0;
}
:global(.board) {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
padding: 0.5rem 0 1rem;
}
:global(.col) {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
min-height: 200px;
outline: none;
}
:global(.col[data-accepting]) {
background: #f0fdf4;
}
:global(.col[data-accepting][data-dragover]) {
background: #dcfce7;
border-color: #16a34a;
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.3);
}
:global(.col[data-disabled]) {
opacity: 0.5;
}
.col-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
color: #475569;
}
.col-count {
background: #e2e8f0;
border-radius: 999px;
padding: 0 0.5rem;
font-size: 0.75rem;
}
.col-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.col-empty {
color: #94a3b8;
font-size: 0.8rem;
text-align: center;
padding: 0.75rem 0;
}
:global(.task) {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.55rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
font-size: 0.85rem;
cursor: grab;
user-select: none;
outline: none;
}
:global(.task:focus) {
border-color: #0070f3;
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.2);
}
:global(.task[data-dragging]) {
opacity: 0.4;
cursor: grabbing;
}
:global(.task.doc) {
background: #fef9c3;
}
.task-kind {
font-size: 0.75rem;
color: #64748b;
}
:global(.preview) {
pointer-events: none;
}
.preview-chip {
padding: 0.4rem 0.7rem;
background: #0f172a;
color: #f8fafc;
border-radius: 4px;
font-size: 0.85rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
</style>

@ -0,0 +1,294 @@
<script lang="ts">
import * as Feed from '$soma/components/feed';
type Post = {
id: number;
title: string;
body: string;
author: string;
replies?: { id: number; author: string; body: string }[];
};
let items = $state<Post[]>(
Array.from({ length: 6 }, (_, i) => ({
id: i + 1,
title: `Post #${i + 1}`,
body: `This is the body of post ${i + 1}. A feed article can have any content.`,
author: ['Ada', 'Alan', 'Grace'][i % 3],
replies:
i === 0
? [
{ id: 101, author: 'Alan', body: 'Great point on this one.' },
{ id: 102, author: 'Grace', body: 'Agreed, let me expand:' }
]
: undefined
}))
);
let busy = $state(false);
let totalItems = $state<number | undefined>(undefined);
let loadCount = $state(0);
let autoLoad = $state(false);
let sentinelHits = $state(0);
async function loadMore() {
if (busy) return;
busy = true;
loadCount += 1;
await new Promise((r) => setTimeout(r, 600));
const start = items.length;
items = [
...items,
...Array.from({ length: 3 }, (_, i) => ({
id: start + i + 1,
title: `Post #${start + i + 1}`,
body: `Loaded by onLoadMore (#${loadCount}).`,
author: ['Linus', 'Dennis'][i % 2]
}))
];
busy = false;
}
function handleSentinel() {
sentinelHits += 1;
if (autoLoad) loadMore();
}
</script>
<svelte:head>
<title>Feed · Soma</title>
</svelte:head>
<div class="page">
<h1>Feed</h1>
<p>
Stream infinito con semántica <code>role="feed"</code>. <kbd>PageDown</kbd> avanza al siguiente
artículo, <kbd>PageUp</kbd> retrocede. En el último artículo, <kbd>PageDown</kbd> dispara
<code>onLoadMore</code>. Cada Article tiene <code>aria-posinset</code> / <code>aria-setsize</code>.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label>
<span>totalItems (optional)</span>
<input
type="number"
bind:value={totalItems}
placeholder="unknown"
min="0"
/>
</label>
<label><input type="checkbox" bind:checked={busy} /><span>busy</span></label>
<label
><input type="checkbox" bind:checked={autoLoad} /><span>auto-load on scroll</span></label
>
<button type="button" onclick={loadMore}>Load 3 more</button>
</div>
<Feed.Provider
{totalItems}
{busy}
onLoadMore={loadMore}
aria-label="Team activity"
class="feed"
>
{#each items as post (post.id)}
<Feed.Article class="feed-article">
{#snippet children({ index, total })}
<Feed.ArticleTitle class="feed-title">
{post.title}
</Feed.ArticleTitle>
<p class="feed-meta">
by {post.author} · <code>{index + 1} / {total ?? 'unknown'}</code>
</p>
<Feed.ArticleDescription class="feed-body">
{post.body}
</Feed.ArticleDescription>
{#if post.replies && post.replies.length > 0}
<Feed.Thread class="feed-thread">
{#each post.replies as reply (reply.id)}
<Feed.Article class="feed-article nested">
<Feed.ArticleTitle class="feed-title">
Reply from {reply.author}
</Feed.ArticleTitle>
<Feed.ArticleDescription class="feed-body">
{reply.body}
</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Thread>
{/if}
{/snippet}
</Feed.Article>
{/each}
{#if busy}
<div class="feed-loading" role="presentation">Loading more…</div>
{/if}
<Feed.Sentinel
onIntersect={handleSentinel}
rootMargin="50px"
disabled={busy}
class="feed-sentinel"
/>
</Feed.Provider>
<dl class="state">
<dt>articles</dt>
<dd><code>{items.length}</code></dd>
<dt>load count</dt>
<dd><code>{loadCount}</code></dd>
<dt>sentinel hits</dt>
<dd><code>{sentinelHits}</code></dd>
<dt>busy</dt>
<dd><code>{busy}</code></dd>
</dl>
<p class="desc">
Enfoca el primer artículo (<kbd>Tab</kbd>) y usa <kbd>PageUp</kbd>/<kbd>PageDown</kbd>
para navegar. <kbd>Ctrl+Home</kbd>/<kbd>Ctrl+End</kbd> saltan al primero/último. El
primer post tiene un <code>Feed.Thread</code> anidado — sus artículos reciben
<code>aria-level={'{'}h+1{'}'}</code> automáticamente. El <code>Feed.Sentinel</code>
dispara <code>onLoadMore</code> cuando aparece en el viewport (activa
<em>auto-load</em> para ver).
</p>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
.desc {
font-size: 0.9rem;
color: #555;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
kbd {
padding: 0 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
font: inherit;
font-size: 0.8em;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
.controls input {
padding: 0.3rem 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-family: inherit;
}
.controls button {
padding: 0.35rem 0.8rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
cursor: pointer;
font-size: 0.85rem;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
:global(.feed) {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 420px;
overflow-y: auto;
padding-right: 0.25rem;
}
:global(.feed-article) {
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
cursor: default;
outline: none;
}
:global(.feed-article:focus) {
border-color: #0070f3;
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.15);
}
:global(.feed-title) {
margin: 0 0 0.2rem;
font-size: 0.95rem;
font-weight: 600;
}
.feed-meta {
margin: 0 0 0.4rem;
font-size: 0.75rem;
color: #64748b;
}
:global(.feed-body) {
margin: 0;
font-size: 0.9rem;
color: #334155;
}
.feed-loading {
padding: 0.5rem;
text-align: center;
font-size: 0.85rem;
color: #64748b;
font-style: italic;
}
:global(.feed-article.nested) {
margin-left: 1.5rem;
background: #f8fafc;
border-color: #cbd5e1;
border-left: 3px solid #94a3b8;
}
:global(.feed-thread) {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.5rem;
}
:global(.feed-sentinel) {
height: 1px;
flex-shrink: 0;
}
</style>

@ -0,0 +1,332 @@
<script lang="ts">
import * as GridList from '$soma/components/grid-list';
import * as Field from '$soma/components/field';
type Row = { value: string; name: string; role: string; email: string };
const rows: Row[] = [
{ value: 'r1', name: 'Ada Lovelace', role: 'Admin', email: 'ada@example.com' },
{ value: 'r2', name: 'Alan Turing', role: 'Owner', email: 'alan@example.com' },
{ value: 'r3', name: 'Grace Hopper', role: 'Member', email: 'grace@example.com' },
{ value: 'r4', name: 'Dennis Ritchie', role: 'Member', email: 'dennis@example.com' },
{ value: 'r5', name: 'Linus Torvalds', role: 'Admin', email: 'linus@example.com' }
];
let value = $state<string[]>([]);
let selectionMode = $state<'none' | 'single' | 'multiple'>('multiple');
let loop = $state(false);
let typeahead = $state(true);
let disabled = $state(false);
let readonly = $state(false);
let invalid = $state(false);
let required = $state(false);
let disableRow = $state('r4');
let lastAction = $state<string | null>(null);
let fieldValue = $state<string[]>(['r1']);
let fieldDisabled = $state(false);
let fieldInvalid = $state(false);
</script>
<svelte:head>
<title>GridList · Soma</title>
</svelte:head>
<div class="page">
<h1>GridList</h1>
<p>
Lista seleccionable con <code>role="grid"</code>. Arriba/Abajo navega filas,
<kbd>Space</kbd> selecciona, <kbd>Shift+Click</kbd> rango,
<kbd>Ctrl+Click</kbd> toggle. Typeahead activo.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label>
<span>selectionMode</span>
<select bind:value={selectionMode}>
<option value="none">none</option>
<option value="single">single</option>
<option value="multiple">multiple</option>
</select>
</label>
<label><input type="checkbox" bind:checked={loop} /><span>loop</span></label>
<label><input type="checkbox" bind:checked={typeahead} /><span>typeahead</span></label>
<label><input type="checkbox" bind:checked={disabled} /><span>disabled</span></label>
<label><input type="checkbox" bind:checked={readonly} /><span>readonly</span></label>
<label><input type="checkbox" bind:checked={required} /><span>required</span></label>
<label><input type="checkbox" bind:checked={invalid} /><span>invalid</span></label>
<label>
<span>disable row value</span>
<input type="text" bind:value={disableRow} placeholder="e.g. r4" />
</label>
</div>
<GridList.Provider
bind:value
{selectionMode}
{loop}
{typeahead}
{disabled}
{readonly}
{required}
{invalid}
aria-label="Users"
class="gl"
>
<div class="gl-header" role="presentation">
<span>Sel</span>
<span>Name</span>
<span>Role</span>
<span>Email</span>
<span>Actions</span>
</div>
{#each rows as row (row.value)}
<GridList.Row
value={row.value}
disabled={row.value === disableRow}
textValue={row.name}
class="gl-row"
>
<GridList.Cell class="gl-cell sel">
<GridList.SelectionCheckbox class="gl-checkbox" />
</GridList.Cell>
<GridList.Cell class="gl-cell">{row.name}</GridList.Cell>
<GridList.Cell class="gl-cell">{row.role}</GridList.Cell>
<GridList.Cell class="gl-cell">{row.email}</GridList.Cell>
<GridList.Cell class="gl-cell actions">
<button
type="button"
class="gl-action"
onclick={(e) => {
e.stopPropagation();
lastAction = `edit ${row.value}`;
}}>Edit</button
>
<a
href="#{row.value}"
class="gl-action"
onclick={(e) => {
e.stopPropagation();
lastAction = `open ${row.value}`;
}}>Open</a
>
</GridList.Cell>
</GridList.Row>
{/each}
</GridList.Provider>
<dl class="state">
<dt>value</dt>
<dd><code>{JSON.stringify(value)}</code></dd>
<dt>count</dt>
<dd><code>{value.length}</code></dd>
<dt>last cell action</dt>
<dd><code>{lastAction ?? '—'}</code></dd>
</dl>
<p class="desc">
Cell 2D nav: enfoca una fila, pulsa <kbd>ArrowRight</kbd> para entrar a los controles de
la fila, navega con <kbd>Arrow</kbd> entre Edit/Open/checkbox, <kbd>ArrowLeft</kbd>
desde el primer control vuelve a la fila.
</p>
</section>
<section>
<h2>2. Field integration</h2>
<Field.Provider disabled={fieldDisabled} invalid={fieldInvalid}>
<Field.Label>Team members</Field.Label>
<Field.Control>
<GridList.Provider bind:value={fieldValue} selectionMode="multiple" class="gl">
{#each rows.slice(0, 3) as row (row.value)}
<GridList.Row value={row.value} textValue={row.name} class="gl-row">
<GridList.Cell class="gl-cell sel">
<GridList.SelectionCheckbox class="gl-checkbox" />
</GridList.Cell>
<GridList.Cell class="gl-cell">{row.name}</GridList.Cell>
<GridList.Cell class="gl-cell">{row.email}</GridList.Cell>
</GridList.Row>
{/each}
</GridList.Provider>
</Field.Control>
<Field.HelperText>Selecciona uno o más miembros.</Field.HelperText>
</Field.Provider>
<div class="controls">
<label
><input type="checkbox" bind:checked={fieldDisabled} /><span>Field disabled</span></label
>
<label
><input type="checkbox" bind:checked={fieldInvalid} /><span>Field invalid</span></label
>
</div>
<p class="result">Field value: <code>{JSON.stringify(fieldValue)}</code></p>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
kbd {
padding: 0 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
font: inherit;
font-size: 0.8em;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
}
.controls label > select,
.controls label > input[type='text'] {
padding: 0.2rem 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-family: inherit;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
.result {
font-size: 0.9rem;
}
:global(.gl) {
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.gl-header {
display: grid;
grid-template-columns: 40px 1fr 1fr 1.5fr 120px;
padding: 0.4rem 0.6rem;
background: #f1f5f9;
font-size: 0.75rem;
font-weight: 600;
color: #475569;
border-bottom: 1px solid #e2e8f0;
}
:global(.gl-row) {
display: grid;
grid-template-columns: 40px 1fr 1fr 1.5fr 120px;
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
outline: none;
}
:global(.gl-row:last-child) {
border-bottom: 0;
}
:global(.gl-row[data-highlighted]) {
background: #f8fafc;
}
:global(.gl-row:focus) {
background: #e0f2fe;
}
:global(.gl-row[data-state='selected']) {
background: #dbeafe;
}
:global(.gl-row[data-state='selected'][data-highlighted]) {
background: #bfdbfe;
}
:global(.gl-row[data-disabled]) {
opacity: 0.4;
cursor: not-allowed;
}
:global(.gl-cell) {
display: flex;
align-items: center;
}
:global(.gl-cell.sel) {
justify-content: center;
}
:global(.gl-checkbox) {
width: 1.1rem;
height: 1.1rem;
border: 1px solid #94a3b8;
border-radius: 3px;
background: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
:global(.gl-checkbox[data-state='checked']) {
background: #0070f3;
border-color: #0070f3;
}
:global(.gl-checkbox[data-state='checked'])::after {
content: '';
width: 5px;
height: 9px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg) translate(-1px, -1px);
}
:global(.gl-cell.actions) {
gap: 0.35rem;
}
:global(.gl-action) {
padding: 0.2rem 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 3px;
background: #fff;
color: #334155;
font: inherit;
font-size: 0.8rem;
text-decoration: none;
cursor: pointer;
}
:global(.gl-action:hover) {
background: #f1f5f9;
}
:global(.gl-action:focus) {
outline: 2px solid #0070f3;
outline-offset: 1px;
}
</style>

@ -0,0 +1,200 @@
<script lang="ts">
import * as Meter from '$soma/components/meter';
let value = $state(72);
let min = $state(0);
let max = $state(100);
let low = $state(25);
let high = $state(75);
let optimum = $state(50);
let valueText = $state('');
</script>
<svelte:head>
<title>Meter · Soma</title>
</svelte:head>
<div class="page">
<h1>Meter</h1>
<p>
<code>role="meter"</code> — valor estático dentro de un rango conocido (disco, batería, score).
<code>low</code> / <code>high</code> / <code>optimum</code> definen zonas; el provider emite
<code>data-state</code> (<code>below | optimum | above</code>) y
<code>--soma-meter-value-pct</code>.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label>
<span>value ({value})</span>
<input type="range" {min} {max} bind:value />
</label>
<label>
<span>min</span><input type="number" bind:value={min} />
</label>
<label>
<span>max</span><input type="number" bind:value={max} />
</label>
<label>
<span>low ({low})</span>
<input type="range" {min} {max} bind:value={low} />
</label>
<label>
<span>high ({high})</span>
<input type="range" {min} {max} bind:value={high} />
</label>
<label>
<span>optimum ({optimum})</span>
<input type="range" {min} {max} bind:value={optimum} />
</label>
<label class="full">
<span>valueText override</span>
<input type="text" bind:value={valueText} placeholder="72% full" />
</label>
</div>
<Meter.Provider
{value}
{min}
{max}
{low}
{high}
{optimum}
valueText={valueText || undefined}
aria-label="Disk usage"
class="meter"
>
<Meter.Indicator class="meter-indicator" />
</Meter.Provider>
<dl class="state">
<dt>state</dt>
<dd><code>{value < low ? 'below' : value > high ? 'above' : 'optimum'}</code></dd>
<dt>aria-valuetext</dt>
<dd><code>{valueText || `${value} / ${max}`}</code></dd>
</dl>
</section>
<section>
<h2>2. Examples</h2>
<div class="examples">
<div>
<strong>Battery — 15%</strong>
<Meter.Provider value={15} low={20} high={80} aria-label="Battery" class="meter">
<Meter.Indicator class="meter-indicator" />
</Meter.Provider>
</div>
<div>
<strong>Score — 62/100</strong>
<Meter.Provider value={62} low={40} high={75} aria-label="Score" class="meter">
<Meter.Indicator class="meter-indicator" />
</Meter.Provider>
</div>
<div>
<strong>Disk usage — 95%</strong>
<Meter.Provider value={95} low={30} high={80} aria-label="Disk" class="meter">
<Meter.Indicator class="meter-indicator" />
</Meter.Provider>
</div>
</div>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.5rem;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
.controls .full {
grid-column: 1 / -1;
}
.controls input {
padding: 0.25rem 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-family: inherit;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
.examples {
display: grid;
gap: 0.75rem;
}
.examples div {
display: grid;
grid-template-columns: 180px 1fr;
align-items: center;
gap: 0.75rem;
font-size: 0.9rem;
}
:global(.meter) {
position: relative;
display: block;
width: 100%;
max-width: 480px;
height: 14px;
border-radius: 7px;
background: #e2e8f0;
overflow: hidden;
}
:global(.meter-indicator) {
position: absolute;
inset: 0;
width: calc(var(--soma-meter-value-pct, 0) * 1%);
transition: width 150ms ease-out;
}
:global(.meter[data-state='optimum'] .meter-indicator) {
background: #16a34a;
}
:global(.meter[data-state='below'] .meter-indicator) {
background: #f59e0b;
}
:global(.meter[data-state='above'] .meter-indicator) {
background: #dc2626;
}
</style>

@ -0,0 +1,167 @@
<script lang="ts">
import * as Progress from '$soma/components/progress';
let value = $state<number | null>(60);
let max = $state(100);
let min = $state(0);
let valueText = $state('');
let indeterminate = $state(false);
let ticking = $state(false);
$effect(() => {
if (!ticking) return;
const id = setInterval(() => {
if (value === null) value = 0;
value = Math.min(max, value + 5);
if (value >= max) ticking = false;
}, 200);
return () => clearInterval(id);
});
</script>
<svelte:head>
<title>Progress · Soma</title>
</svelte:head>
<div class="page">
<h1>Progress</h1>
<p>
<code>role="progressbar"</code> con aria-valuenow / min / max. El Indicator se escala vía
<code>--soma-progress-value-pct</code>. <code>value=null</code> → estado indeterminado.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label>
<span>value</span>
<input type="range" min={min} max={max} bind:value disabled={indeterminate} />
<code>{indeterminate ? 'null' : value}</code>
</label>
<label>
<span>max</span><input type="number" bind:value={max} />
</label>
<label>
<span>min</span><input type="number" bind:value={min} />
</label>
<label>
<span>valueText override</span>
<input type="text" bind:value={valueText} placeholder="75 of 100 MB" />
</label>
<label>
<input
type="checkbox"
bind:checked={indeterminate}
onchange={() => (value = indeterminate ? null : 0)}
/>
<span>indeterminate</span>
</label>
<label>
<input type="checkbox" bind:checked={ticking} />
<span>auto-tick (+5 / 200ms)</span>
</label>
</div>
<Progress.Provider
{value}
{max}
{min}
valueText={valueText || undefined}
aria-label="Demo progress"
class="progress"
>
<Progress.Indicator class="progress-indicator" />
</Progress.Provider>
<dl class="state">
<dt>value</dt>
<dd><code>{value === null ? 'null' : value}</code></dd>
<dt>aria-valuetext</dt>
<dd><code>{valueText || (value === null ? '—' : `${value} / ${max}`)}</code></dd>
</dl>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
:global(.progress) {
position: relative;
width: 100%;
max-width: 480px;
height: 14px;
border-radius: 7px;
background: #e2e8f0;
overflow: hidden;
}
:global(.progress-indicator) {
position: absolute;
inset: 0;
background: #0070f3;
width: calc(var(--soma-progress-value-pct, 0) * 1%);
transition: width 150ms ease-out;
}
:global(.progress[data-state='indeterminate'] .progress-indicator) {
width: 40%;
animation: progress-indeterminate 1s infinite linear;
}
:global(.progress[data-state='loaded'] .progress-indicator) {
background: #16a34a;
}
@keyframes progress-indeterminate {
from {
transform: translateX(-100%);
}
to {
transform: translateX(250%);
}
}
</style>

@ -0,0 +1,222 @@
<script lang="ts">
import * as SearchField from '$soma/components/search-field';
import * as Field from '$soma/components/field';
let value = $state('');
let disabled = $state(false);
let readonly = $state(false);
let required = $state(false);
let invalid = $state(false);
let clearOnEscape = $state(true);
let lastSubmit = $state<string | null>(null);
let lastClear = $state(0);
let fieldValue = $state('apples');
let fieldDisabled = $state(false);
let fieldInvalid = $state(false);
</script>
<svelte:head>
<title>SearchField · Soma</title>
</svelte:head>
<div class="page">
<h1>SearchField</h1>
<p>
<code>&lt;input type="search" role="searchbox"&gt;</code> con clear button integrado,
Escape-to-clear, Enter-to-submit, y OR-merge con <code>Field.Provider</code>.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label><input type="checkbox" bind:checked={disabled} /><span>disabled</span></label>
<label><input type="checkbox" bind:checked={readonly} /><span>readonly</span></label>
<label><input type="checkbox" bind:checked={required} /><span>required</span></label>
<label><input type="checkbox" bind:checked={invalid} /><span>invalid</span></label>
<label
><input type="checkbox" bind:checked={clearOnEscape} /><span>clearOnEscape</span></label
>
</div>
<SearchField.Provider
bind:value
{disabled}
{readonly}
{required}
{invalid}
{clearOnEscape}
placeholder="Search posts…"
onSubmit={(v) => (lastSubmit = v)}
onClear={() => (lastClear += 1)}
class="sf"
>
<SearchField.Input class="sf-input" />
<SearchField.ClearTrigger class="sf-clear" />
</SearchField.Provider>
<dl class="state">
<dt>value</dt>
<dd><code>{JSON.stringify(value)}</code></dd>
<dt>last submit</dt>
<dd><code>{lastSubmit ?? '—'}</code></dd>
<dt>clear fired</dt>
<dd><code>{lastClear}x</code></dd>
</dl>
<p class="desc">Presiona <kbd>Enter</kbd> para submit. <kbd>Escape</kbd> borra el valor.</p>
</section>
<section>
<h2>2. Field integration</h2>
<p class="desc">
<code>disabled</code> / <code>invalid</code> OR-merge con Field. <code>Field.Label</code>
toma precedencia sobre <code>aria-label</code> del SearchField.
</p>
<div class="controls">
<label
><input type="checkbox" bind:checked={fieldDisabled} /><span>Field disabled</span></label
>
<label
><input type="checkbox" bind:checked={fieldInvalid} /><span>Field invalid</span></label
>
</div>
<Field.Provider disabled={fieldDisabled} invalid={fieldInvalid}>
<Field.Label>Find a product</Field.Label>
<Field.Control>
<SearchField.Provider bind:value={fieldValue} placeholder="name, SKU, tag…" class="sf">
<SearchField.Input class="sf-input" />
<SearchField.ClearTrigger class="sf-clear" />
</SearchField.Provider>
</Field.Control>
<Field.HelperText>Enter para buscar, Escape para limpiar.</Field.HelperText>
{#if fieldInvalid}
<Field.ErrorText>El término de búsqueda no es válido.</Field.ErrorText>
{/if}
</Field.Provider>
<p class="result">Field value: <code>{fieldValue || '—'}</code></p>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
.desc {
font-size: 0.9rem;
color: #555;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
kbd {
padding: 0 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
font: inherit;
font-size: 0.8em;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.5rem;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
}
.result {
font-size: 0.9rem;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
:global(.sf) {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
max-width: 360px;
}
:global(.sf[data-focused]) {
border-color: #0070f3;
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.15);
}
:global(.sf[data-invalid]) {
border-color: #dc2626;
}
:global(.sf[data-disabled]) {
opacity: 0.5;
}
:global(.sf-input) {
flex: 1;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 0.9rem;
padding: 0.2rem 0;
}
:global(.sf-input::-webkit-search-cancel-button),
:global(.sf-input::-webkit-search-decoration) {
-webkit-appearance: none;
}
:global(.sf-clear) {
border: 0;
background: transparent;
width: 1.5rem;
height: 1.5rem;
border-radius: 3px;
cursor: pointer;
color: #64748b;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
}
:global(.sf-clear:hover:not([disabled])) {
background: #f1f5f9;
color: #0f172a;
}
:global(.sf[data-empty] .sf-clear) {
visibility: hidden;
}
</style>

@ -8,7 +8,7 @@
const _locale = $derived(soma?.langs.getLocale());
function tt(path: string, fallback: string): string {
void _locale;
return soma?.langs.t(`soma.table.${path}`) || fallback;
return soma?.langs.t(`components.table.${path}`) || fallback;
}
interface Person {
@ -125,97 +125,12 @@
window.addEventListener('mouseup', onUp);
}
// ── Table 3: Expandable rows ─────────────────────────────────────────────
interface Employee {
name: string;
age: number;
email: string;
role: string;
directReports?: Employee[];
}
const orgData: Employee[] = [
{
name: 'Frank Miller',
age: 52,
email: 'frank@example.com',
role: 'Director',
directReports: [
{
name: 'Bob Smith',
age: 45,
email: 'bob@example.com',
role: 'Manager',
directReports: [
{
name: 'Alice Johnson',
age: 32,
email: 'alice@example.com',
role: 'Engineer'
},
{ name: 'Grace Lee', age: 35, email: 'grace@example.com', role: 'Engineer' }
]
},
{
name: 'Henry Wilson',
age: 41,
email: 'henry@example.com',
role: 'Manager',
directReports: [
{
name: 'Jack Taylor',
age: 33,
email: 'jack@example.com',
role: 'Engineer'
},
{ name: 'Leo Garcia', age: 30, email: 'leo@example.com', role: 'Engineer' }
]
}
]
},
{
name: 'Kate Moore',
age: 47,
email: 'kate@example.com',
role: 'Director',
directReports: [
{
name: 'Carol White',
age: 28,
email: 'carol@example.com',
role: 'Designer',
directReports: [
{ name: 'Eve Davis', age: 29, email: 'eve@example.com', role: 'Designer' },
{ name: 'Ivy Chen', age: 26, email: 'ivy@example.com', role: 'Designer' }
]
},
{ name: 'David Brown', age: 38, email: 'david@example.com', role: 'Engineer' }
]
}
];
const empColumns: ColumnDef<Employee>[] = [
{ accessorKey: 'name', header: 'Name', enableSorting: true },
{ accessorKey: 'age', header: 'Age' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Role' }
];
const table3 = createTable<Employee>({
data: () => orgData,
columns: empColumns,
getRowSubRows: (row) => row.directReports ?? [],
sorting: { enabled: true }
});
// ── Table 4: Detail panels ──────────────────────────────────────────────
const table4 = createTable({
data: () => data,
columns,
sorting: { enabled: true },
getRowCanExpand: () => true
sorting: { enabled: true }
});
// ── Helpers ──────────────────────────────────────────────────────────────
@ -664,125 +579,25 @@
</section>
<!-- ═══════════════════════════════════════════════════════════════════════ -->
<!-- TABLE 3: Expandable Rows (Sub-rows) -->
<!-- TABLE 3: Hierarchy moved to TreeGrid -->
<!-- ═══════════════════════════════════════════════════════════════════════ -->
<section class="demo-section">
<div class="section-header">
<h2>Expandable Rows</h2>
<div class="meta-pills">
<span class="pill">{table3.rows.length} rows visible</span>
</div>
</div>
<div class="controls-bar">
<button class="action-btn" onclick={() => table3.toggleAllRowsExpanded()}>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="icon-sm"><path d="M4 6l4 4 4-4" /></svg
>
Toggle all
</button>
<h2>Hierarchy → TreeGrid</h2>
</div>
<div class="table-wrapper">
<Table.Provider table={table3} label="Organization" class="tbl tbl-tree">
<Table.Header>
<tr>
<th class="col-expand"></th>
{#each table3.headers as header}
<Table.ColumnHeader {header}>
<span class="th-inner">
<span class="th-label">{header.label}</span>
{#if table3.getCanSort(header.id)}
<button
class="sort-trigger"
onclick={(e) => table3.toggleSort(header.id, e.shiftKey)}
>
{#if table3.getIsSorted(header.id) === 'asc'}
<svg viewBox="0 0 10 10" fill="currentColor" class="sort-icon"
><path d="M5 2L8.5 7H1.5z" /></svg
>
{:else if table3.getIsSorted(header.id) === 'desc'}
<svg viewBox="0 0 10 10" fill="currentColor" class="sort-icon"
><path d="M5 8L1.5 3H8.5z" /></svg
>
{:else}
<svg
viewBox="0 0 10 14"
fill="currentColor"
class="sort-icon sort-icon-idle"
><path d="M5 1L8.5 5.5H1.5z" opacity=".35" /><path
d="M5 13L1.5 8.5H8.5z"
opacity=".35"
/></svg
>
{/if}
</button>
{/if}
</span>
</Table.ColumnHeader>
{/each}
</tr>
</Table.Header>
<Table.Body>
{#each table3.rows as row (row.id)}
<Table.Row {row}>
<td class="col-expand">
{#if row.getCanExpand}
<button
class="expand-trigger"
class:open={row.getIsExpanded}
aria-label={row.getIsExpanded ? 'Collapse row' : 'Expand row'}
aria-expanded={row.getIsExpanded}
onclick={() => table3.toggleRowExpanded(row.id)}
>
<svg viewBox="0 0 10 10" fill="currentColor" class="chevron-icon"
><path d="M3 1.5l4 3.5-4 3.5z" /></svg
>
</button>
{/if}
</td>
{#each row.cells as cell, colIdx}
<Table.Cell {cell} colIndex={colIdx}>
{#if colIdx === 0}
<span class="tree-indent" style="padding-left: {row.depth * 1.25}rem;">
{#if row.depth > 0}
<span class="tree-line"></span>
{/if}
<span class="tree-content">
<span
class="avatar-sm"
style="--rc: {roleColors[row.original.role] ?? 'var(--c-border)'}"
>{initials(String(cell.value))}</span
>
{cell.value}
</span>
</span>
{:else if cell.column.id === 'role'}
<span
class="role-tag"
style="--rc: {roleColors[String(cell.value)] ?? 'var(--c-border)'}"
>{cell.value}</span
>
{:else}
{cell.value}
{/if}
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Provider>
</div>
<details class="debug-details">
<summary>State</summary>
<pre>{JSON.stringify({ expanded: table3.expanded }, null, 2)}</pre>
</details>
<p style="color: var(--c-text-secondary); line-height: 1.55; margin: 0 0 .5rem;">
Para filas <strong>jerárquicas</strong> (padre → hijos con el mismo esquema) soma
expone <a href="/test/soma/tree-grid">TreeGrid</a>, el pattern WAI-ARIA
<code>role="treegrid"</code> con keyboard APG completo (ArrowRight expande,
ArrowLeft colapsa o va al padre, <code>aria-level</code> automático). Table se
mantiene como tabla plana; no emite <code>role="treegrid"</code> y por eso no es el
sitio correcto para jerarquía.
</p>
<p style="color: var(--c-text-secondary); line-height: 1.55; margin: 0;">
Para <strong>detalle/metadata por fila</strong>, Table sí lo cubre — ver sección
siguiente.
</p>
</section>
<!-- ═══════════════════════════════════════════════════════════════════════ -->
@ -838,17 +653,11 @@
{#each table4.rows as row (row.id)}
<Table.Row {row}>
<td class="col-expand">
<button
class="expand-trigger"
class:open={row.getIsExpanded}
aria-label={row.getIsExpanded ? 'Collapse row' : 'Expand row'}
aria-expanded={row.getIsExpanded}
onclick={() => table4.toggleRowExpanded(row.id)}
>
<Table.RowDetailTrigger {row} class="expand-trigger">
<svg viewBox="0 0 10 10" fill="currentColor" class="chevron-icon"
><path d="M3 1.5l4 3.5-4 3.5z" /></svg
>
</button>
</Table.RowDetailTrigger>
</td>
{#each row.cells as cell, colIdx}
<Table.Cell {cell} colIndex={colIdx}>
@ -875,54 +684,49 @@
</Table.Cell>
{/each}
</Table.Row>
{#if row.getIsExpanded}
<tr class="detail-row">
<td></td>
<td colspan={table4.headers.length}>
<div class="detail-card">
<div
class="detail-avatar"
<Table.RowDetail {row} class="detail-row">
<div class="detail-card">
<div
class="detail-avatar"
style="--rc: {roleColors[row.original.role] ?? 'var(--c-border)'}"
>
{initials(row.original.name)}
</div>
<div class="detail-body">
<div class="detail-name">{row.original.name}</div>
<div class="detail-role">
<span
class="role-tag"
style="--rc: {roleColors[row.original.role] ?? 'var(--c-border)'}"
>{row.original.role}</span
>
{initials(row.original.name)}
</div>
<div class="detail-body">
<div class="detail-name">{row.original.name}</div>
<div class="detail-role">
<span
class="role-tag"
style="--rc: {roleColors[row.original.role] ?? 'var(--c-border)'}"
>{row.original.role}</span
>
<span class="detail-age">{row.original.age} years</span>
</div>
<div class="detail-contact">
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.2"
class="icon-xs"
><rect x="2" y="3" width="12" height="10" rx="1" /><path
d="M2 5l6 4 6-4"
/></svg
>
<a href="mailto:{row.original.email}">{row.original.email}</a>
</div>
<p class="detail-bio">
{row.original.role === 'Engineer'
? 'Builds and maintains core platform infrastructure. Focused on performance, reliability, and developer experience.'
: row.original.role === 'Designer'
? 'Shapes product vision through research, prototyping, and systematic design. Advocates for user clarity.'
: row.original.role === 'Manager'
? 'Leads cross-functional teams, aligns priorities, and removes blockers. Bridges strategy and execution.'
: 'Sets organizational direction, owns P&L outcomes, and drives long-term strategic initiatives.'}
</p>
</div>
<span class="detail-age">{row.original.age} years</span>
</div>
<div class="detail-contact">
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.2"
class="icon-xs"
><rect x="2" y="3" width="12" height="10" rx="1" /><path
d="M2 5l6 4 6-4"
/></svg
>
<a href="mailto:{row.original.email}">{row.original.email}</a>
</div>
</td>
</tr>
{/if}
<p class="detail-bio">
{row.original.role === 'Engineer'
? 'Builds and maintains core platform infrastructure. Focused on performance, reliability, and developer experience.'
: row.original.role === 'Designer'
? 'Shapes product vision through research, prototyping, and systematic design. Advocates for user clarity.'
: row.original.role === 'Manager'
? 'Leads cross-functional teams, aligns priorities, and removes blockers. Bridges strategy and execution.'
: 'Sets organizational direction, owns P&L outcomes, and drives long-term strategic initiatives.'}
</p>
</div>
</div>
</Table.RowDetail>
{/each}
</Table.Body>
</Table.Provider>
@ -1476,7 +1280,7 @@
width: 2.25rem;
text-align: center;
}
.expand-trigger {
:global(.expand-trigger) {
display: inline-flex;
align-items: center;
justify-content: center;
@ -1490,12 +1294,12 @@
padding: 0;
transition: all 0.2s;
}
.expand-trigger:hover {
:global(.expand-trigger:hover) {
background: var(--c-hover);
border-color: var(--c-text-dim);
color: var(--c-text);
}
.expand-trigger.open {
:global(.expand-trigger[data-state='open']) {
background: var(--c-accent-soft);
border-color: var(--c-accent);
color: var(--c-accent);
@ -1505,32 +1309,10 @@
height: 8px;
transition: transform 0.2s;
}
.expand-trigger.open .chevron-icon {
:global(.expand-trigger[data-state='open'] .chevron-icon) {
transform: rotate(90deg);
}
/* ── Tree ────────────────────────────────────────────────────────────── */
.tree-indent {
display: inline-flex;
align-items: center;
position: relative;
}
.tree-line {
position: absolute;
left: -0.4rem;
top: -0.55rem;
width: 0.7rem;
height: calc(100% + 0.1rem);
border-left: 1px solid var(--c-border);
border-bottom: 1px solid var(--c-border);
border-bottom-left-radius: 4px;
}
.tree-content {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/* ── Avatar ──────────────────────────────────────────────────────────── */
.avatar-sm {
display: inline-flex;
@ -1554,7 +1336,7 @@
}
/* ── Detail panel ────────────────────────────────────────────────────── */
.detail-row td {
:global(.detail-row td) {
padding: 0 !important;
border-bottom: 1px solid var(--c-border-subtle);
background: var(--c-bg) !important;

@ -0,0 +1,288 @@
<script lang="ts">
import * as TagGroup from '$soma/components/tag-group';
let items = $state<string[]>(['design', 'dev', 'qa', 'ops']);
let value = $state<string[]>([]);
let selectionMode = $state<'none' | 'single' | 'multiple'>('multiple');
let disabled = $state(false);
let lastRemoved = $state<string | null>(null);
function onRemove(v: string) {
items = items.filter((i) => i !== v);
lastRemoved = v;
}
function reset() {
items = ['design', 'dev', 'qa', 'ops'];
value = [];
lastRemoved = null;
}
</script>
<svelte:head>
<title>TagGroup · Soma</title>
</svelte:head>
<div class="page">
<h1>TagGroup</h1>
<p>
Tags de solo lectura agrupados (<code>role="grid"</code> + <code>role="row"</code>).
<kbd>Arrow</kbd> navega, <kbd>Space</kbd> selecciona, <kbd>Backspace</kbd> / <kbd>Delete</kbd>
elimina.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label>
<span>selectionMode</span>
<select bind:value={selectionMode}>
<option value="none">none</option>
<option value="single">single</option>
<option value="multiple">multiple</option>
</select>
</label>
<label><input type="checkbox" bind:checked={disabled} /><span>disabled</span></label>
<button type="button" onclick={reset}>Reset items</button>
</div>
<TagGroup.Provider
{items}
bind:value
{selectionMode}
{disabled}
{onRemove}
class="tg"
>
<TagGroup.Label class="tg-label">Categories</TagGroup.Label>
<div class="tg-list">
{#each items as item (item)}
<TagGroup.Item value={item} textValue={item} class="tg-item">
<span class="tg-text">{item}</span>
<TagGroup.RemoveButton class="tg-remove" aria-label="Remove {item}"
>×</TagGroup.RemoveButton
>
</TagGroup.Item>
{/each}
{#if items.length === 0}
<span class="tg-empty">No tags.</span>
{/if}
</div>
</TagGroup.Provider>
<dl class="state">
<dt>items</dt>
<dd><code>{JSON.stringify(items)}</code></dd>
<dt>selected</dt>
<dd><code>{JSON.stringify(value)}</code></dd>
<dt>last removed</dt>
<dd><code>{lastRemoved ?? '—'}</code></dd>
</dl>
</section>
<section>
<h2>2. Link tags (navigable)</h2>
<p class="desc">
<code>TagGroup.Link</code> renderiza <code>&lt;a href&gt;</code> en vez de
<code>&lt;div&gt;</code>. Navegación con flechas, Enter activa el link.
</p>
<TagGroup.Provider
items={['svelte', 'typescript', 'accessibility', 'aria', 'soma']}
selectionMode="none"
class="tg"
>
<TagGroup.Label class="tg-label">Browse by tag</TagGroup.Label>
<div class="tg-list">
{#each ['svelte', 'typescript', 'accessibility', 'aria', 'soma'] as tag (tag)}
<TagGroup.Link
value={tag}
href="#/tag/{tag}"
textValue={tag}
class="tg-item tg-link"
>
#{tag}
</TagGroup.Link>
{/each}
</div>
</TagGroup.Provider>
</section>
<section>
<h2>3. Filter chips (read-only display)</h2>
<p class="desc"><code>selectionMode="none"</code> — tags informativos solamente.</p>
<TagGroup.Provider
items={['typescript', 'svelte', 'accessibility', 'aria', '2026']}
selectionMode="none"
class="tg"
>
<TagGroup.Label class="tg-label">Filters applied</TagGroup.Label>
<div class="tg-list">
{#each ['typescript', 'svelte', 'accessibility', 'aria', '2026'] as item (item)}
<TagGroup.Item value={item} class="tg-item static">
<span class="tg-text">{item}</span>
</TagGroup.Item>
{/each}
</div>
</TagGroup.Provider>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
.desc {
font-size: 0.9rem;
color: #555;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
kbd {
padding: 0 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
font: inherit;
font-size: 0.8em;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
}
.controls select {
padding: 0.2rem 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
}
.controls button {
padding: 0.3rem 0.7rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
cursor: pointer;
font-size: 0.85rem;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
:global(.tg) {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
:global(.tg-label) {
font-size: 0.8rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tg-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
:global(.tg-item) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.4rem 0.2rem 0.6rem;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #f8fafc;
font-size: 0.85rem;
cursor: pointer;
outline: none;
}
:global(.tg-item.static) {
cursor: default;
padding: 0.2rem 0.6rem;
}
:global(.tg-item[data-highlighted]) {
border-color: #94a3b8;
}
:global(.tg-item:focus) {
border-color: #0070f3;
box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.15);
}
:global(.tg-item[data-state='selected']) {
background: #dbeafe;
border-color: #3b82f6;
}
:global(.tg-item[data-disabled]) {
opacity: 0.5;
cursor: not-allowed;
}
.tg-text {
line-height: 1;
}
:global(.tg-remove) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-size: 1rem;
line-height: 1;
padding: 0;
}
:global(.tg-remove:hover) {
background: #e2e8f0;
color: #0f172a;
}
:global(.tg-link) {
text-decoration: none;
color: #0070f3;
}
:global(.tg-link:hover) {
background: #e0f2fe;
}
.tg-empty {
font-size: 0.85rem;
color: #94a3b8;
}
</style>

@ -0,0 +1,285 @@
<script lang="ts">
import * as TreeGrid from '$soma/components/tree-grid';
type Node = {
value: string;
name: string;
size: string;
modified: string;
children?: Node[];
};
const tree: Node[] = [
{
value: 'src',
name: 'src',
size: '—',
modified: '2026-04-18',
children: [
{
value: 'src/lib',
name: 'lib',
size: '—',
modified: '2026-04-17',
children: [
{ value: 'src/lib/util.ts', name: 'util.ts', size: '2.3 kb', modified: '2026-04-15' },
{ value: 'src/lib/types.ts', name: 'types.ts', size: '850 b', modified: '2026-04-17' }
]
},
{
value: 'src/routes',
name: 'routes',
size: '—',
modified: '2026-04-18',
children: [
{ value: 'src/routes/+page.svelte', name: '+page.svelte', size: '4.1 kb', modified: '2026-04-18' },
{ value: 'src/routes/+layout.svelte', name: '+layout.svelte', size: '1.2 kb', modified: '2026-04-12' }
]
},
{ value: 'src/app.css', name: 'app.css', size: '512 b', modified: '2026-03-30' }
]
},
{
value: 'docs',
name: 'docs',
size: '—',
modified: '2026-04-10',
children: [
{ value: 'docs/readme.md', name: 'README.md', size: '3.7 kb', modified: '2026-04-10' }
]
},
{ value: 'package.json', name: 'package.json', size: '1.1 kb', modified: '2026-04-18' }
];
let expanded = $state<string[]>(['src', 'src/lib']);
let value = $state<string[]>([]);
let selectionMode = $state<'none' | 'single' | 'multiple'>('multiple');
let loop = $state(false);
let typeahead = $state(true);
</script>
<svelte:head>
<title>TreeGrid · Soma</title>
</svelte:head>
<div class="page">
<h1>TreeGrid</h1>
<p>
<code>role="treegrid"</code> — tabla con filas jerárquicas. <kbd>ArrowDown</kbd>/<kbd>ArrowUp</kbd>
entre filas, <kbd>ArrowRight</kbd> expande o entra al hijo, <kbd>ArrowLeft</kbd> colapsa o va al
padre, <kbd>Space</kbd> selecciona, typeahead por nombre.
</p>
<section>
<h2>1. Interactive testbed</h2>
<div class="controls">
<label>
<span>selectionMode</span>
<select bind:value={selectionMode}>
<option value="none">none</option>
<option value="single">single</option>
<option value="multiple">multiple</option>
</select>
</label>
<label><input type="checkbox" bind:checked={loop} /><span>loop</span></label>
<label><input type="checkbox" bind:checked={typeahead} /><span>typeahead</span></label>
</div>
{#snippet renderNode(nodes: Node[])}
{#each nodes as node (node.value)}
<TreeGrid.Row
value={node.value}
textValue={node.name}
hasChildren={!!node.children && node.children.length > 0}
class="tg-row"
>
{#snippet children({ expanded: isExpanded, hasChildren, level })}
<TreeGrid.Cell class="tg-cell name" style="padding-inline-start:{(level - 1) * 1.25}rem">
{#if hasChildren}
<TreeGrid.ExpandTrigger class="tg-expand">
{isExpanded ? '▾' : '▸'}
</TreeGrid.ExpandTrigger>
{:else}
<span class="tg-expand-spacer"></span>
{/if}
<span class="tg-name">{node.name}</span>
</TreeGrid.Cell>
<TreeGrid.Cell class="tg-cell">{node.size}</TreeGrid.Cell>
<TreeGrid.Cell class="tg-cell">{node.modified}</TreeGrid.Cell>
{#if hasChildren}
<TreeGrid.RowChildren>
{@render renderNode(node.children ?? [])}
</TreeGrid.RowChildren>
{/if}
{/snippet}
</TreeGrid.Row>
{/each}
{/snippet}
<TreeGrid.Provider
bind:expanded
bind:value
{selectionMode}
{loop}
{typeahead}
aria-label="File system"
class="tg"
>
<TreeGrid.Header class="tg-header">
<TreeGrid.ColumnHeader class="tg-col">Name</TreeGrid.ColumnHeader>
<TreeGrid.ColumnHeader class="tg-col">Size</TreeGrid.ColumnHeader>
<TreeGrid.ColumnHeader class="tg-col">Modified</TreeGrid.ColumnHeader>
</TreeGrid.Header>
{@render renderNode(tree)}
</TreeGrid.Provider>
<dl class="state">
<dt>expanded</dt>
<dd><code>{JSON.stringify(expanded)}</code></dd>
<dt>selected</dt>
<dd><code>{JSON.stringify(value)}</code></dd>
</dl>
</section>
</div>
<style>
.page {
max-width: 48rem;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-block: 2rem;
padding: 1.25rem 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #fff;
}
h1,
h2 {
margin-block-start: 0;
}
h2 {
font-size: 1.1rem;
}
code {
padding: 0 0.25rem;
border-radius: 3px;
background: #eee;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
}
kbd {
padding: 0 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #f8fafc;
font: inherit;
font-size: 0.8em;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding-block: 0.5rem 1rem;
}
.controls label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
}
.controls select {
padding: 0.2rem 0.3rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
}
dl.state {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.8rem;
margin-block: 1rem 0;
padding: 0.75rem 1rem;
background: #fbfbfb;
border-top: 1px solid #eee;
font-size: 0.85rem;
}
:global(.tg) {
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
:global(.tg-header) {
display: grid;
grid-template-columns: 2fr 100px 140px;
padding: 0.45rem 0.6rem;
background: #f1f5f9;
font-size: 0.75rem;
font-weight: 600;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #e2e8f0;
}
:global(.tg-col) {
display: flex;
align-items: center;
}
:global(.tg-row) {
display: grid;
grid-template-columns: 2fr 100px 140px;
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
outline: none;
}
:global(.tg-row:last-child) {
border-bottom: 0;
}
:global(.tg-row[data-highlighted]) {
background: #f8fafc;
}
:global(.tg-row:focus) {
background: #e0f2fe;
}
:global(.tg-row[data-state='selected']) {
background: #dbeafe;
}
:global(.tg-row[data-disabled]) {
opacity: 0.4;
cursor: not-allowed;
}
:global(.tg-cell) {
display: flex;
align-items: center;
gap: 0.3rem;
color: #334155;
}
:global(.tg-cell.name) {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
:global(.tg-expand) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 0;
background: transparent;
color: #64748b;
font-size: 0.75rem;
cursor: pointer;
}
.tg-expand-spacer {
display: inline-block;
width: 1rem;
}
.tg-name {
flex: 1;
}
</style>

@ -0,0 +1,11 @@
.air-banner {
display: flex;
align-items: center;
gap: var(--air-banner-gap, 1rem);
padding: var(--air-banner-padding, 0.75rem 1rem);
background: var(--air-banner-bg, transparent);
color: var(--air-banner-color, inherit);
border-bottom: var(--air-banner-border, none);
width: 100%;
box-sizing: border-box;
}

@ -0,0 +1,22 @@
<script lang="ts">
import './banner.css';
import type { BannerProps } from './types';
let {
class: className = '',
children,
...restProps
}: BannerProps = $props();
</script>
<!-- `<header>` gets implicit `role=banner` ONLY when it's a top-level child of
the body. When nested inside main/article/section, the role is lost. The
explicit role stabilises the landmark across layouts. -->
<!-- svelte-ignore a11y_no_redundant_roles -->
<header
class={['air-banner', className].filter(Boolean).join(' ')}
role="banner"
{...restProps}
>
{@render children?.()}
</header>

@ -0,0 +1,2 @@
export { default } from './banner.svelte';
export type { BannerProps } from './types';

@ -0,0 +1,17 @@
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
/**
* Landmark element that renders as `<header role="banner">`. Use once per
* page for the site header. For internal section headers use a plain
* `<header>` (which gets `role="banner"` only as a top-level child of
* `<body>`, per HTML spec). Forcing `role="banner"` manually via this
* component expresses the intent explicitly and works even when nested.
*/
export type BannerProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Accessible name. */
'aria-label'?: string;
/** External label id. */
'aria-labelledby'?: string;
children?: Snippet;
};

@ -0,0 +1,2 @@
export { default } from './link.svelte';
export type { LinkProps, LinkVariant, LinkSize, LinkUnderline } from './types';

@ -0,0 +1,88 @@
.air-link {
display: inline-flex;
align-items: center;
gap: var(--air-link-gap, 0.25em);
color: var(--air-link-color);
text-decoration: none;
border-radius: var(--air-link-radius, 2px);
outline: none;
cursor: pointer;
font: inherit;
transition: color 150ms, text-decoration-color 150ms;
}
.air-link[data-size='sm'] {
font-size: var(--air-link-size-sm, 0.85em);
}
.air-link[data-size='md'] {
font-size: var(--air-link-size-md, 1em);
}
.air-link[data-size='lg'] {
font-size: var(--air-link-size-lg, 1.15em);
}
.air-link[data-variant='default'] {
color: var(--air-link-color, currentColor);
}
.air-link[data-variant='subtle'] {
color: var(--air-link-subtle-color, inherit);
}
.air-link[data-variant='quiet'] {
color: inherit;
}
.air-link[data-variant='emphasized'] {
color: var(--air-link-emphasized-color);
font-weight: var(--air-link-emphasized-weight, 600);
}
.air-link[data-underline='always'] {
text-decoration: underline;
text-decoration-thickness: var(--air-link-underline-thickness, 1px);
text-underline-offset: var(--air-link-underline-offset, 2px);
}
.air-link[data-underline='hover']:hover,
.air-link[data-underline='hover']:focus-visible {
text-decoration: underline;
text-decoration-thickness: var(--air-link-underline-thickness, 1px);
text-underline-offset: var(--air-link-underline-offset, 2px);
}
.air-link[data-underline='none'] {
text-decoration: none;
}
.air-link:hover {
color: var(--air-link-hover-color, var(--air-link-color));
}
.air-link:active {
color: var(--air-link-active-color, var(--air-link-color));
}
.air-link:focus-visible {
outline: 2px solid var(--air-link-focus-ring, currentColor);
outline-offset: 2px;
}
.air-link[aria-disabled='true'] {
cursor: not-allowed;
opacity: 0.55;
pointer-events: none;
}
.air-link-external-icon {
width: 0.85em;
height: 0.85em;
fill: currentColor;
flex-shrink: 0;
}
.air-link-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

@ -0,0 +1,64 @@
<script lang="ts">
import './link.css';
import { getAir } from '$uix/air/system';
import type { LinkProps } from './types';
const air = getAir();
let {
href,
external = false,
variant = 'default',
size = 'md',
underline = 'hover',
disabled = false,
class: className = '',
children,
...restProps
}: LinkProps = $props();
const resolvedVariant = $derived(air.dom.resolve(variant) ?? 'default');
const resolvedSize = $derived(air.dom.resolve(size) ?? 'md');
const resolvedUnderline = $derived(air.dom.resolve(underline) ?? 'hover');
const externalAttrs = $derived(
external ? { target: '_blank', rel: 'noopener noreferrer' } : {}
);
</script>
{#if disabled}
<span
class={['air-link', className].filter(Boolean).join(' ')}
aria-disabled="true"
data-variant={resolvedVariant}
data-size={resolvedSize}
data-underline={resolvedUnderline}
data-disabled=""
>
{@render children?.()}
</span>
{:else}
<a
{href}
class={['air-link', className].filter(Boolean).join(' ')}
data-variant={resolvedVariant}
data-size={resolvedSize}
data-underline={resolvedUnderline}
data-external={external ? '' : undefined}
{...externalAttrs}
{...restProps}
>
{@render children?.()}
{#if external}
<svg
class="air-link-external-icon"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path d="M14 3h7v7h-2V6.41l-9.29 9.3-1.42-1.42L17.59 5H14V3zM5 5h6v2H7v10h10v-4h2v6H5V5z" />
</svg>
<span class="air-link-sr-only">(opens in new tab)</span>
{/if}
</a>
{/if}

@ -0,0 +1,31 @@
import type { HTMLAnchorAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
import type { ResponsiveProp } from '$uix/air/lib';
/** Visual variant. */
export type LinkVariant = 'default' | 'subtle' | 'quiet' | 'emphasized';
/** Size scale. */
export type LinkSize = 'sm' | 'md' | 'lg';
/** Underline behavior. */
export type LinkUnderline = 'none' | 'hover' | 'always';
export type LinkProps = Omit<HTMLAnchorAttributes, 'children' | 'href'> & {
/** Target URL. Omit for a plain rendering without href (useful with SvelteKit `<a>` via `child`). */
href?: string;
/**
* When `external` is `true`, adds `target="_blank"` + `rel="noopener noreferrer"`
* and a visible "opens in new tab" affordance (screen-reader-only text + optional icon).
* @default false
*/
external?: boolean;
/** @default 'default' */
variant?: ResponsiveProp<LinkVariant>;
/** @default 'md' */
size?: ResponsiveProp<LinkSize>;
/** @default 'hover' */
underline?: ResponsiveProp<LinkUnderline>;
/** When `true`, disables the link (renders as `<span>`, no href, aria-disabled). @default false */
disabled?: boolean;
children?: Snippet;
};

@ -0,0 +1,435 @@
# Morfo — propuesta de diseño
> Documento de diseño para revisión externa. Describe la motivación, el shape propuesto y las decisiones abiertas de `morfo`, un artefacto compartido entre las capas de un framework de componentes Svelte 5.
>
> Se busca crítica técnica: puntos ciegos, precedentes pasados por alto, consecuencias no previstas, y alternativas razonables al shape propuesto.
---
## 1. Contexto del framework
El framework en cuestión (llamémoslo **Vicen**) organiza componentes en capas independientes que colaboran:
| Capa | Rol | Estado |
|------|-----|--------|
| **soma** | Headless. Comportamiento, estado, ARIA, keyboard, A11Y. ~66 componentes hoy. | Estable |
| **sema** | Semántica de comportamiento (qué semántica aplica cada componente). | No definido todavía |
| **eidos** | Capa visual. Tokens, variants, recipes, estilos. | Planificado |
| **air** | Primitives visuales sin behavior (Separator, Link, Banner, layout). | Convive con eidos |
Regla del framework: **soma nunca importa de eidos**. eidos y sema consumen soma, no al revés.
Componentes como `Dialog`, `Calendar`, `Table` existirán en **varias capas a la vez**: un `soma/Dialog` (headless) y un `eidos/Dialog` (estilado), ambos refiriéndose a la misma idea de componente.
## 2. El problema
Hoy, la información formal de un componente vive repartida en varios sitios dentro de `soma`:
| Información | Dónde vive hoy |
|-------------|----------------|
| Nombres de parts (`root`, `trigger`, …) | `createAttrs({ parts: [...] })` en el provider |
| Data-attrs + enums válidos | `registerContract({ parts: { trigger: [{ attr: 'data-state', values: ['open','closed'] }]}})` |
| Qué ARIA emite cada part | hardcoded en `props = $derived.by(...)` del provider |
| Keyboard contract | en los handlers + en el README |
| Props | TypeScript en `types.ts` con JSDoc |
| Traducciones | `langs.ts` con idlangref constants |
| Prosa (summary, when-to-use) | README.md |
| Comparativa vs otras libs | README.md |
Problemas derivados:
1. **Drift dentro de soma.** Si renombro un part `content` → `panel`, tengo que tocar `createAttrs`, `registerContract`, la emisión en el provider, los selectores del demo, las tablas del README, y los tests. Fácil olvidar alguno.
2. **Drift futuro entre capas.** Cuando llegue `eidos/Dialog`, su CSS estilará `[data-dialog-content]`. Si `soma/Dialog` emite `[data-dialog-panel]` después de un refactor, los estilos silenciosamente no aplican. Esto es **un clásico** de los design systems con layering físico.
3. **Sin fuente única para tooling.** Docs autogeneradas, low-code builders, AI assistants que generan código consumer, form designers — todos necesitan un descriptor máquina-legible del componente. Hoy lo único cercano es `registerContract`, que solo cubre data-attrs.
4. **Versionado impreciso.** Un cambio en nombres de parts debería ser breaking. Hoy se notifica con una entrada en un CHANGELOG manual; no hay chequeo automático.
## 3. Propuesta: `morfo`
Un **artefacto declarativo estructural, cross-layer, máquina-legible, por componente**. Define la *forma* que cualquier capa que implemente el componente debe respetar.
Se llama `morfo` (de μορφή, "forma") y **no** "ADN" ni "anatomy" porque:
- **Anatomy** (Zag.js tiene `@zag-js/anatomy`) cubre solo la estructura de parts + selectores. Si ampliamos el alcance, el nombre queda estrecho.
- **ADN** sugiere "blueprint completo por célula/capa". Implicaría un ADN por capa (soma ADN, eidos ADN). Lo que queremos es **lo compartido entre capas**, no lo exhaustivo de cada una.
- **Morfo** es la forma que convergentemente implementan las distintas capas. Análogo a cómo un pez y un delfín comparten morfología pero su ADN es distinto.
### 3.1 Alcance
Morfo contiene **solo contrato máquina-legible**:
- Nombres de parts (árbol recursivo con posible nesting)
- Elemento HTML que renderiza cada part
- Data-attrs que emite cada part (con valores válidos enumerables)
- ARIA contract (qué atributo emite, qué estado semántico expone, condición)
- Keyboard contract (teclas por part con acción semántica)
- URL del pattern WAI-ARIA APG si aplica (pointer, no prose)
- Qué capas implementan el componente (`scope: ('soma' | 'sema' | 'eidos')[]`)
Morfo **NO** contiene (y por qué):
| Fuera de morfo | Dónde vive | Por qué |
|----------------|------------|---------|
| Summary, overview | README.md | Prose editorial, no contrato |
| Comparativa vs Radix/BaseUI/Bits | README.md | Drift externo; no ensuciar el contrato |
| Ejemplos de uso | README.md | Narrativa |
| `whenToUse X vs Y` | README.md | Guía de decisión, prose |
| Props (nombre, tipo, default) | `types.ts` con JSDoc | Ya es canónico. Docs parsean JSDoc |
| Event handlers | `{name}-provider.svelte.ts` | Código, no datos |
| State machine | `{name}-provider.svelte.ts` | Código, no datos |
| Traducciones | `langs.ts` (idlangref) | Registry aparte, consumido por el provider |
| Recipes (variants visuales) | eidos (cuando exista) | Capa-específico, fuera del contrato compartido |
### 3.2 Shape propuesto
```ts
// src/uix/morfo/types.ts
export type Layer = 'soma' | 'sema' | 'eidos';
export interface Morfo {
/** Component display name. */
name: string; // "Dialog"
/** kebab-case name. Matches createAttrs({component}). */
kebab: string; // "dialog"
/** Layers that implement this component. */
scope: Layer[]; // ['soma'] | ['soma', 'eidos']
/** WAI-ARIA APG pattern URL, if the component implements a formal pattern. */
apg?: string;
/** Top-level parts. Recursive. */
parts: MorfoPart[];
}
export interface MorfoPart {
/** PascalCase name exposed as a component. */
name: string; // "Trigger"
/** kebab-case matching createAttrs({parts}). Used for data-attr suffix. */
kebab: string; // "trigger"
/** HTML element this part renders. "none" for context-only parts. */
element: string; // "<button>" | "<div>" | "none"
/** Whether the part is required in a valid composition. */
optional: boolean;
/** Data attributes emitted by this part. */
data: MorfoData[];
/** ARIA attributes emitted by this part, with semantic description. */
aria: MorfoAria[];
/** Keyboard contract relevant when focus is on this part. */
keyboard?: MorfoKeyboard[];
/** Nested parts (e.g. Accordion.Item contains Header, Trigger, Content). */
parts?: MorfoPart[];
}
export interface MorfoData {
/** Data attribute name, e.g. "data-state". */
attr: string;
/** If the attribute is enum-valued, the complete set of valid values. */
values?: string[];
/** If omitted + no values, it's a boolean presence flag (present or absent). */
}
export interface MorfoAria {
/** ARIA attribute name, e.g. "aria-expanded". */
attr: string;
/** Semantic ID of the state exposed, e.g. "open", "selected", "expanded".
* Not a literal value; provider decides how to format at runtime. */
valueRef: string;
/** When the attribute is emitted. "always" or a semantic condition. */
condition?: string; // "always" | "when hasChildren" | "role=dialog only"
}
export interface MorfoKeyboard {
/** Key name (matches KeyboardEvent.key). */
key: string;
/** Semantic action description, e.g. "close", "toggle", "next-item". */
action: string;
}
```
**Ningún campo de prosa.** Solo contrato.
### 3.3 Ejemplo: `dialogMorfo`
```ts
// src/uix/morfo/components/dialog.ts
import type { Morfo } from '../types';
export const dialogMorfo: Morfo = {
name: 'Dialog',
kebab: 'dialog',
scope: ['soma'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',
parts: [
{
name: 'Provider',
kebab: 'root', // special: root emits `data-dialog` (no part suffix)
element: 'none',
optional: false,
data: [],
aria: []
},
{
name: 'Trigger',
kebab: 'trigger',
element: '<button>',
optional: false,
data: [
{ attr: 'data-state', values: ['open', 'closed'] }
],
aria: [
{ attr: 'aria-haspopup', valueRef: 'dialog', condition: 'always' },
{ attr: 'aria-expanded', valueRef: 'open', condition: 'always' },
{ attr: 'aria-controls', valueRef: 'contentId', condition: 'always' }
]
},
{
name: 'Content',
kebab: 'content',
element: '<div>',
optional: false,
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-nested' },
{ attr: 'data-starting-style' },
{ attr: 'data-ending-style' }
],
aria: [
{ attr: 'role', valueRef: 'dialog', condition: 'modal' },
{ attr: 'aria-modal', valueRef: 'true', condition: 'modal' },
{ attr: 'aria-labelledby', valueRef: 'titleId', condition: 'when Title present' },
{ attr: 'aria-describedby', valueRef: 'descriptionId', condition: 'when Description present' }
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'trap-forward' },
{ key: 'Shift+Tab', action: 'trap-backward' }
]
},
{
name: 'Title',
kebab: 'title',
element: '<div>',
optional: true,
data: [],
aria: [
{ attr: 'role', valueRef: 'heading', condition: 'always' },
{ attr: 'aria-level', valueRef: '2', condition: 'default' }
]
},
{
name: 'Description',
kebab: 'description',
element: '<div>',
optional: true,
data: [],
aria: []
},
{
name: 'Close',
kebab: 'close',
element: '<button>',
optional: true,
data: [],
aria: [
{ attr: 'aria-label', valueRef: 'translated(close)', condition: 'always' }
]
},
{
name: 'Overlay',
kebab: 'overlay',
element: '<div>',
optional: true,
data: [
{ attr: 'data-state', values: ['open', 'closed'] }
],
aria: [
{ attr: 'aria-hidden', valueRef: 'true', condition: 'always' }
]
}
]
};
```
Queda aprox. 100 líneas por componente. Agradable de leer de cabo a rabo.
### 3.4 Consumo
**Desde el provider** (soma):
```ts
// dialog-provider.svelte.ts
import { dialogMorfo } from '$uix/morfo/components/dialog';
import { createAttrs, registerContract } from '../../attrs';
const attrs = createAttrs(dialogMorfo); // ← lee parts del morfo
registerContract(dialogMorfo); // ← lee data+values del morfo
export class DialogProvider extends Provider<DialogOpts> {
// ... comportamiento
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'aria-haspopup': 'dialog',
'aria-expanded': this.opts.open.current,
// assertProps valida contra dialogMorfo.parts.trigger.aria: si el provider
// no emite uno declarado, dev-mode warning. Si emite uno no declarado, error.
})
);
}
```
**Desde eidos** (futuro):
```ts
// eidos/Dialog.css
// (hipotéticamente) una macro CSS genera los selectores desde dialogMorfo.parts
```
O más realista: un script `npm run generate:eidos-selectors` escribe `dialog.selectors.ts` leyendo `dialogMorfo`:
```ts
export const sel = {
content: '[data-dialog-content]',
trigger: '[data-dialog-trigger]',
title: '[data-dialog-title]',
// ...
};
```
Eidos CSS usa `sel.content`, no strings. Si renombro `content` → `panel` en morfo, el generador cambia `sel`, los estilos siguen aplicando.
**Desde docs:**
```svelte
<script>
import { dialogMorfo } from '$uix/morfo/components/dialog';
import README from '$uix/soma/components/dialog/README.md';
</script>
<MorfoDocs morfo={dialogMorfo} /> <!-- tabla de parts, data, aria, keyboard -->
<PropsTable source="soma/dialog/types" /> <!-- genera de JSDoc -->
<Prose>{@html README}</Prose> <!-- intro, comparativa, when-to-use -->
```
Tres fuentes, cada una con dueño claro, cero duplicación.
### 3.5 Modo estricto
El provider **no puede** emitir data-attrs o ARIA que no estén declarados en morfo. `assertProps` lo chequea en dev:
```ts
// Dentro de assertProps:
// - Si el provider emite data-X que no está en morfo → error: "undeclared data attr"
// - Si el provider emite aria-Y que no está en morfo → warning: "undeclared aria"
// - Si morfo declara aria-Z y el provider no lo emite → warning: "missing declared aria"
```
Escape hatch para data-attrs privados (debug, internal state):
```ts
// Cualquier attr que empieza con `data-_` se considera privado y skipea la validación.
// Convención: nunca exponer data-_* como selector público.
'data-_cursor': someDebugState
```
Para ARIA no hay escape hatch; ARIA es siempre público (semántico).
## 4. Precedentes
| Framework | ¿Qué hace similar? | ¿Qué no? |
|-----------|-------------------|----------|
| **Zag.js / @zag-js/anatomy** | `createAnatomy(name).parts(...)` — fuente de verdad para nombres de parts + selectores + data-attrs | Solo cubre parts/attrs. ARIA y keyboard los emite la state machine; no hay "contract declarativo" |
| **Design Tokens / Style Dictionary** | Tokens como SoT, build genera CSS/JS/Figma | Para tokens, no componentes. Mismo patrón filosófico |
| **React Spectrum / React Aria** | Especificaciones formales por componente (Markdown) | Prescriptivo, no runtime data object |
| **OpenUI (W3C)** | Propuesta de estandarizar semántica de componentes | Solo spec, sin implementación consumible |
| **JSON Schema / OpenAPI** | Contratos machine-readable para APIs | Mismo patrón; distinto dominio |
| **Radix UI** | Primitives TS con data-attrs ad-hoc por componente | Sin fuente única. Cada componente declara lo suyo |
| **Base UI (MUI)** | Hooks-first, sin SoT estructural | — |
| **Ariakit** | Composición con hooks | — |
| **shadcn/ui** | Plantillas de código, no framework | No aplica |
**Nadie** declara ARIA + keyboard contract como datos machine-readable al nivel que se propone aquí. Zag llega más lejos con parts, pero no incluye el resto.
**Hipótesis de por qué nadie lo ha hecho del todo:**
- ARIA es dinámica (`aria-expanded` cambia con state). Declararla pierde matiz.
- El contrato puede quedar desfasado frente al código emisor si no hay validación runtime/build.
- Añade una capa más al desarrollo del componente.
**Contra-argumentos:**
- Declarar el ARIA **contract** (qué attrs emite, con qué condición) no pierde matiz; el valor literal sigue siendo responsabilidad del emisor. Solo declaramos QUÉ se emite, no el valor concreto.
- La validación runtime/build existe (`assertContract` ya lo hace parcialmente).
- La capa extra se paga una vez; se amortiza cada vez que un cambio de parts se propaga automáticamente.
## 5. Decisiones abiertas
### 5.1 Donde vive morfo físicamente
Dos opciones:
**A.** `src/uix/morfo/components/{name}.ts` — cross-layer desde día 1, aunque solo soma lo consuma por ahora.
**B.** `src/uix/soma/components/{name}/morfo.ts` junto al código consumidor; mover a `src/uix/morfo/` cuando eidos arranque.
Opción A es arquitecturalmente honesta (morfo **es** cross-layer por intención). Opción B es colocal y más cómoda ahora. Impacto real en el refactor es el mismo; el import path cambia.
**Inclinación**: A, desde el principio.
### 5.2 Props en morfo — sí o no
- **No**: props se quedan en `types.ts` con JSDoc. Docs parsean JSDoc. Morfo no los toca. Clean separation.
- **Sí**: morfo incluye `props[]` con `{ name, type, default, bindable }` sin `description`. La description vive en JSDoc. Duplicación mínima pero duplicación al fin.
**Inclinación**: No. Evitamos duplicación.
**Contra-inclinación**: si más tarde un tool quiere programáticamente listar props (p. ej. un form-builder que auto-genera UI para props de configuración), le resulta más difícil. Pero ese tool puede parsear `types.ts` con TS AST o TypeDoc.
### 5.3 Traducciones en morfo — sí o no
Soma tiene `langs.ts` por componente con idlangref constants (`#?components.dialog.trigger|Open dialog`). Morfo podría declarar qué keys de traducción consume cada part, para que consumers sepan "este part espera una traducción para X".
**Inclinación**: no por ahora. `langs.ts` ya es canónico para ese mapping.
### 5.4 Versionado
Morfo es contrato. Breaking cambios deberían detectarse.
- Renombrar `content` → `panel` es breaking.
- Añadir un part opcional nuevo es minor.
- Cambiar `values: ['open','closed']` a `values: ['open','closed','opening','closing']` es minor (ensanchar enum) o breaking (si un consumer hacía exhaustive match).
Propuesta: morfo tiene un `version: number` (semver integer) y un CI check compara morfo del branch con morfo de main para clasificar el diff.
### 5.5 Composition constraints
¿Declara morfo la estructura requerida? Ej: "Dialog.Content debe contener Title si no hay aria-label del Provider".
- **Sí**: `parts[x].requires: ['title'] | 'if:not-aria-labelledby'` — morfo expresa constraints de composición. Más poder, más complejidad.
- **No**: los constraints los valida el provider runtime (como ahora — `A4` exige ARIA relationships). Morfo solo dice "qué parts existen".
**Inclinación**: no. Menos poder, menos chance de que el lenguaje de morfo se vaya de madre. Los constraints siguen en el provider.
## 6. Riesgos
1. **Morfo se queda desfasado respecto al provider.** Mitigación: validación dev-time en `assertProps` (estricto). CI opcional que monta cada componente y valida.
2. **Añadir un part nuevo es "tocar dos sitios" (morfo + provider).** Mitigación: es lo que toca para que sea SoT real. Hoy ya son tres sitios (createAttrs + registerContract + provider emission). Morfo reduce a dos + un generador.
3. **El esquema de morfo se queda estrecho y hay que ampliarlo.** Mitigación: empezar minimal. Evolucionar con casos concretos, no adelantarse.
4. **ARIA runtime-dinámica no se representa bien con `condition` como string.** Mitigación: condition es documental, no ejecutable. El provider sigue siendo dueño de la lógica. Morfo solo dice "existe este ARIA".
5. **Low-code tools / codegen consumers tendrán que aprender el shape de morfo.** Mitigación: el shape es pequeño y hay un único punto de consumo.
## 7. Preguntas concretas para revisión
1. **¿Morfo debería incluir props, aunque sea sin description?** (§5.2)
2. **¿Los constraints de composición deberían expresarse en morfo, o quedarse en el provider?** (§5.5)
3. **¿El valor de `valueRef` en `MorfoAria` debería estar enumerado (como `data.values`) o string libre?** Ej: `'open' | 'closed' | 'selected' | …` vs cualquier string.
4. **¿Hay un precedente que se me escapa donde alguien haya hecho algo parecido?** El parecido más cercano que encontré es Zag anatomy + design tokens, pero ninguno llega a ARIA + keyboard contract declarativo.
5. **¿El modo estricto (§3.5) es demasiado restrictivo?** ¿Habría componentes legítimos que necesiten emitir data-attrs no-declarados públicos (no privados con `data-_`)?
6. **¿Qué patologías he pasado por alto?** Especialmente sobre drift vs provider, sobre evolutions del schema, y sobre generación de código downstream.
---
**Gracias por la revisión.** Feedback concreto sobre cualquiera de los 6 puntos o cualquier parte del shape es bienvenido. Este doc es el boceto; todavía no hay líneas de código.

@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { validateMorfo, MorfoInvariantError } from '../schema';
import { dialogMorfo } from './dialog';
describe('dialogMorfo', () => {
it('passes shape + invariant validation', () => {
expect(() => validateMorfo(dialogMorfo)).not.toThrow();
});
it('declares the 7 parts the provider emits', () => {
const kebabs = dialogMorfo.parts.map((p) => p.kebab).sort();
expect(kebabs).toEqual(
['close', 'content', 'description', 'overlay', 'root', 'title', 'trigger'].sort()
);
});
it('declares data-last-action on Content for Sema causal exits', () => {
const content = dialogMorfo.parts.find((p) => p.kebab === 'content')!;
const causal = content.data.find((d) => d.attr === 'data-last-action');
expect(causal).toBeDefined();
expect(causal!.values).toContain('saved');
expect(causal!.values).toContain('cancelled');
});
it('fails validation when a partRef targets a non-existent kebab', () => {
const broken = structuredClone(dialogMorfo);
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!;
const controls = trigger.aria.find((a) => a.attr === 'aria-controls')!;
// Force a partRef to a non-existent target.
(controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part';
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
it('fails validation when stateRef refers to a state not declared in the part', () => {
const broken = structuredClone(dialogMorfo);
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!;
const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')!;
(expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state';
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
it('fails validation when two parts share the same kebab', () => {
const broken = structuredClone(dialogMorfo);
broken.parts[1].kebab = 'content'; // trigger → content (duplicate)
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
it('fails validation with empty scope', () => {
const broken = structuredClone(dialogMorfo);
broken.scope = [];
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
});

@ -0,0 +1,206 @@
/**
* Dialog morfo — canonical first example.
*
* Authored from the current `dialog-provider.svelte.ts` + `sema_pre.md`
* guidelines (data-last-action for asymmetric exits, transition markers).
*
* When the provider is refactored to consume `createAttrs(dialogMorfo)` +
* `registerContract(dialogMorfo)`, this becomes the single source of
* truth for parts, data-attrs, ARIA contract, keyboard, and focus.
*/
import type { Morfo } from '../types';
import { v } from '../types';
export const dialogMorfo: Morfo = {
name: 'Dialog',
kebab: 'dialog',
scope: ['soma'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',
focus: {
initial: 'first-focusable',
trap: true,
return: 'trigger',
restore: true
},
parts: [
{
name: 'Provider',
kebab: 'root',
kind: 'virtual',
defaultElement: 'none',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: []
},
{
name: 'Trigger',
kebab: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-haspopup', value: v.literal('dialog') },
{ attr: 'aria-expanded', value: v.stateRef('open') },
{ attr: 'aria-controls', value: v.partRef('content') }
]
},
{
name: 'Content',
kebab: 'content',
kind: 'public',
defaultElement: 'div',
role: 'dialog',
optional: false,
supportsNesting: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{
/**
* Sema alignment (sema_pre.md §9): causal exit reason. Updated
* BEFORE `data-state` flips to `'closed'` so Sema can tint the
* exit animation per-action. Ordering is validated by a
* dedicated MutationObserver test in the smoke suite.
*/
attr: 'data-last-action',
values: ['saved', 'cancelled', 'dismissed', 'dismissed-outside', 'failed'],
severity: 'optional'
},
{
attr: 'data-nested',
severity: 'optional'
},
{
attr: 'data-nested-open',
severity: 'optional'
},
{
attr: 'data-starting-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'open', value: 'starting' }
},
{
attr: 'data-ending-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'closed', value: 'ending' }
}
],
aria: [
{ attr: 'aria-modal', value: v.literal('true'), condition: { when: 'prop-truthy', prop: 'modal' } },
{
attr: 'aria-labelledby',
value: v.partRef('title'),
condition: { when: 'part-present', part: 'title' },
severity: 'recommended'
},
{
/**
* APG advises against `aria-describedby` on Dialog with rich
* content (lists, tables) — AT announces everything as a
* single string. Optional by design.
*/
attr: 'aria-describedby',
value: v.partRef('description'),
condition: { when: 'part-present', part: 'description' },
severity: 'optional'
},
{
attr: 'aria-roledescription',
value: v.translationRef('#?components.dialog.content.roledescription|dialog window'),
severity: 'optional'
}
],
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
},
{
name: 'Overlay',
kebab: 'overlay',
kind: 'public',
defaultElement: 'div',
optional: true,
supportsNesting: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-nested', severity: 'optional' },
{ attr: 'data-nested-open', severity: 'optional' },
{
attr: 'data-starting-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'open', value: 'starting' }
},
{
attr: 'data-ending-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'closed', value: 'ending' }
}
],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
/**
* Title contributes to the accessible name of Content via
* `aria-labelledby`. Emitted as heading for AT hierarchy, but
* level is consumer-controlled (`propRef('level')`) — not a
* hardcoded '2' — so a dialog can be h1 at top level or h3 in
* a nested context without overriding prose.
*/
name: 'Title',
kebab: 'title',
kind: 'public',
defaultElement: 'div',
role: 'heading',
optional: true,
data: [],
aria: [
{
attr: 'aria-level',
value: v.propRef('level'),
severity: 'recommended'
}
]
},
{
name: 'Description',
kebab: 'description',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Close',
kebab: 'close',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.propRef('aria-label'),
severity: 'recommended',
condition: { when: 'prop-truthy', prop: 'aria-label' }
}
]
}
]
};

@ -0,0 +1,24 @@
/**
* Public entry point for the morfo package.
*
* Consumers (soma providers, eidos generators, docs renderers, sema engine)
* import from `$uix/morfo` for types + builder helpers, and from
* `$uix/morfo/components/{name}` for individual component morfos.
*/
export type {
Layer,
MorfoElement,
MorfoAriaValue,
MorfoCondition,
MorfoSeverity,
MorfoPartKind,
MorfoData,
MorfoAriaEntry,
MorfoKeyboard,
MorfoFocus,
MorfoPart,
Morfo
} from './types';
export { v } from './types';

@ -0,0 +1,384 @@
/**
* Morfo validation.
*
* Two layers of validation:
*
* 1. **Structural** (sium): shape, tagged union discriminants, literal enums.
* Catches type errors in the declaration.
*
* 2. **Invariant** (manual walker): cross-part references.
* - `partRef.target` must match another part's `kebab` in the same morfo.
* - `stateRef.state` must exist in the containing part's `states[]`.
* - `translationRef.key` should exist in the component's `langs.ts`
* (validated by a separate tool that has access to the langs registry
* — not doable from inside morfo/schema.ts without a soma dependency).
* - Every `kebab` unique across the whole morfo.
* - `MorfoCondition` discriminator values resolve (e.g. `state-equals`
* must refer to a state declared somewhere in the morfo).
* - `MorfoFocus.initial`/`return` `partRef` must match a kebab.
*
* Sium has no `lazy()` today, so recursion in `MorfoPart.parts?` is
* handled by a manual walker that calls `morfoPartSchema.decode()` for
* every sub-part. Swap to `lazy()` when sium ships it.
*/
import {
object,
array,
string,
boolean,
literal,
union,
discriminated,
optional
} from '$lib/sium/core';
import type { Schema } from '$lib/sium/core';
import { SiumValidationError } from '$lib/sium/core';
import type {
Morfo,
MorfoPart,
MorfoAriaValue,
MorfoCondition,
MorfoElement
} from './types';
// ── Leaf schemas ──────────────────────────────────────────────────────────
const layerSchema = union(
literal('soma'),
literal('sema'),
literal('eidos')
);
/**
* Literal union of allowed HTML elements. Keep in sync with `MorfoElement`
* in `types.ts` — TS catches mismatches at compile time.
*/
const elementSchema = union(
literal('button'),
literal('div'),
literal('span'),
literal('a'),
literal('input'),
literal('ul'),
literal('ol'),
literal('li'),
literal('tr'),
literal('td'),
literal('th'),
literal('table'),
literal('thead'),
literal('tbody'),
literal('tfoot'),
literal('header'),
literal('nav'),
literal('section'),
literal('article'),
literal('main'),
literal('aside'),
literal('footer'),
literal('img'),
literal('label'),
literal('form'),
literal('fieldset'),
literal('legend'),
literal('select'),
literal('option'),
literal('textarea'),
literal('none')
) as Schema<MorfoElement, MorfoElement>;
const severitySchema = union(
literal('required'),
literal('recommended'),
literal('optional')
);
const partKindSchema = union(literal('public'), literal('virtual'));
// ── MorfoAriaValue (tagged union) ─────────────────────────────────────────
const ariaValueSchema = discriminated('kind', [
object({ kind: literal('literal'), value: string() }),
object({ kind: literal('stateRef'), state: string() }),
object({ kind: literal('partRef'), target: string() }),
object({ kind: literal('propRef'), prop: string() }),
object({ kind: literal('translationRef'), key: string() })
]) as Schema<MorfoAriaValue, MorfoAriaValue>;
// ── MorfoCondition (tagged union with 'always' literal + objects) ─────────
const conditionObjectSchema = discriminated('when', [
object({ when: literal('part-present'), part: string() }),
object({
when: literal('state-equals'),
state: string(),
value: string()
}),
object({ when: literal('prop-truthy'), prop: string() }),
object({ when: literal('prop-falsy'), prop: string() })
]);
const conditionSchema = union(literal('always'), conditionObjectSchema) as Schema<
MorfoCondition,
MorfoCondition
>;
// ── MorfoData / MorfoAriaEntry / MorfoKeyboard ────────────────────────────
const dataSchema = object({
attr: string(),
values: optional(array(string())),
condition: optional(conditionSchema),
severity: optional(severitySchema)
});
const ariaEntrySchema = object({
attr: string(),
value: ariaValueSchema,
condition: optional(conditionSchema),
severity: optional(severitySchema)
});
const keyboardSchema = object({
key: string(),
action: string(),
condition: optional(conditionSchema)
});
// ── MorfoFocus ────────────────────────────────────────────────────────────
const focusTargetSchema = union(
literal('first-focusable'),
literal('trigger'),
literal('previous'),
object({ partRef: string() })
);
const focusSchema = object({
initial: optional(focusTargetSchema),
trap: optional(boolean()),
return: optional(focusTargetSchema),
restore: optional(boolean())
});
// ── MorfoPart (non-recursive — `parts?` handled by manual walker) ─────────
//
// Sium lacks `lazy()`, so we validate a single part without its `parts?`
// children, then the walker recurses.
const partShallowSchema = object({
name: string(),
kebab: string(),
kind: partKindSchema,
defaultElement: elementSchema,
role: optional(string()),
optional: boolean(),
supportsNesting: optional(boolean()),
states: optional(array(string())),
data: array(dataSchema),
aria: array(ariaEntrySchema),
keyboard: optional(array(keyboardSchema)),
parts: optional(array(object({}, { unknownKeys: 'passthrough' })))
// ^ children passed through opaquely — shape is checked by the walker
});
// ── Morfo root ────────────────────────────────────────────────────────────
const morfoShallowSchema = object({
name: string(),
kebab: string(),
scope: array(layerSchema),
apg: optional(string()),
focus: optional(focusSchema),
parts: array(object({}, { unknownKeys: 'passthrough' }))
// ^ parts are opaque here; walker recurses with `partShallowSchema`
});
// ── Manual walker + invariants ────────────────────────────────────────────
/** Collect every part (recursively) into a flat list with ancestor path. */
function flattenParts(
parts: MorfoPart[],
path: ReadonlyArray<string> = []
): Array<{ part: MorfoPart; path: ReadonlyArray<string> }> {
const result: Array<{ part: MorfoPart; path: ReadonlyArray<string> }> = [];
for (const p of parts) {
const nextPath = [...path, p.kebab];
result.push({ part: p, path: nextPath });
if (p.parts) {
result.push(...flattenParts(p.parts, nextPath));
}
}
return result;
}
/** Validate every part (recursively) against `partShallowSchema`. */
function validatePartsRecursively(parts: MorfoPart[]): void {
for (const { part, path } of flattenParts(parts)) {
try {
// `decodeSync` throws SiumValidationError synchronously on shape mismatch.
partShallowSchema.decodeSync(part as never);
} catch (err) {
if (err instanceof SiumValidationError) {
throw new MorfoInvariantError(
`Part at path ${path.join('.')} failed shape validation: ${err.message}`,
path as readonly string[]
);
}
throw err;
}
}
}
/** Classify known vocabularies so the external vocabulary-check script can use them. */
export const CANONICAL_VOCABULARIES = {
disclosure: ['open', 'closed'],
active: ['active', 'inactive'],
checked: ['checked', 'unchecked', 'indeterminate'],
toggle: ['on', 'off'],
lifecycle: ['loading', 'idle', 'success', 'error'],
selection: ['selected', 'unselected'],
orientation: ['horizontal', 'vertical']
} as const;
/** Thrown when a morfo fails invariant validation after shape passes. */
export class MorfoInvariantError extends Error {
readonly path: ReadonlyArray<string>;
constructor(message: string, path: ReadonlyArray<string> = []) {
super(message);
this.name = 'MorfoInvariantError';
this.path = path;
}
}
/**
* Cross-reference invariants.
*
* 1. Every `kebab` unique across the whole morfo (flat namespace).
* 2. Every `partRef.target` resolves to a declared part.
* 3. Every `stateRef.state` resolves to a state declared in the SAME part.
* 4. Every `state-equals` condition's `state` exists somewhere in the part.
* 5. `focus.initial.partRef` / `focus.return.partRef` resolve to a part.
* 6. Root `scope` non-empty.
* 7. Morfo `kebab` is valid kebab-case (a-z0-9-).
*
* Not validated here (requires external sources):
* - `translationRef.key` existence (needs langs.ts registry → separate tool).
* - Cross-component vocabulary consistency (needs morfo registry →
* scripts/morfo-vocabulary-check.mjs).
*/
function validateInvariants(morfo: Morfo): void {
if (morfo.scope.length === 0) {
throw new MorfoInvariantError(
`morfo "${morfo.kebab}" has empty scope — must implement at least one layer`
);
}
if (!/^[a-z][a-z0-9-]*$/.test(morfo.kebab)) {
throw new MorfoInvariantError(
`morfo kebab "${morfo.kebab}" must be kebab-case (a-z, 0-9, - only)`
);
}
const flat = flattenParts(morfo.parts);
const kebabs = new Set<string>();
for (const { part, path } of flat) {
if (kebabs.has(part.kebab)) {
throw new MorfoInvariantError(
`duplicate kebab "${part.kebab}" in morfo "${morfo.kebab}" at path ${path.join('.')}`,
path
);
}
kebabs.add(part.kebab);
}
for (const { part, path } of flat) {
for (const ariaEntry of part.aria) {
const v = ariaEntry.value;
if (v.kind === 'partRef' && !kebabs.has(v.target)) {
throw new MorfoInvariantError(
`aria[${ariaEntry.attr}].partRef "${v.target}" does not match any part in "${morfo.kebab}"`,
path
);
}
if (v.kind === 'stateRef') {
const states = part.states ?? [];
if (!states.includes(v.state)) {
throw new MorfoInvariantError(
`aria[${ariaEntry.attr}].stateRef "${v.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`,
path
);
}
}
}
const checkCondition = (c: MorfoCondition | undefined, context: string) => {
if (!c || c === 'always') return;
if (c.when === 'part-present' && !kebabs.has(c.part)) {
throw new MorfoInvariantError(
`${context}: condition part-present references unknown part "${c.part}"`,
path
);
}
if (c.when === 'state-equals') {
const states = part.states ?? [];
if (!states.includes(c.state)) {
throw new MorfoInvariantError(
`${context}: condition state-equals references unknown state "${c.state}" in part "${part.kebab}"`,
path
);
}
}
};
for (const d of part.data) {
checkCondition(d.condition, `data[${d.attr}]`);
}
for (const a of part.aria) {
checkCondition(a.condition, `aria[${a.attr}]`);
}
for (const k of part.keyboard ?? []) {
checkCondition(k.condition, `keyboard[${k.key}]`);
}
}
if (morfo.focus) {
const f = morfo.focus;
if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) {
throw new MorfoInvariantError(
`focus.initial.partRef "${f.initial.partRef}" does not match any part`
);
}
if (typeof f.return === 'object' && !kebabs.has(f.return.partRef)) {
throw new MorfoInvariantError(
`focus.return.partRef "${f.return.partRef}" does not match any part`
);
}
}
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Validate a `Morfo` value. Throws `SiumValidationError` on shape errors
* and `MorfoInvariantError` on cross-reference errors.
*
* Intended for build-time / dev-time. Run once per morfo on first load;
* results are cacheable.
*/
export function validateMorfo(morfo: unknown): Morfo {
// 1. Shape: root shell (scope, apg, focus) + opaque parts.
morfoShallowSchema.decodeSync(morfo as never);
const m = morfo as Morfo;
// 2. Shape: every part (recursively) — sium has no lazy() today.
validatePartsRecursively(m.parts);
// 3. Invariants: kebab uniqueness, partRef/stateRef/conditions, focus.
validateInvariants(m);
return m;
}
export { morfoShallowSchema, partShallowSchema };

@ -0,0 +1,458 @@
# Morfo — study: iterative design with AI peer review
> Record of the design process for `morfo`, iterated across three independent AI reviews (Gemini, Grok, ChatGPT). Each round shifted the shape in concrete ways; this document captures what each reviewer contributed, where they converged, where they disagreed, and the consolidated v5 shape.
>
> Sibling documents: [DESIGN.md](./DESIGN.md) for the standalone design doc shared with reviewers; this file is the meta-record of the review process and its outcomes.
---
## 0. Motivation for running peer review
The initial morfo design (shape v1) was written self-contained as [DESIGN.md](./DESIGN.md). Before committing to any implementation touching 66 components and three framework layers, the doc was shared with three independent AI reviewers chosen for methodological diversity: Google's Gemini, xAI's Grok, OpenAI's ChatGPT. Same prompt to each: standalone review of the doc, critical feedback, point out blind spots, suggest concrete alternatives.
The goal was not validation (seeking agreement) but **adversarial review** — intentionally using different models to surface distinct angles.
## 1. Baseline: Morfo v1 (pre-review shape)
Starting shape proposed to the reviewers:
```ts
interface Morfo {
name: string;
kebab: string;
scope: Layer[];
apg?: string;
parts: MorfoPart[];
}
interface MorfoPart {
name: string;
kebab: string;
element: string; // "<button>" literal string
optional: boolean;
data: MorfoData[];
aria: MorfoAria[];
keyboard?: MorfoKeyboard[];
parts?: MorfoPart[];
}
interface MorfoData { attr: string; values?: string[]; }
interface MorfoAria { attr: string; valueRef: string; condition?: string; }
interface MorfoKeyboard { key: string; action: string; }
```
Six open questions included (1) props in morfo, (2) composition constraints, (3) `valueRef` type shape, (4) missed precedents, (5) strict mode appropriateness, (6) unseen patologías.
## 2. Round 1 — Gemini's review
### Agreements
- Props out of morfo (match with inclination).
- Composition constraints in provider, not morfo (would create a poorly-typed DSL).
- Strict mode + `data-_*` escape hatch is correct.
- Option A for location (Conway's Law: physical placement dictates semantics).
### Key contributions
**(a) Tagged union for `valueRef`.** The flat `valueRef: string` is too permissive — no validation of id-refs, state names, or translation keys. Proposed tagged union:
```ts
type MorfoAriaValue =
| { kind: 'literal'; value: string }
| { kind: 'state'; state: string }
| { kind: 'id-ref'; target: string }
| { kind: 'translated'; key: string }
| { kind: 'prop'; prop: string }
| { kind: 'computed'; semantic: string };
```
**(b) Custom Elements Manifest (CEM).** Precedent missed in v1. `custom-elements.json` is the Web Components community standard — documents attributes, properties, events, slots, cssParts. Closest prior art after Zag.
**(c) `defaultElement` not `element`.** Polymorphism (consumer rendering via `child` snippet as `<a>` or `<div>`) means the element is not fixed. Rename acknowledges consumer override.
**(d) Strict mode scope.** Validation should only apply to **provider-emitted** attrs, not consumer restProps (`data-testid`, custom `aria-controls` to consumer's own id, etc.). Matters for correctness.
### Final question
> "¿Cómo reconcilias `defaultElement` con emitir ARIA distinta según `<button>` o `<a>`?"
### My reply to Gemini
1. Accepted tagged union + enriched it with a sixth-variant analysis — accepted.
2. Accepted CEM as precedent, but flagged it for inclusion as precedent, not as internal format.
3. Accepted `defaultElement` rename.
4. Accepted strict mode scope restriction.
5. Answered polymorphism question by splitting `defaultElement` (advisory) from `role` (always-emitted). Keyboard handlers are element-agnostic by convention (already soma practice).
### Resulting shape v2
Same as v1 with:
- `MorfoAriaValue` tagged union (6 variants).
- `element` → `defaultElement`.
- New `role?` field (always-emitted).
- Strict mode scoped to provider-emitted props only.
## 3. Round 2 — Grok's review
### Agreements
- Props out — reinforces inclination with ts-morph / TypeDoc as existing tooling.
- Constraints in provider, not morfo.
- Strict mode + `data-_*` escape is exactly right.
- Option A for location — physical honesty.
### Key contributions
**(a) Disagreement with Gemini on `valueRef` shape.** Grok proposed flat string-literal union instead of tagged union:
```ts
type SemanticValue =
| 'open' | 'closed' | 'expanded' | 'collapsed'
| 'selected' | 'deselected'
| 'true' | 'false' | 'undefined'
| 'dialog' | 'menu' | 'listbox'
| string; // escape
```
Simpler, better autocompletion for common cases, less ceremony. Trade-off vs Gemini: loses cross-validation (id-ref → part kebab, state → declared states).
**(b) Silent drift in CI.** `assertProps` fires only in dev. If CI runs `npm run build -- --skip-validation` or if component tests don't mount the real provider, morfo can diverge from the emitted DOM without being caught. Mitigation: extend the existing Playwright smoke (items 37–39 of COMPONENT_GUIDE) to validate emitted DOM vs morfo per-component.
**(c) Evolution plan.** Adding a new field (e.g. `role?`) to `MorfoPart` breaks all 66 morfos at compile time. Needs `morfoVersion: number` + codemods from day one, or at minimum a plan for schema evolution.
**(d) `defaultElement` as literal union.** Strengthens Gemini's rename:
```ts
defaultElement: 'button' | 'div' | 'span' | 'a' | 'input' | 'ul' | 'li' | 'none' | ...;
```
Catches typos, improves autocomplete. Free win.
**(e) Parts visual-only in eidos.** Can the eidos implementation add parts not in morfo (e.g. `DecorativeBackdrop`)? Clarification needed: morfo = **minimum shared form**; each layer can extend with layer-specific parts.
**(f) Keyboard state-dependence.** `Home`/`End` in Tabs only work with focus in tablist. Single `key + action` is too flat. Added `condition` to `MorfoKeyboard` to match how `MorfoAria` has a condition.
**(g) Zod for runtime validation of morfos.** Suggested Zod as schema validator for the morfo files themselves — build-time validation of invariants TS can't express (e.g. "every id-ref.target must exist as a part kebab").
### My reply to Grok
1. Resolved the valueRef disagreement in favour of Gemini's tagged union — cross-validation is the main reason morfo exists. Introduced helper builders (`v.state()`, `v.idRef()`) to compensate for the verbosity.
2. Accepted CI smoke extension — added as item 40 to COMPONENT_GUIDE plan.
3. Accepted `morfoVersion: number` + codemods.
4. Accepted `defaultElement` literal union.
5. Clarified parts visual-only: morfo = minimum shared, each layer extends.
6. Accepted `condition?` on `MorfoKeyboard`.
7. **Rejected Zod** — the project has its own schema layer `sium` (Schema In Use Module) at `src/lib/sium/`. Required re-researching sium's API to propose a sium-based validator.
### sium substitution
After reading `src/lib/sium/README.md` and `core/index.ts`:
- sium exports `string`, `number`, `boolean`, `literal`, `enumOf`, `optional`, `nullable`, `defaulted`, `object`, `array`, `union`, `discriminated`, `pipe`, `refine`, `transform`, `codec`, `meta`, plus refinements (`min`, `max`, `length`, `regex`, `email`, `url`, `integer`).
- Implements Standard Schema v1 via `'~standard'` field — interop with TanStack Form, tRPC, Hono.
- `discriminated('kind', [...])` covers the tagged union case natively.
Three concerns raised for sium + morfo:
1. **Recursion/lazy schemas** — morfo parts are recursive (`MorfoPart.parts?`). Sium needs a `lazy()` equivalent (like Zod's `z.lazy()`). Current sium status unclear on this. Three fallbacks if absent: add `lazy` to sium, max-depth flatten, or validate parts separately outside the recursive call.
2. **Cross-field refine.** Invariants like "`aria.value.kind === 'id-ref'` → target must match a part kebab in the same morfo" need `refine` that sees the whole value. If sium's `refine` receives only the local value, custom validation is needed.
3. **Pre-existing sium issues.** `RefineFailure` undefined, `defaulted` O vs I semantics, field-level vs schema-level validation — blockers if morfo runs into them.
### Resulting shape v3/v4
- Tagged union for `valueRef` retained from v2.
- `defaultElement` as literal union.
- `morfoVersion: number` added.
- `condition?` on `MorfoKeyboard`.
- Sium schema for runtime validation (contingent on lazy support).
## 4. Round 3 — ChatGPT's review
### Agreements
- Props out — but flagged future exception for props that change the accessible contract.
- Option A for location.
- Morfo is the right abstraction; validated with concrete precedents.
### Key contributions (net-new)
**(a) W3C UI Specification Schema Community Group** — precedent missed by both prior reviewers. Formed **August 13, 2025**, explicitly aims at a meta-model machine-readable for behavior, constraints, and accessibility requirements. Strongest validation that morfo's problem space is legitimate and actively under standardization discussion at W3C.
**(b) Public vs virtual parts.** Root/Provider parts with `element: 'none'` reveals a mixing of natures: public (Trigger, Content, Title) vs virtual/internal (context-only Provider, future FocusGuards, Portal roots, SafePolygon trackers, eidos visual wrappers). Virtual parts should not render public data-attrs, docs should hide them, validator should ignore them. Proposed `kind: 'public' | 'virtual'` on `MorfoPart` or split collections (`parts` vs `internals`).
**(c) Focus policy as a dedicated section.** `MorfoKeyboard` alone covers half the semantic of overlay components. APG Dialog makes initial focus, Tab-trap, Escape, and focus return **central** to the pattern; React Aria bundles ARIA/keyboard/focus as inseparable. Without `focus`, morfo gives a false sense of completeness.
```ts
interface MorfoFocus {
initial?: 'first-focusable' | 'trigger' | { partRef: string };
trap?: boolean;
return?: 'trigger' | 'previous' | { partRef: string };
restore?: boolean;
}
```
**(d) Severity.** ARIA attrs aren't binary required/omit. APG Dialog explicitly advises **omitting** `aria-describedby` when content is rich (lists, tables, multiple paragraphs) because announcing it as a single string worsens screen reader UX. `aria-controls` on trigger is recommended, not required.
Without severity, strict mode treats everything as required → false positives. With severity:
```ts
type MorfoSeverity = 'required' | 'recommended' | 'optional';
```
`required` missing = error; `recommended` missing = warning; `optional` missing = silent.
**(e) Scope derivation from workspace, not manual.** `scope: Layer[]` as hand-declared field drifts — dev forgets to update it when adding a layer implementation. Better: derive from filesystem presence (if `src/uix/eidos/components/dialog/` exists, 'eidos' is in scope).
**(f) Version via snapshot diff, not manual number.** `morfoVersion: number` (from Grok) drifts for the same reason. CI should snapshot the morfo, diff against previous release, classify change type (breaking / minor / patch) automatically by rules (rename part → breaking, add optional part → minor, widen enum → minor, narrow enum → breaking). Same outcome, zero manual bookkeeping.
**(g) Dropped `translated`, needs reintroduction.** My v4 had dropped `translated` from `MorfoAriaValue` arguing it was provider implementation detail. ChatGPT's implicit push: docs and codegen consumers **do** need to distinguish `literal('Close')` (static English — bug in i18n product) from `translationRef('common.buttons.close')` (localized). Different runtime semantics; should be different kinds.
**(h) `aria-describedby` is NOT obligatory.** Concrete catch in the Dialog example. APG recommends against it when content is rich.
**(i) `aria-modal="true"` caveat.** Only correct when fond is really inert and focus is confined. Marking modal without modal behavior is an AT disaster. Argues for richer semantics or at least severity.
**(j) `Title` role/level is editorial.** Forcing `role="heading"` + `aria-level="2"` as required overreaches. A dialog title can be h1, h2, div, delegated to consumer. Morfo should distinguish "semantics emitted by provider" from "semantics contributed by composition/host element".
### Patologías surfaced
- **Name collisions in nested parts** — `Accordion.Item.Trigger` vs `Accordion.Header.Trigger`. Flat kebab namespace suffices only if all kebabs are globally unique. Otherwise needs path IDs (`item.trigger`).
- **`element` too concrete** — same semantic over `<button>` / `<a>` / `<div role=button>` / `<dialog>`. Adding to Gemini's polymorphism point.
- **Scope + version manual = drift candidates.** Addressed above.
- **TS object as sole format not portable.** JSON Schema export needed for low-code / AI consumers external to Vicen.
### Disagreements I held
**Layer namespaces for experimental attrs (`data-soma-*`).** ChatGPT suggested opening strict mode with layer namespaces. **This violates project rule A2** — data-attrs are always `data-{component}[-{part}]`, never `data-soma-*`. The escape for experimental stays `severity: 'experimental'` on the morfo entry (informational) and `data-_*` for private. No layer prefix.
**Constraints composition vocabulary.** ChatGPT suggested a small vocabulary (`requiresOneOf`, `mutuallyExclusive`, `idRefTargets`, `labeling`). Legitimate but speculative — no evidence yet of 5+ components wanting the same constraint. **YAGNI**. Let patterns emerge before codifying.
## 5. Convergence / divergence matrix
| Topic | Gemini | Grok | ChatGPT | v5 outcome |
|-------|--------|------|---------|------------|
| Props in morfo | ❌ out | ❌ out | ❌ out | ❌ out |
| Composition constraints in morfo | ❌ out | ❌ out | ⚠️ small vocab future | ❌ out (YAGNI) |
| Strict mode validation scope | ⚠️ provider only | ✅ default strict | ⚠️ open with namespace | ✅ provider only + severity |
| `valueRef` shape | ✅ tagged union | ⚠️ flat literal union | ✅ tagged union | ✅ tagged union |
| `translationRef` variant | — | — | ✅ included | ✅ reintroduced |
| CEM as internal format | ⚠️ precedent | — | ❌ never | ❌ use converter if needed |
| `defaultElement` rename | ✅ | — | — | ✅ |
| `defaultElement` literal union | — | ✅ | — | ✅ |
| `role` always-emitted | ✅ | — | — | ✅ |
| `MorfoSeverity` | — | — | ✅ | ✅ |
| `MorfoFocus` | — | — | ✅ | ✅ |
| `public` vs `virtual` parts | — | — | ✅ | ✅ |
| `morfoVersion` field | — | ✅ number | ❌ snapshot diff | ❌ snapshot diff |
| Sium validator | — | (Zod, rejected) | — | ✅ sium (with caveats) |
| CI smoke extension | — | ✅ | — | ✅ |
| Nested part collisions | — | — | ✅ raised | ⚠️ flat unique kebabs with CI check |
| JSON Schema export | — | — | ✅ | ⚠️ later if needed |
| Location: `src/uix/morfo/` | ✅ A | ✅ A | ✅ A | ✅ A |
High convergence on structural decisions. Single meaningful divergence (valueRef shape) resolved through explicit trade-off analysis.
## 6. Precedent map after 3 rounds
| Source | Contribution | Added in round |
|--------|-------------|----------------|
| Zag.js `@zag-js/anatomy` | Closest existing — parts + selectors + data-attrs | v1 |
| Design Tokens / Style Dictionary | Philosophical parallel (SoT → multi-output) | v1 |
| React Spectrum / React Aria | ARIA+keyboard+focus as inseparable contract | v1, reinforced r3 |
| OpenUI (W3C) | Design system documentation as JSON schema | v1, r3 |
| Custom Elements Manifest (CEM) | WC-community standard for component metadata | r1 (Gemini) |
| W3C UI Specification Schema Community Group | **Aug 13, 2025 — W3C formal effort at same problem space** | r3 (ChatGPT) |
| Radix / Base UI / Ariakit / shadcn | All lack unified SoT for component metadata | v1 |
| JSON Schema / OpenAPI | Same pattern, different domain | v1 |
**Conclusion**: nobody in the JS ecosystem ships morfo's full scope (anatomy + DOM contract + ARIA + keyboard + focus) as a single machine-readable artifact. Zag comes closest (anatomy + parts). W3C is **currently standardizing** this exact problem space, which both validates the direction and raises the bar on rigor.
## 7. Final shape v5
```ts
// src/uix/morfo/types.ts
export type Layer = 'soma' | 'sema' | 'eidos';
export type MorfoElement =
| 'button' | 'div' | 'span' | 'a' | 'input'
| 'ul' | 'ol' | 'li' | 'tr' | 'td' | 'th'
| 'header' | 'nav' | 'section' | 'article' | 'main' | 'aside' | 'footer'
| 'none';
export type MorfoAriaValue =
| { kind: 'literal'; value: string }
| { kind: 'stateRef'; state: string }
| { kind: 'partRef'; target: string }
| { kind: 'propRef'; prop: string }
| { kind: 'translationRef'; key: string };
export type MorfoCondition =
| 'always'
| { when: 'part-present'; part: string }
| { when: 'state-equals'; state: string; value: string }
| { when: 'prop-truthy'; prop: string }
| { when: 'prop-falsy'; prop: string };
export type MorfoSeverity = 'required' | 'recommended' | 'optional';
export interface MorfoAriaEntry {
attr: string;
value: MorfoAriaValue;
condition?: MorfoCondition;
severity?: MorfoSeverity;
}
export interface MorfoData {
attr: string;
values?: string[];
condition?: MorfoCondition;
severity?: MorfoSeverity;
}
export interface MorfoKeyboard {
key: string;
action: string;
condition?: MorfoCondition;
}
export interface MorfoFocus {
initial?: 'first-focusable' | 'trigger' | { partRef: string };
trap?: boolean;
return?: 'trigger' | 'previous' | { partRef: string };
restore?: boolean;
}
export type MorfoPartKind = 'public' | 'virtual';
export interface MorfoPart {
name: string;
kebab: string;
kind: MorfoPartKind;
defaultElement: MorfoElement;
role?: string;
optional: boolean;
supportsNesting?: boolean;
states?: string[];
data: MorfoData[];
aria: MorfoAriaEntry[];
keyboard?: MorfoKeyboard[];
parts?: MorfoPart[];
}
export interface Morfo {
name: string;
kebab: string;
scope: Layer[];
apg?: string;
focus?: MorfoFocus;
parts: MorfoPart[];
}
```
## 8. Validation strategy (three layers)
1. **sium schema** (build/dev): tagged unions, enums, and cross-field invariants (every `partRef.target` exists as a kebab in the same morfo, every `stateRef.state` exists in the containing part's `states[]`, every `translationRef.key` exists in the component's `langs.ts`).
2. **Strict mode in `assertProps`** (dev runtime): the provider's emitted data-attrs and ARIA are exactly what morfo declares. Missing `required` = error, missing `recommended` = warning, undeclared attr = error (unless `data-_*` private).
3. **Playwright smoke permutations** (CI): mount each component with matrix of prop/slot combinations. Verify DOM attrs per permutation match morfo's predicted output for that permutation's `condition` values.
Each layer catches a distinct class of bug. All three together = tight contract.
## 9. Learnings about peer-reviewing design with AIs
- **Methodological diversity matters.** Gemini surfaced the tagged union and CEM. Grok surfaced the CI drift and schema evolution. ChatGPT surfaced W3C, focus policy, severity, and public/virtual separation. None of the three would have caught all the blind spots alone.
- **Disagreement is the signal.** The only real divergence (valueRef shape) forced articulating the trade-off (validation power vs ergonomics) and committing to a principle (validation is why morfo exists → accept verbosity).
- **The final question each AI asked was valuable.** Each ended with a specific pointed question that hadn't been answered in the doc. Gemini: polymorphism. ChatGPT: ID linking for optional parts. These questions forced concretization of design areas that were vague.
- **Cost of three rounds was low** relative to the cost of starting to refactor 66 components on a v1 shape that would need rework.
## 10. Alignment with Sema (post-review context)
After the three AI review rounds, reading `src/uix/sema/sema_pre.md` revealed that Sema (the planned semantic/perceptual layer) consumes exactly the DOM surface that morfo already models. The match is tight, but surfaces authoring guidelines that constrain how morfos are written without changing the shape.
### What Sema reads from the DOM
Sema's only inputs are (1) DOM attribute mutations (`data-state`, `data-last-action`, `data-starting-style`, `data-ending-style`) and (2) standard DOM events (click, pointerdown, focus-visible, change). No special hooks. No custom events with Sema-specific names.
### Tight match with morfo
| Sema requirement | How morfo expresses it |
|---|---|
| Stable `data-{component}[-{part}]` attrs | `MorfoPart.kebab` + `createAttrs(morfo)` |
| Enumerable `data-state` values | `MorfoData.values: string[]` |
| Transition phase markers (`data-starting-style` / `data-ending-style`) | `MorfoData` entries with presence flags + `condition` |
| Causal exit state (`data-last-action`) | `MorfoData` with enumerable `values` |
| Cross-component value consistency (`open|closed` not `visible|hidden`) | CI-checkable: walk all morfos, flag divergent vocabularies for same semantic |
| Micro (control) vs macro (region) plane separation | Part tree + ARIA role already express this |
| Asymmetric enter/exit semantics | Combine `data-state` + `data-last-action` + transition markers |
| OpenUI terminology alignment | `Provider`, `Trigger`, `Content`, `Item`, `Header` already convention |
### Authoring guidelines derived from Sema alignment
These do not change morfo's shape but constrain its contents. Codified as rules for writing morfos:
1. **Any component with enter/exit transitions declares** `data-starting-style` and `data-ending-style` as `MorfoData` entries on the transitioning part, with appropriate `condition`.
2. **Any component with multiple semantically distinct exit paths declares** `data-last-action` with enumerable `values`. Canonical example: Dialog close via `saved | cancelled | dismissed | failed`.
3. **Cross-component value consistency is enforced via CI diff.** If Accordion uses `data-state: ['open', 'closed']`, Dialog and Drawer use the same tokens, not `visible|hidden`. The diff tool walks all morfos, groups components by semantic (disclosure → same states), and fails when a new component introduces a divergent vocabulary.
4. **`data-last-action` is updated before `data-state` changes.** This is a provider-side protocol; morfo's strict mode validates ordering by watching DOM mutation timing in smoke permutations.
5. **No semantic props in Soma (`<Button intent="threat">`, `variant="danger"`).** Sema applies intents via `.csem` files post-hoc. Morfo refuses props that represent perceptual/affective dimensions.
### Why this matters
Morfo was designed to be cross-layer (soma + eidos). Sema is the third consumer being designed in parallel. The fact that morfo's shape already supports everything Sema needs — without adding a single field for "semantics" — validates the abstraction: morfo is the **DOM-surface contract**, and every consumer layer (eidos for styling, sema for perceptual channels, future layers) reads the same contract.
The three AI reviews pushed toward a minimal contract (reject `computed`, reject composition DSL, reject embedded prose). That minimal contract turns out to be exactly what Sema's "observable from outside the component" principle requires. The alignment is not coincidental — both abstractions converge on *"the DOM is the API between layers"*.
### Implication for Dialog morfo
The initial `dialogMorfo` example omitted `data-last-action`. With Sema context, Dialog's Content part must include it:
```ts
{
name: 'Content',
kebab: 'content',
kind: 'public',
defaultElement: 'div',
role: 'dialog',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{
attr: 'data-last-action',
values: ['saved', 'cancelled', 'dismissed', 'dismissed-outside', 'failed'],
severity: 'optional'
},
{
attr: 'data-starting-style',
condition: { when: 'state-equals', state: 'open', value: 'starting' }
},
{
attr: 'data-ending-style',
condition: { when: 'state-equals', state: 'closed', value: 'ending' }
},
{ attr: 'data-nested' }
],
// aria, keyboard, focus, etc.
}
```
Same pattern applies to Drawer, Toast, Dropdown, Popover, AlertDialog, and any component with asymmetric exits.
## 11. Final decision
Commit to v5. Implementation order:
1. `src/uix/morfo/types.ts` — v5 types.
2. `src/uix/morfo/schema.ts` — sium validator (contingent on sium `lazy` availability).
3. `src/uix/morfo/components/dialog.ts` — first canonical morfo, validates shape on a rich component.
4. Refactor `createAttrs(morfo)` / `registerContract(morfo)` — providers read parts/data from morfo rather than duplicating.
5. Extend `scripts/smoke-check.mjs` to validate emitted DOM vs morfo per-component.
6. Port remaining 65 components by category (Overlay, Forms, Dates, …).
7. `scripts/morfo-diff.mjs` — CI snapshot diff for breaking-change detection.
After Dialog is working end-to-end (morfo → provider → runtime assertion → smoke validation), proceed to batch porting.
---
**Process time cost**: ~4 hours of conversation across reviews. **Design maturity gained**: more than any single-authored architectural spec in the project to date. Worth the investment.

@ -0,0 +1,341 @@
/**
* Morfo — cross-layer component contract.
*
* A morfo is the single machine-readable source of truth for a component's
* **public DOM surface**: the parts it exposes, the data-attrs it emits, the
* ARIA contract it honours, the keyboard contract, and the focus policy.
*
* Consumers:
* - soma providers consume morfo via `createAttrs(morfo)` + `registerContract(morfo)`
* - eidos generates CSS selectors from morfo's parts/data
* - sema reads the DOM surface that morfo declares (see sema_pre.md)
* - docs render morfo as part / data / aria / keyboard tables
*
* Morfo is the DOM-surface contract. It does NOT contain prose (README),
* props (types.ts + JSDoc), provider behaviour (*-provider.svelte.ts), state
* machines, translations (langs.ts idlangref), or eidos recipes.
*
* Shape v5 — consolidated after 3 independent AI reviews (Gemini, Grok,
* ChatGPT) + Sema alignment. See ./study.md for the review history and
* ./DESIGN.md for the original proposal.
*/
// ── Layer ──────────────────────────────────────────────────────────────────
/**
* The framework layer that implements a component. A single component can
* live in multiple layers (soma + eidos + sema).
*/
export type Layer = 'soma' | 'sema' | 'eidos';
// ── HTML element ──────────────────────────────────────────────────────────
/**
* Default element a part renders. Literal union (not free `string`) so typos
* are caught at compile time. Consumer can still override via the `child`
* snippet — this is *advisory*. Provider's `role` field is what guarantees
* semantic correctness across polymorphic overrides.
*
* `'none'` = part has no DOM of its own (context-only providers, virtual
* coordinators).
*/
export type MorfoElement =
| 'button'
| 'div'
| 'span'
| 'a'
| 'input'
| 'ul'
| 'ol'
| 'li'
| 'tr'
| 'td'
| 'th'
| 'table'
| 'thead'
| 'tbody'
| 'tfoot'
| 'header'
| 'nav'
| 'section'
| 'article'
| 'main'
| 'aside'
| 'footer'
| 'img'
| 'label'
| 'form'
| 'fieldset'
| 'legend'
| 'select'
| 'option'
| 'textarea'
| 'none';
// ── ARIA value taxonomy ────────────────────────────────────────────────────
/**
* Source semantics of an ARIA attribute's value. Tagged union so the
* validator can enforce cross-references:
*
* - `literal` → static string ("dialog", "true")
* - `stateRef` → must match a state in the containing part's `states[]`
* - `partRef` → must match another part's `kebab` in the same morfo
* - `propRef` → consumer-controlled via component prop
* - `translationRef` → must exist as idlangref key in the component's `langs.ts`
*
* No `computed` escape hatch — if a value doesn't fit these five shapes,
* the declaration is modelling the wrong thing. Revisit the semantics.
*/
export type MorfoAriaValue =
| { kind: 'literal'; value: string }
| { kind: 'stateRef'; state: string }
| { kind: 'partRef'; target: string }
| { kind: 'propRef'; prop: string }
| { kind: 'translationRef'; key: string };
// ── Condition taxonomy ────────────────────────────────────────────────────
/**
* When an attribute / keyboard action is emitted or applicable. Small DSL
* instead of free-form prose so the validator can cross-check runtime
* behaviour in smoke permutations.
*
* `'always'` — unconditional
* `{ when: 'part-present', part }` — emitted iff part is rendered
* `{ when: 'state-equals', state, value }` — emitted iff state === value
* `{ when: 'prop-truthy', prop }` — emitted iff consumer prop is truthy
* `{ when: 'prop-falsy', prop }` — emitted iff consumer prop is falsy
*/
export type MorfoCondition =
| 'always'
| { when: 'part-present'; part: string }
| { when: 'state-equals'; state: string; value: string }
| { when: 'prop-truthy'; prop: string }
| { when: 'prop-falsy'; prop: string };
// ── Severity ──────────────────────────────────────────────────────────────
/**
* How the strict-mode validator treats a declared attribute or state.
*
* `required` — must be emitted when its condition holds. Missing = error.
* `recommended` — should be emitted. Missing = warning.
* `optional` — may be emitted. Missing = silent.
*
* Defaults to `required` when omitted.
*
* Example of `optional`: APG advises against `aria-describedby` on Dialog
* when content is rich (lists, tables). The attr exists in the contract but
* is deliberately omitted in those cases.
*/
export type MorfoSeverity = 'required' | 'recommended' | 'optional';
// ── Part kind ─────────────────────────────────────────────────────────────
/**
* Distinguishes parts that are public API (consumer composes them,
* docs render them, eidos styles them) from parts that are internal
* coordinators (provider context, focus guards, portal roots, SafePolygon
* trackers). Virtual parts are ignored by the contract validator and
* hidden from docs.
*/
export type MorfoPartKind = 'public' | 'virtual';
// ── Data attribute ────────────────────────────────────────────────────────
/**
* A data attribute emitted on a part's DOM element. The contract for eidos
* selectors and sema semantic channels.
*
* When `values` is present, the attr is enum-valued. When omitted, the attr
* is a presence flag (present or absent, no value).
*/
export interface MorfoData {
/** Attribute name including the `data-` prefix, e.g. `"data-state"`. */
attr: string;
/** Complete set of valid values. Omit for presence flags. */
values?: string[];
/** When this attr is emitted. Defaults to `'always'`. */
condition?: MorfoCondition;
/** Validator severity. Defaults to `'required'`. */
severity?: MorfoSeverity;
}
// ── ARIA entry ────────────────────────────────────────────────────────────
/**
* A single ARIA attribute declaration on a part. The contract for
* accessibility tooling and assistive technology expectations.
*/
export interface MorfoAriaEntry {
/** ARIA attribute name, e.g. `"aria-expanded"`. Includes `role` as a pseudo-ARIA attr. */
attr: string;
/** Source semantic of the value (see `MorfoAriaValue`). */
value: MorfoAriaValue;
/** When this attr is emitted. Defaults to `'always'`. */
condition?: MorfoCondition;
/** Validator severity. Defaults to `'required'`. */
severity?: MorfoSeverity;
}
// ── Keyboard ──────────────────────────────────────────────────────────────
/**
* A keyboard interaction applicable to a part.
*
* `key` is the KeyboardEvent.key value, optionally with modifier prefixes
* separated by `+`: `"Escape"`, `"Shift+Tab"`, `"Ctrl+A"`.
*
* `action` is a semantic identifier: `"close"`, `"activate"`, `"next-item"`.
* Not a JS function — just documentation + cross-component consistency.
*/
export interface MorfoKeyboard {
/** KeyboardEvent.key with optional modifier prefix, e.g. `"Escape"`, `"Shift+Tab"`. */
key: string;
/** Semantic action identifier. Keep consistent across components for the same intent. */
action: string;
/** When this keyboard handler is active. Defaults to `'always'` (whenever the part has focus). */
condition?: MorfoCondition;
}
// ── Focus policy ──────────────────────────────────────────────────────────
/**
* Focus coordination for overlay / composite components.
*
* Not every component has one — a Tooltip trigger doesn't trap focus, a
* Dialog does. When omitted, the component has no special focus policy.
*
* Critical for overlay components per WAI-ARIA APG and React Aria:
* keyboard + ARIA alone are incomplete without focus discipline.
*/
export interface MorfoFocus {
/**
* What receives focus when the component activates.
*
* `'first-focusable'` — first focusable descendant of the primary part
* `'trigger'` — the component's trigger element
* `{ partRef: <kebab> }`— a specific part by kebab name
*/
initial?: 'first-focusable' | 'trigger' | { partRef: string };
/** Whether Tab / Shift+Tab are trapped within the component while active. */
trap?: boolean;
/**
* Where focus goes when the component deactivates.
*
* `'trigger'` — back to the trigger (standard modal behaviour)
* `'previous'` — to whatever had focus before activation
* `{ partRef: <kebab> }`— a specific part by kebab name
*/
return?: 'trigger' | 'previous' | { partRef: string };
/** Whether focus is restored on unmount (vs dismissed state). Defaults to `true`. */
restore?: boolean;
}
// ── Part ──────────────────────────────────────────────────────────────────
/**
* A single part of a component. Parts compose into a tree.
*
* Every `kebab` must be unique within the enclosing `Morfo` — flat
* namespace, not path-scoped. Validator enforces uniqueness at build.
*/
export interface MorfoPart {
/** PascalCase name exposed as a soma component, e.g. `"Trigger"`. */
name: string;
/**
* kebab-case matching `createAttrs({ parts: [...] })` in the provider.
* Used as the data-attr suffix: `data-{component}-{kebab}`.
* Unique across the whole morfo (no nested path namespacing).
*/
kebab: string;
/** Public API part vs internal coordinator (see `MorfoPartKind`). */
kind: MorfoPartKind;
/**
* Element the provider renders by default. Advisory — consumer can
* override via `child` snippet, which is why `role` is emitted
* explicitly alongside.
*/
defaultElement: MorfoElement;
/**
* Explicit ARIA role the provider emits regardless of `defaultElement`.
* Guarantees semantic correctness under polymorphism (if consumer
* renders as `<a>` via `child`, the role is still correct).
*
* Omit for parts whose role is inherent to their element (e.g. `<button>`
* naturally has `role=button`) or parts that don't need an explicit role.
*/
role?: string;
/** Whether the part is required in a valid composition. */
optional: boolean;
/**
* Whether the component supports nesting itself (e.g. Dialog inside
* Dialog, Menu inside Menu). Documentation flag for consumers + hint to
* eidos that CSS selectors may need `:not([data-nested])` scoping.
*/
supportsNesting?: boolean;
/**
* States the part can be in. Referenced by `MorfoAriaValue.kind === 'stateRef'`
* and by `MorfoCondition` discriminators (`state-equals`).
*/
states?: string[];
/** Data attributes emitted on this part's DOM element. */
data: MorfoData[];
/** ARIA attributes emitted on this part's DOM element. */
aria: MorfoAriaEntry[];
/** Keyboard interactions relevant when focus is on (or within) this part. */
keyboard?: MorfoKeyboard[];
/** Nested parts (e.g. `Accordion.Item` contains `Header`, `Trigger`, `Content`). */
parts?: MorfoPart[];
}
// ── Root ──────────────────────────────────────────────────────────────────
/**
* The top-level contract for a component.
*
* Minimal by design. Editorial content (summary, comparison table,
* when-to-use prose) lives in the component's README, not here.
* Props live in the component's `types.ts` with JSDoc. Translations
* live in `langs.ts`.
*/
export interface Morfo {
/** Component display name, PascalCase. */
name: string;
/** kebab-case component identifier. Must match `createAttrs({ component })`. */
kebab: string;
/** Layers that implement this component. At minimum `['soma']`. */
scope: Layer[];
/** WAI-ARIA APG pattern URL when the component implements a formal pattern. */
apg?: string;
/**
* Focus policy for overlay / composite components. Omit when the
* component has no special focus coordination (plain controls).
*/
focus?: MorfoFocus;
/** The component's part tree. */
parts: MorfoPart[];
}
// ── Builder helpers ───────────────────────────────────────────────────────
/**
* Builders for `MorfoAriaValue`. Use these instead of the raw object literal
* for brevity and to keep the discriminant hidden from the call site.
*
* ```ts
* aria: [
* { attr: 'aria-expanded', value: v.stateRef('open') },
* { attr: 'aria-labelledby', value: v.partRef('title') },
* { attr: 'aria-haspopup', value: v.literal('dialog') }
* ]
* ```
*/
export const v = {
literal: (value: string): MorfoAriaValue => ({ kind: 'literal', value }),
stateRef: (state: string): MorfoAriaValue => ({ kind: 'stateRef', state }),
partRef: (target: string): MorfoAriaValue => ({ kind: 'partRef', target }),
propRef: (prop: string): MorfoAriaValue => ({ kind: 'propRef', prop }),
translationRef: (key: string): MorfoAriaValue => ({ kind: 'translationRef', key })
} as const;

@ -0,0 +1,246 @@
# SemaUIX — Capa Sema: Contexto para el diseño de Soma
Este documento resume el diseño de la capa semántica (Sema) para que sirva de contexto en la implementación de los componentes de Soma. **No hay que implementar Sema todavía.** El objetivo es que Soma se diseñe de forma que Sema pueda engancharse después sin fricción.
---
## Arquitectura en tres capas
SemaUIX tiene tres capas independientes que se coordinan exclusivamente a través del DOM estándar:
- **Soma** — comportamiento, estado, accesibilidad (HTML/Svelte/React/Vue). Produce DOM con atributos estándar.
- **Eidos** — presentación visual (CSS). Consume los atributos de Soma para estilar.
- **Sema** — semántica perceptivo-afectiva (`.csem` + JSON). Consume los mismos atributos para aplicar canales sensoriales (motion, sound, color, depth, form, presence).
**Principio clave:** cada capa expone un artefacto distinto y ninguna conoce a las otras. Soma no sabe que Sema existe. Eidos no sabe que Sema existe. Sema lee lo que Soma produce en el DOM.
---
## Qué necesita Soma exponer para que Sema funcione después
**Nada especial.** Lo que Soma ya necesita para que Eidos pueda estilar es exactamente lo que Sema va a consumir. No hay atributos "para Sema".
La disciplina es una sola: **todo momento perceptivamente significativo del componente debe ser observable desde fuera del componente**, como:
1. **Atributos DOM declarativos** sobre el estado (`data-state`, `data-orientation`, `data-disabled`, etc.)
2. **Eventos DOM estándar** que burbujean normalmente (click, change, focus, pointerdown/up)
3. **ARIA roles y atributos** correctos
Si un momento perceptivamente relevante ocurre solo como estado interno de JS que no se refleja en el DOM, Sema no podrá engancharse. Esta es la única regla dura.
---
## Convenciones de atributos que Soma debe seguir
El patrón observado en el Accordion ya implementado es correcto y debe mantenerse en todos los componentes:
### Identificación de partes
- `data-{component}` en el Provider/root (ej: `data-accordion`)
- `data-{component}-{part}` en cada parte (ej: `data-accordion-trigger`, `data-accordion-content`)
### Estado del componente
- `data-state` con valores semánticamente claros: `open | closed`, `active | inactive`, `checked | unchecked`, `on | off`, `loading | idle | success | error`, etc.
- `data-disabled` (presencia como flag) cuando aplique
- `data-orientation` (`horizontal | vertical`) cuando aplique
- Otros `data-*` específicos que reflejen propiedades del modelo
### Fases de transición (cuando las haya)
- `data-starting-style` — presente 1 frame al inicio de una transición de entrada
- `data-ending-style` — presente durante transición de salida
Estos dos marcadores son especialmente valiosos para Sema porque permiten sincronización temporal precisa entre la coreografía visual de Eidos y la semántica perceptiva.
### Estado causal (nuevo, importante)
Para componentes donde la salida puede depender de cómo se llegó a ella, exponer un atributo que registre la última acción significativa:
- `data-last-action` en el nodo contextual (ej: un Dialog que puede cerrarse por save/cancel/dismiss/fail)
**Ejemplo concreto:** un Dialog debe actualizar `data-last-action="saved" | "cancelled" | "dismissed" | "failed"` antes de cambiar `data-state` a `closed`. Esto permite que Sema exprese la salida con distinta semántica según la acción causante, sin necesidad de wrappers ni props semánticas.
Si un componente tiene múltiples caminos de salida con significado distinto, debe tener `data-last-action` (o equivalente). Si todas las salidas son iguales semánticamente, no lo necesita.
---
## Asimetría enter/exit
Muchos componentes tienen transiciones asimétricas: la entrada es unívoca (un modal se abre de una manera), pero la salida depende del contexto (se cerró con éxito, se canceló, falló, etc.).
**Implicación para Soma:** cualquier componente con transiciones debe permitir distinguir sus fases de entrada y salida a través del DOM. La combinación `data-state` + `data-last-action` + `data-starting-style` / `data-ending-style` cubre todos los casos.
No hace falta modelar esta asimetría explícitamente en el código del componente — basta con que los atributos reflejen la verdad del modelo en cada momento.
---
## Plano micro vs plano macro
Sema opera en dos planos simultáneos que Soma debe respetar sin mezclar:
- **Plano micro** — la expresión perceptiva de un control individual al interactuar con él (el feedback táctil de un botón al pulsarlo).
- **Plano macro** — la expresión perceptiva de una región al transitar entre estados (un dialog que se abre/cierra, un accordion que se expande).
**Implicación para Soma:** un componente compuesto (como Dialog con botones dentro, o Accordion con triggers y content) debe exponer estado y eventos tanto del control individual como del contenedor. No reducir todo a un nivel.
Ejemplo: en un Dialog con botón "Save":
- El Button debe emitir su propio click event (micro — feedback táctil)
- El Dialog debe actualizar `data-state` y `data-last-action` como consecuencia (macro — cambio de régimen con causa)
Ambos planos son independientes y Sema los modela por separado.
---
## Composición y jerarquía
Cuando un componente tiene múltiples partes anidadas (Provider, Item, Header, Trigger, Content), cada parte tiene un rol funcional que Sema va a respetar:
- **Provider** — orquestador sin presencia perceptiva propia
- **Item / Región agrupadora** — la unidad con ciclo de vida (aquí vivirá el enter/exit semántico)
- **Header / Wrapper ARIA** — sin presencia perceptiva propia
- **Trigger** — el control activable (aquí vive el feedback táctil)
- **Content** — el contenido que aparece/desaparece (aquí vive la realización física de la transición)
**Implicación para Soma:** mantener las partes bien separadas como en el ejemplo de Accordion. No colapsar roles (ej: no hacer que el Trigger sea también Header; no mezclar Item y Content).
---
## Especificidad de selectores
Sema va a targetear componentes con selectores CSS sobre los atributos `data-*`. Por tanto:
- Los atributos deben ser **estables y predecibles** (no generarlos aleatoriamente, no prefijarlos con hashes)
- Los valores deben ser **enumerables y documentados** (no strings arbitrarios)
- Los atributos no deben **desaparecer y reaparecer innecesariamente** (mejor cambiar de valor que eliminarse)
El contrato debe ser lo suficientemente estable como para que un selector tipo `[data-accordion-content][data-state="open"]` sea fiable a lo largo del ciclo de vida.
---
## Eventos que Sema va a escuchar
Como referencia, estos son los tipos de eventos que el engine de Sema va a observar. Soma no tiene que emitir nada especial — basta con que el DOM funcione como es estándar:
| Plano | Evento | Uso por Sema |
|---|---|---|
| DOM estándar | `click`, `keydown(Enter/Space)` | feedback táctil, completion, destruction |
| DOM estándar | `pointerdown/move/up` | manipulation (drag & drop) |
| DOM estándar | `focus-visible` | navigation táctil por teclado |
| DOM estándar | `change`, `input` | selection, feedback de input |
| Mutaciones DOM | `data-state` cambia | enter/exit de context, revelation, expansion |
| Mutaciones DOM | `data-starting-style` / `data-ending-style` | sincronización fina con transición visual |
| Mutaciones DOM | `data-last-action` cambia | teñir la salida con la acción causante |
---
## Principios operativos durante la implementación de Soma
1. **No añadir atributos especulativamente "para Sema".** Todo atributo debe justificarse por su propio uso en Soma o Eidos. Si Sema necesita algo que no está, se añadirá con criterio en la auditoría posterior.
2. **No modelar semántica perceptiva dentro de Soma.** Nada de props `intent`, `variant` perceptiva, `feedback`. La estética afectiva no es responsabilidad de Soma.
3. **Exponer los cambios de estado del modelo como mutaciones de atributos DOM, no solo como estado interno de JS.** Si un dialog tiene `open = false` internamente, que el DOM lo refleje como `data-state="closed"`.
4. **Cuando una transición puede ocurrir por razones semánticamente distintas, reflejar la razón en el DOM antes de iniciar la transición.** El orden correcto es: (1) actualizar `data-last-action`, (2) cambiar `data-state`, (3) dejar que transicione.
5. **Mantener consistencia de naming entre componentes.** Si Accordion usa `data-state="open|closed"`, Dialog y Drawer deben usar los mismos valores. No inventar `data-state="visible|hidden"` para uno y `"open|closed"` para otro.
6. **Alinear terminología con OpenUI (W3C Community Group) donde sea gratuito.** Nombres como Trigger, Provider, Item, Content son convenciones compartidas. Divergir solo cuando haya razón específica.
---
## Qué NO hay que hacer durante Soma
- **No crear un wrapper `<Sema>`.** La capa Sema no se expresa en el markup. Vive en archivos `.csem` separados que se escribirán después.
- **No añadir props semánticas a los componentes.** Nada de `<Button intent="threat">`. La semántica se aplicará por selectores CSS desde `.csem` más tarde.
- **No mezclar Soma con Eidos en props.** Props como `variant="danger"` o `color="red"` son Eidos. Soma debe ser agnóstico de estilo.
- **No emitir eventos customizados con nombres propios de Sema.** Usar eventos DOM estándar y atributos DOM. Si un componente emite `on:save`, está bien para su API, pero el cambio de estado debe reflejarse también en el DOM.
---
## Checklist por componente
Al diseñar cada componente, verificar:
- [ ] ¿Cada parte relevante tiene su atributo `data-{component}-{part}`?
- [ ] ¿El estado del componente está en `data-state` con valores enumerables?
- [ ] ¿Las transiciones tienen marcadores `data-starting-style` / `data-ending-style` si aplica?
- [ ] Si el componente puede cerrarse/completarse por múltiples razones semánticamente distintas, ¿expone `data-last-action`?
- [ ] ¿Los cambios de estado interno del modelo se reflejan en el DOM?
- [ ] ¿Los eventos DOM estándar burbujean correctamente sin preventDefault/stopPropagation innecesarios?
- [ ] ¿Las partes están separadas sin colapso de roles?
- [ ] ¿Los valores de atributos son consistentes con otros componentes de la librería?
---
## Referencia mínima del modelo de Sema (para contexto)
Sema, cuando se implemente, será:
- Un archivo `.csem` por componente (sintaxis CSS, consumido por el engine de Sema, no por el browser)
- Un `sema-map.json` global (valores de canal: frecuencias, duraciones, colores por combinación semántica × intent)
- Un engine runtime ligero que lee el CSSOM de `.csem`, escucha eventos y mutaciones DOM, y aplica canales (Web Animations API + Web Audio API)
- Una API imperativa de escape (`sema.trigger`, `sema.override`) para casos no expresables declarativamente
Ejemplo ilustrativo (no hay que implementarlo todavía):
```css
/* accordion.csem — se escribirá después, solo para referencia */
[data-accordion-trigger] {
--sema-type: feedback;
--sema-intent: neutral;
}
[data-accordion-content] {
--sema-enter: expansion;
--sema-exit: expansion;
}
```
Lo único que Soma tiene que garantizar es que `[data-accordion-trigger]` y `[data-accordion-content]` existen con sus `data-state` correctos. Lo demás es problema de Sema, más tarde.
---
## Gramática de Sema (referencia para la auditoría posterior)
### Las 14 semánticas
**Valenciales (aceptan modulación por intent):**
feedback, selection, manipulation, reversion, attention, notification, emphasis, completion, destruction
**Transicionales (sin intent — cambio de régimen puro):**
revelation, context, expansion, navigation, persistence
### Los 5 intents (solo para semánticas valenciales)
Derivados del espacio circumplejo de Russell (1980):
- `threat` — valencia muy negativa, arousal alto (peligro, irreversible)
- `risk` — valencia negativa, arousal medio-bajo (precaución, fricción)
- `neutral` — valencia neutra, arousal bajo (operación rutinaria)
- `affirm` — valencia leve positiva, arousal bajo (correcto, adecuado)
- `fulfill` — valencia positiva, arousal medio-alto (éxito, logro, encaje)
### Los 6 canales perceptivos
motion, sound, color, depth, form, presence
### Custom properties de `.csem`
```
--sema-type /* nombre de la semántica */
--sema-intent /* nombre del intent (hereda por cascada) */
--sema-enter /* opcional: "type" o "type intent" para la entrada */
--sema-exit /* opcional: "type" o "type intent" para la salida */
```
Si no hay `--sema-enter` o `--sema-exit`, se usa la pareja `--sema-type` + `--sema-intent` para ambas fases.
---
## Resumen ejecutivo
**Para el diseño e implementación de Soma:**
Diseña Soma como si Sema no existiera. Pero sigue disciplinadamente una regla: **todo estado y toda transición perceptivamente relevante debe ser observable desde el DOM** mediante atributos estables y eventos estándar. Si cumples esto, Sema se enganchará después sin requerir cambios en Soma.
El ejemplo de Accordion ya implementado es la plantilla correcta. Repítelo en el resto de componentes.

@ -18,12 +18,17 @@ Document what soma includes and what it skips (with reason).
### 2. Verify membership criteria
The component must have BOTH:
The component must meet ALL of these:
- Composition of parts (2+ sub-components communicating via context)
- Complex behavior (keyboard nav, focus management, floating, ARIA relationships, state machines, drag, or form integration)
- **WAI-ARIA pattern or semantic role** — the component implements a pattern from the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/) (Dialog, Combobox, Treegrid, Feed, Tabs, Toolbar, …) OR a canonical ARIA role (`role="status"`, `role="meter"`, `role="progressbar"`, `role="searchbox"`, …). If the browser's native HTML gives you the right role + keyboard model with no extra behavior required (e.g. `<a>` for Link, `<hr>` for Separator, `<img>` for Image), the primitive belongs in **air**, not soma.
- **Composition of parts** — 2+ sub-components communicating via context (Provider + Trigger + Content, Provider + Row + Cell, etc.). A single-DOM wrapper is air-level styling, not headless behavior.
- **Complex behavior** — keyboard navigation, focus management, floating, ARIA relationships, state machines, drag, form integration, or live-region coordination. Adding `role="…"` + `aria-label` to a single element is not enough.
If it only has one or neither → it's air-native, not soma.
If it fails any of these → it's air-native, not soma.
Accepted exceptions (composition criterion waived when WAI-ARIA defines a tight contract):
- `Announce` — a live-region primitive per WAI-ARIA 1.2 live regions; meets complex-behavior via dual-region A/B dispatch + auto-clear + priority routing, even though its surface is a single region per priority.
- `Progress` / `Meter` — canonical single-element roles with computed ARIA values and CSS custom properties for the decorative indicator; shipped with an `Indicator` part so consumers have two slots (the role host and the fill), crossing the composition threshold.
## File Structure
@ -281,7 +286,12 @@ Pattern: `soma-{component}-{part}-{uid}`. Always descriptive.
state readout. Not a gallery of canned snippets.
[ ] 22. Add link to /test/soma/+page.svelte index
[ ] 23. svelte-check: 0 errors
[ ] 24. Test in browser — verify HTTP 200 and exercise the interactive controls
[ ] 24. Run `npm run smoke` — all routes pass, including the new one.
Smoke script catches runtime errors that svelte-check + HTTP 200 miss:
`pageerror` (uncaught throws during hydration), `console.error`,
translation-key-not-found, and `Context "X" not found`. HTTP 200 alone
is SSR — it does NOT exercise client hydration. Interactively exercise
every control in DevTools afterwards.
[ ] 25. Document gaps vs reference libraries
[ ] 26. Create README.md in the component folder — anatomy, props, data-attrs,
keyboard, ARIA, and at least one composition example. Follow the
@ -345,6 +355,37 @@ Pattern: `soma-{component}-{part}-{uid}`. Always descriptive.
reference libraries that ship it, why it's deferred, and a cost
estimate. This becomes the PR backlog — no feature dies in a
footnote.
# Translation + topology audits — mandatory (A34)
[ ] 37. **Translation namespace grep.** After touching any lang-related
code in a component or demo, grep the repo for `soma\.` inside
quoted string literals outside `.md` files:
grep -n "['\"]soma\.[a-z-]" src --include=!*.md
soma's translation namespace is ALWAYS `components.{kebab-name}.*`
(or `common.*` for shared strings). Any `langs.t('soma.…')` /
`langs.ts('soma.…')` is a bug and will log `Translation key not
found` at runtime. Always route through idlangref constants from
the component's `langs.ts` (A3).
[ ] 38. **DOM topology vs `.require()` audit.** For every `X.require()`
call in the provider file, answer: "is the required provider's
component a DOM ancestor of the consumer of my component?". If
the answer is NO, `.require()` WILL throw at runtime — context
only flows to descendants. The classic trap is HTML constraints:
`<tr>` cannot nest `<tr>`, so `Table.RowDetail` (a sibling `<tr>`)
cannot `TableRowProvider.require()` even though it "belongs" to a
row conceptually. Fix by: (a) receive the object via prop, (b) use
`.get()` + fallback, or (c) restructure the DOM. Svelte-check
never catches this — smoke does.
[ ] 39. **Smoke script is part of done.** A component is not done until
`npm run smoke` (with `npm run dev` running) reports PASS for
its new route AND all existing routes. Regressions in unrelated
components caused by translation-table edits, lang-key typos, or
core context changes must be caught here before declaring the
work complete.
```
## Common Mistakes
@ -955,3 +996,35 @@ This is O(N) per mutation (copies the whole Map), looks like a bug to future rea
- VirtualList dynamic heights (2026-04-19) — `ResizeObserver` wrote sizes to `$state(Map)` cache, `offsets` derived read `.get(key)` and never re-ran. Every row stayed at the 60 px estimate.
- Form `touched` + `registry` Maps — `setFieldTouched` / `registerField` use `.set` direct, but `isTouched` / `isDirty` / `firstInvalidField` derivations read `.values()` / `.keys()` / `.has()`. Same bug, harder to notice because `values` + `errors` state cover most user-visible flows.
- Combobox `labelRegistry` — used the "clone-and-reassign" workaround. Works today but fragile.
### A34. Verification before "done": translation namespace + DOM topology + smoke
Three classes of bug cannot be caught by `svelte-check` or HTTP 200 — they all require either a runtime grep or a real browser. They must be run every time a component, demo, or lang entry is touched.
1. **Translation namespace grep.** soma's namespace is `components.{kebab-name}.*` (or `common.*` for shared strings). Any `soma.…` or other prefix inside a quoted translation path is a bug that logs `[lang] Translation key not found` at runtime. Check with:
```sh
grep -rn "['\"]soma\.[a-z-]" src --include=!*.md
```
Fixes: route through idlangref constants from the component's `langs.ts` — don't build translation paths via template strings in demos or providers. If a demo needs a dynamic path helper (like `tt('columns', 'Columns')`), hard-code the namespace prefix `components.{name}.` correctly.
2. **DOM topology vs `.require()` audit.** Svelte's context (via `getContext`) flows only to descendants. Every `X.require()` call must be reachable from a descendant of the component that set the context. The trap is HTML: `<tr>` cannot nest `<tr>`, so `Table.RowDetail` (rendered as a sibling `<tr>` of `Table.Row`) cannot `TableRowProvider.require()`. Use one of:
- Receive the object via a prop (consumer passes `{row}` or similar explicitly). This is consistent with `<Table.Row {row}>` / `<Table.Cell {cell}>` — Table already requires explicit objects.
- Use `.get()` + a fallback for truly optional context (e.g. `FeedProvider.get()` inside `Feed.Sentinel`, which can live outside a Feed).
- Restructure so the child actually lives inside the parent's subtree.
Svelte-check never catches this — the error is thrown on mount. Smoke catches it.
3. **`npm run smoke`.** The smoke script (`scripts/smoke-check.mjs`) walks every `/test/soma/*` route with Playwright and surfaces:
- `pageerror` (uncaught throw during hydration — e.g. `Context "X" not found`)
- `console.error` (runtime exceptions caught by the framework)
- Translation key missing warnings
- `[soma]` context-not-found warnings
Run it before declaring a component done. Regressions in unrelated components caused by lang-table edits or core changes surface here too. `npm run smoke` requires `npm run dev` running in another terminal and auto-detects the port on 5173–5180.
**Incidents:**
- Table demo (2026-04-19) — `tt('columns')` built `soma.table.columns` instead of `components.table.columns`. Dozens of `Translation key not found` logs, silent in svelte-check.
- Pagination item `aria-label` (pre-existing) — provider called `langs.t('soma.pagination.page')` directly. Same class of bug; fixed by migrating to an idlangref constant (`PAGINATION_LANGS.PAGE`).
- `Table.RowDetail` (2026-04-19) — first version called `TableRowProvider.require()`. Threw `Context "TableRow" not found` because `<tr>` cannot nest and the Detail is a DOM sibling, not descendant. Fixed by taking `{row}` as prop + deriving the `aria-controls` id deterministically from `row.id`.

@ -1,31 +1,52 @@
/**
* Data contract system for soma components.
* Validates that data-* attributes match the registered contract.
* Only runs in development mode.
*
* Contracts are derived from a component's morfo via `registerContract(morfo)`.
* `assertContract` validates emitted `data-*` values against the contract
* in development; it is a no-op in production.
*/
export interface DataAttr {
import type { Morfo, MorfoPart } from '../../morfo/types';
/** Internal shape of a registered contract. Not consumer-facing. */
interface DataAttrRecord {
attr: string;
values?: readonly string[];
description: string;
since?: string;
}
export interface ComponentContract {
interface RegisteredContract {
name: string;
version: number;
parts: Record<string, DataAttr[]>;
parts: Record<string, DataAttrRecord[]>;
}
const CONTRACTS = new Map<string, ComponentContract>();
const CONTRACTS = new Map<string, RegisteredContract>();
/** Register a component contract. Name is normalized to lowercase. */
export function registerContract(contract: ComponentContract): void {
CONTRACTS.set(contract.name.toLowerCase(), contract);
/** Flatten a morfo's recursive parts into `{ [kebab]: DataAttrRecord[] }`. */
function flattenMorfoParts(parts: readonly MorfoPart[]): Record<string, DataAttrRecord[]> {
const result: Record<string, DataAttrRecord[]> = {};
for (const p of parts) {
result[p.kebab] = p.data.map((d) => ({ attr: d.attr, values: d.values }));
if (p.parts && p.parts.length > 0) {
Object.assign(result, flattenMorfoParts(p.parts));
}
}
return result;
}
/**
* Register a component contract from its morfo. The morfo is the single
* source of truth — this function only caches the lookup table used by
* `assertContract` at runtime.
*/
export function registerContract(morfo: Morfo): void {
CONTRACTS.set(morfo.kebab.toLowerCase(), {
name: morfo.kebab,
parts: flattenMorfoParts(morfo.parts)
});
}
/** Get a registered contract. Name is normalized to lowercase. */
export function getContract(name: string): ComponentContract | undefined {
export function getContract(name: string): RegisteredContract | undefined {
return CONTRACTS.get(name.toLowerCase());
}

@ -1,36 +1,51 @@
import type { Morfo, MorfoPart } from '../../morfo/types';
/**
* Creates data-attribute strings for a soma component.
* Creates data-attribute names for a soma component from its morfo.
*
* ```ts
* import { dialogMorfo } from '$uix/morfo/components/dialog';
* const attrs = createAttrs(dialogMorfo);
* attrs.root // "data-dialog"
* attrs.trigger // "data-dialog-trigger"
* attrs.selector('content') // "[data-dialog-content]"
* ```
*
* Usage:
* const attrs = createAttrs({ component: 'accordion', parts: ['root', 'item', 'trigger', 'content'] as const });
* attrs.root // "data-accordion"
* attrs.item // "data-accordion-item"
* attrs.trigger // "data-accordion-trigger"
* attrs.selector('content') // "[data-accordion-content]"
* Morfo is the single cross-layer source of truth — changes to part names
* or data-attr structure happen in the morfo, never here.
*/
export type AttrsReturn<T extends readonly string[]> = {
[K in T[number]]: string;
} & {
selector: (part: T[number]) => string;
export type AttrsReturn = {
readonly [part: string]: string | ((part: string) => string);
selector: (part: string) => string;
};
export function createAttrs<const T extends readonly string[]>(config: {
component: string;
parts: T;
}): AttrsReturn<T> {
const { component, parts } = config;
/** Walk a morfo's recursive part tree and collect every unique kebab. */
function collectKebabs(parts: readonly MorfoPart[]): string[] {
const result: string[] = [];
for (const p of parts) {
result.push(p.kebab);
if (p.parts && p.parts.length > 0) {
result.push(...collectKebabs(p.parts));
}
}
return result;
}
export function createAttrs(morfo: Morfo): AttrsReturn {
const component = morfo.kebab;
const parts = collectKebabs(morfo.parts);
const entries = parts.map((part) => {
// 'root' generates data-{component} (no -root suffix)
// 'root' generates data-{component} (no -root suffix).
const attr = part === 'root' ? `data-${component}` : `data-${component}-${part}`;
return [part, attr] as const;
});
const attrs = Object.fromEntries(entries) as Record<T[number], string>;
const attrs = Object.fromEntries(entries) as Record<string, string>;
function selector(part: T[number]): string {
function selector(part: string): string {
return `[${attrs[part]}]`;
}
return { ...attrs, selector } as AttrsReturn<T>;
return { ...attrs, selector } as AttrsReturn;
}

@ -1,11 +1,5 @@
export { createAttrs, type AttrsReturn } from './create-attrs';
export {
assertContract,
registerContract,
getContract,
type ComponentContract,
type DataAttr
} from './contracts';
export { assertContract, registerContract, getContract } from './contracts';
export {
boolToStr,
boolToEmptyStrOrUndef,

@ -0,0 +1,178 @@
# Announce
A headless primitive for screen reader announcements. The Provider mounts dual internal live regions (polite + assertive) and exposes an imperative `announce(message, priority, timeout)` API. Use for save confirmations, form error summaries, chat messages, and anything that a sighted user sees visually but a screen reader user would otherwise miss.
Mount `Announce.Provider` **once** near the app root — typically in the root layout. From any descendant, call `announce()` via the snippet props or via `AnnounceProvider.require().announce(...)`.
## Anatomy
```svelte
<!-- Root layout -->
<Announce.Provider>
{#snippet children({ announce })}
<MyApp {announce} />
{/snippet}
</Announce.Provider>
```
Inside any descendant:
```svelte
<script>
import { AnnounceProvider } from '$soma/components/announce';
const announce = AnnounceProvider.require().announce;
</script>
<button onclick={() => announce('Saved!', 'polite')}>Save</button>
```
Or a purpose-specific visible region:
```svelte
<Announce.Region role="log" message={chatMessage} visuallyHidden={false} />
```
## Parts
| Part | Element | Description |
| ---------- | ------- | --------------------------------------------------------------------------- |
| `Provider` | `<div>` | Root context. Renders internal dual live regions. Exposes `announce()`. |
| `Region` | `<div>` | Declarative live region for persistent, purpose-specific announcements. |
## Props
### `Provider`
| Prop | Type | Default | Description |
| ---------------- | --------- | ------- | -------------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `defaultTimeout` | `number` | `1000` | Ms before a posted message is cleared. Clearing is what allows the same text to fire twice. |
Snippet props: `{ announce, clear }`.
### `Region`
| Prop | Type | Default | Description |
| ----------------- | ---------------------------------------- | ------------------- | ---------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `role` | `'status' \| 'alert' \| 'log' \| 'timer'` | `'status'` | ARIA role. Each implies a default `aria-live`. |
| `aria-live` | `'polite' \| 'assertive'` | derived from `role` | Override the implicit urgency. |
| `aria-atomic` | `boolean` | `true` | Whether AT reads the full region or the diff. |
| `aria-relevant` | `string` | `'additions text'` | What mutations trigger the announcement. |
| `message` | `string` | — | Bound reactive text. Changes are announced. |
| `visuallyHidden` | `boolean` | `true` | Whether to apply the SR-only CSS clip. Set `false` for visible regions. |
## ARIA
| Part | Attribute | Value |
| ----------------- | --------------------- | ------------------------------------------------------- |
| Provider (inner) | `role` | `status` (polite) + `alert` (assertive) |
| Provider (inner) | `aria-live` | `polite` / `assertive` |
| Provider (inner) | `aria-atomic` | `true` |
| Region | `role` | `status` \| `alert` \| `log` \| `timer` |
| Region | `aria-live` | Resolved from `role` unless overridden |
| Region | `aria-atomic` | As prop |
| Region | `aria-relevant` | As prop |
### Role semantics
- **`status`** (polite) — non-critical state changes. Default. Use for "Saved", "Copied".
- **`alert`** (assertive) — urgent. Interrupts current speech. Use sparingly — for errors, session expiry.
- **`log`** (polite) — running history (chat, activity). New content at the end.
- **`timer`** (polite) — time-sensitive. AT decides update cadence (don't ticker every second).
## Data Attributes
| Part | Attribute | Values |
| ------- | ----------------- | --------------------------- |
| Provider | `data-announce` | Always present |
| Region | `data-announce-region` | Always present |
| Region | `data-role` | `status \| alert \| log \| timer` |
| Region | `data-live` | `polite \| assertive` |
## Dual-region A/B trick
Screen readers dedupe consecutive identical strings — writing "Saved" twice in a row is silent. The Provider alternates between two internal regions (A/B) on each `announce()` call; the AT sees a change in the "other" region and re-reads the message.
## Global API (`createAnnouncer()`)
For utility modules that can't assume a `Provider` ancestor (toast libraries, form validation helpers, worker-response handlers), soma ships a singleton-style global announcer:
```ts
import { createAnnouncer } from '$soma/components/announce';
const { announce, clear } = createAnnouncer();
// From any handler, any module:
announce('Saved!', 'polite');
announce('Connection lost', 'assertive');
```
Behavior:
- Regions are created lazily on the **first** `announce()` call and attached to `document.body`.
- Regions are singletons per document — multiple `createAnnouncer()` calls share the same DOM nodes. No double-announces.
- Server-side rendered environments: the function is safe to call; it's a no-op when `document` is undefined.
- Independent from `Announce.Provider`. The two do not cross-post — mount the Provider if you want consistent cleanup tied to a subtree; use the global API for cross-cutting utilities.
A pre-built shared handle is exported as `announcer`:
```ts
import { announcer } from '$soma/components/announce';
announcer.announce('Hello', 'polite');
```
## Comparison
| Feature | Soma | Radix | Ark UI | react-aria | APG |
| ------------------------------------- | :--: | :---: | :----: | :--------: | :-: |
| Standalone component / API | ✅ | ❌ | ⚠️¹ | ✅ | N/A |
| `role=status` / `role=alert` regions | ✅ | — | ✅ | ✅ | ✅ |
| A/B toggle (repeat-announce) | ✅ | — | ✅ | ✅ | ✅ |
| Priority routing (polite/assertive) | ✅ | — | ✅ | ✅ | ✅ |
| Auto-clear timeout | ✅ | — | ✅ | ✅ | — |
| Imperative `announce()` via context | ✅ | — | ✅ | ✅ | — |
| Singleton-style global announcer | ✅ | — | ❌ | ✅ | — |
| Declarative `Region` variant | ✅ | — | ❌ | ❌ | — |
| `role=log` / `role=timer` | ✅ | — | ❌ | ❌ | ✅ |
| `aria-atomic` / `aria-relevant` opt | ✅ | — | ❌ | ❌ | ✅ |
| Translated default `aria-label` | ✅ | — | ❌ | ❌ | — |
¹ Ark uses an announcer built into ProgressCircle/DragDrop; no standalone API.
## Usage
### Save confirmation
```svelte
<script>
import { AnnounceProvider } from '$soma/components/announce';
const { announce } = AnnounceProvider.require();
async function save() {
await fetch('/api/save', { method: 'POST' });
announce('Changes saved.', 'polite');
}
</script>
<button onclick={save}>Save</button>
```
### Form error summary
```svelte
<Announce.Region role="alert" message={errorSummary} visuallyHidden={true} />
```
When `errorSummary` changes (e.g. after form validation), the new text is assertively announced.
### Chat log
```svelte
<Announce.Region role="log" visuallyHidden={false} class="chat-log">
{#each messages as m (m.id)}
<p><strong>{m.author}:</strong> {m.text}</p>
{/each}
</Announce.Region>
```
New messages are read politely in the order they appear.

@ -0,0 +1,211 @@
import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider';
import { createAttrs, registerContract, boolToStr } from '../../attrs';
import { type ActiveProps } from '../../reactive';
import { Soma } from '../../core/soma.svelte';
import { ANNOUNCE_LANGS } from './langs';
import type { AnnouncePriority, AnnounceRole } from './types';
const attrs = createAttrs({
component: 'announce',
parts: ['root', 'region'] as const
});
registerContract({
name: 'announce',
version: 1,
parts: {
root: [],
region: [
{
attr: 'data-role',
values: ['status', 'alert', 'log', 'timer'],
description: 'The ARIA live-region role'
},
{
attr: 'data-live',
values: ['polite', 'assertive'],
description: 'Resolved aria-live value (may differ from role default)'
}
]
}
});
/** Visually-hidden style kept in-sync with the WAI "sr-only" recipe. */
const VISUALLY_HIDDEN_STYLE = {
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
'white-space': 'nowrap',
border: '0'
} as const;
/** Default `aria-live` value for each role. */
const ROLE_LIVE_DEFAULTS: Record<AnnounceRole, AnnouncePriority> = {
status: 'polite',
alert: 'assertive',
log: 'polite',
timer: 'polite'
};
// ── Root provider ──────────────────────────────────────────────────────────
interface AnnounceOpts extends ProviderOpts, ActiveProps<{ defaultTimeout: number }> {}
export class AnnounceProvider extends Provider<AnnounceOpts> {
static readonly ctx = context<AnnounceProvider>('Announce');
static get(): AnnounceProvider | undefined {
return this.ctx.getOr(undefined) as AnnounceProvider | undefined;
}
static require(): AnnounceProvider {
return this.ctx.get();
}
static create(opts: AnnounceOpts) {
return new AnnounceProvider(opts);
}
readonly soma = Soma.get();
/** Current text for each internal region. Swap alternates so repeated announcements re-fire. */
politeA = $state('');
politeB = $state('');
assertiveA = $state('');
assertiveB = $state('');
private politeToggle = false;
private assertiveToggle = false;
private politeTimer: ReturnType<typeof setTimeout> | null = null;
private assertiveTimer: ReturnType<typeof setTimeout> | null = null;
private constructor(opts: AnnounceOpts) {
super(opts, 'Announce', 'root', attrs.root, AnnounceProvider.ctx);
$effect(() => {
return () => {
if (this.politeTimer) clearTimeout(this.politeTimer);
if (this.assertiveTimer) clearTimeout(this.assertiveTimer);
};
});
}
announce = (
message: string,
priority: AnnouncePriority = 'polite',
timeout?: number
): void => {
if (typeof window === 'undefined') return;
const clearAfter = timeout ?? this.opts.defaultTimeout.current;
if (priority === 'assertive') {
// Alternate between A/B so that identical consecutive messages
// still fire the screen reader (writing the same string into the
// same region is a no-op for most ATs).
this.assertiveToggle = !this.assertiveToggle;
if (this.assertiveToggle) {
this.assertiveA = message;
this.assertiveB = '';
} else {
this.assertiveA = '';
this.assertiveB = message;
}
if (this.assertiveTimer) clearTimeout(this.assertiveTimer);
this.assertiveTimer = setTimeout(() => {
this.assertiveA = '';
this.assertiveB = '';
this.assertiveTimer = null;
}, clearAfter);
} else {
this.politeToggle = !this.politeToggle;
if (this.politeToggle) {
this.politeA = message;
this.politeB = '';
} else {
this.politeA = '';
this.politeB = message;
}
if (this.politeTimer) clearTimeout(this.politeTimer);
this.politeTimer = setTimeout(() => {
this.politeA = '';
this.politeB = '';
this.politeTimer = null;
}, clearAfter);
}
};
clear = (): void => {
this.politeA = '';
this.politeB = '';
this.assertiveA = '';
this.assertiveB = '';
if (this.politeTimer) clearTimeout(this.politeTimer);
if (this.assertiveTimer) clearTimeout(this.assertiveTimer);
this.politeTimer = null;
this.assertiveTimer = null;
};
readonly snippetProps = $derived.by(() => ({
announce: this.announce,
clear: this.clear
}));
readonly internalRegionStyle = VISUALLY_HIDDEN_STYLE;
readonly politeLabel = $derived.by(() =>
this.soma?.langs.ts(ANNOUNCE_LANGS.LABEL) ?? 'Announcements'
);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps
} as const)
);
}
// ── Region ─────────────────────────────────────────────────────────────────
interface AnnounceRegionOpts
extends WithRefOpts,
ActiveProps<{
role: AnnounceRole;
ariaLive: AnnouncePriority | undefined;
ariaAtomic: boolean;
ariaRelevant: string;
visuallyHidden: boolean;
}> {}
export class AnnounceRegionProvider extends Provider<AnnounceRegionOpts> {
static create(opts: AnnounceRegionOpts) {
return new AnnounceRegionProvider(opts);
}
/** Optional — a Region can live outside a Provider (pure declarative use). */
readonly provider: AnnounceProvider | undefined;
private constructor(opts: AnnounceRegionOpts) {
super(opts, 'Announce', 'region', attrs.region);
this.provider = AnnounceProvider.get();
}
readonly resolvedLive = $derived.by(
() => this.opts.ariaLive.current ?? ROLE_LIVE_DEFAULTS[this.opts.role.current]
);
readonly props = $derived.by(() => {
const hidden = this.opts.visuallyHidden.current;
return this.assertProps({
...this.baseProps,
role: this.opts.role.current,
'aria-live': this.resolvedLive,
'aria-atomic': boolToStr(this.opts.ariaAtomic.current),
'aria-relevant': this.opts.ariaRelevant.current,
'data-role': this.opts.role.current,
'data-live': this.resolvedLive,
style: hidden ? VISUALLY_HIDDEN_STYLE : undefined
} as const);
});
}

@ -0,0 +1,47 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AnnounceRegionProvider } from '../announce-provider.svelte';
import type { AnnounceRegionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'announce-region'),
role = 'status',
'aria-live': ariaLive,
'aria-atomic': ariaAtomic = true,
'aria-relevant': ariaRelevant = 'additions text',
message,
visuallyHidden = true,
children,
child,
...restProps
}: AnnounceRegionProps = $props();
const provider = AnnounceRegionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
role: readableActive(() => role),
ariaLive: readableActive(() => ariaLive),
ariaAtomic: readableActive(() => ariaAtomic),
ariaRelevant: readableActive(() => ariaRelevant),
visuallyHidden: readableActive(() => visuallyHidden)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{#if message !== undefined}{message}{/if}
{@render children?.()}
</div>
{/if}

@ -0,0 +1,68 @@
<script lang="ts">
import { readableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AnnounceProvider } from '../announce-provider.svelte';
import type { AnnounceProps } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'announce'),
defaultTimeout = 1000,
children,
child,
...restProps
}: AnnounceProps = $props();
const provider = AnnounceProvider.create({
id: readableActive(() => id),
defaultTimeout: readableActive(() => defaultTimeout)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
<div
role="status"
aria-live="polite"
aria-atomic="true"
aria-relevant="additions text"
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
>
{provider.politeA}
</div>
<div
role="status"
aria-live="polite"
aria-atomic="true"
aria-relevant="additions text"
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
>
{provider.politeB}
</div>
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
aria-relevant="additions text"
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
>
{provider.assertiveA}
</div>
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
aria-relevant="additions text"
style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
>
{provider.assertiveB}
</div>
</div>
{/if}

@ -0,0 +1,13 @@
export { default as Provider } from './components/announce.svelte';
export { default as Region } from './components/announce-region.svelte';
export { createAnnouncer, announcer } from './global.svelte';
export type {
AnnounceProps as ProviderProps,
AnnounceRegionProps as RegionProps,
AnnounceProviderSnippetProps as ProviderSnippetProps,
AnnouncePriority,
AnnounceRole,
AnnounceApi
} from './types';

@ -0,0 +1,140 @@
import type { AnnouncePriority, AnnounceApi } from './types';
/**
* A global screen-reader announcer independent of `Announce.Provider`.
*
* Creates two hidden live regions (polite + assertive) attached to
* `document.body` lazily on the first `announce()` call. The regions are
* singletons per document, so multiple calls to `createAnnouncer()` share
* the same DOM nodes.
*
* Use this when:
* - A utility module needs to announce from outside the component tree.
* - You cannot (or prefer not to) mount `<Announce.Provider>` at the app root.
*
* When both a `createAnnouncer()`-backed call AND a mounted
* `Announce.Provider` exist, they are independent — messages do not
* cross-post. Prefer the Provider when available; the global API is a
* fallback.
*
* Server-side: `announce()` is a no-op (no `document`).
*/
const POLITE_ID = 'soma-announce-global-polite';
const ASSERTIVE_ID = 'soma-announce-global-assertive';
const VISUALLY_HIDDEN_STYLE =
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0';
type RegionPair = {
a: HTMLDivElement;
b: HTMLDivElement;
toggle: boolean;
timer: ReturnType<typeof setTimeout> | null;
};
function ensurePair(
politeOrAssertive: AnnouncePriority,
baseId: string
): RegionPair | null {
if (typeof document === 'undefined') return null;
const cache = (globalThis as typeof globalThis & {
__somaAnnouncer?: Map<string, RegionPair>;
});
if (!cache.__somaAnnouncer) cache.__somaAnnouncer = new Map();
let pair = cache.__somaAnnouncer.get(baseId);
if (pair) return pair;
const host = document.createElement('div');
host.id = baseId;
host.style.cssText = VISUALLY_HIDDEN_STYLE;
const role = politeOrAssertive === 'assertive' ? 'alert' : 'status';
const make = (suffix: string) => {
const el = document.createElement('div');
el.id = `${baseId}-${suffix}`;
el.setAttribute('role', role);
el.setAttribute('aria-live', politeOrAssertive);
el.setAttribute('aria-atomic', 'true');
el.setAttribute('aria-relevant', 'additions text');
return el;
};
const a = make('a');
const b = make('b');
host.appendChild(a);
host.appendChild(b);
document.body.appendChild(host);
pair = { a, b, toggle: false, timer: null };
cache.__somaAnnouncer.set(baseId, pair);
return pair;
}
function write(pair: RegionPair, text: string, timeout: number) {
pair.toggle = !pair.toggle;
if (pair.toggle) {
pair.a.textContent = text;
pair.b.textContent = '';
} else {
pair.a.textContent = '';
pair.b.textContent = text;
}
if (pair.timer) clearTimeout(pair.timer);
pair.timer = setTimeout(() => {
pair.a.textContent = '';
pair.b.textContent = '';
pair.timer = null;
}, timeout);
}
/**
* Returns the global announce/clear pair. Safe to call from any module — does
* not require a mounted `<Announce.Provider>`. Regions are created lazily on
* first call and persist for the lifetime of the document.
*
* ```ts
* import { createAnnouncer } from '$soma/components/announce';
*
* const { announce } = createAnnouncer();
* // From any handler:
* announce('Saved!', 'polite');
* ```
*
* @param defaultTimeout Ms before messages are cleared. @default 1000
*/
export function createAnnouncer(defaultTimeout = 1000): AnnounceApi {
const announce: AnnounceApi['announce'] = (message, priority = 'polite', timeout) => {
const baseId = priority === 'assertive' ? ASSERTIVE_ID : POLITE_ID;
const pair = ensurePair(priority, baseId);
if (!pair) return;
write(pair, message, timeout ?? defaultTimeout);
};
const clear: AnnounceApi['clear'] = () => {
if (typeof document === 'undefined') return;
const cache = (globalThis as typeof globalThis & {
__somaAnnouncer?: Map<string, RegionPair>;
}).__somaAnnouncer;
if (!cache) return;
for (const pair of cache.values()) {
pair.a.textContent = '';
pair.b.textContent = '';
if (pair.timer) clearTimeout(pair.timer);
pair.timer = null;
}
};
return { announce, clear };
}
/**
* Convenience: the shared singleton announcer. Equivalent to calling
* `createAnnouncer()` once and reusing the result. Lazy: does not touch the
* DOM until first `.announce()` call.
*/
export const announcer: AnnounceApi = {
announce: (msg, priority, timeout) => createAnnouncer().announce(msg, priority, timeout),
clear: () => createAnnouncer().clear()
};

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,10 @@
/**
* Idlangref constants for the Announce component.
*
* Announce is a headless primitive for screen reader live-region messages.
* Most strings are supplied by the consumer at announce time; only the
* optional default `aria-label` on declarative regions uses a translation.
*/
export const ANNOUNCE_LANGS = {
LABEL: '#?components.announce.label|Announcements'
} as const;

@ -0,0 +1,115 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
/** How urgently the message interrupts the user's current activity. */
export type AnnouncePriority = 'polite' | 'assertive';
/** ARIA live-region role. Distinct semantics in ATs: */
/** `status` = non-critical updates, `alert` = urgent, `log` = running history, `timer` = time-sensitive. */
export type AnnounceRole = 'status' | 'alert' | 'log' | 'timer';
/** Imperative API exposed by the Provider. */
export type AnnounceApi = {
/**
* Post a message to the appropriate internal live region.
* The message is cleared after `timeout` ms so repeat announcements
* of the same text still re-trigger the screen reader.
*
* @param message Text to announce.
* @param priority `'polite'` (default) queues behind current speech, `'assertive'` interrupts.
* @param timeout Milliseconds before the region is cleared. @default 1000
*/
announce: (message: string, priority?: AnnouncePriority, timeout?: number) => void;
/** Clear all internal live regions immediately. */
clear: () => void;
};
/** Snippet props for `Announce.Provider`. */
export type AnnounceProviderSnippetProps = AnnounceApi;
// ── Root provider ──────────────────────────────────────────────────────────
/**
* Props for `Announce.Provider`.
*
* Mounts two internal hidden live regions (polite + assertive) and exposes
* an imperative `announce()` API via snippet props and via context
* (descendants can call `AnnounceProvider.require().announce(...)`).
*
* Mount this ONCE near the app root — typically in the root layout. Nested
* providers work but create duplicate regions; screen readers may read the
* same message twice.
*
* For purpose-specific regions (chat log, form errors, timer), compose with
* `Announce.Region` inside the Provider.
*/
export type AnnounceProps = WithChild<
{
/** DOM id. Auto-generated if omitted. */
id?: string;
/**
* Default timeout in ms before a posted message is cleared from the
* region. Clearing is what allows the same text to be announced again
* — screen readers dedupe identical consecutive strings. @default 1000
*/
defaultTimeout?: number;
children?: Snippet<[AnnounceProviderSnippetProps]>;
},
AnnounceProviderSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Region ─────────────────────────────────────────────────────────────────
/**
* Props for `Announce.Region` — a declarative live region for persistent,
* purpose-specific announcements (chat log, running timer, form error summary).
*
* Unlike the imperative `announce()` path which uses the Provider's shared
* polite/assertive regions, a `Region` is an addressable element in the DOM
* — consumers can write directly to it via `Region.write(text)` or let it
* reflect a bound `message` prop.
*/
export type AnnounceRegionProps = WithChild<{
/** DOM id. Auto-generated if omitted. */
id?: string;
/**
* ARIA role. Each role implies different AT handling:
* - `status` (polite) — non-critical status updates.
* - `alert` (assertive) — interrupts speech. Use for errors.
* - `log` (polite) — running history (chat, activity). Newer content at the end.
* - `timer` (polite) — time-sensitive info. Do not announce every tick.
* @default 'status'
*/
role?: AnnounceRole;
/**
* Override the implicit `aria-live` value from `role`. Only use when you
* deliberately want a non-default urgency (e.g. an `alert` region that
* should be polite, or a `status` that should be assertive).
*/
'aria-live'?: AnnouncePriority;
/**
* Whether the AT reads the entire region on change (`true`) or just the
* diff (`false`). @default true
*/
'aria-atomic'?: boolean;
/**
* What types of changes trigger an announcement: `'additions'`, `'removals'`,
* `'text'`, `'all'`, or a space-separated combination. @default 'additions text'
*/
'aria-relevant'?: string;
/**
* Bound reactive message. When the value changes, the region's content
* updates and the AT announces the new text. Alternative to writing
* children directly.
*/
message?: string;
/**
* Whether the region is visually hidden via the standard SR-only technique.
* Set `false` only when the region doubles as visible UI (e.g. a status
* banner that's both visible and announced). @default true
*/
visuallyHidden?: boolean;
}> &
Without<PrimitiveDivAttributes, { role?: unknown; 'aria-live'?: unknown; 'aria-atomic'?: unknown; 'aria-relevant'?: unknown }>;

@ -0,0 +1,163 @@
# Avatar
An image avatar with a fallback that shows until the image loads (or fails). The Provider preloads the URL out-of-band; `Image` only becomes visible when `status='loaded'`. A configurable `delayMs` prevents the fallback from flashing for fast-loading images.
## Anatomy
```svelte
<Avatar.Provider>
<Avatar.Image src="/users/jp.jpg" alt="Juan Pérez" />
<Avatar.Fallback>JP</Avatar.Fallback>
</Avatar.Provider>
```
## Parts
| Part | Element | Description |
| ----------- | --------- | ---------------------------------------------------------------------------- |
| `Provider` | `<span>` | Root context. Holds `status`. Emits `data-status`. |
| `Image` | `<img>` | `<img>` with `display: none` until the Provider resolves to `loaded`. |
| `Fallback` | `<span>` | Visible while `status !== 'loaded'`. Consumer content (initials, icon, …). |
## Props
### `Provider`
| Prop | Type | Default | Description |
| ---------------- | ---------------------------------------------- | ------- | ----------------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `delayMs` | `number` | `0` | Minimum ms before `status='loaded'` is applied (debounce against flicker). |
| `onStatusChange` | `(status: AvatarLoadingStatus) => void` | — | Fires on every status transition. |
Snippet props: `{ status }`.
### `Image`
| Prop | Type | Default | Description |
| ---------------- | ----------------------------------------- | ------- | --------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `src` | `string \| null \| undefined` | — | Image URL. Empty/null/undefined → error. |
| `crossorigin` | `'anonymous' \| 'use-credentials' \| ''` | — | Passed through to the img + preloader. |
| `referrerpolicy` | `ReferrerPolicy` | — | Passed through to the img + preloader. |
### `Fallback`
| Prop | Type | Default | Description |
| ---- | -------- | ------- | ----------- |
| `id` | `string` | auto | DOM id. |
## ARIA
No explicit ARIA roles. `Avatar.Image` is a plain `<img alt>`; the Fallback is a decorative `<span>`. Consumers must provide `alt` on `Image` for the image to carry its semantic name.
## Data Attributes
| Part | Attribute | Values |
| --------- | --------------------- | ----------------------------------------------- |
| Provider | `data-avatar` | Always present |
| Provider | `data-status` | `idle` \| `loading` \| `loaded` \| `error` |
| Image | `data-avatar-image` | Always present |
| Image | `data-status` | Inherited from provider |
| Fallback | `data-avatar-fallback`| Always present |
| Fallback | `data-status` | Inherited from provider |
## Status machine
```
idle → loading → loaded
↓
error
```
- `idle`: no `src` set yet, or component just mounted.
- `loading`: `src` is non-empty; preloader in flight.
- `loaded`: image decoded; the `<img>` becomes visible, fallback hides.
- `error`: preload failed, `src` is empty/null, or the URL is unreachable.
Preloading uses a detached `new Image()` so the browser decodes the bitmap before the rendered `<img>` becomes visible — no flash of unstyled image.
## Comparison
| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
| ---------------------------------- | :--: | :---: | :----: | :-----: | :--------: |
| Dedicated component | ✅ | ✅ | ✅ | ✅ | ❌¹ |
| Image preload via `new Image()` | ✅ | ✅ | ✅ | ✅ | — |
| `delayMs` debounce | ✅ | ✅ | ✅ | ✅ | — |
| `onStatusChange` callback | ✅ | ✅ | ✅ | ✅ | — |
| `data-status` on root | ✅ | ❌² | ✅ | ✅ | — |
| `Group` primitive | ❌³ | ❌ | ✅ | ❌ | — |
| Built-in initials helper | ❌⁴ | ❌ | ❌ | ❌ | — |
| `crossorigin` / `referrerpolicy` | ✅ | ❌ | ❌ | ❌ | — |
¹ react-aria leaves Avatar to the styled layer.
² Radix only propagates status via snippet / data-attrs on child parts.
³ Avatar stacks are purely visual; the air layer owns `Avatar.Group` with spacing tokens and overlap.
⁴ `getInitials(name)` is a one-liner for the consumer; not part of the headless contract.
## Out of scope
- **Group stack**: overlap and spacing are visual concerns. Air implements `Avatar.Group` with `--air-avatar-overlap` tokens.
- **Initials derivation**: consumers pass their own text. Different locales / naming conventions would make a built-in helper wrong for someone.
- **Skeleton state**: use the `idle` / `loading` status directly in CSS — `Avatar.Fallback[data-status='loading']` can be styled as a pulse.
## Usage
### Basic
```svelte
<Avatar.Provider>
<Avatar.Image src="/avatars/jp.jpg" alt="Juan Pérez" />
<Avatar.Fallback>JP</Avatar.Fallback>
</Avatar.Provider>
```
### With delay (no flash on fast loads)
```svelte
<Avatar.Provider delayMs={200}>
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar.Provider>
```
### Reacting to status (loader ring, skeleton)
```svelte
<Avatar.Provider>
{#snippet children({ status })}
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback>
{#if status === 'loading'}
<svg class="spinner" />
{:else}
{user.initials}
{/if}
</Avatar.Fallback>
{/snippet}
</Avatar.Provider>
```
### Styling with `data-status`
```css
[data-avatar] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: #e2e8f0;
}
[data-avatar-image] {
width: 100%;
height: 100%;
object-fit: cover;
}
[data-avatar][data-status='error'] {
background: #fee2e2;
color: #991b1b;
}
```

@ -0,0 +1,221 @@
import { watch } from 'runed';
import { Provider, context, type WithRefOpts } from '../../provider';
import { createAttrs, registerContract } from '../../attrs';
import { state, type ActiveProps, type StateProps } from '../../reactive';
import type { OnChangeFn } from '../../types';
import type { HTMLImgAttributes } from 'svelte/elements';
import type { AvatarLoadingStatus } from './types';
const attrs = createAttrs({
component: 'avatar',
parts: ['root', 'image', 'fallback'] as const
});
registerContract({
name: 'avatar',
version: 1,
parts: {
root: [
{
attr: 'data-status',
values: ['idle', 'loading', 'loaded', 'error'],
description: 'Image loading status'
}
],
image: [
{
attr: 'data-status',
values: ['idle', 'loading', 'loaded', 'error'],
description: 'Image loading status (inherited)'
}
],
fallback: [
{
attr: 'data-status',
values: ['idle', 'loading', 'loaded', 'error'],
description: 'Image loading status (inherited)'
}
]
}
});
type CrossOrigin = HTMLImgAttributes['crossorigin'];
type ReferrerPolicy = HTMLImgAttributes['referrerpolicy'];
// ── Root provider ──────────────────────────────────────────────────────────
interface AvatarOpts
extends WithRefOpts,
StateProps<{ status: AvatarLoadingStatus }>,
ActiveProps<{
delayMs: number;
onStatusChange: OnChangeFn<AvatarLoadingStatus> | undefined;
}> {}
export class AvatarProvider extends Provider<AvatarOpts> {
static readonly ctx = context<AvatarProvider>('Avatar');
static get(): AvatarProvider | undefined {
return this.ctx.getOr(undefined) as AvatarProvider | undefined;
}
static require(): AvatarProvider {
return this.ctx.get();
}
static create(opts: AvatarOpts) {
return new AvatarProvider(opts);
}
private constructor(opts: AvatarOpts) {
super(opts, 'Avatar', 'root', attrs.root, AvatarProvider.ctx);
// Fire onStatusChange whenever status transitions.
let previous = opts.status.current;
$effect(() => {
const current = opts.status.current;
if (current !== previous) {
opts.onStatusChange.current?.(current);
previous = current;
}
});
}
setStatus(next: AvatarLoadingStatus) {
if (this.opts.status.current === next) return;
this.opts.status.current = next;
}
/**
* Preload the image in the background. Updates `status` through
* `loading → loaded` (with optional `delayMs` debounce) or `→ error`.
* Returns a cleanup function that cancels the pending delay timer + the
* image listeners if `src` changes before load resolves.
*/
loadImage(
src: string,
crossorigin?: CrossOrigin,
referrerPolicy?: ReferrerPolicy
): (() => void) | void {
if (typeof window === 'undefined') return;
const image = new Image();
let timerId: ReturnType<typeof setTimeout> | undefined;
image.src = src;
if (crossorigin !== undefined) image.crossOrigin = crossorigin as string;
if (referrerPolicy) image.referrerPolicy = referrerPolicy;
this.setStatus('loading');
image.onload = () => {
const delay = this.opts.delayMs.current;
if (delay > 0) {
timerId = setTimeout(() => this.setStatus('loaded'), delay);
} else {
this.setStatus('loaded');
}
};
image.onerror = () => {
this.setStatus('error');
};
return () => {
image.onload = null;
image.onerror = null;
if (timerId !== undefined) clearTimeout(timerId);
};
}
readonly snippetProps = $derived.by(() => ({
status: this.opts.status.current
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-status': this.opts.status.current
} as const)
);
}
// ── Image ──────────────────────────────────────────────────────────────────
interface AvatarImageOpts
extends WithRefOpts,
ActiveProps<{
src: string | null | undefined;
crossOrigin: CrossOrigin;
referrerPolicy: ReferrerPolicy;
}> {}
export class AvatarImageProvider extends Provider<AvatarImageOpts> {
static create(opts: AvatarImageOpts) {
return new AvatarImageProvider(opts);
}
readonly provider: AvatarProvider;
private constructor(opts: AvatarImageOpts) {
super(opts, 'Avatar', 'image', attrs.image);
this.provider = AvatarProvider.require();
// Preload on src change. `watch.pre` runs synchronously so the
// status flips to 'loading' before the first paint.
watch.pre(
[
() => this.opts.src.current,
() => this.opts.crossOrigin.current,
() => this.opts.referrerPolicy.current
],
([src, crossOrigin, referrerPolicy]) => {
if (!src) {
this.provider.setStatus('error');
return;
}
return this.provider.loadImage(src, crossOrigin, referrerPolicy);
}
);
}
readonly props = $derived.by(() => {
const status = this.provider.opts.status.current;
return this.assertProps({
...this.baseProps,
// The img element exists in DOM for all states, but is hidden
// until `loaded` so preloading doesn't flicker.
style: {
display: status === 'loaded' ? 'block' : 'none'
},
src: this.opts.src.current || undefined,
crossorigin: this.opts.crossOrigin.current,
referrerpolicy: this.opts.referrerPolicy.current,
'data-status': status
} as const);
});
}
// ── Fallback ───────────────────────────────────────────────────────────────
interface AvatarFallbackOpts extends WithRefOpts {}
export class AvatarFallbackProvider extends Provider<AvatarFallbackOpts> {
static create(opts: AvatarFallbackOpts) {
return new AvatarFallbackProvider(opts);
}
readonly provider: AvatarProvider;
private constructor(opts: AvatarFallbackOpts) {
super(opts, 'Avatar', 'fallback', attrs.fallback);
this.provider = AvatarProvider.require();
}
readonly props = $derived.by(() => {
const status = this.provider.opts.status.current;
// Visible while idle / loading / error. Hidden once image loaded.
return this.assertProps({
...this.baseProps,
style: status === 'loaded' ? { display: 'none' } : undefined,
'data-status': status
} as const);
});
}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AvatarFallbackProvider } from '../avatar-provider.svelte';
import type { AvatarFallbackProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'avatar-fallback'),
children,
child,
...restProps
}: AvatarFallbackProps = $props();
const state = AvatarFallbackProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,33 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AvatarImageProvider } from '../avatar-provider.svelte';
import type { AvatarImageProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'avatar-image'),
src,
crossorigin,
referrerpolicy,
...restProps
}: AvatarImageProps = $props();
const state = AvatarImageProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
src: readableActive(() => src),
crossOrigin: readableActive(() => crossorigin),
referrerPolicy: readableActive(() => referrerpolicy)
});
const mergedProps = $derived(mergeProps({}, restProps, state.props));
</script>
<img {...mergedProps} alt={restProps.alt ?? ''} />

@ -0,0 +1,45 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { AvatarProvider } from '../avatar-provider.svelte';
import type { AvatarProps, AvatarLoadingStatus } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'avatar'),
delayMs = 0,
onStatusChange = () => {},
children,
child,
...restProps
}: AvatarProps = $props();
let status = $state<AvatarLoadingStatus>('idle');
const provider = AvatarProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
status: writableActive(
() => status,
(v) => (status = v)
),
delayMs: readableActive(() => delayMs),
onStatusChange: readableActive(() => onStatusChange)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.(provider.snippetProps)}
</span>
{/if}

@ -0,0 +1,11 @@
export { default as Provider } from './components/avatar.svelte';
export { default as Image } from './components/avatar-image.svelte';
export { default as Fallback } from './components/avatar-fallback.svelte';
export type {
AvatarProps as ProviderProps,
AvatarImageProps as ImageProps,
AvatarFallbackProps as FallbackProps,
AvatarProviderSnippetProps as ProviderSnippetProps,
AvatarLoadingStatus
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,78 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveDivAttributes,
PrimitiveSpanAttributes
} from '../../types';
import type { HTMLImgAttributes } from 'svelte/elements';
/** Image loading status tracked internally. */
export type AvatarLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
/** Snippet props exposed by `Avatar.Provider`. */
export type AvatarProviderSnippetProps = {
status: AvatarLoadingStatus;
};
// ── Root provider ──────────────────────────────────────────────────────────
/**
* Props for the root `Avatar.Provider`.
*
* Composes `Image` (the actual `<img>`) + `Fallback` (initials, icon, or
* placeholder). The Provider preloads the image out-of-band; only when
* `status='loaded'` does `Image` show and `Fallback` hide. A configurable
* `delayMs` prevents fallback flash for fast-loading images.
*/
export type AvatarProps = WithChild<
{
/** DOM id. Auto-generated if omitted. */
id?: string;
/**
* Minimum delay in ms before the Fallback shows. Prevents the
* initials flashing for images that load in <100ms. @default 0
*/
delayMs?: number;
/** Fires on every status transition. */
onStatusChange?: OnChangeFn<AvatarLoadingStatus>;
children?: Snippet<[AvatarProviderSnippetProps]>;
},
AvatarProviderSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Image ──────────────────────────────────────────────────────────────────
/**
* Props for `Avatar.Image`. Wraps `<img>`; the Provider preloads the URL
* and only reveals the element (`display: block`) when `status='loaded'`.
*
* When `src` becomes `undefined` / `null`, the Provider flips to
* `status='error'` so the Fallback shows.
*/
export type AvatarImageProps = WithChild<{
id?: string;
/** Image source. Required. */
src: string | null | undefined;
/** `crossorigin` attribute passed through to the img + preloader. */
crossorigin?: HTMLImgAttributes['crossorigin'];
/** `referrerpolicy` attribute passed through. */
referrerpolicy?: HTMLImgAttributes['referrerpolicy'];
}> &
Without<
HTMLImgAttributes,
{ src?: unknown; crossorigin?: unknown; referrerpolicy?: unknown }
>;
// ── Fallback ───────────────────────────────────────────────────────────────
/**
* Props for `Avatar.Fallback` — shown while `status` is `idle`, `loading`,
* or `error`. Consumer chooses the content (initials `<span>{JP}</span>`,
* icon, placeholder image). The Provider hides it via inline `display: none`
* once `status='loaded'`.
*/
export type AvatarFallbackProps = WithChild<{
id?: string;
}> &
Without<PrimitiveSpanAttributes, {}>;

@ -0,0 +1,174 @@
# Clipboard
A `navigator.clipboard.writeText` wrapper with a `copied` state that auto-resets after a configurable timeout. Consumers compose a `Trigger` button (copy on click) and an optional `Indicator` that renders only while the copy is fresh.
`navigator.clipboard` is required — there is no `execCommand('copy')` fallback. 98%+ of browsers in 2026 support the async API, and the legacy API's synchronous-gesture requirement is incompatible with Svelte 5's microtask-based event handling.
## Anatomy
```svelte
<Clipboard.Provider value="npm install soma">
<Clipboard.Trigger>Copy</Clipboard.Trigger>
<Clipboard.Indicator>Copied!</Clipboard.Indicator>
</Clipboard.Provider>
```
## Parts
| Part | Element | Description |
| ----------- | ---------- | --------------------------------------------------------------------------- |
| `Provider` | `<div>` | Root context. Holds `value` + `copied` + imperative `copy()`. |
| `Trigger` | `<button>` | Copies on click. Announces `data-copied` for visual feedback. |
| `Indicator` | `<span>` | Renders only while `copied=true` (unless `forceMount`). Decorative. |
## Props
### `Provider`
| Prop | Type | Default | Description |
| ---------- | ------------------------------- | ------- | ------------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `value` | `string` | — | **Required.** String to copy. |
| `timeout` | `number` | `2000` | Ms to keep `copied=true` after a successful copy. |
| `onCopy` | `(value: string) => void` | — | Fires on successful copy. |
| `onError` | `(err: unknown) => void` | — | Fires when the clipboard API throws (permissions, blur, missing API). |
Snippet props: `{ value, copied, copy }`.
### `Trigger`
| Prop | Type | Default | Description |
| ------------ | -------- | --------------------------------- | ------------------- |
| `id` | `string` | auto | DOM id. |
| `aria-label` | `string` | translated `Copy to clipboard` / `Copied` (swaps with state) | Accessible name. |
### `Indicator`
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | --------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `forceMount` | `boolean` | `false` | Keep in DOM even when `copied=false`, for CSS exit transitions. |
## ARIA
| Part | Attribute | Value |
| --------- | ----------------- | ----------------------------------------------------- |
| Trigger | `type` | `button` |
| Trigger | `aria-label` | Translated `Copy to clipboard` → `Copied` on transition |
| Trigger | `aria-live` | `polite` — announces the label change |
| Indicator | `aria-hidden` | `true` (decorative; the Trigger's label is the source of truth) |
## Data Attributes
| Part | Attribute | Values |
| --------- | --------------------------- | ---------------------------------------------------- |
| Provider | `data-clipboard` | Always present |
| Provider | `data-copied` | Present (empty string) while fresh |
| Trigger | `data-clipboard-trigger` | Always present |
| Trigger | `data-copied` | Present while fresh |
| Indicator | `data-clipboard-indicator` | Always present when rendered |
| Indicator | `data-copied` | Always present (Indicator only renders while copied) |
## Keyboard
| Key | Action |
| ---------------- | ---------------------------------- |
| `Enter` / `Space` | Clicks the Trigger → calls `copy()` |
## i18n
| Key | English | Spanish |
| -------- | ------------------ | ----------------------- |
| `copy` | `Copy to clipboard` | `Copiar al portapapeles` |
| `copied` | `Copied` | `Copiado` |
Override per-instance via `aria-label` on `Trigger`.
## Comparison
| Feature | Soma | Radix | Ark UI | bits-ui |
| --------------------------------- | :--: | :---: | :----: | :-----: |
| Dedicated component | ✅ | ❌¹ | ✅ | ❌ |
| `navigator.clipboard.writeText` | ✅ | — | ✅ | — |
| Auto-reset `copied` flag | ✅ | — | ✅ | — |
| Configurable `timeout` | ✅ | — | ✅ | — |
| `onCopy` / `onError` callbacks | ✅ | — | ✅ | — |
| Indicator conditional mount | ✅ | — | ✅ | — |
| `data-copied` attr | ✅ | — | ✅ | — |
| `aria-live` on Trigger label | ✅ | — | ❌ | — |
| Translated label swap | ✅ | — | ❌ | — |
| `execCommand` fallback | ❌² | — | ❌ | — |
¹ Radix does not ship a Clipboard primitive. Consumers roll their own `navigator.clipboard` call + `useState(copied)`.
² Dropped by design — see the intro note. The legacy API requires a synchronous gesture path that Svelte 5 does not provide.
## Usage
### Button with state swap
```svelte
<Clipboard.Provider value={code}>
{#snippet children({ copied })}
<Clipboard.Trigger>
{copied ? '✓ Copied!' : 'Copy'}
</Clipboard.Trigger>
{/snippet}
</Clipboard.Provider>
```
### Separate Indicator (icon swap)
```svelte
<Clipboard.Provider value={code}>
<Clipboard.Trigger>
<CopyIcon />
</Clipboard.Trigger>
<Clipboard.Indicator>
<CheckIcon />
</Clipboard.Indicator>
</Clipboard.Provider>
```
### Imperative copy (no button)
```svelte
<Clipboard.Provider value={shareUrl}>
{#snippet children({ copy, copied })}
<p>Link copied automatically</p>
<button onclick={() => copy()}>Share</button>
{#if copied}<span role="status">Done!</span>{/if}
{/snippet}
</Clipboard.Provider>
```
### Styling with `data-copied`
```css
[data-clipboard-trigger] {
transition: background 150ms;
}
[data-clipboard-trigger][data-copied] {
background: #dcfce7;
color: #166534;
}
```
### Error handling
```svelte
<script>
let error = $state<string | null>(null);
</script>
<Clipboard.Provider
value={code}
onError={(err) => {
error = err instanceof Error ? err.message : 'Could not copy';
}}
>
<Clipboard.Trigger>Copy</Clipboard.Trigger>
</Clipboard.Provider>
{#if error}<p role="alert">{error}</p>{/if}
```
Permission denial (e.g. the browser blocks the API in insecure contexts) reaches `onError`; the Provider doesn't swallow it.

@ -0,0 +1,177 @@
import { Provider, context, type WithRefOpts } from '../../provider';
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
import { readableActive, type Active, type ActiveProps } from '../../reactive';
import type { OnChangeFn, SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { CLIPBOARD_LANGS } from './langs';
const attrs = createAttrs({
component: 'clipboard',
parts: ['root', 'trigger', 'indicator'] as const
});
registerContract({
name: 'clipboard',
version: 1,
parts: {
root: [{ attr: 'data-copied', description: 'Fresh copy within timeout window' }],
trigger: [{ attr: 'data-copied', description: 'Fresh copy within timeout window' }],
indicator: [{ attr: 'data-copied', description: 'Always present when rendered' }]
}
});
// ── Root provider ──────────────────────────────────────────────────────────
interface ClipboardOpts
extends WithRefOpts,
ActiveProps<{
value: string;
timeout: number;
onCopy: OnChangeFn<string> | undefined;
onError: ((err: unknown) => void) | undefined;
}> {}
export class ClipboardProvider extends Provider<ClipboardOpts> {
static readonly ctx = context<ClipboardProvider>('Clipboard');
static get(): ClipboardProvider | undefined {
return this.ctx.getOr(undefined) as ClipboardProvider | undefined;
}
static require(): ClipboardProvider {
return this.ctx.get();
}
static create(opts: ClipboardOpts) {
return new ClipboardProvider(opts);
}
readonly soma = Soma.get();
copied = $state(false);
private resetTimer: ReturnType<typeof setTimeout> | null = null;
private constructor(opts: ClipboardOpts) {
super(opts, 'Clipboard', 'root', attrs.root, ClipboardProvider.ctx);
$effect(() => {
return () => {
if (this.resetTimer) clearTimeout(this.resetTimer);
};
});
}
/**
* Copy the current value. Returns a promise that resolves when the
* clipboard write finishes (or rejects if the API throws / is missing).
* Also schedules a `copied=false` reset after `timeout` ms.
*/
copy = async (): Promise<void> => {
const value = this.opts.value.current;
if (typeof navigator === 'undefined' || !navigator.clipboard) {
const err = new Error(
'navigator.clipboard is not available in this environment.'
);
this.opts.onError.current?.(err);
throw err;
}
try {
await navigator.clipboard.writeText(value);
this.copied = true;
this.opts.onCopy.current?.(value);
if (this.resetTimer) clearTimeout(this.resetTimer);
this.resetTimer = setTimeout(() => {
this.copied = false;
this.resetTimer = null;
}, this.opts.timeout.current);
} catch (err) {
this.opts.onError.current?.(err);
throw err;
}
};
readonly snippetProps = $derived.by(() => ({
value: this.opts.value.current,
copied: this.copied,
copy: this.copy
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-copied': boolToEmptyStrOrUndef(this.copied)
} as const)
);
}
// ── Trigger ────────────────────────────────────────────────────────────────
interface ClipboardTriggerOpts
extends WithRefOpts,
ActiveProps<{ ariaLabel: string | undefined }> {}
export class ClipboardTriggerProvider extends Provider<ClipboardTriggerOpts> {
static create(opts: ClipboardTriggerOpts) {
return new ClipboardTriggerProvider(opts);
}
readonly provider: ClipboardProvider;
private constructor(opts: ClipboardTriggerOpts) {
super(opts, 'Clipboard', 'trigger', attrs.trigger);
this.provider = ClipboardProvider.require();
}
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() => {
const override = this.opts.ariaLabel.current;
if (override) return override;
// When copied, announce the copied state; otherwise the copy action.
return this.provider.copied
? this.provider.soma?.langs.ts(CLIPBOARD_LANGS.COPIED)
: this.provider.soma?.langs.ts(CLIPBOARD_LANGS.COPY);
});
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
this.provider.copy().catch(() => {
// onError already fired from the provider; swallow here to avoid
// unhandled-rejection noise on button click.
});
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-label': this.resolvedAriaLabel.current,
'aria-live': 'polite' as const,
'data-copied': boolToEmptyStrOrUndef(this.provider.copied),
onclick: this.onclick
} as const)
);
}
// ── Indicator ──────────────────────────────────────────────────────────────
interface ClipboardIndicatorOpts extends WithRefOpts {}
export class ClipboardIndicatorProvider extends Provider<ClipboardIndicatorOpts> {
static create(opts: ClipboardIndicatorOpts) {
return new ClipboardIndicatorProvider(opts);
}
readonly provider: ClipboardProvider;
private constructor(opts: ClipboardIndicatorOpts) {
super(opts, 'Clipboard', 'indicator', attrs.indicator);
this.provider = ClipboardProvider.require();
}
readonly isPresent = $derived.by(() => this.provider.copied);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'aria-hidden': true as const,
'data-copied': ''
} as const)
);
}

@ -0,0 +1,38 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ClipboardIndicatorProvider } from '../clipboard-provider.svelte';
import type { ClipboardIndicatorProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'clipboard-indicator'),
forceMount = false,
children,
child,
...restProps
}: ClipboardIndicatorProps = $props();
const state = ClipboardIndicatorProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isPresent || forceMount}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ClipboardTriggerProvider } from '../clipboard-provider.svelte';
import type { ClipboardTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'clipboard-trigger'),
'aria-label': ariaLabel,
children,
child,
...restProps
}: ClipboardTriggerProps = $props();
const state = ClipboardTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,43 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { ClipboardProvider } from '../clipboard-provider.svelte';
import type { ClipboardProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'clipboard'),
value,
timeout = 2000,
onCopy,
onError,
children,
child,
...restProps
}: ClipboardProps = $props();
const state = ClipboardProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
timeout: readableActive(() => timeout),
onCopy: readableActive(() => onCopy),
onError: readableActive(() => onError)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -0,0 +1,10 @@
export { default as Provider } from './components/clipboard.svelte';
export { default as Trigger } from './components/clipboard-trigger.svelte';
export { default as Indicator } from './components/clipboard-indicator.svelte';
export type {
ClipboardProps as ProviderProps,
ClipboardTriggerProps as TriggerProps,
ClipboardIndicatorProps as IndicatorProps,
ClipboardProviderSnippetProps as ProviderSnippetProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,5 @@
/** Idlangref constants for the Clipboard component. */
export const CLIPBOARD_LANGS = {
COPY: '#?components.clipboard.copy|Copy to clipboard',
COPIED: '#?components.clipboard.copied|Copied',
} as const;

@ -0,0 +1,81 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveDivAttributes,
PrimitiveButtonAttributes,
PrimitiveSpanAttributes
} from '../../types';
/** Snippet props exposed by `Clipboard.Provider`. */
export type ClipboardProviderSnippetProps = {
/** Current value that gets copied on trigger. */
value: string;
/** `true` for `timeout` ms after a successful copy. */
copied: boolean;
/** Imperative: call to copy immediately (bypasses Trigger click). */
copy: () => Promise<void>;
};
// ── Root provider ──────────────────────────────────────────────────────────
/**
* Props for the root `Clipboard.Provider`.
*
* Wraps `navigator.clipboard.writeText` with a `copied` state that auto-
* resets after `timeout` ms. Exposes `Trigger` (the button that triggers
* the copy) and `Indicator` (only visible while `copied=true`).
*
* `navigator.clipboard` is required — no `execCommand('copy')` fallback.
* 98% of browsers in 2026 support the async API; the fallback's security
* model (requires direct user gesture + sync execution) is incompatible
* with Svelte 5's microtask-based event handling.
*/
export type ClipboardProps = WithChild<
{
/** DOM id. Auto-generated if omitted. */
id?: string;
/** String to copy. Required. */
value: string;
/** Alias for value if preferred ergonomically. */
/** ms to keep `copied=true` after a successful copy. @default 2000 */
timeout?: number;
/** Fires on successful copy. */
onCopy?: OnChangeFn<string>;
/** Fires when clipboard API throws (permission denied, blur, etc.). */
onError?: (err: unknown) => void;
children?: Snippet<[ClipboardProviderSnippetProps]>;
},
ClipboardProviderSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Trigger ────────────────────────────────────────────────────────────────
/**
* Props for `Clipboard.Trigger` — button that copies `Provider.value` on
* click. Emits `data-copied` while the copy is fresh so CSS can style
* feedback (color change, icon swap via `:not(.copied)` / `.copied`).
*/
export type ClipboardTriggerProps = WithChild<{
id?: string;
/** Accessible name. @default translated `'Copy to clipboard'` */
'aria-label'?: string;
}> &
Without<PrimitiveButtonAttributes, { 'aria-label'?: string }>;
// ── Indicator ──────────────────────────────────────────────────────────────
/**
* Props for `Clipboard.Indicator` — decorative marker that renders only
* while `copied=true` (unless `forceMount`). Consumer puts a "Copied!"
* label or a check icon inside.
*/
export type ClipboardIndicatorProps = WithChild<{
id?: string;
/** Keep in DOM while not copied, for CSS exit transitions. @default false */
forceMount?: boolean;
}> &
Without<PrimitiveSpanAttributes, {}>;

@ -23,39 +23,16 @@ import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte
import { ScrollLock } from '../../layers/scroll-lock.svelte';
import { TextSelection } from '../../layers/text-selection.svelte';
const attrs = createAttrs({
component: 'dialog',
parts: ['root', 'trigger', 'content', 'overlay', 'title', 'description', 'close'] as const
});
registerContract({
name: 'dialog',
version: 2,
parts: {
root: [
{ attr: 'data-state', values: ['open', 'closed'], description: 'Open state' },
{ attr: 'data-disabled', description: 'Disabled flag' }
],
trigger: [{ attr: 'data-state', values: ['open', 'closed'], description: 'Open state' }],
content: [
{ attr: 'data-state', values: ['open', 'closed'], description: 'Open state' },
{ attr: 'data-nested', description: 'Present when nested inside another dialog' },
{ attr: 'data-nested-open', description: 'Present when a nested dialog is open' },
{ attr: 'data-starting-style', description: 'Present during open animation' },
{ attr: 'data-ending-style', description: 'Present during close animation' }
],
overlay: [
{ attr: 'data-state', values: ['open', 'closed'], description: 'Open state' },
{ attr: 'data-nested', description: 'Present when nested' },
{ attr: 'data-nested-open', description: 'Present when nested dialog open' },
{ attr: 'data-starting-style', description: 'Present during open animation' },
{ attr: 'data-ending-style', description: 'Present during close animation' }
],
title: [],
description: [],
close: []
}
});
import { dialogMorfo } from '../../../morfo/components/dialog';
// Parts, data-attrs, and their valid enums are sourced from `dialogMorfo` —
// the single cross-layer contract. Changing a part name or a data-attr
// value here requires editing the morfo, never this file.
const attrs = createAttrs(dialogMorfo);
// Data-attr contract comes from the morfo. Any change in valid values
// or attr names is done in `morfo/components/dialog.ts`.
registerContract(dialogMorfo);
// ── Provider (root) ─────────────────────────────────────────────────────────

@ -0,0 +1,212 @@
# DragDrop
A headless drag-and-drop system — pointer + keyboard + screen reader. Coordinates draggable sources and drop targets within a Provider boundary, fires a single `onDrop` event with a typed payload, and announces each drag step through the global `Announce` live region.
Soma chooses this system over HTML5 native `draggable` because HTML5 DnD has poor mobile support, incompatible cross-browser dataTransfer semantics, and no built-in keyboard model. This primitive works everywhere pointer events work (mouse, touch, pen) plus a full WAI-ARIA-aligned keyboard contract.
## Anatomy
```svelte
<DragDrop.Provider onDrop={(e) => moveItem(e.value, e.target)}>
<DragDrop.Draggable value="task-1" data={{ kind: 'task' }}>
Drag me
</DragDrop.Draggable>
<DragDrop.Droppable accept={(data) => data.kind === 'task'} textValue="Done">
Drop here
</DragDrop.Droppable>
<DragDrop.Preview>
{#snippet children({ active })}
<div class="ghost">{active.label}</div>
{/snippet}
</DragDrop.Preview>
</DragDrop.Provider>
```
## Parts
| Part | Element | Description |
| ----------- | ---------- | -------------------------------------------------------------------- |
| `Provider` | `<div>` | Coordinates the drag gesture across descendants. Announces ARIA. |
| `Draggable` | `<div>` | An item that can be picked up. Pointer + keyboard gesture host. |
| `Droppable` | `<div>` | A target that accepts drops. Filters via `accept(data, value)`. |
| `Preview` | `<div>` | Optional floating ghost element that follows the pointer during drag.|
## Props
### `Provider`
| Prop | Type | Default | Description |
| ------------------ | --------------------------------------------------------------------- | ------- | ------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `onDragStart` | `(e: { value, data, preventDefault }) => void` | — | Fires before drag starts. Call `preventDefault` to cancel. |
| `onDragEnd` | `(e: DragEndEvent) => void` | — | Fires on drop or cancel. |
| `onDrop` | `(e: DropEvent) => void` | — | Fires on successful drop. |
| `announceEnabled` | `boolean` | `true` | Emit live-region announcements via `Announce` global API. |
Snippet props: `{ active, cancel }`.
### `Draggable`
| Prop | Type | Default | Description |
| ------------ | -------------------- | ------- | --------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `value` | `string` | — | **Required.** Unique identifier; returned in `onDrop.value`. |
| `data` | `Record<string, any>`| `{}` | Payload; returned in `onDrop.data`. |
| `disabled` | `boolean` | `false` | Disables dragging. |
| `moveBuffer` | `number` | `5` | Pixels the pointer must move before a drag is recognised. |
| `textValue` | `string` | — | Label for announcements. Falls back to aria-label → textContent. |
Snippet props: `{ dragging, handleProps }`.
### `Droppable`
| Prop | Type | Default | Description |
| ----------- | ----------------------------------------- | --------------- | ----------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `accept` | `(data, value) => boolean` | `() => true` | Filter — returning `false` removes this target from keyboard nav. |
| `disabled` | `boolean` | `false` | Disables all drops on this element. |
| `textValue` | `string` | — | Label for announcements. |
Snippet props: `{ over, accepting }`.
## ARIA
| Part | Attribute | Value |
| --------- | ------------------------ | ------------------------------------------------------------ |
| Draggable | `role` | `button` |
| Draggable | `aria-roledescription` | Translated `'draggable'` |
| Draggable | `aria-grabbed` | `true` during active drag (legacy but widely supported) |
| Draggable | `tabindex` | `0` (disabled: `-1`) |
| Droppable | `role` | `region` |
| Droppable | `aria-roledescription` | Translated `'drop zone'` |
| Droppable | `aria-dropeffect` | `'move'` while accepting, `'none'` otherwise |
| Droppable | `tabindex` | `0` during drag if accepting; `-1` otherwise |
Each drag step is announced via the `Announce` global API (see the component's [langs.ts](./langs.ts)): drag-started, drag-over-target, dropped, cancelled, no-targets-available. Disable with `announceEnabled={false}` on the Provider when wiring your own.
## Data Attributes
| Part | Attribute | Values |
| --------- | --------------------------------- | -------------------------------- |
| Provider | `data-drag-drop` | Always present |
| Provider | `data-dragging` | Present during active drag |
| Draggable | `data-drag-drop-draggable` | Always present |
| Draggable | `data-dragging` | This draggable is the source |
| Draggable | `data-disabled` / `data-value` / `data-text-value` | — |
| Droppable | `data-drag-drop-droppable` | Always present |
| Droppable | `data-dragover` | Pointer / focus over this target |
| Droppable | `data-accepting` | Would accept the current drag |
| Droppable | `data-disabled` | Drops disabled |
| Preview | `data-drag-drop-preview` | Always present |
| Preview | `data-active` | Present during active drag |
## Keyboard
| Focus on | Key | Action |
| ----------- | ---------------------------- | -------------------------------------------------------------------- |
| Draggable | `Space` / `Enter` | Begin drag. Focus jumps to the first accepting Droppable. |
| Droppable | `ArrowDown` / `ArrowRight` / `Tab` | Move to next accepting Droppable (wraps). |
| Droppable | `ArrowUp` / `ArrowLeft` / `Shift+Tab` | Move to previous accepting Droppable. |
| Droppable | `Space` / `Enter` | Drop. |
| any | `Escape` | Cancel the drag. Focus returns to the source Draggable. |
Droppables are explicitly added/removed from the tab order based on the accept filter — a disabled or non-accepting target is silently skipped during keyboard navigation.
## Comparison
| Feature | Soma | Radix | Ark UI | react-aria | dnd-kit |
| ------------------------------------ | :--: | :---: | :----: | :--------: | :-----: |
| Pointer (mouse + touch + pen) | ✅ | ❌ | ❌ | ✅ | ✅ |
| Keyboard drag (Space/Enter/Escape) | ✅ | — | — | ✅ | ✅ |
| Arrow / Tab to navigate drop targets | ✅ | — | — | ✅ | ⚠️¹ |
| Accept filter per target | ✅ | — | — | ✅ | ✅ |
| Typed `data` payload | ✅ | — | — | ✅ | ✅ |
| Cancel (Escape → source focus) | ✅ | — | — | ✅ | ✅ |
| Live-region announcements | ✅ | — | — | ✅ | ⚠️² |
| Translated messages | ✅ | — | — | ❌ | ❌ |
| DragPreview (ghost element) | ✅ | — | — | ✅ | ✅ |
| Integration with Announce global API | ✅ | — | — | — | — |
| Sortable (within-list reorder) | ⚠️³ | — | — | ✅ | ✅ |
| Auto-scroll near viewport edges | ❌⁴ | — | — | ✅ | ✅ |
¹ dnd-kit uses keyboard sensors that move items directionally; no explicit Tab between targets.
² dnd-kit provides announcer hooks; consumers must wire the message strings themselves.
³ Consumers implement sort by rendering N Droppables between items (one before, one after each row) and collapsing empty slots. Works but more verbose than a `<Sortable>` wrapper. First-class Sortable primitive can land when a consumer hits the verbosity wall.
⁴ Use native CSS overflow + scroll behavior of the container. Auto-scroll on viewport edges is a plausible future addition — the API surface would be a `autoScroll={true}` prop on the Provider.
## Usage
### Kanban board
```svelte
<DragDrop.Provider onDrop={moveTask}>
{#each columns as col (col.id)}
<DragDrop.Droppable
accept={(data) => col.accepts.includes(data.kind)}
textValue={col.title}
>
<h3>{col.title}</h3>
{#each col.tasks as t (t.id)}
<DragDrop.Draggable value={t.id} data={{ kind: t.kind }} textValue={t.title}>
{t.title}
</DragDrop.Draggable>
{/each}
</DragDrop.Droppable>
{/each}
<DragDrop.Preview>
{#snippet children({ active })}
<span class="chip">{active.label}</span>
{/snippet}
</DragDrop.Preview>
</DragDrop.Provider>
```
### File explorer drop zone
```svelte
<DragDrop.Provider onDrop={moveFile}>
<TreeGrid.Provider>
{#each files as f (f.id)}
<TreeGrid.Row value={f.id}>
<TreeGrid.Cell>
<DragDrop.Draggable value={f.id} data={{ kind: 'file' }} textValue={f.name}>
{f.name}
</DragDrop.Draggable>
</TreeGrid.Cell>
</TreeGrid.Row>
{/each}
</TreeGrid.Provider>
{#each folders as folder (folder.id)}
<DragDrop.Droppable
accept={(data, value) => data.kind === 'file' && !folder.contains(value)}
textValue={folder.name}
>
📁 {folder.name}
</DragDrop.Droppable>
{/each}
</DragDrop.Provider>
```
### Sortable list (reorder)
Render a Droppable "gap" between every pair of items. Each gap's `accept` returns true when the dragged item isn't adjacent to that gap (the move would be a no-op).
```svelte
<DragDrop.Provider onDrop={(e) => reorder(e.value, parseInt(e.target.dataset.gapIndex ?? '0'))}>
{#each items as item, i (item.id)}
<DragDrop.Droppable
accept={(_, v) => v !== item.id && v !== items[i - 1]?.id}
textValue="Gap {i}"
data-gap-index={i}
/>
<DragDrop.Draggable value={item.id} textValue={item.label}>
{item.label}
</DragDrop.Draggable>
{/each}
<DragDrop.Droppable textValue="End" data-gap-index={items.length} />
</DragDrop.Provider>
```

@ -0,0 +1,45 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { DraggableProvider } from '../drag-drop-provider.svelte';
import type { DraggableProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'drag-drop-draggable'),
value,
data = {},
disabled = false,
moveBuffer = 5,
textValue,
children,
child,
...restProps
}: DraggableProps = $props();
const provider = DraggableProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
data: readableActive(() => data),
disabled: readableActive(() => disabled),
moveBuffer: readableActive(() => moveBuffer),
textValue: readableActive(() => textValue)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
</div>
{/if}

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { DroppableProvider } from '../drag-drop-provider.svelte';
import type { DroppableProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'drag-drop-droppable'),
accept,
disabled = false,
textValue,
children,
child,
...restProps
}: DroppableProps = $props();
const provider = DroppableProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
accept: readableActive(() => accept),
disabled: readableActive(() => disabled),
textValue: readableActive(() => textValue)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
</div>
{/if}

@ -0,0 +1,32 @@
<script lang="ts">
import { readableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { DragPreviewProvider } from '../drag-drop-provider.svelte';
import type { DragPreviewProps } from '../types';
const uid = $props.id();
let {
id = createId(uid, 'drag-drop-preview'),
children,
child,
...restProps
}: DragPreviewProps = $props();
const provider = DragPreviewProvider.create({
id: readableActive(() => id)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if provider.provider.active}
{#if child}
{@render child({ active: provider.provider.active, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.({ active: provider.provider.active })}
</div>
{/if}
{/if}

@ -0,0 +1,43 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { DragDropProvider } from '../drag-drop-provider.svelte';
import type { DragDropProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'drag-drop'),
onDragStart = () => {},
onDragEnd = () => {},
onDrop = () => {},
announceEnabled = true,
children,
child,
...restProps
}: DragDropProps = $props();
const provider = DragDropProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
onDragStart: readableActive(() => onDragStart),
onDragEnd: readableActive(() => onDragEnd),
onDrop: readableActive(() => onDrop),
announceEnabled: readableActive(() => announceEnabled)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
</div>
{/if}

@ -0,0 +1,634 @@
import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider';
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
import { readableActive, type Active, type ActiveProps } from '../../reactive';
import type { SomaKeyboardEvent } from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { createAnnouncer } from '../announce/global.svelte';
import { DRAG_DROP_LANGS } from './langs';
import type { ActiveDrag, DragData, DragEndEvent, DropEvent } from './types';
const attrs = createAttrs({
component: 'drag-drop',
parts: ['root', 'draggable', 'droppable', 'preview'] as const
});
registerContract({
name: 'drag-drop',
version: 1,
parts: {
root: [
{ attr: 'data-dragging', description: 'A drag is currently active' }
],
draggable: [
{ attr: 'data-dragging', description: 'This draggable is the active source' },
{ attr: 'data-disabled', description: 'Dragging disabled' }
],
droppable: [
{ attr: 'data-dragover', description: 'The active drag is currently hovering' },
{ attr: 'data-accepting', description: 'Would accept the current drag' },
{ attr: 'data-disabled', description: 'Drops disabled' }
],
preview: [
{ attr: 'data-active', description: 'Rendered during an active drag' }
]
}
});
const globalAnnounce = /* @__PURE__ */ createAnnouncer(1500);
interface DragDropOpts
extends WithRefOpts,
ActiveProps<{
onDragStart:
| ((e: { value: string; data: DragData; preventDefault: () => void }) => void)
| undefined;
onDragEnd: ((e: DragEndEvent) => void) | undefined;
onDrop: ((e: DropEvent) => void) | undefined;
announceEnabled: boolean;
}> {}
// ── Root provider ──────────────────────────────────────────────────────────
export class DragDropProvider extends Provider<DragDropOpts> {
static readonly ctx = context<DragDropProvider>('DragDrop');
static get(): DragDropProvider | undefined {
return this.ctx.getOr(undefined) as DragDropProvider | undefined;
}
static require(): DragDropProvider {
return this.ctx.get();
}
static create(opts: DragDropOpts) {
return new DragDropProvider(opts);
}
readonly soma = Soma.get();
/** Currently active drag (pointer or keyboard). */
active = $state<ActiveDrag | null>(null);
/** Droppable element currently under the pointer / keyboard focus. */
overTarget = $state<HTMLElement | null>(null);
/** `true` when the active drag originated from keyboard Space/Enter. */
keyboard = $state(false);
private pointerMoveListener: ((e: PointerEvent) => void) | null = null;
private pointerUpListener: ((e: PointerEvent) => void) | null = null;
private keyListener: ((e: KeyboardEvent) => void) | null = null;
private constructor(opts: DragDropOpts) {
super(opts, 'DragDrop', 'root', attrs.root, DragDropProvider.ctx);
$effect(() => {
return () => this.teardown();
});
}
// ── Announcements ───────────────────────────────────────────────────────
private announce(key: string, priority: 'polite' | 'assertive' = 'polite') {
if (!this.opts.announceEnabled.current) return;
const ling = this.soma?.langs;
const text = ling ? ling.ts(key) : key;
globalAnnounce.announce(text, priority);
}
private announceTemplate(
key: string,
vars: Record<string, string>,
priority: 'polite' | 'assertive' = 'polite'
) {
if (!this.opts.announceEnabled.current) return;
const ling = this.soma?.langs;
const text = ling ? ling.t(key, vars) : key;
globalAnnounce.announce(text, priority);
}
// ── DOM queries ────────────────────────────────────────────────────────
/** All enabled Droppable elements inside this Provider. */
getDroppables(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(`[${attrs.droppable}]:not([data-disabled])`)
).filter((el) => el.closest(`[${attrs.root}]`) === root);
}
/**
* Droppables that accept the current drag, in DOM order.
*/
getAcceptingDroppables(): HTMLElement[] {
const drag = this.active;
if (!drag) return [];
return this.getDroppables().filter((el) => this.isAccepting(el, drag));
}
private isAccepting(el: HTMLElement, drag: ActiveDrag): boolean {
const accept = (el as HTMLElement & { __somaAccept?: (data: DragData, value: string) => boolean })
.__somaAccept;
if (!accept) return true;
try {
return accept(drag.data, drag.value) !== false;
} catch {
return false;
}
}
private dropTargetFromPoint(x: number, y: number): HTMLElement | null {
if (typeof document === 'undefined') return null;
const el = document.elementFromPoint(x, y);
if (!(el instanceof HTMLElement)) return null;
return el.closest<HTMLElement>(`[${attrs.droppable}]:not([data-disabled])`);
}
// ── Start / commit / cancel ────────────────────────────────────────────
/** Begin a drag. Returns `false` if the `onDragStart` callback prevented it. */
startDrag(params: {
value: string;
data: DragData;
label: string;
source: HTMLElement;
pointer: { x: number; y: number } | null;
keyboard: boolean;
}): boolean {
if (this.active) return false;
let prevented = false;
this.opts.onDragStart.current?.({
value: params.value,
data: params.data,
preventDefault: () => {
prevented = true;
}
});
if (prevented) return false;
this.active = {
value: params.value,
data: params.data,
label: params.label,
pointer: params.pointer,
source: params.source
};
this.keyboard = params.keyboard;
this.announceTemplate(DRAG_DROP_LANGS.DRAG_STARTED, { item: params.label });
if (params.keyboard) {
this.setupKeyboardNav();
const firstTarget = this.getAcceptingDroppables()[0];
if (firstTarget) {
this.setOverTarget(firstTarget, { focus: true });
} else {
this.announce(DRAG_DROP_LANGS.DRAG_NO_TARGETS);
}
} else {
this.setupPointerDrag();
}
return true;
}
private setOverTarget(el: HTMLElement | null, opts?: { focus?: boolean }) {
if (this.overTarget === el) return;
this.overTarget = el;
if (!el) return;
if (opts?.focus) el.focus();
const label = el.getAttribute('data-text-value') || el.getAttribute('aria-label') || el.textContent || '';
this.announceTemplate(DRAG_DROP_LANGS.DRAG_OVER_TARGET, { target: label.trim() });
}
commitDrop(target: HTMLElement) {
const drag = this.active;
if (!drag) return;
if (!this.isAccepting(target, drag)) {
this.cancelDrag();
return;
}
const targetLabel =
target.getAttribute('data-text-value') ||
target.getAttribute('aria-label') ||
target.textContent ||
'';
const event: DropEvent = {
value: drag.value,
data: drag.data,
target,
targetLabel: targetLabel.trim()
};
this.opts.onDrop.current?.(event);
this.opts.onDragEnd.current?.({
value: drag.value,
data: drag.data,
outcome: 'drop',
target
});
this.announceTemplate(DRAG_DROP_LANGS.DRAG_DROPPED, {
item: drag.label,
target: targetLabel.trim()
});
this.finish();
}
cancelDrag() {
const drag = this.active;
if (!drag) return;
this.opts.onDragEnd.current?.({
value: drag.value,
data: drag.data,
outcome: 'cancel'
});
this.announce(DRAG_DROP_LANGS.DRAG_CANCELLED);
// Return focus to the source for keyboard drags.
const source = drag.source;
this.finish();
if (this.keyboard && source && typeof source.focus === 'function') {
source.focus();
}
}
private finish() {
this.active = null;
this.overTarget = null;
this.keyboard = false;
this.teardown();
}
private teardown() {
if (this.pointerMoveListener) {
window.removeEventListener('pointermove', this.pointerMoveListener);
this.pointerMoveListener = null;
}
if (this.pointerUpListener) {
window.removeEventListener('pointerup', this.pointerUpListener);
window.removeEventListener('pointercancel', this.pointerUpListener);
this.pointerUpListener = null;
}
if (this.keyListener) {
window.removeEventListener('keydown', this.keyListener);
this.keyListener = null;
}
}
// ── Pointer drag ───────────────────────────────────────────────────────
private setupPointerDrag() {
this.pointerMoveListener = (e: PointerEvent) => {
if (!this.active) return;
this.active = { ...this.active, pointer: { x: e.clientX, y: e.clientY } };
const target = this.dropTargetFromPoint(e.clientX, e.clientY);
if (target && this.active && this.isAccepting(target, this.active)) {
if (this.overTarget !== target) this.setOverTarget(target);
} else {
this.overTarget = null;
}
};
this.pointerUpListener = (e: PointerEvent) => {
if (!this.active) return;
if (e.type === 'pointercancel') {
this.cancelDrag();
return;
}
const target = this.dropTargetFromPoint(e.clientX, e.clientY);
if (target) {
this.commitDrop(target);
} else {
this.cancelDrag();
}
};
window.addEventListener('pointermove', this.pointerMoveListener);
window.addEventListener('pointerup', this.pointerUpListener);
window.addEventListener('pointercancel', this.pointerUpListener);
}
// ── Keyboard drag ──────────────────────────────────────────────────────
private setupKeyboardNav() {
this.keyListener = (e: KeyboardEvent) => {
if (!this.active || !this.keyboard) return;
if (e.key === KEYS.ESCAPE) {
e.preventDefault();
this.cancelDrag();
return;
}
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
if (this.overTarget) {
e.preventDefault();
this.commitDrop(this.overTarget);
}
return;
}
if (
e.key === KEYS.ARROW_DOWN ||
e.key === KEYS.ARROW_RIGHT ||
e.key === KEYS.TAB
) {
e.preventDefault();
const list = this.getAcceptingDroppables();
if (list.length === 0) return;
const idx = this.overTarget ? list.indexOf(this.overTarget) : -1;
const next = list[(idx + 1) % list.length];
if (next) this.setOverTarget(next, { focus: true });
return;
}
if (
e.key === KEYS.ARROW_UP ||
e.key === KEYS.ARROW_LEFT ||
(e.shiftKey && e.key === KEYS.TAB)
) {
e.preventDefault();
const list = this.getAcceptingDroppables();
if (list.length === 0) return;
const idx = this.overTarget ? list.indexOf(this.overTarget) : 0;
const prev = list[(idx - 1 + list.length) % list.length];
if (prev) this.setOverTarget(prev, { focus: true });
return;
}
};
window.addEventListener('keydown', this.keyListener);
}
// ── Root props ─────────────────────────────────────────────────────────
readonly snippetProps = $derived.by(() => ({
active: this.active,
cancel: () => this.cancelDrag()
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-dragging': boolToEmptyStrOrUndef(this.active !== null)
} as const)
);
}
// ── Draggable ──────────────────────────────────────────────────────────────
interface DraggableOpts
extends WithRefOpts,
ActiveProps<{
value: string;
data: DragData;
disabled: boolean;
moveBuffer: number;
textValue: string | undefined;
}> {}
export class DraggableProvider extends Provider<DraggableOpts> {
static create(opts: DraggableOpts) {
return new DraggableProvider(opts);
}
readonly provider: DragDropProvider;
/** Pending pointer-down state; promoted to active drag after moveBuffer exceeded. */
private pending: {
id: number;
startX: number;
startY: number;
} | null = null;
private constructor(opts: DraggableOpts) {
super(opts, 'DragDrop', 'draggable', attrs.draggable);
this.provider = DragDropProvider.require();
}
readonly isActive = $derived.by(
() => this.provider.active?.value === this.opts.value.current
);
private getLabel(): string {
return (
this.opts.textValue.current ||
this.opts.ref.current?.getAttribute('aria-label') ||
this.opts.ref.current?.textContent?.trim() ||
this.opts.value.current
);
}
readonly onpointerdown = (e: PointerEvent) => {
if (this.opts.disabled.current || e.button !== 0) return;
const el = this.opts.ref.current;
if (!el) return;
this.pending = { id: e.pointerId, startX: e.clientX, startY: e.clientY };
const onMove = (me: PointerEvent) => {
if (!this.pending || me.pointerId !== this.pending.id) return;
const dx = me.clientX - this.pending.startX;
const dy = me.clientY - this.pending.startY;
if (Math.hypot(dx, dy) < this.opts.moveBuffer.current) return;
// Promote to active drag.
const pending = this.pending;
this.pending = null;
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onCancel);
window.removeEventListener('pointercancel', onCancel);
this.provider.startDrag({
value: this.opts.value.current,
data: this.opts.data.current,
label: this.getLabel(),
source: el,
pointer: { x: me.clientX, y: me.clientY },
keyboard: false
});
};
const onCancel = () => {
this.pending = null;
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onCancel);
window.removeEventListener('pointercancel', onCancel);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onCancel);
window.addEventListener('pointercancel', onCancel);
};
readonly onkeydown = (e: KeyboardEvent) => {
if (this.opts.disabled.current) return;
if (this.provider.active) return; // drag already in progress
if (e.key !== KEYS.SPACE && e.key !== KEYS.ENTER) return;
const el = this.opts.ref.current;
if (!el) return;
e.preventDefault();
this.provider.startDrag({
value: this.opts.value.current,
data: this.opts.data.current,
label: this.getLabel(),
source: el,
pointer: null,
keyboard: true
});
};
readonly handleProps = $derived.by(
() =>
({
onpointerdown: this.onpointerdown,
onkeydown: this.onkeydown,
tabindex: 0,
'data-drag-handle': ''
}) as Record<string, unknown>
);
readonly snippetProps = $derived.by(() => ({
dragging: this.isActive,
handleProps: this.handleProps
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'button' as const,
tabindex: this.opts.disabled.current ? -1 : 0,
'aria-disabled': this.opts.disabled.current ? true : undefined,
'aria-roledescription': this.provider.soma?.langs.ts(
DRAG_DROP_LANGS.DRAGGABLE_ROLE
),
'aria-grabbed': this.isActive ? true : undefined,
'data-dragging': boolToEmptyStrOrUndef(this.isActive),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current,
'data-value': this.opts.value.current,
onpointerdown: this.onpointerdown,
onkeydown: this.onkeydown,
'style:touch-action': 'none'
} as const)
);
}
// ── Droppable ──────────────────────────────────────────────────────────────
interface DroppableOpts
extends WithRefOpts,
ActiveProps<{
accept: ((data: DragData, value: string) => boolean) | undefined;
disabled: boolean;
textValue: string | undefined;
}> {}
export class DroppableProvider extends Provider<DroppableOpts> {
static create(opts: DroppableOpts) {
return new DroppableProvider(opts);
}
readonly provider: DragDropProvider;
private constructor(opts: DroppableOpts) {
super(opts, 'DragDrop', 'droppable', attrs.droppable);
this.provider = DragDropProvider.require();
// Attach the accept predicate directly to the DOM element so the
// Provider can read it without a registry (which would require reactive
// bookkeeping + cleanup for every Droppable mount/unmount).
$effect(() => {
const el = opts.ref.current;
const accept = opts.accept.current;
if (!el) return;
(el as HTMLElement & { __somaAccept?: typeof accept }).__somaAccept = accept;
return () => {
delete (el as HTMLElement & { __somaAccept?: unknown }).__somaAccept;
};
});
}
readonly isOver = $derived.by(() => this.provider.overTarget === this.opts.ref.current);
readonly isAccepting = $derived.by(() => {
const drag = this.provider.active;
if (!drag || this.opts.disabled.current) return false;
const accept = this.opts.accept.current;
if (!accept) return true;
try {
return accept(drag.data, drag.value) !== false;
} catch {
return false;
}
});
readonly onpointerup = (e: PointerEvent) => {
// Safety: the Provider's window listener already handles commit via
// elementFromPoint, but in case setPointerCapture steals events we
// also catch the release here.
if (!this.provider.active || this.opts.disabled.current) return;
if (!this.isAccepting) return;
const el = this.opts.ref.current;
if (!el) return;
e.stopPropagation();
this.provider.commitDrop(el);
};
readonly snippetProps = $derived.by(() => ({
over: this.isOver,
accepting: this.isAccepting
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'region' as const,
tabindex: this.provider.active && this.isAccepting ? 0 : -1,
'aria-roledescription': this.provider.soma?.langs.ts(
DRAG_DROP_LANGS.DROPPABLE_ROLE
),
'aria-dropeffect':
this.provider.active && this.isAccepting ? ('move' as const) : ('none' as const),
'aria-disabled': this.opts.disabled.current ? true : undefined,
'data-dragover': boolToEmptyStrOrUndef(this.isOver),
'data-accepting': boolToEmptyStrOrUndef(this.isAccepting),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-text-value': this.opts.textValue.current,
onpointerup: this.onpointerup
} as const)
);
}
// ── Preview ────────────────────────────────────────────────────────────────
interface DragPreviewOpts extends ProviderOpts {}
export class DragPreviewProvider extends Provider<DragPreviewOpts> {
static create(opts: DragPreviewOpts) {
return new DragPreviewProvider(opts);
}
readonly provider: DragDropProvider;
private constructor(opts: DragPreviewOpts) {
super(opts, 'DragDrop', 'preview', attrs.preview);
this.provider = DragDropProvider.require();
}
readonly active: Active<typeof this.provider.active> = readableActive(
() => this.provider.active
);
readonly style = $derived.by(() => {
const a = this.provider.active;
if (!a) return { display: 'none' };
const pt = a.pointer;
if (!pt) {
const rect = a.source.getBoundingClientRect();
return {
position: 'fixed',
left: `${rect.left}px`,
top: `${rect.top}px`,
'pointer-events': 'none',
'z-index': '9999'
};
}
return {
position: 'fixed',
left: `${pt.x + 12}px`,
top: `${pt.y + 12}px`,
'pointer-events': 'none',
'z-index': '9999'
};
});
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'aria-hidden': true as const,
'data-active': boolToEmptyStrOrUndef(this.provider.active !== null),
style: this.style
} as const)
);
}

@ -0,0 +1,18 @@
export { default as Provider } from './components/drag-drop.svelte';
export { default as Draggable } from './components/drag-drop-draggable.svelte';
export { default as Droppable } from './components/drag-drop-droppable.svelte';
export { default as Preview } from './components/drag-drop-preview.svelte';
export type {
DragDropProps as ProviderProps,
DraggableProps,
DroppableProps,
DragPreviewProps as PreviewProps,
DragProviderSnippetProps as ProviderSnippetProps,
DraggableSnippetProps,
DroppableSnippetProps,
DragData,
ActiveDrag,
DropEvent,
DragEndEvent
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,18 @@
export const DRAG_DROP_LANGS = {
/** Announced when a drag begins via keyboard or pointer. `{{item}}` is the draggable's textValue / aria-label. */
DRAG_STARTED: '#?components.drag-drop.drag-started|Started dragging {{item}}',
/** Announced when the pointer / keyboard focus enters a valid drop zone. */
DRAG_OVER_TARGET: '#?components.drag-drop.drag-over|Over drop zone {{target}}',
/** Announced on successful drop. */
DRAG_DROPPED: '#?components.drag-drop.dropped|Dropped {{item}} on {{target}}',
/** Announced when the drag is cancelled. */
DRAG_CANCELLED: '#?components.drag-drop.cancelled|Drag cancelled',
/** Announced when no valid drop target is available. */
DRAG_NO_TARGETS: '#?components.drag-drop.no-targets|No drop targets available',
/** `aria-roledescription` for a Draggable. */
DRAGGABLE_ROLE: '#?components.drag-drop.draggable-role|draggable',
/** `aria-roledescription` for a Droppable. */
DROPPABLE_ROLE: '#?components.drag-drop.droppable-role|drop zone',
/** Label for a Droppable currently accepting the active drag. */
DROPPABLE_ACTIVE: '#?components.drag-drop.droppable-active|active drop zone'
} as const;

@ -0,0 +1,175 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
/** Arbitrary drag payload. Consumer-defined shape. */
export type DragData = Record<string, unknown>;
/** Info about the currently active drag. */
export type ActiveDrag<D extends DragData = DragData> = {
/** Stable identifier from the Draggable's `value` prop. */
value: string;
/** Consumer-supplied payload. */
data: D;
/** Text label (textValue, aria-label, or element textContent) for announcements. */
label: string;
/** Current pointer position in viewport coords, or `null` for keyboard drags not yet moved. */
pointer: { x: number; y: number } | null;
/** Source element. */
source: HTMLElement;
};
/** Info passed to `onDrop`. */
export type DropEvent<D extends DragData = DragData> = {
value: string;
data: D;
/** Drop target DOM element. */
target: HTMLElement;
/** Drop target label (textValue / aria-label / textContent). */
targetLabel: string;
};
export type DragEndEvent<D extends DragData = DragData> = {
value: string;
data: D;
/** `'drop'` when dropped on a valid target, `'cancel'` when cancelled (Escape / invalid release). */
outcome: 'drop' | 'cancel';
target?: HTMLElement;
};
export type DragProviderSnippetProps = {
active: ActiveDrag | null;
cancel: () => void;
};
export type DraggableSnippetProps = {
dragging: boolean;
handleProps: Record<string, unknown>;
};
export type DroppableSnippetProps = {
over: boolean;
accepting: boolean;
};
// ── Provider ───────────────────────────────────────────────────────────────
/**
* Props for `DragDrop.Provider`.
*
* Coordinates pointer + keyboard drag-and-drop across a subtree. Mount one
* Provider around the smallest region that contains both the Draggables and
* the Droppables that participate together.
*
* The Provider does NOT own the data — it only coordinates the gesture +
* announces via live regions. Consumer receives `onDrop` and mutates their
* own state.
*/
export type DragDropProps = WithChild<
{
id?: string;
/**
* Fires before the drag starts. Call `e.preventDefault()` to cancel.
* (Rarely needed — `Draggable.disabled` is preferred.)
*/
onDragStart?: (e: { value: string; data: DragData; preventDefault: () => void }) => void;
/** Fires when the drag ends (drop or cancel). */
onDragEnd?: (e: DragEndEvent) => void;
/** Fires on successful drop. */
onDrop?: (e: DropEvent) => void;
/**
* Emit screen-reader announcements via the global `Announce` API
* (drag start, over, drop, cancel). @default true
*/
announceEnabled?: boolean;
children?: Snippet<[DragProviderSnippetProps]>;
},
DragProviderSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Draggable ──────────────────────────────────────────────────────────────
/**
* Props for `DragDrop.Draggable`.
*
* Wraps a DOM element that can be dragged. The consumer can use the snippet
* `handleProps` pattern to restrict the drag "grip" to a child element
* (e.g. a handle icon) — otherwise the whole element is the handle.
*/
export type DraggableProps = WithChild<
{
id?: string;
/** Unique identifier for this item. Returned in onDrop. */
value: string;
/** Arbitrary payload the consumer receives in onDrop. */
data?: DragData;
/** Disable dragging. @default false */
disabled?: boolean;
/**
* Pixels the pointer must move before a drag is recognised. Prevents
* accidental drags on clicks. @default 5
*/
moveBuffer?: number;
/** Label for announcements. Falls back to aria-label → textContent. */
textValue?: string;
children?: Snippet<[DraggableSnippetProps]>;
},
DraggableSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Droppable ──────────────────────────────────────────────────────────────
/**
* Props for `DragDrop.Droppable`.
*
* A drop target. When a drag is in progress and the pointer hovers this
* element (or keyboard focus reaches it), `data-dragover` is present. On
* drop, `onDrop` fires on the Provider with the source's value / data.
*/
export type DroppableProps = WithChild<
{
id?: string;
/**
* Filter — return `true` if the current drag can be dropped here.
* When `false`, the droppable is skipped in keyboard nav and pointer
* hits are ignored. @default () => true
*/
accept?: (data: DragData, value: string) => boolean;
/** Label for announcements. Falls back to aria-label → textContent. */
textValue?: string;
/** Disable drops on this target. @default false */
disabled?: boolean;
children?: Snippet<[DroppableSnippetProps]>;
},
DroppableSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Preview ────────────────────────────────────────────────────────────────
/** Snippet props for `DragDrop.Preview`. */
export type DragPreviewSnippetProps = {
active: ActiveDrag;
};
/**
* Props for `DragDrop.Preview`.
*
* Optional floating element that follows the pointer during drag. Positioned
* `fixed` at the current pointer position with `pointer-events: none`. For
* keyboard drags (no pointer position), rendered at the source element's
* position until first move.
*/
export type DragPreviewProps = WithChild<
{
id?: string;
children?: Snippet<[DragPreviewSnippetProps]>;
},
DragPreviewSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;

@ -0,0 +1,267 @@
# Feed
Implements the WAI-ARIA [Feed pattern](https://www.w3.org/WAI/ARIA/apg/patterns/feed/) — a scrollable stream of `role="article"` children where assistive tech navigates between articles with `PageUp` / `PageDown`. Each article exposes its position via `aria-posinset` / `aria-setsize` so the user always knows where they are.
Use Feed for activity streams, chat histories, threaded comments — anything where the article count can grow via lazy loading. Use `GridList` or `Listbox` instead when items are bounded and the user picks one.
## Anatomy
```svelte
<Feed.Provider {totalItems} {busy} {onLoadMore}>
{#each posts as post (post.id)}
<Feed.Article>
<Feed.ArticleTitle level={3}>{post.title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Provider>
```
## Parts
| Part | Element | Description |
| -------------------- | --------- | ------------------------------------------------------------------------------------ |
| `Provider` | `<div>` | `role="feed"`. Coordinates PageUp/PageDown navigation. |
| `Article` | `<div>` | `role="article"`. Focusable, auto-computed `aria-posinset` / `aria-setsize` / level. |
| `ArticleTitle` | `<div>` | `role="heading"` + auto-derived `aria-level` from thread depth. |
| `ArticleDescription` | `<div>` | Linked via `aria-describedby` on the enclosing Article. |
| `Thread` | `<div>` | Nested sub-feed (`role="feed"`). Child articles inherit level + 1. |
| `Sentinel` | `<div>` | Invisible IntersectionObserver probe. Fires `onLoadMore` when visible. |
## Props
### `Provider`
| Prop | Type | Default | Description |
| ----------------- | --------------------- | ---------- | ------------------------------------------------------------------------ |
| `id` | `string` | auto | DOM id. |
| `totalItems` | `number \| undefined` | — | Total across all pages. `undefined` when unknown (infinite / unbounded). |
| `busy` | `boolean` | `false` | Whether more items are loading. Sets `aria-busy`. |
| `onLoadMore` | `() => void` | — | Called when the user PageDown's past the last article. |
| `aria-label` | `string` | translated | Accessible name (default `'Feed'`). |
| `aria-labelledby` | `string` | — | External label id. |
Snippet props: `{ busy }`.
### `Article`
| Prop | Type | Default | Description |
| ----------- | -------- | ------------- | --------------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `posinset` | `number` | auto (1-based DOM index) | Explicit position in the full set (use when DOM order ≠ feed order). |
Snippet props: `{ index, total }` (0-based `index`, `total` is `totalItems` or `undefined`).
### `ArticleTitle`
| Prop | Type | Default | Description |
| ------- | ------------------------ | ---------------------------------------------- | -------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `level` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | derived from thread depth (3 top, 4 nested, …) | Heading level. Explicit value wins. |
### `ArticleDescription`
| Prop | Type | Default | Description |
| ---- | -------- | ------- | ----------- |
| `id` | `string` | auto | DOM id. |
### `Thread`
| Prop | Type | Default | Description |
| ---- | -------- | ------- | ----------- |
| `id` | `string` | auto | DOM id. |
### `Sentinel`
| Prop | Type | Default | Description |
| ------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `rootMargin` | `string` | `'200px'` | IntersectionObserver rootMargin. |
| `threshold` | `number \| number[]` | `0` | IntersectionObserver threshold. |
| `root` | `Element \| Document \| null` | `null` | Observer root (`null` = viewport). |
| `onIntersect` | `() => void` | parent Feed's `onLoadMore` | Callback when the sentinel enters the observed zone. |
| `disabled` | `boolean` | `false` | Skip observer setup. |
## ARIA
| Part | Attribute | Value |
| ------------------- | ------------------ | ---------------------------------------- |
| Provider | `role` | `feed` |
| Provider | `aria-busy` | `true` when `busy` |
| Provider | `aria-label` | Translated default or override |
| Article | `role` | `article` |
| Article | `tabindex` | `0` (focusable for PageUp/PageDown nav) |
| Article | `aria-posinset` | Explicit `posinset` or auto-computed |
| Article | `aria-setsize` | `totalItems`, or `-1` when unknown (per APG) |
| Article | `aria-labelledby` | Auto-wired to `ArticleTitle` id |
| Article | `aria-describedby` | Auto-wired to `ArticleDescription` id |
| ArticleTitle | `role` | `heading` |
| ArticleTitle | `aria-level` | As prop (`3` by default) |
Per [the APG](https://www.w3.org/WAI/ARIA/apg/patterns/feed/):
- `aria-setsize="-1"` means "total unknown" (soma emits this when `totalItems` is undefined).
- `aria-busy="true"` signals that new articles are being loaded; AT may defer announcements.
## Data Attributes
| Part | Attribute | Values |
| ------------------- | --------------------------------- | ----------------------------------- |
| Provider | `data-feed` | Always present |
| Provider | `data-busy` | Present when busy |
| Article | `data-feed-article` | Always present |
| Article | `data-posinset` | Numeric position |
| Article | `data-level` | Nesting depth (1 = top, 2 = Thread) |
| ArticleTitle | `data-feed-article-title` | Always present |
| ArticleDescription | `data-feed-article-description` | Always present |
| Thread | `data-feed-thread` | Always present |
| Thread | `data-level` | Thread depth |
| Sentinel | `data-feed-sentinel` | Always present |
| Sentinel | `data-intersecting` | Present while in the observed zone |
## Keyboard
| Key | Action |
| ------------------------ | --------------------------------------------------------------------- |
| `PageDown` (on Article) | Focus next Article. At the last one, calls `onLoadMore()`. |
| `PageUp` (on Article) | Focus previous Article. |
| `Ctrl+Home` | Focus first Article. |
| `Ctrl+End` | Focus last Article. |
| `Tab` (within Article) | Enters inner interactive elements per native tab order. |
## Virtualization
Compose with [`VirtualList`](../virtual-list/README.md) for long streams:
```svelte
<Feed.Provider aria-label="Activity">
<VirtualList.Provider count={posts.length} itemSize={120} getItemKey={(i) => posts[i].id}>
{#snippet children({ virtualItems, totalSize })}
<VirtualList.Viewport class="feed-viewport">
<div style:height="{totalSize}px" style:position="relative">
{#each virtualItems as v (v.key)}
<VirtualList.Item index={v.index} start={v.start} size={v.size}>
<Feed.Article>
<Feed.ArticleTitle>{posts[v.index].title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{posts[v.index].body}</Feed.ArticleDescription>
</Feed.Article>
</VirtualList.Item>
{/each}
</div>
</VirtualList.Viewport>
{/snippet}
</VirtualList.Provider>
<Feed.Sentinel onIntersect={loadMore} />
</Feed.Provider>
```
PageUp/PageDown still work — the Provider's `getArticles()` queries the current DOM, so only mounted articles participate. Pair with `Feed.Sentinel` for auto-load at the viewport's tail.
## Comparison
| Feature | Soma | Radix | Ark UI | react-aria | APG |
| ---------------------------------- | :--: | :---: | :----: | :--------: | :-: |
| `role="feed"` root | ✅ | ❌ | ❌ | ❌¹ | ✅ |
| `role="article"` children | ✅ | — | — | — | ✅ |
| `aria-posinset` / `aria-setsize` | ✅ | — | — | — | ✅ |
| `aria-busy` during load | ✅ | — | — | — | ✅ |
| PageUp / PageDown navigation | ✅ | — | — | — | ✅ |
| `onLoadMore` callback | ✅ | — | — | — | — |
| Ctrl+Home / Ctrl+End jumps | ✅ | — | — | — | ✅ |
| Auto-wired title/description | ✅ | — | — | — | — |
| Nested threads (`Feed.Thread`) | ✅ | — | — | ❌ | — |
| Auto-derived heading level | ✅ | — | — | ❌ | — |
| IntersectionObserver auto-load | ✅ | — | — | ✅ | — |
| Virtualization (via compose) | ✅² | — | — | ✅ | — |
¹ react-aria recommends `GridList` or `ListBox` rather than `role=feed`. Soma ships Feed because WAI-ARIA defines the pattern and it's the correct semantic for activity streams.
² Compose with `VirtualList` — see the section above. No coupling between soma and the virtualizer.
## Usage
### Basic infinite feed
```svelte
<script>
let posts = $state<Post[]>([]);
let busy = $state(false);
let done = $state(false);
async function loadMore() {
if (busy || done) return;
busy = true;
const page = await fetchPosts({ after: posts.at(-1)?.id });
if (page.length === 0) done = true;
else posts = [...posts, ...page];
busy = false;
}
</script>
<Feed.Provider {busy} onLoadMore={loadMore} aria-label="Activity">
{#each posts as post (post.id)}
<Feed.Article>
<Feed.ArticleTitle>{post.title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Provider>
```
### Known total (paginated feed)
```svelte
<Feed.Provider totalItems={500} aria-label="Comments">
{#each visible as c (c.id)}
<Feed.Article posinset={c.index + 1}>
<!-- index/total available via snippet props -->
</Feed.Article>
{/each}
</Feed.Provider>
```
### IntersectionObserver auto-load
Drop a `Feed.Sentinel` inside the Provider. When it scrolls into view it fires the Provider's `onLoadMore` automatically:
```svelte
<Feed.Provider {busy} onLoadMore={loadMore}>
{#each posts as p (p.id)}
<Feed.Article>...</Feed.Article>
{/each}
<Feed.Sentinel rootMargin="400px" disabled={busy} />
</Feed.Provider>
```
Keyboard users still trigger `onLoadMore` via `PageDown` at the last article — the Sentinel is additive for pointer/scroll users.
### Threaded replies
Wrap replies in a `Feed.Thread` inside the parent Article. Nested articles automatically inherit:
- `aria-setsize` scoped to the thread (not the root feed).
- `aria-level` on the heading bumped by one per nesting level.
- `data-level` attribute on both Thread and Article.
```svelte
<Feed.Provider aria-label="Discussion">
{#each posts as post (post.id)}
<Feed.Article>
<Feed.ArticleTitle>{post.title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
{#if post.replies?.length}
<Feed.Thread>
{#each post.replies as r (r.id)}
<Feed.Article>
<!-- aria-level is auto: 4 when the parent was level-3 -->
<Feed.ArticleTitle>Reply from {r.author}</Feed.ArticleTitle>
<Feed.ArticleDescription>{r.body}</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Thread>
{/if}
</Feed.Article>
{/each}
</Feed.Provider>
```
Threads nest to any depth — the level computation walks up through enclosing Threads. Each Thread is a fresh `role="feed"` with its own posinset/setsize.

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { FeedArticleDescriptionProvider } from '../feed-provider.svelte';
import type { FeedArticleDescriptionProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'feed-article-description'),
children,
child,
...restProps
}: FeedArticleDescriptionProps = $props();
const provider = FeedArticleDescriptionProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { FeedArticleTitleProvider } from '../feed-provider.svelte';
import type { FeedArticleTitleProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'feed-article-title'),
level,
children,
child,
...restProps
}: FeedArticleTitleProps = $props();
const provider = FeedArticleTitleProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
level: readableActive(() => level)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { FeedArticleProvider } from '../feed-provider.svelte';
import type { FeedArticleProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'feed-article'),
posinset,
children,
child,
...restProps
}: FeedArticleProps = $props();
const provider = FeedArticleProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
posinset: readableActive(() => posinset)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
</div>
{/if}

@ -0,0 +1,45 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { FeedSentinelProvider } from '../feed-provider.svelte';
import type { FeedSentinelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'feed-sentinel'),
rootMargin = '200px',
threshold = 0,
root = null,
onIntersect,
disabled = false,
children,
child,
...restProps
}: FeedSentinelProps = $props();
const provider = FeedSentinelProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
rootMargin: readableActive(() => rootMargin),
threshold: readableActive(() => threshold),
root: readableActive(() => root),
onIntersect: readableActive(() => onIntersect),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { FeedThreadProvider } from '../feed-provider.svelte';
import type { FeedThreadProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'feed-thread'),
children,
child,
...restProps
}: FeedThreadProps = $props();
const provider = FeedThreadProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,45 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { FeedProvider } from '../feed-provider.svelte';
import type { FeedProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'feed'),
totalItems,
busy = false,
onLoadMore = () => {},
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
child,
...restProps
}: FeedProps = $props();
const provider = FeedProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
totalItems: readableActive(() => totalItems),
busy: readableActive(() => busy),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby),
onLoadMore: readableActive(() => onLoadMore)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
</div>
{/if}

@ -0,0 +1,17 @@
export { default as Provider } from './components/feed.svelte';
export { default as Article } from './components/feed-article.svelte';
export { default as ArticleTitle } from './components/feed-article-title.svelte';
export { default as ArticleDescription } from './components/feed-article-description.svelte';
export { default as Thread } from './components/feed-thread.svelte';
export { default as Sentinel } from './components/feed-sentinel.svelte';
export type {
FeedProps as ProviderProps,
FeedArticleProps as ArticleProps,
FeedArticleTitleProps as ArticleTitleProps,
FeedArticleDescriptionProps as ArticleDescriptionProps,
FeedThreadProps as ThreadProps,
FeedSentinelProps as SentinelProps,
FeedProviderSnippetProps as ProviderSnippetProps,
FeedArticleSnippetProps as ArticleSnippetProps
} from './types';

@ -0,0 +1,434 @@
import { Provider, context, type WithRefOpts } from '../../provider';
import {
createAttrs,
registerContract,
boolToEmptyStrOrUndef
} from '../../attrs';
import { readableActive, state, type Active, type ActiveProps } from '../../reactive';
import type { SomaKeyboardEvent } from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { FEED_LANGS } from './langs';
const attrs = createAttrs({
component: 'feed',
parts: [
'root',
'article',
'article-title',
'article-description',
'thread',
'sentinel'
] as const
});
registerContract({
name: 'feed',
version: 1,
parts: {
root: [{ attr: 'data-busy', description: 'Loading more items' }],
article: [
{ attr: 'data-posinset', description: 'Position in the full set' },
{ attr: 'data-level', description: 'Nesting depth (1 = top level)' }
],
'article-title': [],
'article-description': [],
thread: [{ attr: 'data-level', description: 'Nesting depth (1 = top level)' }],
sentinel: [
{ attr: 'data-intersecting', description: 'Currently in viewport' }
]
}
});
// ── Root provider ──────────────────────────────────────────────────────────
interface FeedOpts
extends WithRefOpts,
ActiveProps<{
totalItems: number | undefined;
busy: boolean;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onLoadMore: (() => void) | undefined;
}> {}
export class FeedProvider extends Provider<FeedOpts> {
static readonly ctx = context<FeedProvider>('Feed');
static get(): FeedProvider | undefined {
return this.ctx.getOr(undefined) as FeedProvider | undefined;
}
static require(): FeedProvider {
return this.ctx.get();
}
static create(opts: FeedOpts) {
return new FeedProvider(opts);
}
readonly soma = Soma.get();
private constructor(opts: FeedOpts) {
super(opts, 'Feed', 'root', attrs.root, FeedProvider.ctx);
}
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() => {
if (this.opts.ariaLabelledby.current) return undefined;
return (
this.opts.ariaLabel.current ||
this.soma?.langs.ts(FEED_LANGS.LABEL) ||
undefined
);
});
/** All article elements, in DOM order. */
getArticles(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
return Array.from(root.querySelectorAll<HTMLElement>(`[${attrs.article}]`)).filter(
(el) => el.closest(`[${attrs.root}]`) === root
);
}
/**
* Articles that belong directly to the given feed container (root or
* thread). Used to compute posinset/setsize per nesting level.
*/
getArticlesInContainer(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(`[${attrs.article}]`)).filter(
(el) => {
// Closest enclosing feed container (root OR thread).
const enclosing = el.parentElement?.closest<HTMLElement>(
`[${attrs.root}], [${attrs.thread}]`
);
return enclosing === container;
}
);
}
private focusAt(index: number) {
const articles = this.getArticles();
if (articles.length === 0) return;
const clamped = Math.max(0, Math.min(articles.length - 1, index));
articles[clamped]?.focus();
}
/** Fired on the feed root (capture Page nav) and on articles (for end-of-feed). */
handleArticleKeydown(e: SomaKeyboardEvent<HTMLElement>) {
const articles = this.getArticles();
if (articles.length === 0) return;
const current = e.currentTarget;
const currentIndex = articles.indexOf(current);
if (e.key === KEYS.PAGE_DOWN) {
e.preventDefault();
if (currentIndex >= articles.length - 1) {
// End of feed — request more.
this.opts.onLoadMore.current?.();
return;
}
this.focusAt(currentIndex + 1);
return;
}
if (e.key === KEYS.PAGE_UP) {
e.preventDefault();
this.focusAt(currentIndex - 1);
return;
}
if (e.key === KEYS.HOME && e.ctrlKey) {
e.preventDefault();
this.focusAt(0);
return;
}
if (e.key === KEYS.END && e.ctrlKey) {
e.preventDefault();
this.focusAt(articles.length - 1);
return;
}
}
readonly snippetProps = $derived.by(() => ({
busy: this.opts.busy.current
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'feed' as const,
'aria-label': this.resolvedAriaLabel.current,
'aria-labelledby': this.opts.ariaLabelledby.current,
'aria-busy': this.opts.busy.current ? true : undefined,
'data-busy': boolToEmptyStrOrUndef(this.opts.busy.current)
} as const)
);
}
// ── Thread (nested article container) ──────────────────────────────────────
interface FeedThreadOpts extends WithRefOpts {}
/**
* Contains a nested sub-feed of articles (replies, sub-comments). Emits
* `role="feed"` so assistive tech recognises the nested stream. Children
* `Feed.Article`s inherit the correct nesting level automatically — their
* heading defaults become `aria-level={parent_level + 1}`.
*/
export class FeedThreadProvider extends Provider<FeedThreadOpts> {
static readonly ctx = context<FeedThreadProvider>('FeedThread');
static get(): FeedThreadProvider | undefined {
return this.ctx.getOr(undefined) as FeedThreadProvider | undefined;
}
static create(opts: FeedThreadOpts) {
return new FeedThreadProvider(opts);
}
readonly parentArticle: FeedArticleProvider;
readonly level: number;
private constructor(opts: FeedThreadOpts) {
super(opts, 'Feed', 'thread', attrs.thread, FeedThreadProvider.ctx);
this.parentArticle = FeedArticleProvider.require();
this.level = this.parentArticle.level + 1;
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'feed' as const,
'aria-labelledby': this.parentArticle.titleId.current || undefined,
'data-level': this.level
} as const)
);
}
// ── Article ────────────────────────────────────────────────────────────────
interface FeedArticleOpts
extends WithRefOpts,
ActiveProps<{ posinset: number | undefined }> {}
export class FeedArticleProvider extends Provider<FeedArticleOpts> {
static readonly ctx = context<FeedArticleProvider>('FeedArticle');
static get(): FeedArticleProvider | undefined {
return this.ctx.getOr(undefined) as FeedArticleProvider | undefined;
}
static require(): FeedArticleProvider {
return this.ctx.get();
}
static create(opts: FeedArticleOpts) {
return new FeedArticleProvider(opts);
}
readonly provider: FeedProvider;
readonly thread: FeedThreadProvider | undefined;
/** 1 for top-level, +1 for each enclosing `Feed.Thread`. */
readonly level: number;
/** Registered by child parts on mount. */
titleId = state('');
descriptionId = state('');
private constructor(opts: FeedArticleOpts) {
super(opts, 'Feed', 'article', attrs.article, FeedArticleProvider.ctx);
this.provider = FeedProvider.require();
this.thread = FeedThreadProvider.get();
this.level = this.thread ? this.thread.level : 1;
}
/** Enclosing feed container — either the root or a `Feed.Thread`. */
readonly containerEl = $derived.by(() => {
const el = this.opts.ref.current;
if (!el) return undefined;
return el.parentElement?.closest<HTMLElement>(
`[${attrs.root}], [${attrs.thread}]`
) ?? undefined;
});
/** DOM-order index of this article in its container (1-based). */
readonly autoPosinset = $derived.by(() => {
const el = this.opts.ref.current;
const container = this.containerEl;
if (!el || !container) return undefined;
const siblings = this.provider.getArticlesInContainer(container);
const idx = siblings.indexOf(el);
return idx >= 0 ? idx + 1 : undefined;
});
readonly resolvedPosinset = $derived.by(
() => this.opts.posinset.current ?? this.autoPosinset
);
readonly resolvedSetsize = $derived.by(() => {
// Only the root feed uses the consumer-supplied totalItems; nested
// threads always get -1 (unknown) or the sibling count.
if (this.level === 1) {
const total = this.provider.opts.totalItems.current;
if (total !== undefined) return total;
}
return -1;
});
readonly snippetProps = $derived.by(() => ({
index: (this.resolvedPosinset ?? 1) - 1,
total:
this.provider.opts.totalItems.current !== undefined
? this.provider.opts.totalItems.current
: undefined
}));
readonly onkeydown = (e: KeyboardEvent) => {
this.provider.handleArticleKeydown(e as SomaKeyboardEvent<HTMLElement>);
};
readonly props = $derived.by(() => {
const posinset = this.resolvedPosinset;
return this.assertProps({
...this.baseProps,
role: 'article' as const,
tabindex: 0,
'aria-posinset': posinset,
'aria-setsize': this.resolvedSetsize,
'aria-labelledby': this.titleId.current || undefined,
'aria-describedby': this.descriptionId.current || undefined,
'data-posinset': posinset,
'data-level': this.level,
onkeydown: this.onkeydown
} as const);
});
}
// ── ArticleTitle ───────────────────────────────────────────────────────────
interface FeedArticleTitleOpts
extends WithRefOpts,
ActiveProps<{ level: number | undefined }> {}
export class FeedArticleTitleProvider extends Provider<FeedArticleTitleOpts> {
static create(opts: FeedArticleTitleOpts) {
return new FeedArticleTitleProvider(opts);
}
readonly article: FeedArticleProvider;
private constructor(opts: FeedArticleTitleOpts) {
super(opts, 'Feed', 'article-title', attrs['article-title']);
this.article = FeedArticleProvider.require();
// A30: direct assignment.
this.article.titleId.current = opts.id.current;
}
/**
* Heading level: explicit prop wins; otherwise derive from the article's
* nesting depth (top-level → h3, nested once → h4, etc.). Clamped to 1–6.
*/
readonly resolvedLevel = $derived.by(() => {
const explicit = this.opts.level.current;
if (explicit !== undefined && explicit !== null) return explicit;
const derived = 2 + this.article.level; // level=1 → 3; level=2 → 4; …
return Math.min(6, Math.max(1, derived));
});
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'heading' as const,
'aria-level': this.resolvedLevel
} as const)
);
}
// ── ArticleDescription ─────────────────────────────────────────────────────
interface FeedArticleDescriptionOpts extends WithRefOpts {}
export class FeedArticleDescriptionProvider extends Provider<FeedArticleDescriptionOpts> {
static create(opts: FeedArticleDescriptionOpts) {
return new FeedArticleDescriptionProvider(opts);
}
readonly article: FeedArticleProvider;
private constructor(opts: FeedArticleDescriptionOpts) {
super(opts, 'Feed', 'article-description', attrs['article-description']);
this.article = FeedArticleProvider.require();
this.article.descriptionId.current = opts.id.current;
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps
} as const)
);
}
// ── Sentinel ───────────────────────────────────────────────────────────────
interface FeedSentinelOpts
extends WithRefOpts,
ActiveProps<{
rootMargin: string;
threshold: number | number[];
root: Element | Document | null;
onIntersect: (() => void) | undefined;
disabled: boolean;
}> {}
/**
* Invisible probe element that fires a callback when scrolled into view.
* When nested inside a `Feed.Provider`, defaults to calling the Feed's
* `onLoadMore`. When disabled or unmounted, the observer is torn down.
*/
export class FeedSentinelProvider extends Provider<FeedSentinelOpts> {
static create(opts: FeedSentinelOpts) {
return new FeedSentinelProvider(opts);
}
/** Optional — sentinel can live outside Feed.Provider (e.g. custom streams). */
readonly feed: FeedProvider | undefined;
intersecting = $state(false);
private constructor(opts: FeedSentinelOpts) {
super(opts, 'Feed', 'sentinel', attrs.sentinel);
this.feed = FeedProvider.get();
$effect(() => {
const el = opts.ref.current;
const disabled = opts.disabled.current;
if (!el || disabled || typeof IntersectionObserver === 'undefined') return;
const rootMargin = opts.rootMargin.current;
const threshold = opts.threshold.current;
const rootOpt = opts.root.current;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.target !== el) continue;
this.intersecting = entry.isIntersecting;
if (entry.isIntersecting) {
const handler = opts.onIntersect.current ?? this.feed?.opts.onLoadMore.current;
handler?.();
}
}
},
{
// `root: Document` isn't spec'd for IO — coerce to null.
root: rootOpt instanceof Document ? null : rootOpt,
rootMargin,
threshold
}
);
observer.observe(el);
return () => observer.disconnect();
});
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'aria-hidden': true as const,
'data-intersecting': boolToEmptyStrOrUndef(this.intersecting)
} as const)
);
}

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,4 @@
export const FEED_LANGS = {
LABEL: '#?components.feed.label|Feed',
ARTICLE_LABEL: '#?components.feed.article-label|Article'
} as const;

@ -0,0 +1,154 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
/** Snippet props for `Feed.Provider`. */
export type FeedProviderSnippetProps = {
busy: boolean;
};
/** Snippet props for `Feed.Article`. */
export type FeedArticleSnippetProps = {
index: number;
total: number | undefined;
};
// ── Root provider ──────────────────────────────────────────────────────────
/**
* Props for `Feed.Provider`.
*
* Implements the WAI-ARIA Feed pattern (`role="feed"`) — a section of
* scrollable content where assistive tech navigates between `role="article"`
* children using PageUp / PageDown. Each article exposes its position via
* `aria-posinset` / `aria-setsize`.
*
* Use Feed for content streams (activity feeds, chat histories, threaded
* comments) where the article count can grow (lazy-loaded). Use `GridList`
* or `Listbox` for bounded item selection.
*/
export type FeedProps = WithChild<
{
/** DOM id. Auto-generated if omitted. */
id?: string;
/**
* Total number of items across all pages. Used for each article's
* `aria-setsize`. `undefined` means unknown (AT reads "n of unknown").
*/
totalItems?: number | undefined;
/** Whether more items are currently loading. Sets `aria-busy`. @default false */
busy?: boolean;
/**
* Fires when the user reaches the end of the loaded items (PageDown on
* the last article, or infinite-scroll observer hit). Consumer loads
* more items and sets `busy` to `true` until the response arrives.
*/
onLoadMore?: () => void;
/**
* Accessible name for the feed landmark. @default translated `'Feed'`
*/
'aria-label'?: string;
/** ID of an external label. */
'aria-labelledby'?: string;
children?: Snippet<[FeedProviderSnippetProps]>;
},
FeedProviderSnippetProps
> &
Without<PrimitiveDivAttributes, { 'aria-label'?: string; 'aria-labelledby'?: string; 'aria-busy'?: unknown }>;
// ── Article ────────────────────────────────────────────────────────────────
/**
* Props for `Feed.Article`.
*
* Each article gets `role="article"`, `aria-posinset`, `aria-setsize`, and
* `aria-labelledby` wired to the `Feed.ArticleTitle` (when present).
*
* Articles are focusable (`tabindex=0`) so PageUp/PageDown navigation jumps
* between them. The focus handler on the Provider intercepts PageUp/PageDown.
*/
export type FeedArticleProps = WithChild<
{
id?: string;
/**
* 1-based position of this article in the full set (including
* unloaded pages). @default `index + 1` where `index` is the article's
* order in the Feed at mount time (auto-computed).
*/
posinset?: number;
children?: Snippet<[FeedArticleSnippetProps]>;
},
FeedArticleSnippetProps
> &
Without<
PrimitiveDivAttributes,
{ role?: unknown; 'aria-posinset'?: unknown; 'aria-setsize'?: unknown }
>;
// ── ArticleTitle ───────────────────────────────────────────────────────────
/**
* Props for `Feed.ArticleTitle`. Linked via `aria-labelledby` on the
* enclosing `Article`. When `level` is omitted the heading level is derived
* from the enclosing `Feed.Thread` depth (top level → 3, nested → 4, etc.).
*/
export type FeedArticleTitleProps = WithChild<{
id?: string;
/** Heading level (1–6). Auto-derived from thread depth when omitted. */
level?: 1 | 2 | 3 | 4 | 5 | 6;
}> &
Without<PrimitiveDivAttributes, { role?: unknown; 'aria-level'?: unknown }>;
// ── Thread ─────────────────────────────────────────────────────────────────
/**
* Props for `Feed.Thread` — a nested sub-feed of articles (replies /
* sub-comments). Renders as `role="feed"` so assistive tech recognises the
* nested stream. Child articles automatically inherit the correct nesting
* level.
*/
export type FeedThreadProps = WithChild<{
id?: string;
}> &
Without<PrimitiveDivAttributes, { role?: unknown }>;
// ── Sentinel ───────────────────────────────────────────────────────────────
/**
* Props for `Feed.Sentinel` — an invisible probe element that fires
* `onIntersect` when scrolled into view. Use to trigger `onLoadMore` when
* the user approaches the end of the feed.
*/
export type FeedSentinelProps = WithChild<{
id?: string;
/** Root margin for the IntersectionObserver. @default '200px' */
rootMargin?: string;
/** Intersection threshold 0–1. @default 0 */
threshold?: number | number[];
/**
* Observer root. `null` → viewport. @default null
*/
root?: Element | Document | null;
/**
* Callback invoked when the sentinel enters the observed zone. When the
* sentinel is inside a `Feed.Provider`, defaults to firing the Feed's
* `onLoadMore`.
*/
onIntersect?: () => void;
/** Disable the observer without unmounting. @default false */
disabled?: boolean;
}> &
Without<PrimitiveDivAttributes, {}>;
// ── ArticleDescription ─────────────────────────────────────────────────────
/** Props for `Feed.ArticleDescription` — linked via `aria-describedby`. */
export type FeedArticleDescriptionProps = WithChild<{
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;

@ -0,0 +1,239 @@
# GridList
A selectable list with grid semantics (`role="grid"`). Rows are focusable via roving tabindex; cells host content.
## When to use GridList vs Listbox vs Table vs TreeGrid
| Component | ARIA role | Use when |
| -------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------- |
| **GridList** (this) | `grid` | Flat selectable list where rows have multiple interactive elements (actions, links, per-row checkboxes). |
| [`Listbox`](../listbox/README.md) | `listbox` | Flat selectable list where rows are atomic options (no inner actions). |
| [`Table`](../table/README.md) | `table` | Tabular data with sorting / filtering / paginating. "Expandable rows" → `Table.RowDetail`. |
| [`TreeGrid`](../tree-grid/README.md) | `treegrid` | Hierarchy + columns (file manager, org chart). `aria-level` auto-derived. |
## Anatomy
```svelte
<GridList.Provider bind:value selectionMode="multiple">
{#each users as user (user.id)}
<GridList.Row value={user.id} textValue={user.name}>
<GridList.Cell>
<GridList.SelectionCheckbox />
</GridList.Cell>
<GridList.Cell>{user.name}</GridList.Cell>
<GridList.Cell>{user.role}</GridList.Cell>
</GridList.Row>
{/each}
</GridList.Provider>
```
## Parts
| Part | Element | Description |
| -------------------- | ---------- | ---------------------------------------------------------------------- |
| `Provider` | `<div>` | `role="grid"`, roving tabindex host, selection state. |
| `Row` | `<div>` | `role="row"`. One per item. Focusable. |
| `Cell` | `<div>` | `role="gridcell"`. Content slot within a row. |
| `SelectionCheckbox` | `<button>` | `role="checkbox"`. Toggles the enclosing row's selection. |
## Props
### `Provider`
| Prop | Type | Default | Description |
| ----------------- | ------------------------------------- | ------------ | ------------------------------------------------------------------------ |
| `id` | `string` | auto | DOM id. |
| `value` | `string[]` | `[]` | Selected row values. Bindable. |
| `onValueChange` | `(value: string[]) => void` | — | Fires on selection change. |
| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'single'` | How many rows can be selected. |
| `loop` | `boolean` | `false` | Whether arrow navigation wraps. |
| `typeahead` | `boolean` | `true` | Whether pressing characters jumps to a matching row. |
| `typeaheadTimeout`| `number` | `500` | Ms before the typeahead buffer resets. |
| `disabled` | `boolean` | `false` | OR-merged with `Field.disabled`. |
| `readonly` | `boolean` | `false` | OR-merged with `Field.readonly`. |
| `required` | `boolean` | `false` | OR-merged with `Field.required`. |
| `invalid` | `boolean` | `false` | OR-merged with `Field.invalid`. |
| `name` | `string` | — | Name for form submission. Emits hidden inputs for each selected value. |
| `aria-label` | `string` | translated | Accessible name. |
| `aria-labelledby` | `string` | — | External label id. |
Snippet props: `{ value, isEmpty, selectAll, clear }`.
### `Row`
| Prop | Type | Default | Description |
| ------------ | --------- | ------- | ---------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `value` | `string` | — | **Required.** Unique row value. |
| `textValue` | `string` | — | Text used by typeahead. Falls back to `textContent`. |
| `disabled` | `boolean` | `false` | Whether this row is disabled (skipped by keyboard navigation). |
Snippet props: `{ selected, disabled }`.
### `SelectionCheckbox`
| Prop | Type | Default | Description |
| ------------- | -------- | ---------- | ------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `aria-label` | `string` | translated | Accessible name (defaults to `'Select row'`). |
## ARIA
| Part | Attribute | Value |
| ------------------ | --------------------- | ----------------------------------------------------------- |
| Provider | `role` | `grid` |
| Provider | `aria-multiselectable`| `true` when `selectionMode='multiple'` |
| Provider | `aria-disabled` | When disabled |
| Provider | `aria-readonly` | When readonly |
| Provider | `aria-invalid` | When invalid |
| Provider | `aria-required` | When required |
| Row | `role` | `row` |
| Row | `aria-selected` | `true`/`false` (omitted in `none` mode) |
| Row | `aria-disabled` | When disabled |
| Row | `tabindex` | `0` for roving target, `-1` for others |
| Cell | `role` | `gridcell` |
| SelectionCheckbox | `role` | `checkbox` |
| SelectionCheckbox | `aria-checked` | Matches the enclosing row's selection state |
## Data Attributes
| Part | Attribute | Values |
| ------------------ | --------------------------------- | ------------------------------------------ |
| Provider | `data-grid-list` | Always present |
| Provider | `data-disabled` / `data-readonly` / `data-invalid` / `data-required` / `data-empty` | Flags |
| Provider | `data-selection-mode` | `none \| single \| multiple` |
| Row | `data-grid-list-row` | Always present |
| Row | `data-state` | `selected \| unselected` |
| Row | `data-highlighted` | Present when this is the roving target |
| Row | `data-disabled` | When disabled |
| Row | `data-value` / `data-text-value` | As props |
| Cell | `data-grid-list-cell` | Always present |
| SelectionCheckbox | `data-grid-list-selection-checkbox` | Always present |
| SelectionCheckbox | `data-state` | `checked \| unchecked` |
## Keyboard
| Key | Action |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| `ArrowDown` / `ArrowUp` | Move focus between rows (RTL-aware). |
| `ArrowRight` (on row) | Enter the row — focus the first interactive descendant (button, link, checkbox). |
| `ArrowRight` / `ArrowLeft` (on cell) | Move between focusable descendants within the row (RTL-aware). |
| `ArrowLeft` (on first cell) | Return focus to the row. |
| `Home` / `End` | Jump to first / last row. |
| `PageUp` / `PageDown` | Jump 10 rows. |
| `Space` | Toggle/replace selection on the focused row. |
| `Shift+Space` / `Shift+Click`| Extend selection as a range (in `multiple` mode). |
| `Ctrl/Meta + Click` | Toggle selection without clearing others (in `multiple` mode). |
| `Ctrl/Meta + A` | Select all (in `multiple` mode). |
| `Escape` | Clear selection. |
| `a–z` / `0–9` | Typeahead to the next row matching the prefix. |
### Cell-level 2D navigation
Rows with interactive descendants (buttons, links, checkboxes) support a WAI-ARIA `grid`-pattern 2D focus model:
1. Focus starts on the row (roving tabindex).
2. `ArrowRight` enters the row — focus moves to the first interactive descendant.
3. Within the row, `ArrowRight` / `ArrowLeft` cycle through interactive descendants (RTL-aware).
4. `ArrowLeft` from the first descendant returns focus to the row.
5. `ArrowUp` / `ArrowDown` always navigate between rows, regardless of whether focus is on a row or on a cell descendant.
This matches [react-aria's GridList pattern](https://react-spectrum.adobe.com/react-aria/GridList.html) and the APG `grid` keyboard contract.
## Virtualization
Compose with [`VirtualList`](../virtual-list/README.md) when the dataset is large:
```svelte
<GridList.Provider bind:value selectionMode="multiple" aria-label="Members">
<VirtualList.Provider count={rows.length} itemSize={44} getItemKey={(i) => rows[i].id}>
{#snippet children({ virtualItems, totalSize })}
<VirtualList.Viewport class="gl-viewport">
<div style:height="{totalSize}px" style:position="relative">
{#each virtualItems as v (v.key)}
<VirtualList.Item index={v.index} start={v.start} size={v.size}>
<GridList.Row value={rows[v.index].id} textValue={rows[v.index].name}>
<GridList.Cell><GridList.SelectionCheckbox /></GridList.Cell>
<GridList.Cell>{rows[v.index].name}</GridList.Cell>
</GridList.Row>
</VirtualList.Item>
{/each}
</div>
</VirtualList.Viewport>
{/snippet}
</VirtualList.Provider>
</GridList.Provider>
```
Keyboard navigation still works — the Provider's `getRows()` queries the DOM, so only mounted rows participate. The user can still `Home`/`End` to jump to the first/last mounted row; `PageUp/PageDown` jump a screenful.
## Comparison
| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
| ----------------------------------- | :--: | :---: | :----: | :-----: | :--------: |
| Dedicated `role=grid` list | ✅ | ❌ | ❌ | ❌ | ✅ |
| Single / multiple / none selection | ✅ | — | — | — | ✅ |
| Row-level actions (checkbox etc.) | ✅ | — | — | — | ✅ |
| Typeahead | ✅ | — | — | — | ✅ |
| Shift-range, Ctrl-toggle | ✅ | — | — | — | ✅ |
| `Select all` via Ctrl+A | ✅ | — | — | — | ✅ |
| Field OR-merge (disabled/invalid) | ✅ | — | — | — | ⚠️¹ |
| Cell-level 2D navigation | ✅ | — | — | — | ✅ |
| Virtualization (via compose) | ✅² | — | — | — | ✅ |
| Drag-and-drop reorder | ✅³ | — | — | — | ✅ |
¹ react-aria uses its own FieldContext; soma shares `Field.Provider` with all form primitives.
² Compose with `VirtualList` — see the section above. No coupling between soma and the virtualizer.
³ Compose with [`DragDrop`](../drag-drop/README.md) — wrap rows in `<DragDrop.Draggable>` and gap slots in `<DragDrop.Droppable>`. See the DragDrop README's "Sortable list" example.
## Usage
### Single-select
```svelte
<GridList.Provider bind:value selectionMode="single">
{#each options as option (option.id)}
<GridList.Row value={option.id} textValue={option.label}>
<GridList.Cell>{option.label}</GridList.Cell>
</GridList.Row>
{/each}
</GridList.Provider>
```
### Multi-select with checkboxes
```svelte
<GridList.Provider bind:value selectionMode="multiple" name="members">
{#snippet children({ selectAll, clear })}
<div class="toolbar">
<button onclick={selectAll}>Select all</button>
<button onclick={clear}>Clear</button>
</div>
{/snippet}
{#each users as user (user.id)}
<GridList.Row value={user.id} textValue={user.name}>
<GridList.Cell><GridList.SelectionCheckbox /></GridList.Cell>
<GridList.Cell>{user.name}</GridList.Cell>
<GridList.Cell>
<button onclick={() => removeUser(user.id)}>Remove</button>
</GridList.Cell>
</GridList.Row>
{/each}
</GridList.Provider>
```
Because the row has `role="row"` and the inner button is a real focusable element, Tab within a selected row reaches the "Remove" button while arrow keys keep navigating rows.
### Field integration
```svelte
<Field.Provider invalid={!!error}>
<Field.Label>Team members</Field.Label>
<Field.Control>
<GridList.Provider bind:value selectionMode="multiple">
<!-- … -->
</GridList.Provider>
</Field.Control>
{#if error}<Field.ErrorText>{error}</Field.ErrorText>{/if}
</Field.Provider>
```

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { GridListCellProvider } from '../grid-list-provider.svelte';
import type { GridListCellProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'grid-list-cell'),
children,
child,
...restProps
}: GridListCellProps = $props();
const provider = GridListCellProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { GridListRowProvider } from '../grid-list-provider.svelte';
import type { GridListRowProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'grid-list-row'),
value,
textValue,
disabled = false,
children,
child,
...restProps
}: GridListRowProps = $props();
const provider = GridListRowProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
textValue: readableActive(() => textValue),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
</div>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { GridListSelectionCheckboxProvider } from '../grid-list-provider.svelte';
import type { GridListSelectionCheckboxProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'grid-list-selection-checkbox'),
'aria-label': ariaLabel,
children,
child,
...restProps
}: GridListSelectionCheckboxProps = $props();
const provider = GridListSelectionCheckboxProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,72 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { GridListProvider } from '../grid-list-provider.svelte';
import type { GridListProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'grid-list'),
value = $bindable([]),
onValueChange = () => {},
selectionMode = 'single',
loop = false,
typeahead = true,
typeaheadTimeout = 500,
disabled = false,
readonly = false,
required = false,
invalid = false,
name,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
child,
...restProps
}: GridListProps = $props();
const provider = GridListProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
selectionMode: readableActive(() => selectionMode),
loop: readableActive(() => loop),
typeahead: readableActive(() => typeahead),
typeaheadTimeout: readableActive(() => typeaheadTimeout),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
ariaLabel: readableActive(() => ariaLabel),
ariaLabelledby: readableActive(() => ariaLabelledby),
onValueChange: readableActive(() => onValueChange)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ ...provider.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(provider.snippetProps)}
{#if name}
{#each value as v (v)}
<input type="hidden" {name} value={v} />
{/each}
{/if}
</div>
{/if}

@ -0,0 +1,14 @@
export { default as Provider } from './components/grid-list.svelte';
export { default as Row } from './components/grid-list-row.svelte';
export { default as Cell } from './components/grid-list-cell.svelte';
export { default as SelectionCheckbox } from './components/grid-list-selection-checkbox.svelte';
export type {
GridListProps as ProviderProps,
GridListRowProps as RowProps,
GridListCellProps as CellProps,
GridListSelectionCheckboxProps as SelectionCheckboxProps,
GridListProviderSnippetProps as ProviderSnippetProps,
GridListRowSnippetProps as RowSnippetProps,
GridListSelectionMode
} from './types';

@ -0,0 +1,650 @@
import { Provider, context, type WithRefOpts } from '../../provider';
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
import {
readableActive,
type Active,
type ActiveProps,
type StateProps
} from '../../reactive';
import type {
OnChangeFn,
Direction,
SomaKeyboardEvent,
SomaMouseEvent
} from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { FieldProvider } from '../field/field-provider.svelte';
import { GRID_LIST_LANGS } from './langs';
import type { GridListSelectionMode } from './types';
const attrs = createAttrs({
component: 'grid-list',
parts: ['root', 'row', 'cell', 'selection-checkbox'] as const
});
registerContract({
name: 'grid-list',
version: 1,
parts: {
root: [
{ attr: 'data-disabled', description: 'Disabled' },
{ attr: 'data-readonly', description: 'Read-only' },
{ attr: 'data-invalid', description: 'Invalid' },
{ attr: 'data-required', description: 'Required' },
{ attr: 'data-empty', description: 'No rows selected' },
{
attr: 'data-selection-mode',
values: ['none', 'single', 'multiple'],
description: 'Selection mode'
}
],
row: [
{
attr: 'data-state',
values: ['selected', 'unselected'],
description: 'Selection state'
},
{ attr: 'data-highlighted', description: 'Currently focused row' },
{ attr: 'data-disabled', description: 'Row disabled' }
],
cell: [],
'selection-checkbox': [
{
attr: 'data-state',
values: ['checked', 'unchecked'],
description: 'Matches parent row selection'
},
{ attr: 'data-disabled', description: 'Disabled' }
]
}
});
// ── Root provider ──────────────────────────────────────────────────────────
interface GridListOpts
extends WithRefOpts,
StateProps<{ value: string[] }>,
ActiveProps<{
selectionMode: GridListSelectionMode;
loop: boolean;
typeahead: boolean;
typeaheadTimeout: number;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
name: string | undefined;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onValueChange: OnChangeFn<string[]> | undefined;
}> {}
export class GridListProvider extends Provider<GridListOpts> {
static readonly ctx = context<GridListProvider>('GridList');
static get(): GridListProvider | undefined {
return this.ctx.getOr(undefined) as GridListProvider | undefined;
}
static require(): GridListProvider {
return this.ctx.get();
}
static create(opts: GridListOpts) {
return new GridListProvider(opts);
}
readonly soma = Soma.get();
readonly field = FieldProvider.get();
private typeaheadBuffer = '';
private typeaheadTimer: ReturnType<typeof setTimeout> | null = null;
/** Anchor for Shift+Click range selection. */
private anchor: string | null = null;
private constructor(opts: GridListOpts) {
super(opts, 'GridList', 'root', attrs.root, GridListProvider.ctx);
$effect(() => {
return () => {
if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
};
});
}
// ── Field-aware flags ───────────────────────────────────────────────────
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
);
readonly isReadonly = $derived.by(
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
);
readonly isRequired = $derived.by(
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly isInvalid = $derived.by(
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
);
readonly resolvedDir: Active<Direction> = readableActive(
() => this.soma?.presentation.getDir() ?? 'ltr'
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
this.opts.ariaLabelledby.current
? undefined
: this.opts.ariaLabel.current ||
this.soma?.langs.ts(GRID_LIST_LANGS.LABEL) ||
undefined
);
readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0);
// ── DOM queries ─────────────────────────────────────────────────────────
/** Enabled row elements in DOM order, scoped to this root. */
getRows(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(`[${attrs.row}]:not([data-disabled])`)
).filter((el) => el.closest(`[${attrs.root}]`) === root);
}
/** All rows including disabled — used for range-select over contiguous rows. */
private getAllRows(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
return Array.from(root.querySelectorAll<HTMLElement>(`[${attrs.row}]`)).filter(
(el) => el.closest(`[${attrs.root}]`) === root
);
}
/** Single lifted derivation for the roving-tabindex target — O(1) per row. */
readonly rovingTargetEl = $derived.by(() => {
const rows = this.getRows();
if (rows.length === 0) return undefined;
const selected = new Set(this.opts.value.current);
return (
rows.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ??
rows[0]
);
});
// ── Selection ───────────────────────────────────────────────────────────
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
}
select(value: string, mode: 'replace' | 'toggle' | 'range') {
if (this.isDisabled || this.isReadonly) return;
const selectionMode = this.opts.selectionMode.current;
if (selectionMode === 'none') return;
const current = this.opts.value.current;
let next: string[];
if (mode === 'range' && selectionMode === 'multiple' && this.anchor) {
next = this.computeRange(this.anchor, value);
} else if (mode === 'toggle' && selectionMode === 'multiple') {
next = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value];
} else {
// 'replace' or 'toggle' on single mode
next = current.length === 1 && current[0] === value ? current : [value];
}
if (next !== current) {
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
}
if (mode !== 'range') this.anchor = value;
}
private computeRange(from: string, to: string): string[] {
const all = this.getAllRows().map((el) => el.getAttribute('data-value') ?? '');
const i = all.indexOf(from);
const j = all.indexOf(to);
if (i < 0 || j < 0) return [to];
const [lo, hi] = i <= j ? [i, j] : [j, i];
return all.slice(lo, hi + 1);
}
selectAll() {
if (
this.isDisabled ||
this.isReadonly ||
this.opts.selectionMode.current !== 'multiple'
)
return;
const next = this.getRows().map((el) => el.getAttribute('data-value') ?? '');
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
}
clear() {
if (this.isDisabled || this.isReadonly) return;
if (this.opts.value.current.length === 0) return;
this.opts.value.current = [];
this.opts.onValueChange.current?.([]);
this.anchor = null;
}
// ── Keyboard navigation ─────────────────────────────────────────────────
private focusAt(index: number) {
const rows = this.getRows();
if (rows.length === 0) return;
const clamped = Math.max(0, Math.min(rows.length - 1, index));
rows[clamped]?.focus();
}
/**
* Interactive descendants of a row, in tab order. Used for cell-level
* 2D navigation (ArrowLeft/Right within a row).
*/
private getFocusableCellsInRow(row: HTMLElement): HTMLElement[] {
const selector =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([data-grid-list-row])';
return Array.from(row.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.closest(`[${attrs.row}]`) === row && !el.hasAttribute('data-grid-list-row')
);
}
/** ArrowRight on a row: move focus to the first focusable cell descendant. */
private focusFirstCellInRow(row: HTMLElement): boolean {
const cells = this.getFocusableCellsInRow(row);
if (cells.length === 0) return false;
cells[0]!.focus();
return true;
}
/** ArrowLeft/Right within a row: cycle between focusable descendants. */
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>) {
if (this.isDisabled) return;
const dir = this.resolvedDir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
const target = e.target as HTMLElement | null;
if (!target) return;
const row = target.closest<HTMLElement>(`[${attrs.row}]`);
if (!row) return;
if (e.key === nextKey) {
const cells = this.getFocusableCellsInRow(row);
const idx = cells.indexOf(target);
if (idx < 0) return;
if (idx < cells.length - 1) {
e.preventDefault();
e.stopPropagation();
cells[idx + 1]!.focus();
}
return;
}
if (e.key === prevKey) {
const cells = this.getFocusableCellsInRow(row);
const idx = cells.indexOf(target);
if (idx < 0) return;
e.preventDefault();
e.stopPropagation();
if (idx === 0) {
// Return focus to the row itself.
row.focus();
} else {
cells[idx - 1]!.focus();
}
return;
}
// ArrowUp/Down / Home / End bubble up to the row handler so vertical
// navigation still works while focus is inside a cell.
}
handleRowKeydown(e: SomaKeyboardEvent<HTMLElement>) {
if (this.isDisabled) return;
const dir = this.resolvedDir.current;
// GridList is always vertical for navigation (grid pattern).
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal');
const rows = this.getRows();
if (rows.length === 0) return;
const currentIndex = rows.indexOf(e.currentTarget);
const loop = this.opts.loop.current;
// Horizontal — only handled when focus is on the row itself (not a cell).
// From the row, ArrowRight enters the first focusable cell. ArrowLeft
// is a no-op (you're already at the row level).
if (e.target === e.currentTarget && e.key === hNextKey) {
if (this.focusFirstCellInRow(e.currentTarget)) {
e.preventDefault();
return;
}
}
if (e.target === e.currentTarget && e.key === hPrevKey) {
// no-op; consume so the event doesn't scroll the viewport.
return;
}
if (e.key === nextKey) {
e.preventDefault();
const next = loop
? (currentIndex + 1) % rows.length
: Math.min(currentIndex + 1, rows.length - 1);
this.focusAt(next);
return;
}
if (e.key === prevKey) {
e.preventDefault();
const prev = loop
? (currentIndex - 1 + rows.length) % rows.length
: Math.max(currentIndex - 1, 0);
this.focusAt(prev);
return;
}
if (e.key === KEYS.HOME) {
e.preventDefault();
this.focusAt(0);
return;
}
if (e.key === KEYS.END) {
e.preventDefault();
this.focusAt(rows.length - 1);
return;
}
if (e.key === KEYS.PAGE_UP) {
e.preventDefault();
this.focusAt(Math.max(currentIndex - 10, 0));
return;
}
if (e.key === KEYS.PAGE_DOWN) {
e.preventDefault();
this.focusAt(Math.min(currentIndex + 10, rows.length - 1));
return;
}
if (e.key === KEYS.SPACE) {
e.preventDefault();
const value = e.currentTarget.getAttribute('data-value') ?? undefined;
if (value !== undefined) {
const mode =
e.shiftKey && this.opts.selectionMode.current === 'multiple'
? 'range'
: e.ctrlKey || e.metaKey
? 'toggle'
: this.opts.selectionMode.current === 'multiple'
? 'toggle'
: 'replace';
this.select(value, mode);
}
return;
}
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 'a' || e.key === 'A') &&
this.opts.selectionMode.current === 'multiple'
) {
e.preventDefault();
this.selectAll();
return;
}
if (e.key === KEYS.ESCAPE) {
if (this.opts.value.current.length > 0) {
e.preventDefault();
this.clear();
}
return;
}
if (
this.opts.typeahead.current &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
e.key.length === 1 &&
/\S/.test(e.key)
) {
this.handleTypeahead(e.key, currentIndex);
}
}
private handleTypeahead(char: string, fromIndex: number) {
this.typeaheadBuffer += char.toLowerCase();
if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
this.typeaheadTimer = setTimeout(() => {
this.typeaheadBuffer = '';
}, this.opts.typeaheadTimeout.current);
const rows = this.getRows();
if (rows.length === 0) return;
const needle = this.typeaheadBuffer;
const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
for (let i = 0; i < rows.length; i++) {
const idx = (start + i) % rows.length;
const el = rows[idx];
if (!el) continue;
const text = (el.getAttribute('data-text-value') || el.textContent || '')
.trim()
.toLowerCase();
if (text.startsWith(needle)) {
el.focus();
return;
}
}
}
handleRowClick(value: string, e: SomaMouseEvent<HTMLElement>) {
if (this.isDisabled || this.isReadonly) return;
const mode =
e.shiftKey && this.opts.selectionMode.current === 'multiple'
? 'range'
: (e.ctrlKey || e.metaKey) && this.opts.selectionMode.current === 'multiple'
? 'toggle'
: 'replace';
this.select(value, mode);
e.currentTarget.focus();
}
// ── Root props ──────────────────────────────────────────────────────────
readonly snippetProps = $derived.by(() => ({
value: this.opts.value.current,
isEmpty: this.isEmpty,
selectAll: () => this.selectAll(),
clear: () => this.clear()
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'grid' as const,
'aria-label': this.resolvedAriaLabel.current,
'aria-labelledby': this.opts.ariaLabelledby.current,
'aria-multiselectable':
this.opts.selectionMode.current === 'multiple' ? true : undefined,
'aria-disabled': this.isDisabled ? true : undefined,
'aria-readonly': this.isReadonly ? true : undefined,
'aria-invalid': this.isInvalid ? true : undefined,
'aria-required': this.isRequired ? true : undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
'data-required': boolToEmptyStrOrUndef(this.isRequired),
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
'data-selection-mode': this.opts.selectionMode.current
} as const)
);
}
// ── Row ────────────────────────────────────────────────────────────────────
interface GridListRowOpts
extends WithRefOpts,
ActiveProps<{
value: string;
textValue: string | undefined;
disabled: boolean;
}> {}
export class GridListRowProvider extends Provider<GridListRowOpts> {
static create(opts: GridListRowOpts) {
return new GridListRowProvider(opts);
}
readonly provider: GridListProvider;
private constructor(opts: GridListRowOpts) {
super(opts, 'GridList', 'row', attrs.row);
this.provider = GridListProvider.require();
}
readonly isSelected = $derived.by(() =>
this.provider.isSelected(this.opts.value.current)
);
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.provider.isDisabled
);
readonly isRovingTarget = $derived.by(
() => this.opts.ref.current === this.provider.rovingTargetEl
);
readonly onclick = (e: MouseEvent) => {
if (this.isDisabled) return;
this.provider.handleRowClick(this.opts.value.current, e as SomaMouseEvent<HTMLElement>);
};
readonly onkeydown = (e: KeyboardEvent) => {
this.provider.handleRowKeydown(e as SomaKeyboardEvent<HTMLElement>);
};
readonly snippetProps = $derived.by(() => ({
selected: this.isSelected,
disabled: this.isDisabled
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'row' as const,
tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
'aria-selected':
this.provider.opts.selectionMode.current === 'none'
? undefined
: this.isSelected,
'aria-disabled': this.isDisabled ? true : undefined,
'data-value': this.opts.value.current,
'data-text-value': this.opts.textValue.current,
'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const),
'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.onclick,
onkeydown: this.onkeydown
} as const)
);
}
// ── Cell ───────────────────────────────────────────────────────────────────
interface GridListCellOpts extends WithRefOpts {}
export class GridListCellProvider extends Provider<GridListCellOpts> {
static create(opts: GridListCellOpts) {
return new GridListCellProvider(opts);
}
readonly provider: GridListProvider;
private constructor(opts: GridListCellOpts) {
super(opts, 'GridList', 'cell', attrs.cell);
this.provider = GridListProvider.require();
}
/**
* Delegate horizontal arrow navigation to the Provider so the user can
* move between focusable descendants of a row with ArrowLeft/Right, and
* ArrowLeft from the first cell returns focus to the row.
*/
readonly onkeydown = (e: KeyboardEvent) => {
this.provider.handleCellKeydown(e as SomaKeyboardEvent<HTMLElement>);
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'gridcell' as const,
onkeydown: this.onkeydown
} as const)
);
}
// ── SelectionCheckbox ──────────────────────────────────────────────────────
interface GridListSelectionCheckboxOpts
extends WithRefOpts,
ActiveProps<{ ariaLabel: string | undefined }> {}
export class GridListSelectionCheckboxProvider extends Provider<GridListSelectionCheckboxOpts> {
static create(opts: GridListSelectionCheckboxOpts) {
return new GridListSelectionCheckboxProvider(opts);
}
readonly provider: GridListProvider;
/** Resolved from the nearest enclosing Row's value. */
private rowValue: string | null = null;
private constructor(opts: GridListSelectionCheckboxOpts) {
super(opts, 'GridList', 'selection-checkbox', attrs['selection-checkbox']);
this.provider = GridListProvider.require();
}
/** Read the enclosing row's value lazily (on click). */
private findRowValue(el: HTMLElement | null): string | null {
if (!el) return null;
const row = el.closest<HTMLElement>(`[${attrs.row}]`);
return row?.getAttribute('data-value') ?? null;
}
readonly isChecked = $derived.by(() => {
if (!this.rowValue) return false;
return this.provider.isSelected(this.rowValue);
});
readonly onclick = (e: MouseEvent) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
this.rowValue = this.findRowValue(target);
if (!this.rowValue) return;
this.provider.select(this.rowValue, 'toggle');
};
readonly resolvedAriaLabel = $derived.by(
() =>
this.opts.ariaLabel.current ||
this.provider.soma?.langs.ts(GRID_LIST_LANGS.SELECT_ROW) ||
undefined
);
readonly props = $derived.by(() => {
// Read current row association via DOM for aria-state — safe on every render.
const el = this.opts.ref.current;
this.rowValue = this.findRowValue(el);
const checked = this.rowValue
? this.provider.isSelected(this.rowValue)
: false;
return this.assertProps({
...this.baseProps,
type: 'button' as const,
role: 'checkbox' as const,
'aria-checked': checked,
'aria-label': this.resolvedAriaLabel,
'aria-disabled': this.provider.isDisabled || this.provider.isReadonly || undefined,
'data-state': checked ? ('checked' as const) : ('unchecked' as const),
'data-disabled': boolToEmptyStrOrUndef(
this.provider.isDisabled || this.provider.isReadonly
),
onclick: this.onclick
} as const);
});
}

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,5 @@
export const GRID_LIST_LANGS = {
LABEL: '#?components.grid-list.label|Grid list',
SELECT_ROW: '#?components.grid-list.select-row|Select row',
SELECT_ALL: '#?components.grid-list.select-all|Select all'
} as const;

@ -0,0 +1,132 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveDivAttributes,
PrimitiveButtonAttributes
} from '../../types';
/** Selection mode. */
export type GridListSelectionMode = 'none' | 'single' | 'multiple';
/** Snippet props for `GridList.Provider`. */
export type GridListProviderSnippetProps = {
value: string[];
isEmpty: boolean;
selectAll: () => void;
clear: () => void;
};
/** Snippet props for `GridList.Row`. */
export type GridListRowSnippetProps = {
selected: boolean;
disabled: boolean;
};
// ── Root provider ──────────────────────────────────────────────────────────
/**
* Props for `GridList.Provider`.
*
* A selectable list with grid semantics (`role="grid"`) — rows are focusable,
* cells are the content slots. Supports keyboard navigation, multi-select via
* Shift/Ctrl click, typeahead, and form integration via a hidden input.
*
* Use `GridList` over `Listbox` when rows contain multiple interactive
* elements (actions, links, checkboxes) — the `grid` role lets assistive
* tech navigate those with Tab / cell-level commands. Use `Listbox` when
* rows are atomic options.
*/
export type GridListProps = WithChild<
{
/** DOM id. Auto-generated if omitted. */
id?: string;
/** Selected row values. Bindable. @default [] */
value?: string[];
/** Fires on selection change. */
onValueChange?: OnChangeFn<string[]>;
/** Selection mode. @default 'single' */
selectionMode?: GridListSelectionMode;
/** Whether arrow navigation wraps at the ends. @default false */
loop?: boolean;
/** Whether typing characters jumps focus to matching row. @default true */
typeahead?: boolean;
/** Ms before the typeahead buffer resets. @default 500 */
typeaheadTimeout?: number;
// Flags — OR-merged with enclosing `Field.Provider`
/** @default false */
disabled?: boolean;
/** @default false */
readonly?: boolean;
/** @default false */
required?: boolean;
/** @default false */
invalid?: boolean;
/** Name for form submission. */
name?: string;
/**
* Accessible name for the grid. Ignored when `aria-labelledby` is set
* or when inside a `Field.Provider` with a `Field.Label`.
* @default translated `'Grid list'`
*/
'aria-label'?: string;
/** ID of an external element that labels this grid. */
'aria-labelledby'?: string;
children?: Snippet<[GridListProviderSnippetProps]>;
},
GridListProviderSnippetProps
> &
Without<PrimitiveDivAttributes, { 'aria-label'?: string; 'aria-labelledby'?: string }>;
// ── Row ────────────────────────────────────────────────────────────────────
/**
* Props for `GridList.Row`.
*
* Rendered as `role="row"`. One row per `value`. Participates in roving
* tabindex — only the selected (or first) row has `tabindex=0`.
*/
export type GridListRowProps = WithChild<
{
id?: string;
/** Unique value for this row. Used for selection + typeahead. */
value: string;
/**
* Optional text used by typeahead search. Falls back to the row's
* `textContent`. Useful when the visible text is stylized (icons only, etc).
*/
textValue?: string;
/** Whether the row is disabled. @default false */
disabled?: boolean;
children?: Snippet<[GridListRowSnippetProps]>;
},
GridListRowSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Cell ───────────────────────────────────────────────────────────────────
/** Props for `GridList.Cell` — rendered as `role="gridcell"`. */
export type GridListCellProps = WithChild<{
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
// ── SelectionCheckbox ──────────────────────────────────────────────────────
/**
* Props for `GridList.SelectionCheckbox` — toggles row selection, with an
* accessible label that matches the row's text.
*/
export type GridListSelectionCheckboxProps = WithChild<{
id?: string;
/** Accessible name override. @default translated `'Select row'` */
'aria-label'?: string;
}> &
Without<PrimitiveButtonAttributes, { 'aria-label'?: string; type?: unknown }>;

@ -1,9 +1,12 @@
export * as Accordion from './accordion';
export * as AlertDialog from './alert-dialog';
export * as Announce from './announce';
export * as Avatar from './avatar';
export * as Breadcrumb from './breadcrumb';
export * as Calendar from './calendar';
export * as Carousel from './carousel';
export * as Checkbox from './checkbox';
export * as Clipboard from './clipboard';
export * as Collapsible from './collapsible';
export * as ColorField from './color-field';
export * as ColorPicker from './color-picker';
@ -15,23 +18,29 @@ export * as DatePicker from './date-picker';
export * as DateRangeField from './date-range-field';
export * as DateRangePicker from './date-range-picker';
export * as Dialog from './dialog';
export * as DragDrop from './drag-drop';
export * as DropdownMenu from './dropdown-menu';
export * as Editable from './editable';
export * as Feed from './feed';
export * as Field from './field';
export * as FileUpload from './file-upload';
export * as Form from './form';
export * as GridList from './grid-list';
export * as LinkPreview from './link-preview';
export * as Listbox from './listbox';
export * as Menubar from './menubar';
export * as Meter from './meter';
export * as NavigationMenu from './navigation-menu';
export * as NumberField from './number-field';
export * as Pagination from './pagination';
export * as PinInput from './pin-input';
export * as Popover from './popover';
export * as Progress from './progress';
export * as RadioGroup from './radio-group';
export * as RangeCalendar from './range-calendar';
export * as RatingGroup from './rating-group';
export * as ScrollArea from './scroll-area';
export * as SearchField from './search-field';
export * as Select from './select';
export * as Slider from './slider';
export * as Splitter from './splitter';
@ -39,6 +48,7 @@ export * as Stepper from './stepper';
export * as Switch from './switch';
export * as Table from './table';
export * as Tabs from './tabs';
export * as TagGroup from './tag-group';
export * as TagsInput from './tags-input';
export * as TimeField from './time-field';
export * as TimePicker from './time-picker';
@ -49,6 +59,7 @@ export * as Toggle from './toggle';
export * as ToggleGroup from './toggle-group';
export * as Toolbar from './toolbar';
export * as Tooltip from './tooltip';
export * as TreeGrid from './tree-grid';
export * as TreeView from './tree-view';
export * as VirtualGrid from './virtual-grid';
export * as VirtualList from './virtual-list';

@ -0,0 +1,143 @@
# Meter
A `role="meter"` for a known-range value — disk usage, battery, score, rating strength. Unlike `Progress`, the value is static (not tied to a duration), and the component exposes zone semantics via `low` / `high` / `optimum` so themes can color each zone differently.
Rule of thumb: use `Progress` for "how much of this task is done", use `Meter` for "where does this value sit in the range".
## Anatomy
```svelte
<Meter.Provider value={72} low={25} high={75}>
<Meter.Indicator />
</Meter.Provider>
```
## Parts
| Part | Element | Description |
| ----------- | -------- | ------------------------------------------------------------------------ |
| `Provider` | `<div>` | `role="meter"`. Emits ARIA value attrs, `data-state`, `--soma-meter-value-pct`. |
| `Indicator` | `<div>` | Decorative fill. Inherits `data-state` for per-zone styling. |
## Props
### `Provider`
| Prop | Type | Default | Description |
| ----------------- | -------- | ------------------------- | -------------------------------------------------------------------------------- |
| `id` | `string` | auto | DOM id. |
| `value` | `number` | `0` | Current value. |
| `min` | `number` | `0` | Lower bound. |
| `max` | `number` | `100` | Upper bound. |
| `low` | `number` | `min` | Upper edge of the "below" zone. |
| `high` | `number` | `max` | Lower edge of the "above" zone. |
| `optimum` | `number` | `(low + high) / 2` | Optimum target. Read-only ARIA hint; doesn't affect zone computation. |
| `valueText` | `string` | `${value} / ${max}` | Override for `aria-valuetext`. |
| `aria-label` | `string` | translated `Meter` | Accessible name. |
| `aria-labelledby` | `string` | — | External label. |
## ARIA
| Part | Attribute | Value |
| -------- | ----------------- | ------------------------------- |
| Provider | `role` | `meter` |
| Provider | `aria-valuemin` | Value of `min` |
| Provider | `aria-valuemax` | Value of `max` |
| Provider | `aria-valuenow` | Value of `value` |
| Provider | `aria-valuetext` | Resolved value text |
| Provider | `aria-label` | Translated default or override |
Note: `aria-valuelow`/`aria-valuehigh`/`aria-valueoptimum` are **not** emitted — those were part of the original HTML `<meter>` element only, not the `role="meter"` ARIA semantics. Zones are expressed via `data-state` for styling instead.
## Data Attributes
| Part | Attribute | Values |
| --------- | --------------------- | ------------------------------------- |
| Provider | `data-meter` | Always present |
| Provider | `data-state` | `below` \| `optimum` \| `above` |
| Provider | `data-value` | Numeric value |
| Provider | `data-min` | Numeric min |
| Provider | `data-max` | Numeric max |
| Indicator | `data-meter-indicator`| Always present |
| Indicator | `data-state` | Inherited from provider |
Zone resolution:
- `value < low` → `below`
- `value > high` → `above`
- otherwise → `optimum`
## CSS Variables
| Variable | Part | Description |
| -------------------------- | -------- | -------------------------------------------------- |
| `--soma-meter-value-pct` | Provider | 0–100 percentage, clamped to `[min, max]`. |
## Comparison
| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
| ------------------------------------ | :--: | :---: | :----: | :-----: | :--------: |
| Dedicated component | ✅ | ❌¹ | ✅ | ❌ | ✅ |
| `role="meter"` | ✅ | — | ✅ | — | ✅ |
| `low` / `high` / `optimum` props | ✅ | — | ✅ | — | ✅² |
| `data-state` zone attribute | ✅ | — | ✅ | — | ❌ |
| `--soma-meter-value-pct` CSS var | ✅ | — | ✅ | — | ❌ |
| Translated default `aria-label` | ✅ | — | ❌ | — | ❌ |
| Indicator part | ✅ | — | ✅ | — | ❌ |
| `aria-valuetext` override | ✅ | — | ✅ | — | ✅ |
¹ Radix does not ship a Meter primitive.
² react-aria's `Meter` returns `formatOptions` for percentage/unit formatting. Soma pushes that to `valueText` so the consumer controls exact text.
## Usage
### Battery indicator
```svelte
<Meter.Provider value={batteryPct} low={20} high={80} optimum={100}>
<Meter.Indicator />
</Meter.Provider>
```
### Password strength (3 zones → 3 colors)
```svelte
<Meter.Provider
value={strength}
max={4}
low={1}
high={3}
valueText={['Too weak', 'Weak', 'OK', 'Strong', 'Excellent'][strength]}
aria-label="Password strength"
>
<Meter.Indicator />
</Meter.Provider>
```
```css
[data-meter] {
position: relative;
height: 8px;
border-radius: 4px;
background: #e2e8f0;
overflow: hidden;
}
[data-meter-indicator] {
position: absolute;
inset: 0;
width: calc(var(--soma-meter-value-pct, 0) * 1%);
transition: width 150ms ease-out;
}
[data-meter][data-state='below'] [data-meter-indicator] { background: #dc2626; }
[data-meter][data-state='optimum'] [data-meter-indicator] { background: #16a34a; }
[data-meter][data-state='above'] [data-meter-indicator] { background: #f59e0b; }
```
### Disk usage (`above` is bad)
```svelte
<Meter.Provider value={used} max={total} low={0} high={total * 0.8} aria-label="Disk">
<Meter.Indicator />
</Meter.Provider>
```
Zone coloring is inverted here — `above` (near-full) should be red, `optimum` (plenty free) should be green. Because soma emits `data-state` values instead of baking in a semantics, the theme is free to assign colors as the domain requires.

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

Loading…
Cancel
Save

Powered by TurnKey Linux.