Most passive components missing the A-1.3 audit point (texts.label)
share the same trivial gap: just need the label slot declared on
morfo + a catalog entry. One-off migration script
`scripts/add-texts-label-batch.ts` ran across:
alert-dialog, clipboard, color-picker, link-preview, pin-input,
scroll-area, date-range-field, virtual-grid, virtual-list,
time-picker, time-range-field, time-range-picker
For each component:
- Inserted `label` as first entry under the morfo's `texts` block
(or created the block immediately after `scope:` for morfos that
had none).
- Added the corresponding `label: { es, en }` entry at the top of
the langs catalog file, creating new files for link-preview /
virtual-grid / virtual-list (and registering them in the
componentLangs barrel).
- Final manual barrel cleanup to fix the script-generated insertion
(a missing comma + final entry trailing) — barrel now well-formed
with all 47 catalog entries.
Audit: PASS 11 → 23. Sweep doubled the PASS count in one pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of drawer (modal slide-in, parallel to dialog):
- Morfo: validated. 6 events (present/save/cancel/dismiss/dismiss-
outside/after-fail). Focus policy with trap/return/restore. 9 parts
(Provider/Trigger/Content/Overlay/Handle + Header/Footer/Title/
Description/Close).
- `texts.label` added with catalog entry. Drawer was already wired in
the langs barrel.
- README `## Baseline` section added (drawer DOES have an air baseline
at `glm-5:src/uix/air/components/drawer`, plus the soma additions
for drag/snap points). `## Gaps` with dispositions:
shouldScaleBackground (descartar — shell-level concern), free-resize
without snap points (diferir), Handle ARIA slider role (implementar
— accessibility gap for snap points), pulse-to-peek (diferir),
nested visual stacking (implementar — same gap as dialog),
AlertDrawer variant (descartar — compose AlertDialog inside).
Audit: PASS 10 → 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of popover (base of combobox / date-picker / dropdown-menu /
context-menu / tooltip):
- Morfo: validated. 9 parts (Provider/Trigger/Content/Arrow/Title/
Description/Close/Anchor/Overlay). 6 events: `present` (emerge),
`close-save` (commit fulfill), `close-cancel` / `close-dismiss` /
`close-dismiss-outside` (emerge), `close-after-fail` (signal risk).
Focus trap + return-to-trigger + restore. Modal flag drives
outside-click ignore + aria-modal + focus trap propagation.
- `texts.label` added with catalog entry. New `popover` entry in the
componentLangs barrel.
- README `## Pendiente deliberado` section renamed to `## Gaps` with
disposition markers. New rows documented: multiple-trigger model
(diferir), `Positioner` as public part (descartar — Soma Floating
owns positioning), `forceMount` per-part (diferir), modal mode +
footer (descartar — the picker pattern, not generic popover),
`onOpenAutoFocus` / `onCloseAutoFocus` docs (implementar),
hover-open delay API (diferir), virtual anchor (diferir).
Audit script refinement (A-3.6):
- Bare canonical verbs are now accepted as valid event names. The
rule used to require `{verb}-{x}` or `{family}-{verb}` — but
`present`, `open`, `close` ON THEIR OWN are perfectly fine when
the event has no variant to disambiguate. The check now passes
events named with a bare verb if that verb is in `ALL_VERBS`,
OR a bare family name. Popover's `present` event no longer
generates a warn.
Audit: PASS 8 → 9. Popover flips to PASS clean. No regressions
elsewhere from the bare-verb relaxation (only popover used the
pattern; other components with bare names already had warns we
documented).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of Form (largest finding set in the project):
- Morfo: validated. 14 parts — Provider/Submit/Reset/ErrorSummary +
9 AutoFields parts for the reflective renderer. Provider declares
data-pending/dirty/touched/invalid/submitted on the contract. Sema
events `commit-submit` (fulfill), `signal-invalid` (risk),
`commit-reset` (neutral) are correct.
- `texts.label` added with catalog entry (`label = 'Formulario' / 'Form'`).
- Recipe CSS: `[data-form][data-invalid]` rule added. Low-emphasis
affordance — the ErrorSummary picks up the risk border but field
cells keep their own `[data-invalid]` styling via the Field recipe.
- README rewritten with canonical sections:
- `## Baseline` summarizing air / soma / morfo coverage
- `## Comparativa` (was `## Reference Comparison`) — extended to
include shadcn-svelte plus AutoFields differentiators
(discriminated unions, array fields, first-error focus,
validation timing modes)
- `## Decisiones` documenting the small-wrapper rule, AutoFields
exception, validation timing ownership, the no-noise-on-load
default, sema event placement, and the low-emphasis invalid
treatment
- `## Gaps` (new) with disposition markers — apg is `descartar`
(no APG for "Form" — APG covers individual widgets), AutoFields
i18n is `implementar`, multi-step / submission feedback / etc.
are `diferir`, auto-save / optimistic UI are `descartar`
Audit script refinement:
- R-1.2 (data-disabled styles) now only fires when the morfo
*declares* `data-disabled` on any part. Form's Provider doesn't
emit a disabled state at the root (individual fields handle it
themselves), so demanding defensive CSS for a state the contract
never emits was a false-positive. The rule still fires correctly
for components that DO declare `data-disabled` in their morfo.
Audit: PASS 7 → 8. Form flips to PASS with only one remaining
warn (`A-1.4` no apg URL — `descartar` documented as a gap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of combobox (popover + editable input + listbox):
- Morfo: validated. 10 parts (Provider/Input/Trigger/Content/Viewport/
Item/Group/GroupHeading/Separator/Arrow). Single + multiple via
`data-type` on Provider, `aria-multiselectable` conditional on
`prop:multiple`. Item declares `data-state` checked/unchecked,
`data-value`, `data-highlighted`, `data-disabled`. Keyboard: 6 keys
but only 2 mutate state (Enter→select, Escape→close-delegated).
- Decision documented: combobox does NOT emit emerge events for
open/close/typing/filtering/highlighting — these are navigation
mechanics, not commits. Escape close is delegated to the popover
layer's `close-dismiss` event. The 2 `commit-*` events cover the
actual user intent (choosing an item).
- `texts.label` added with catalog entry. The toggle key already
existed.
Audit script refinements:
- Extended focus-move action list to cover listbox/menu/tablist
navigation (`next-item`, `prev-item`, `first-item`, `last-item`,
`next-tab`, `prev-tab`, `first-tab`, `last-tab`) plus the popover/
dialog actions that are delegated to the containing overlay layer
(`open`, `close`, `dismiss`, `toggle`). A combobox / select / menu
that hosts Escape→close is not under-declared if the overlay layer
owns the sema event.
README rewritten with canonical sections:
- `## Baseline` summarizing air + soma + morfo coverage.
- `## Comparativa` (was `## Comparison`) — table preserved.
- `## Decisiones` documenting the no-emerge-events policy, focus-only
keys, Control composition rationale, multi-select aria propagation.
- `## Gaps` (was `## Out of scope`) rewritten with disposition tokens
(implementar / diferir / descartar) per row. ClearTrigger and
Validation state get `implementar`; Modal+footer gets `descartar`
(combobox is inline by design, not a confirm-style picker).
Audit: PASS 6 → 7. No regressions; the relaxed focus-move list
benefits other listbox/menu components too (will surface on their
audits).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of icon (passive eidos-only primitive):
- Morfo: validated correct as-is. Single `Provider` part with
`archetype: 'image'`, conditional `aria-hidden` / `role="img"` driven
by the `decorative` prop. Scope `['eidos']` is right (no soma
counterpart). 0 events is right (leaf visual primitive).
- Eidos: 1696 lucide glyphs + IntentIcon. Default exported via the
re-export pattern `export { default } from './icon.svelte';` —
validates the audit refinement below.
- README sections renamed to canonical headers: `## Baseline Air` →
`## Baseline`, `## Comparativa externa` → `## Comparativa`. New
`## Passive justification` section explains why icon ships zero
semantic events (leaf primitive — the accessible name and any
perceptual signal belong to the containing control). `## Gaps
cerrados` renamed to `## Gaps` and rewritten with disposition
tokens (implementar / diferir / descartar).
Audit script refined for two false-positives surfaced by icon:
- A-1.3 (texts.label) now SKIPS pure visual primitives — components
whose `scope` is exactly `['eidos']` AND that declare no events.
Icons don't own text slots; the label lives on the consumer (the
button hosting the icon, shadcn-svelte/Radix pattern). Reported as
pass with "eidos-only passive primitive — no text slot".
- E-1.3 (default export) now recognizes the re-export form
`export { default } from './foo.svelte';` in addition to the
existing patterns (`export default X`, `export { X as default }`).
Audit: PASS 5 → 6. Icon flips to PASS. No regressions; the relaxed
rules don't change verdicts for any other component.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the calendar audit findings:
- README sections renamed to canonical headers: `## Baseline` (was
"Baseline audit"), `## Comparativa` (was "Reference comparison").
Added `## Decisiones` documenting the affirm-on-select choice, the
collapsed `shift-navigate` event for all month/year movement, why
arrow keys don't get their own events (pure focus moves), and the
read-only treatment. Rewrote `## Gaps` with disposition tokens
(implementar/diferir/descartar) for each entry.
- Recipe CSS gains `[data-calendar][data-readonly]` styling: cells
drop their hover affordance and the cursor stays neutral. Selection
+ navigation remain interactive (matches `aria-readonly` semantics).
- Audit A-3.7 refined per the COMPONENT_GUIDE rule "pure focus moves
don't need an event". The check now compares MUTATING keyboard
actions to declared events. Focus-move actions
(next-day/prev-day/next-week/prev-week/next-month/prev-month/
next-year/prev-year/first-day-of-week/last-day-of-week/
focus-{next,prev,first,last,up,down,left,right}) are excluded from
the ratio. Calendar's 10 keys / 2 mutating (Enter+Space → `select`)
/ 1 `commit-select` event now passes correctly.
Project audit: PASS 3 → 5, no new regressions. `range-calendar` also
flips to PASS as a side effect of the focus-move filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Living backlog for UIX. Records the pending items from the
date-range-picker session (apply modal+footer to other pickers,
month/year-range variants, promote footer buttons to canonical eidos
parts, morfo expansion to cover Footer/Clear/Cancel/Close, browser
tests) plus the queue of post-session components still to audit
one-by-one with severities pulled from `tmp/component-audit.md`.
Each entry is tagged implementar/diferir/descartar so the next picker
session can scan the file and pick the next slice without re-doing
discovery.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback batch from incidencia 2026-05-20:
1. Modal vs inline mode
- New `mode: 'inline' | 'modal'` prop on the soma `DateRangePicker.Provider`.
- Modal wires the popover `modal: true` → outside-click and Escape are
ignored; user must commit via the footer Close button (or revert via
Cancel).
- Provider exposes helpers `clear()`, `cancel()`, `commit()` for the
footer. `cancel()` restores the value snapshot taken on the OPEN edge
(captured via a `watch` on `opts.open` true-edge transition).
2. Footer buttons as boolean props
- `clearButton`, `cancelButton`, `closeButton` props on the picker.
The footer renders only if at least one is true. In `mode='modal'`
the closeButton is forced on (the user always needs an exit).
- `picker-actions.svelte` in the demo reads the picker context via
`DateRangePickerProvider.require()` and renders the enabled buttons
against `provider.clear/cancel/commit`.
3. Range field shape (revert to Chakra-style two boxes)
- Removed the `data-date-range-field-group` wrapper from the demo so
the start and end inputs are rendered as two separate boxed fields
with the icon embedded in the end box, matching Chakra's layout.
- The recipe CSS rules for `data-date-range-field-group` stay
available as an opt-in for consumers who prefer the unified pill.
4. Demo defaults
- `open` starts at `false` so the picker exercises the real
open/close flow when the user clicks the trigger — the segments are
for direct keyboard entry, the popover is for visual exploration.
- Mode toggle (inline / modal) + footer button switches surfaced as
controls in the demo.
5. Plumbing
- DateRangePickerOpts gains `mode`, `clearButton`, `cancelButton`,
`closeButton` (StateProps for mode, ActiveProps for the booleans).
- `DateRangePickerProvider` and `DateRangePickerMode` are now re-
exported from the soma barrel for consumers that wire footer
actions in the calendar tree.
- Test factory updated to seed the new opts.
Verified in browser: trigger opens; click-outside in modal mode is
ignored; Close commits & closes; Cancel reverts to snapshot & closes;
Clear empties the range & keeps open. Heading "May – June 2026" (year
collapse) and centered per-calendar titles still working from the
previous commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia point B (range field shouldn't show two separate
boxes). New CSS contract: `[data-date-range-field-group]` on the
wrapper element collapses the chrome around its descendants:
- The group owns the border, background, focus ring, invalid state,
variant/ghost treatment, and disabled opacity.
- Each `[data-date-range-field-input]` inside loses its own border,
background, and padding; it just contributes its segments to the
shared row.
- The "to" separator (.range-separator or `[data-range-separator]`)
picks up the field's literal tone.
- The embedded trigger from the previous commit sits flush at the
trailing edge of the group, since `data-date-field-input` already
detects the embedded case.
Demo: add `data-date-range-field-group` to the `.range-field-row`
wrapper. Visually now reads as one input pill: `05 / 20 / 2026 to
06 / 03 / 2026 📅`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia points D + E:
- Heading collapses the shared year when both visible months are the
same year: "May – June 2026" instead of "May 2026 – June 2026".
Cross-year still renders both ends explicitly ("June 2025 – July
2026"). Implemented in `RangeCalendarProvider.headingValue`.
- date-range-picker demo: drop the per-month title that previously
rendered BELOW each grid (duplicate of the main heading). Instead,
render one centered title per visible calendar INSIDE the header
bar between the prev/next buttons. The grid below now shows just
the weeks. A `range-header-titles` grid container splits the
available header space equally across `month-count` titles, so
each label sits centered over its calendar grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of date-range-picker incidencia 2026-05-20 #1 (auto-paging
to next month when the popup opens with a complete range): both
endpoint inputs of a DateRangeField create their own DateFieldProvider
sharing a single placeholder. Each provider has a `$effect` that
mirrors value → placeholder so the calendar jumps to the value's
month. With two endpoints, the END field's effect overrides the
START's on every render and the popup auto-pages to the end's month.
Fix: add an explicit `syncPlaceholderToValue?: boolean` opt on
`DateFieldOpts` (default `true` — preserves single-field UX). The
DateRangeField endpoint Input passes `false`; range placeholder
coordination stays with the range provider.
Verified in browser: value `{ start: 2026-05-31, end: 2026-06-09 }`
with `placeholder = 2026-05-31` now keeps the calendar on
"May 2026 – June 2026" instead of jumping to "June – July". Both
endpoints render correctly with the start/end stripes.
The earlier `reanchorInitialSelection` removal handled the same
symptom inside the range-calendar provider for fresh selections;
this commit handles the OTHER source — the field provider auto-sync
on already-set values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The calendar icon now lives INSIDE the date input as a flush trailing
affordance, not as a separate sibling button. The change is markup +
recipe CSS only — no API change.
- Demo (date-picker): drop the `.picker-control` grid wrapper; put
`<DatePicker.Trigger />` as the last child of the Input snippet,
after the segments. Codeblock snippets updated to match.
- Demo (date-range-picker): keep the dual-input + separator layout
but move `<DateRangePicker.Trigger />` into the END input, since
the trigger represents the trailing affordance of the whole range.
Both code snippets updated.
- Recipe CSS (both): add nested rules that detect the embedded case
(`[data-date-field-input] > [data-date-picker-trigger]`,
`[data-date-range-field-input] > [data-date-range-picker-trigger]`).
Embedded trigger drops its own border/background, matches the
segment height, sits flush at the trailing edge with
`margin-inline-start: auto`, and uses the segment focus ring on
hover/focus instead of the standalone field ring.
The standalone (sibling) treatment stays available for consumers who
prefer the icon as a separate control.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the second BROKEN component from the audit (after date-range-picker).
Five errors collapse to zero:
- Morfo declares the three `emerge` events the recipe animations were
already keying off (`open`, `close`, `close-dismiss`) plus a
`texts.label` slot. `data-disabled` is now declared on `Trigger` (with
optional severity) so the recipe rule has a real contract to land on.
`aria-disabled` and `texts.label` round out the surface; the new
`tooltip.label` catalog entry ships under
`src/uix/langs/components/tooltip.ts`.
- Recipe CSS: explicit `[data-tooltip-trigger][data-disabled]` and
`[data-tooltip-trigger]:focus-visible` rules. The disabled rule
suppresses pointer events; the focus rule provides a defensive
outline so a focusable trigger shell is at least keyboard-visible.
- README: convert the old `## Pendiente deliberado` section into a
proper `## Gaps` table with explicit dispositions
(implementar/diferir/descartar), and update `## Eventos Sema` to
reflect that tooltip now declares its three emerge events instead of
claiming zero. Comparativa was already complete; the regex fix from
the previous commit picked it up.
Audit: tooltip BROKEN → PASS. Project-wide goes from 1/64/2 to 3/64/0
(PASS / NEEDS-WORK / BROKEN). No more BROKEN components.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia 2026-05-20 #7 plus the remaining audit gaps:
- Morfo now declares the full event surface for the picker layer:
`open` (emerge, with intent fromProp), `close-range-commit` (commit
fulfill), `close-cancel` / `close-dismiss` / `close-dismiss-outside`
(emerge), and `commit-clear` (commit reset). Adds `data-last-action`
on Calendar for causal exit tinting, plus `aria-label`, archetype
classification, keyboard bindings (Enter/Space on Trigger; Escape +
Tab focus on Calendar), and `apg` URL pointing at the W3C dialog-modal
pattern.
- `texts.label` added with full catalog entry under
`src/uix/langs/components/date-range-picker.ts` so A-1.3 passes.
- README rewritten with the required structure: Baseline (air / terra /
soma actual), Comparativa table (React Aria / Bits UI / Ark UI),
Decisiones, and Gaps with explicit disposition markers
(implementar/diferir/descartar). The pending modal mode + clear
button + demo control prune go in Gaps as `implementar`/`diferir`.
- Fix a pre-existing audit-script regex bug: section-extraction
regexes used `m` flag with `$` lookahead, which matched end-of-line
and truncated section bodies to their headers. Switched to
`\n## ` as the explicit section boundary.
Audit: date-range-picker BROKEN → PASS. Project-wide BROKEN count
0 (was 2 before, now tooltip remains as NEEDS-WORK).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia 2026-05-20 #4 plus the R-2.1 audit finding:
- Remove raw-hex fallbacks (`#16845f`, `#106d4e`, `#2ba878`, `#ffffff`)
from `date-range-picker.css`. The `--color-affirm-*` and
`--calendar-day-selected-color` tokens are guaranteed by the
generated base.css; the safety hex hid that they weren't being
refreshed.
- Regenerate `eidos/generated/base.css` so `--calendar-day-*` tokens
(color, selected-color, today-border, etc.) materialize from the
recipe definitions in `lib/recipes/base.ts`. Fixes the stale
`keeps generated/base.css in sync` contract test.
- Start endpoint now reads `--color-affirm-*` cleanly; end follows the
picker's `data-color` accent. When the picker's accent IS in the
affirm family (`data-color="affirm"` or `"fulfill"`), the start
swaps to `secondary` so the two endpoints stay perceptually distinct.
- Both endpoints get an inset edge stripe + semibold weight so the
start vs end direction reads at a glance — addresses the user's
"indistinguible o transparente" complaint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 2 bugs from the date-range-picker incidencia 2026-05-20:
#1 — `RangeCalendarProvider` no longer auto-shifts the placeholder when
a selection lands in the last visible month. The visible months stay
where the user put them; navigation is explicit (prev/next/month/year
controls or keyboard). `reanchorInitialSelection` is removed and its
`shift-navigate` trigger goes with it.
#2 — clicking an endpoint of a completed range now drops only that
endpoint and re-anchors on the surviving one. The previous behavior
cleared both endpoints, which forced users to rebuild the entire range
to amend it. The provider already implemented this; only the test
codified the old behavior. Test rewritten to match the documented
intent and symmetric for start/end.
The two range-calendar tests that previously asserted the wrong
behavior now cover the correct invariants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Morfo now declares text slots as absolute idlangrefs in `texts: Record<string, LangRef>`,
not literal multilingual records. Multilingual content moves out of morfo entirely
into per-component catalogs under `src/uix/langs/components/{kebab}.ts`, merged
globally by `ActiveUix` via the new `componentLangs` barrel. Morfo stays a pure
abstract surface; localization lives in its own layer.
- Type: `translations?: LangNode` → `texts?: Record<string, LangRef>` in
`src/uix/morfo/types.ts`. Doc and test fixtures updated.
- Catalog: new `src/uix/langs/` directory with `common.ts` (was `langs.ts`),
`components/` (one file per migrated morfo, plus a barrel), and an aggregate
`uixLangs`. `ActiveUix` registers both namespaces at boot.
- Registry: `registerMorfo` no longer carries a translations fingerprint and
no longer extends `ActiveLangs` dynamically. `connectMorfoTranslations` and
`MorfoTranslationTarget` deleted from the public morfo barrel.
- Validation: schema.ts drops the relative-translationRef-in-morfo.translations
check; catalog presence is enforced by the new `scripts/translations-check.ts`
(wired as `npm run translations:check`).
- Audit: `scripts/component-audit.ts` rule A-1.3 now validates `texts.label`
idlangref shape (`#?components.{kebab}.label|Fallback`) and explicitly errors
on any surviving legacy `translations:` field.
- Migration: `scripts/migrate-translations-to-texts.ts` is the one-off Node
script that produced the 42 morfo rewrites and 42 catalog files.
- Tests: `morfo/registry.test.ts` rewritten around the simpler contract;
`active-uix.svelte.test.ts` reflects boot-time catalog availability instead
of lazy per-morfo extension.
Verification: `npm run check` (0 errors), `npm run translations:check`
(0/0), `npm run morfo:check` (36/36 routed). The 4 pre-existing failures
in `test` are unrelated (date-picker types, css regen, range-calendar
clear, color contract).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new Eidos components shipped end-to-end (wrapper + recipe + demo +
Soma provider hardening) plus a checklist-driven audit pipeline that
scores all 67 morfo components against doctrinal completion criteria.
New components:
- date-picker: full popover-anchored picker over date-field + calendar,
with calendar/content/trigger parts and demo route.
- date-range-picker: standalone wrapper with own calendar/grid/segment
surface, demo route, and recipe CSS.
- Both wrappers follow Option C disciplined (root + parts attached via
explicit assignment, no Object.assign).
Supporting Soma changes:
- range-calendar provider tightened (211 LOC of behavior, 167 LOC of
tests), README brought up to component doctrine.
- date-field, date-picker, date-range-field, date-range-picker Soma
providers + READMEs updated for new wrappers.
- popover provider/close gain props needed by the picker wrappers.
Morfo updates:
- date-picker / date-range-picker / range-calendar morfos refined for
the new APIs (parts, events, ARIA).
Audit infrastructure (new):
- src/uix/COMPONENT_COMPLETION_CHECKLIST.md — 81 doctrinal rules across
morfo / eidos wrapper / recipe CSS / demo / README / cross-layer
scripts. Each rule keyed to active_architecture.md, GUIA_IMPLEMENTACION
and DEMO_AUTHORING_GUIDE.
- scripts/component-audit.ts + `npm run component:audit` — regex parser
over all 67 components, emits tmp/component-audit.md with summary
scoreboard + per-component findings. Validates against canonical
SEMA_FAMILIES / SEMA_VERBS / ARCHETYPE_VOCABULARY / INTENTS.
- Initial baseline: 1 PASS, 64 NEEDS-WORK, 2 BROKEN (tooltip,
date-range-picker). Top systemic gaps: translations.label (49),
README Gaps/Comparativa/Baseline sections (87 combined), keyboard
/event ratio under-declaration (15), apg URL absent (19).
Misc:
- src/uix/kimi-audit-eidos.md — supplementary audit notes.
- .gitignore: ignore .codex-* agent scratch artifacts at repo root.
- continue.md + READMEs updated through the migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the doctrine update in GUIA §13: eidos for multi-part components
exposes BOTH a flat default that auto-composes Provider/Trigger/Content
AND the compound parts for advanced cases. Soma + morfo remain the
universal composable foundation underneath.
Why: with the previous "compound only" rule for multi-part, eidos
collapsible was a pure pass-through that added nothing operational —
the consumer wrote the same import + same shape as soma. The flat
default makes eidos earn its layer by collapsing the 90% case to a
single call:
// 90% case — flat ergonomic
<Collapsible bind:open>
{#snippet trigger()}Show details{/snippet}
<p>Body content.</p>
</Collapsible>
// Advanced — compound (parts in different subtrees, multi-trigger,
// conditional content)
<header><Collapsible.Provider bind:open>
<Collapsible.Trigger>Show</Collapsible.Trigger>
</Collapsible.Provider></header>
<aside>
<Collapsible.Content>...</Collapsible.Content>
</aside>
The architectural virtue: if the flat doesn't fit, a developer drops
to soma + morfo and composes freely. No vendor lock-in in the visual
layer; eidos is the design system convention, soma is the universal
primitive.
Files:
- src/uix/eidos/components/collapsible/collapsible.svelte (NEW): flat
default that auto-composes Provider + Trigger + Content via a
`trigger` snippet for the toggle label and `children` for the body.
- src/uix/eidos/components/collapsible/types.ts: adds
`CollapsibleFlatProps = SomaCollapsibleProps & { trigger?: Snippet }`.
- src/uix/eidos/components/collapsible/index.ts: exports `default`
(flat) plus named `Provider`/`Trigger`/`Content` (compound).
- src/uix/eidos/components/collapsible/{trigger,content,provider}.svelte:
fixed children destructuring so snippets propagate cleanly to soma
(was previously spreading $props() blindly which can lose snippet
bindings in Svelte 5).
- src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md: NEW §13 "Doctrina del API:
soma compound, eidos opinionado" formalises the two-shape rule and
the architectural virtue (soma+morfo as universal foundation).
Renumbered §13/§14 → §14/§15.
- web/routes/collapsible/+page.svelte: live preview now uses the flat
form; eidos snippet shows the flat shape; soma snippet keeps the
compound shape; Notes tab updated.
- src/uix/eidos/components/collapsible/README.md: rewrote API section
with both shapes + the "if neither fits, use soma+morfo" escape.
Tests 218/218 green. npm run check baseline 39.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real bugs reported on the collapsible demo:
1. **Dark mode broken across all migrated components.**
- The docs site layout sets `document.documentElement.dataset.theme =
'light'|'dark'` — those are the selector names the docs CSS uses.
- The eidos themes (themes/base/{light,dark}.css) only respond to
`[data-theme='base-light'|'base-dark']`. Different namespace.
- Result: docs grays swap in dark mode but eidos doctrinal palette
(primary/secondary/neutral/affirm/fulfill/risk/threat/loss) stays
in light values, so component text renders dark on dark.
- Fix: each eidos theme now accepts BOTH selectors. `[data-theme=
'base-light'], [data-theme='light'], :root { ... }` and equivalent
for dark. Existing consumers that use `base-*` keep working; the
short `light/dark` aliases handle the docs site convention.
2. **Collapsible trigger jumps from center to left when expanded.**
- The preview-stage uses `display: flex; justify-content: center;
align-items: center;` — natural for buttons/switches that don't
grow.
- `Collapsible.Provider` is `display: block` so it shrink-fits its
content as a flex item. Closed = trigger-width (centered visually);
open = trigger + content width (longer, so the visual center
anchors trigger at the left edge).
- Fix: collapsible demo overrides the preview-stage style with
`justify-content: flex-start; align-items: flex-start` and wraps
the provider in a `max-inline-size: 32rem` panel. Disclosure
ergonomics need anchor-left + grow-down, not center.
- Also swapped the body's `--gray-50` (docs site token) for
`--color-surface-raised` + `--color-content-primary` (eidos
doctrinal tokens) so the panel inherits the dark-mode swap.
npm run check holds at the 39-error baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third component migrated to the eidos wrapper subdirectory pattern,
following the new "check air baseline first" discipline.
Air baseline recovered from commit 0a391408^:
- air/components/collapsible: 3 wrappers (Provider/Trigger/Content)
with `class` passthrough, `bind:open`, `onOpenChange`, manual
`air.interaction.play('expansion', enter|exit)` perceptual emit.
- No size, intent, color, or chevron slot.
Migration decisions (presented BEFORE coding per scope-approval rule):
- Two doctrinal events (`expand` + `collapse`, both emerge family,
sequence='pre', no intent — emerge is transitional). Replaces air's
manual `air.interaction.play` with `runtime.trigger('expand'|
'collapse')` which atravieses visual + sound + vibra channels.
- No intent/color/size — air didn't have them and disclosure is
non-evaluative per guide §3.1.
- Multi-part API: Provider + Trigger + Content (no flat default per
doctrine §10).
Files:
- src/uix/morfo/components/collapsible.ts: scope expanded to ['soma',
'sema'], events array added with two emerge events.
- src/uix/soma/components/collapsible/collapsible-provider.svelte.ts:
runtime.events handlers `expand`/`collapse` mutate state; toggle()
fires runtime.trigger(next ? 'expand' : 'collapse').
- src/uix/eidos/components/collapsible/{collapsible.css,
collapsible-provider.svelte, collapsible-trigger.svelte,
collapsible-content.svelte, types.ts, index.ts, README.md}.
- src/uix/eidos/components/collapsible.css (legacy flat) deleted.
- src/uix/eidos/index.css: import path updated to subdirectory.
- web/routes/collapsible/+page.svelte: full interactive testbed with
tabs (Live, Soma, Morfo, Sema, Eidos, Notes) mirroring switch's
structure. Sema tab shows per-channel signature cards for both
expand and collapse events.
Disciplina formalizada en CLAUDE.md hand-off + memory entry: "Check
air baseline first" before any future eidos migration. Switch shipped
without this check and lost ResponsiveProp<Size> (regression). Don't
repeat with collapsible/dialog/drawer/popover/toast/avatar.
npm run check holds at the 39-error baseline. Tests 218/218 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architectural correction. Morfo was importing Svelte runes, $adom, and
$uix/sema — all violations of "morfo is pure declarative DNA". The
runtime that interprets a CompiledMorfo lives where it executes: soma.
Move:
- src/uix/morfo/runtime.svelte.ts → src/uix/soma/runtime.svelte.ts
- src/uix/morfo/runtime.svelte.test.ts → src/uix/soma/runtime.svelte.test.ts
Rename (consistent with the move; "soma's runtime", per the doctrine
"morfo declares, soma executes"):
- class MorfoRuntime → SomaRuntime
- createMorfoRuntime() → createSomaRuntime()
- type MorfoRuntimeSources → SomaRuntimeSources
- error prefixes [morfo-runtime] → [soma-runtime]
Sema is now genuinely ornamental:
- ActiveUix.semantic returns EngineSemantic | undefined (no longer
throws when no engine is configured). Components in SSR / headless
tests / audio-disabled environments stay functional.
- SomaRuntime.trigger() skips the emit step AND the target-resolution
check when no engine is present — only requires a DOM target when
something will actually be emitted.
- Soma.semantic getter returns | undefined too (was non-optional).
Consumers updated:
- src/uix/active-uix/active-uix.svelte.ts — imports createSomaRuntime
from $soma; semantic getter returns | undefined
- src/uix/active-uix/types.ts — interface SomaRuntime, semantic | undefined
- 8 providers (toggle, switch, collapsible, dialog, drawer, popover,
toast, avatar) — type SomaRuntime, import path $soma
- src/uix/soma/index.ts — re-exports createSomaRuntime + types
- src/uix/morfo/index.ts — drops the runtime re-exports (morfo is pure)
- src/uix/sema/signal.ts — JSDoc reference updated
- src/uix/soma/core/soma.svelte.ts — semantic getter | undefined
Test added: SomaRuntime.trigger() succeeds without a semantic engine
(skips emit + target check, runs handler directly).
Docs aligned to the new shape:
- active_architecture.md — runtime locations, sema as ornamental
- soma/SOMA_ARCHITECTURE.md, soma/README.md — name + import paths
- morfo/README.md — file listing now flags morfo as PURE TS, points
at soma/runtime.svelte.ts for the interpreter
- sema/README.md — SomaRuntime references
- README.md — root architecture diagram
- CLAUDE.md — hand-off note
Tests 218/218 green. npm run check holds at the 39-error baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 (docs alignment with the canonical guide):
- active_architecture.md — Dialog example refactored to the new event
shape (target inside semantic, explicit verb + sequence). Verbs
table grouped by family with the post-canon vocabulary (select/
toggle moved to commit, edit removed from handle, new verbs added).
Section 10 (Estado actual) updated to 2026-05-08 with current
progress: doctrina cerrada, eidos en producción, SoundChannel
eager-init, switch migrated, etc. "Para profundizar" §14 points at
the canonical guide.
- morfo/README.md — Step 5.5 added covering the doctrinal event
shape with all fields (semantic.family, .verb, .target, .sequence,
.intent) and the two naming shapes ({verb}-{variant} and
{family}-{verb}).
- sema/README.md — verbs section restructured to Record-by-family
matching the runtime; documents the verbs that moved between
families per the canon. Naming shapes section updated with both
forms and validateEventName signature.
- CLAUDE.md — "Session hand-off" rewritten with the post-canon
state. Pointer at GUIA_IMPLEMENTACION_SEMAUIX.md as the canonical
source. Pending list refreshed: switch is the immediate next
component, persistence/a11ySemantic/polymorphism deferred until
real consumers appear.
Switch migration to the eidos wrapper pattern:
- morfo/components/switch.ts — adds events: [{ commit-toggle }] with
the doctrinal shape (verb=toggle, sequence=post, intent fromProp
binding to the 4 supported intents). New data-* (data-color with
the 6-token Toggle/Switch subset, data-readonly, data-invalid,
data-required) and corresponding aria-*. scope expanded to
['soma', 'sema'].
- soma/components/switch/types.ts — adds intent, color, readonly,
invalid, aria-labelledby props with full JSDoc. Subset constrained
per guide §3.1 (Toggle/Switch row).
- soma/components/switch/switch-provider.svelte.ts — runtime-direct,
Field integration via FieldProvider.get(), isDisabled/isReadonly/
isRequired/isInvalid OR-merge derivations, intent ↔ color
resolution (intent wins when evaluative), runtime.trigger(
'commit-toggle') in onclick.
- soma/components/switch/components/switch.svelte — wires the new
props through bindProps.
- eidos/components/switch/{switch.css, switch.svelte, types.ts,
index.ts, README.md} — full subdirectory pattern, mirroring
toggle. Recipe drops legacy success/warning/danger/info selectors;
declares the 6 doctrinal data-color values directly. Wrapper
auto-renders Switch.Thumb inside the track and exposes an optional
`thumb` snippet for in-thumb glyphs.
- eidos/components/switch.css (legacy flat) deleted; eidos/index.css
updated to import the subdirectory recipe.
- web/routes/switch/+page.svelte — interactive testbed with all soma
props as live controls + tabs (Live, Soma, Morfo, Sema, Eidos,
Notes). Mirrors toggle's structure.
npm run check holds at the 39-error baseline. Tests 217/217 green.
Pending:
- Migrate collapsible / dialog / drawer / popover / toast / avatar
to the eidos wrapper pattern.
- Persistence + holds-by-intent — defer until first signal.warn /
signal.alert consumer.
- a11ySemantic + polymorphic events — defer until concrete need.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new doctrinal guide at src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md
supersedes src/docs/sema-implementation-guide.md and locks the
canonical vocabulary, event shape and color tokens. This commit aligns
the codebase to that canon in three concrete moves.
Phase 1 — sema/verbs.ts vocabulary:
- Restructured from flat array to family-keyed Record per the guide.
- Moved select / toggle / acknowledge → commit (these fix state, not
contact). Removed edit from handle (it's shift.enter-mode now).
- Added new verbs per the canon: contact (tap, focus, trigger,
release), commit (complete, restore, expire, set, remove, reorder),
signal (remind), handle (rotate, scroll, reorder), shift (route,
step, return, context), sustain (start, loading, waiting, syncing,
processing, streaming, pending, retrying, end).
- validateEventName now recognises both naming shapes:
{verb}-{variant} (head=verb) and {family}-{verb} (head=family).
- New helper familyForVerb. Tests rewritten — 16/16 green.
Phase 2 — morfo event shape:
- target moved from event-level into event.semantic. Adds optional
semantic.verb (advisory, validated against SEMA_VERBS[family]) and
optional semantic.sequence ('pre' | 'coincident' | 'post').
- compile.ts and schema.ts updated. The 22 events across toggle,
toast, popover, drawer, dialog refactored to the new shape with
explicit verb + sequence.
- Polymorphic capability (allowedFamilies + defaultSemantic) and
a11ySemantic deferred — no current consumer needs them; will land
when the first concrete usage appears.
Phase 5 — color tokens (doctrinal 8):
- themes/base/light.css and dark.css rewritten to expose primary,
secondary, neutral, affirm, fulfill, risk, threat, loss. Renames
success→fulfill, warning→risk, danger→threat (same hex). New
primitives for secondary (slate-blue), affirm (teal-mint, low
activation), loss (deep violet-grave, posterior). info palette
deleted entirely — per guide, "info no es intent, es signal.announce
+ neutral".
- _static.css legacy compatibility aliases (--color-bg, --color-success,
--color-error, --color-accent, ...) deleted. Clean cut, no
transition. Consumers reference doctrinal palettes directly.
- 29 consumers (token files + recipes) migrated:
- All --*-color-info-* declarations dropped.
- Component palettes extended with secondary / affirm / loss.
- switch + checkbox recipes updated to use [data-color='affirm'|
'risk'|'threat'|'secondary'] (was 'success'|'warning'|'danger'|
'info').
- toast tokens + recipe renamed to affirm/fulfill/risk/threat/loss
(was success/error/warning/info).
- meter, progress, mark, badge, button tokens renamed.
- Contracts (contracts/contract.css + contracts/components/*)
updated.
- focus-ring-color-error in themes now points to --color-risk-border
(validation errors are signal.warn + risk per doctrina, not threat).
Phase 4 (partial):
- src/docs/sema-implementation-guide.md deleted (superseded by the
canonical guide).
Tests: 217/217 green across uix. npm run check holds at the 39-error
baseline (all pre-existing in unrelated test files; none introduced).
Pending in follow-up commits:
- Update active_architecture.md, sema/README.md, morfo/README.md,
CLAUDE.md to reflect the canon.
- Switch component migration to the eidos wrapper pattern.
- Persistence field (untilAction / untilFix / stateBound) and
holds-by-intent — defer until the first signal.warn / signal.alert
consumer appears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deleted 60 obsolete documents that no longer reflect the active-uix
state:
- 47 per-component audit logs (*-audit.md, audit_N.md, AUDIT.md)
- 5 global soma audits (AUDIT_1, codex_audit, soma-audit-04-20/21,
audit-prompt template)
- 5 closed studies (soma/study, morfo/study, morfo/DESIGN, morfo/
PROVIDER_STUDY_2026-04-23, uix/CONTINUITY_2026-04-24)
- 2 superseded refactor proposals (refactor_claude, refactor_code)
- 1 stale bug log (form/BUG-onchange-onblur-hang — repro path
src/routes/test/sium/kitchen-sink no longer exists)
Updated the remaining operational references to drop dead links and
reflect the post-cleanup architecture:
- src/uix/README.md — rewritten links section, dropped pointers to
terra/air/CONTINUITY/refactor docs, updated adom/dom paths
- src/uix/active_architecture.md — header now dated 2026-05-08, lists
the major shifts since 2026-04-26 (provider inheritance dropped,
eidos in production, doctrina parte IV, sound eager-init); pendings
section reflects the eidos migration roadmap; "see also" purged of
deleted files
- src/uix/morfo/README.md — file tree updated (removed DESIGN/study,
added compile/runtime/PERMUTATION_RUNNER); see-also pruned
- src/uix/sema/README.md — SoundChannel section now describes the
eager-init pattern (constructor-time, not lazy); pointer to
sema-implementation-guide added
- src/uix/soma/README.md — drops terra/air mentions, declares the
actual dependency direction (eidos -> soma, not the reverse)
- src/uix/soma/SOMA_ARCHITECTURE.md — §15 rewritten as "Estado actual
y deuda histórica" reflecting Provider-inheritance-drop; demo path
updated to web/routes/
- src/uix/soma/COMPONENT_GUIDE.md — A23 dates section now points to
$libs/days (no external/dates façade); demo paths swapped from
/test/soma/* to web/routes/
- src/uix/morfo/PERMUTATION_RUNNER.md — paths swapped to web/routes/
with a relocation note
- CLAUDE.md — Refactor Documents section replaced with Reference
Documents pointing to per-layer READMEs
npm run check holds at baseline (39 pre-existing errors, none new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper +
types + index + README) replacing the flat CSS-only form. Pattern is
documented in eidos/components/README.md and the toggle README.
Shared types live in eidos/lib/types.ts. First export is `Size` (8 values
xxs..xxl + full); components narrow with `Extract<Size, ...>` per the
per-component-subset doctrine. No `Eidos` prefix on types — module path
already conveys the layer.
API doctrine:
- soma stays compound (Toggle.Provider) for symmetry with multi-part
- eidos exports both default + Provider so single-part components
accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>`
(compound-style consumers)
SoundChannel eager-init fixes the autoplay race: AudioContext is created
+ resumed synchronously on the first user gesture (capture-phase
listener registered in the constructor), avoiding the previous race
where the first emit() scheduled the resume in a microtask outside the
gesture window.
Demo page (web/routes/toggle/+page.svelte) restructured so the live
preview renders ALWAYS above the tablist — Sema-tab Play buttons can
fire on the real toggle. Motion preview amplifies scale ×8 visually
only; doctrinal values stay in the <dl>.
Conventions 7-13 added to src/docs/sema-implementation-guide.md
covering: directory structure, wrapper composition, no Eidos prefix,
soma compound vs eidos flat, iconOnly sr-only body, sound eager-init,
docs-preview amplification.
CLAUDE.md gets a session hand-off block listing where things stand and
next concrete steps (migrate switch/collapsible/dialog/drawer/popover/
toast/avatar; wire topbar sound mute to masterGain; rename theme
tokens to drop the success/warning/danger fallback aliases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Phase A of the soma cleanup. Every soma component that was
already runtime-aware (Toggle, Switch, Collapsible, Avatar from prior
commits + the four in this commit) now lives without the `Provider<T>`
inheritance.
Pattern applied to each (8 classes total across the four components):
- Drop `extends Provider<...>`. Each class is plain.
- Drop module-level `createAttrs(morfo)` and `registerContract(morfo)`
side-effect calls.
- Drop `super(opts, ...)`. Constructors inline:
root: this.opts = opts; XxxProvider.ctx.set(this); …
child: this.opts = opts; this.provider = XxxProvider.require(); …
- Drop `assertProps()` wrapping. Eidos `lint.ts` covers contract
validation at design time; morfo type system covers enum mismatches
at compile time.
- `props` derived: `{ ...runtime.partProps(part), …overrides }`.
- For sub-parts that own a per-instance prop (Title.level,
Toast.Action.altText), pass the source through `registerPart(...,
{ props: { x: () => opts.x.current } })` so resolution stays local
to that part's effect.
- For ref-tracking callbacks (Dialog/Drawer/Popover content/overlay,
Drawer trigger/handle), use `registerPart(..., { onRefChange })`
instead of the legacy `super(opts, spec, undefined, onRefChange)`.
Component-specific notes:
- Dialog: 7 classes; root tracks parent + nesting via context lookup
BEFORE `ctx.set(this)`. `dismissWith(action)` API kept identical.
- Drawer: 8 classes; gesture/snap/handle plumbing untouched. The
drawer morfo doesn't declare `value:` sources for `data-state`,
`data-side`, `data-dragging`, etc., so those stay as inline
overrides in each part's `props` (component-specific computed
state, not morfo contract). To be lifted into the morfo as a
follow-up if it pays off in eidos selectors.
- Popover: 8 classes; floating layer (`FloatingProvider`/Anchor/
Content/Arrow) wires through unchanged.
- Toast: 7 classes; multi-runtime structure preserved — root
runtime carries Viewport, each Item creates its own runtime so
`partRef('item')` resolves to that toast's element. Title /
Description / Action / Close inside an Item register on the
Item's runtime, not the root's.
- alert-dialog: small drive-by — `this.dialog.soma?.langs.ts(…)`
became `Soma.get()?.langs.ts(…)` because `DialogProvider.soma` is
no longer an instance field.
Verified: 209/209 UIX tests green; TS clean for all migrated files
(remaining repo-wide TS errors pre-existing, unrelated).
After this commit:
- 8 soma components are runtime-direct (toggle, switch, collapsible,
avatar, dialog, drawer, popover, toast).
- ~52 components still use `Provider<T>` via the legacy `baseProps +
resolveMorfoProps` path. Those will migrate as Fase B —
opportunistically (each time one is touched for another reason)
rather than in a sweep, per the user's standing preference for
small, surgical commits.
- The `Provider<T>` base class stays as transitional infrastructure
until Phase B drains. Final deletion lands in Phase C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same migration shape as toggle / collapsible. After 4 pilots the
pattern is consistent enough to extract a tiny base class — that's a
follow-up commit.
Switch (root + Thumb) — exercises `runtime.keydown` for action
dispatch (Enter / Space → 'toggle' action that calls
`preventDefault()` before flipping state).
Avatar (root + Image + Fallback) — exercises a multi-part component
where one child (Image) drives a watch.pre side effect to preload the
image and update parent state, and another (Fallback) reads the
parent's `status` reactively.
Per-class diff in both files:
- drop `extends Provider<...>`
- drop module-level `createAttrs(morfo)` and `registerContract(morfo)`
- drop `super(opts, ...)` calls; root inlines `ctx.set(this)`,
children inline `this.opts = opts; this.provider = Parent.require()`
- drop `this.assertProps(...)` wrapping in `props` derived
- drop `WithRefOpts` extension; inline `id: Active<string>; ref:
State<HTMLElement | null>` per child opts shape (more opt-in)
Avatar's `AvatarFallbackOpts` keeps `ref: State<HTMLElement | null>`
required because the fallback always renders DOM. `AvatarImageOpts` and
the root `AvatarOpts` derive from props via `OptsFromProps`.
Verified: 209/209 UIX tests green, TS clean for both files.
After this commit the migration sample is:
- toggle (single part, no ctx)
- switch (single part + ctx + actions/keyboard)
- collapsible (3 parts + ctx + cross-part id sources via partRef)
- avatar (3 parts + ctx + side-effect watch.pre + multi-state)
Common shape across all four:
- `readonly opts: T;` declaration + `this.opts = opts` assignment
- root: `XxxProvider.ctx.set(this)` (when ctx present)
- root: `Soma.require()` + `soma.uix.runtime(morfo, sources)` +
`runtime.registerPart('provider', { id, ref })`
- children: `this.provider = XxxProvider.require()` +
`parent.runtime.registerPart(part, { id, ref })`
- `props` derived → `{ ...runtime.partProps(part), ...customProps }`
The 60+ unmigrated components still use the legacy `Provider<T>` base
(baseProps + resolveMorfoProps path). That class stays put until those
components are also runtime-aware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second pilot. Collapsible exercises the multi-part case Toggle didn't:
3 providers (root + Trigger + Content), one shared morfo runtime
created by the root, and Svelte context wiring so children can find
the parent. The migration shape mirrors Toggle but factors in the
extra plumbing.
What changed:
- All three classes (`CollapsibleProvider`, `CollapsibleTriggerProvider`,
`CollapsibleContentProvider`) drop `extends Provider<...>`.
- Root: `CollapsibleProvider.ctx.set(this)` is called manually in the
constructor (replaces the implicit `super(opts, spec, ctx)` setup).
The `context()` helper from `\$soma/provider/context` is independent
of the `Provider` base class and remains unchanged.
- Children: just store `this.opts = opts` and look up the parent via
`CollapsibleProvider.require()`. No `super(opts, spec)` call.
- Drop module-level `createAttrs(collapsibleMorfo)` and
`registerContract(collapsibleMorfo)` — same dead-side-effect pattern
Toggle had.
- Drop `assertProps` in every `props` derived. Eidos linter + morfo
types cover what the runtime contract validation did.
What stayed identical:
- Public surface — `CollapsibleProvider.create(opts)`,
`CollapsibleProvider.require()`, `state.props`, `state.toggle()`.
- The shared-runtime pattern: root creates `uix.runtime(morfo, sources)`
once; children call `parent.runtime.registerPart('trigger' | 'content',
{ id, ref })`. This is what the morfo runtime was designed for.
- The `triggerId` / `contentId` cross-part state sources for
`partRef` resolution. Children publish their id via direct assign
(per the "id registration: direct assign" rule, not \$effect).
- All 209 UIX tests green.
Note on `WithRefOpts`: the `Provider` base used `extends WithRefOpts,
StateProps<...>, ActiveProps<...>` to compose the opts type. Without
the base, the migrated classes inline `id: Active<string>; ref:
State<HTMLElement | null>` directly. Tradeoff: a couple of extra lines
of opts shape per class, but the type composition is now opt-in
(children that don't need a ref can omit it) instead of mandated by
inheritance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First soma component to live without the `Provider` base class. Toggle
already used `uix.runtime(toggleMorfo, sources)` for the morfo-derived
attrs since an earlier refactor; this commit removes the residual
inheritance + dev-time validation scaffolding that was no longer
pulling its weight.
What changed:
- `ToggleProvider` no longer extends `Provider<ToggleOpts>`. It is a
plain class that owns `opts`, the field hookup, and the runtime.
- Drop the module-level `createAttrs(toggleMorfo)` (dead — return
value was unused) and `registerContract(toggleMorfo)` (the contract
registry is only read by `assertContract`, which we no longer call
for this component).
- Drop `super(opts, { morfo: toggleMorfo, part: 'provider' })`. The
runtime's `registerPart('provider', { id, ref })` already attaches
the ref via `attachRef` internally.
- Drop `this.assertProps(...)` wrapping in the `props` derived. The
eidos linter (`scripts/eidos-lint.ts`) already validates emitted
selectors against the morfo's `cssSelectors` contract at design
time, and the morfo type system catches enum mismatches at
compile time. Runtime contract validation was belt-and-suspenders.
What stayed identical:
- Public surface (`ToggleProvider.create(opts)`, `state.props`,
`state.snippetProps`).
- `OptsFromProps`/`bindProps` from `\$soma/provider/opts` — those
helpers are independent of the `Provider` class and remain useful.
- The wrapper `toggle.svelte` is untouched; it still consumes
`state.props` and `state.snippetProps` as before.
- All 209 UIX tests green.
The `Provider` base class is intentionally not deprecated. The other
60+ soma components still depend on it. Toggle proves the pattern
works for runtime-only components; future migrations follow per
concrete need (a 2nd consumer of the new pattern justifies extracting
shared bits, per the "no premature abstraction" rule).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Provider base class re-implemented every helper from the morfo
resolver verbatim — 9 functions plus its own `resolveMorfoProps` walk.
The runtime already routes through `compileMorfo` + `evalAttrPlan`
since Phase 2a; the Provider is now wired the same way.
What changed in soma/provider/provider.svelte.ts:
- Drop the local `findMorfoPart`, `shouldEmitMorfoEntry`,
`resolveMorfoPrimitiveSource`, `resolveMorfoSource`,
`resolveMorfoDataValue`, `resolveMorfoAriaValue` (~75 LOC of literal
duplication of resolver.ts).
- Drop the manual `resolveMorfoProps` walk (~28 LOC). New body is
seven lines: spread `compiledPart.staticAttrs`, then `evalAttrPlan`
each `dynamicAttrs` plan and skip undefined results.
- Drop the `createAttrs(spec.morfo)` call from the constructor; read
`compiled.parts.attrs[part]` directly. No semantic change.
- Drop the `findMorfoPart` walk for the part metadata; read
`compiled.parts.byKebab.get(part)` (O(1)).
- `_morfoPartMeta: MorfoPart | undefined` becomes
`_compiledPart: CompiledPart | undefined`. Same role, richer info.
Net diff: provider.svelte.ts goes 287 → 149 lines (-138).
What stayed identical: the public API (`baseProps` getter, `assertProps`
helper, `resolveMorfoProps(bindings)` signature, the dual constructor
overload for legacy subclasses without a morfo spec) plus the
behavioral contract — same context registration, same data-attr
contract validation, same prop snapshot shape for subclass
`\$derived.by` blocks. Subclasses are not touched.
Side effect: `resolveMorfoProps` now emits ARIA boolean values as the
literal strings 'true'/'false' (via `evalAttrPlan`'s `aria-boolean`
mode) instead of JS booleans. Aligns the Provider with the morfo
runtime's behavior (resolver.ts already returned strings; only
provider.svelte.ts had been emitting booleans). Svelte stringifies
both identically when spread onto an element, so the rendered DOM is
unchanged. All 209 UIX tests still green.
Also re-export the compile.ts surface (`compileMorfo`, `evalAttrPlan`,
`CompiledMorfo`, `CompiledPart`, `AttrPlan`, `KeyboardPlan`,
`ActionPlan`, contracts, etc.) from `\$uix/morfo` so consumers don't
have to dig into the file. Fix a stale comment that still pointed at
the deleted `\$uix/lib/types`.
Phase 2b proper (kernel rewrite with the 8 abstractions:
ComponentInstance / PartScope / PartRegistry / SourceRegistry /
ActionDispatcher / SemanticEmission / LayoutFacts / BehaviorRegistry)
is parked on purpose. None of those abstractions has two concrete
consumers today; deduplicating against the existing compiler is the
honest first move and unblocks any future kernel work without churn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The runtime is now the first production caller of `compileMorfo`. Each
per-tick effect previously walked the raw morfo tree; now it reads
pre-resolved plans off the compiled output.
What changed in runtime.svelte.ts:
- Single `compileMorfo(morfo)` call at the top (cached by WeakMap, so
multiple component instances of the same morfo share the result).
- `findMorfoPart` linear scan → `compiled.parts.byKebab.get(...)` O(1).
- `createAttrs(morfo)` walk → `compiled.parts.attrs` (pre-built).
- `findEvent` linear scan → `compiled.actions.byName.get(...)` O(1).
- `resolvePartAttrs` per-effect walk over `data` + `aria` arrays →
`{ ...staticAttrs, ...evalAttrPlan(plan, bindings) for plan in dynamicAttrs }`.
`staticAttrs` (literals + role) are pre-resolved at compile time;
only `dynamicAttrs` is re-evaluated per tick.
- Keyboard matching: `matchesKeySpec(specString, event)` re-parsed
the string every keystroke. Now the compiler emits `KeyboardPlan`
with a precomputed `ParsedKey`; matching is a flat field compare.
- Event dispatch uses `ActionPlan` (compiled view) instead of raw
`MorfoEvent`. `event.target.target` partRef-unwrap → `action.target`.
What was added in compile.ts:
- `evalAttrPlan(plan, bindings)` — evaluates one AttrPlan: short-circuits
on falsy condition, then branches once on `mode` instead of on
`source.kind` × `values.length` × `value.kind` per tick. Reuses the
pure helpers in resolver.ts (`shouldEmitMorfoEntry`,
`resolveMorfoSource`).
- Bug fix in `parseKey`: do not trim the literal key segment. `' '`
(Space) was being collapsed to empty string. The pre-existing
`matchesKeySpec` had a comment warning about this; the refactor
needed to preserve the same invariant. Caught by an existing
runtime test (Switch keyboard with Enter+Space).
Verified: 209/209 UIX tests green (morfo + soma + sema + eidos +
active-uix). The remaining 2 TS errors in runtime.svelte.test.ts
(lines 366 and 385) are pre-existing test casts and unrelated.
resolver.ts is no longer called from runtime.svelte.ts directly except
for the two pure helpers `shouldEmitMorfoEntry` (used by evalAttrPlan
and by keydown's condition check) and `MorfoBindings` (the binding
shape). The non-pure walk functions (`findMorfoPart`,
`resolvePartAttrs`) remain in resolver.ts for any consumer that still
imports them, but the runtime no longer does.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The form component still imported from paths that were deleted in the
active-uix cleanup:
- form.svelte: \$lib/sium/svelte (SiumProvider)
- form-auto-fields: \$lib/sium/core (Schema, SchemaKind, SiumShape)
\$langs (LangInstance)
Changes:
- form.svelte: drop the SiumProvider context fallback. The form
receives the schema via the `schema` prop (or none, for forms with
only `defaults`). The old fallback let a parent context inject the
schema implicitly; that was a single-call-site coupling and the
new ecosystem has no equivalent context yet. Re-introduce it later
via a soma-level scope only if a second consumer asks for it
(2-of-3 rule).
- form-auto-fields.svelte:
\$lib/sium/core → \$sium (Schema, SchemaKind, SiumShape are
re-exported from the package barrel)
\$langs (LangInstance) → \$lang (ActiveLang). The CompatibleLang
Pick<…, 'ts' | 't'> shape stays
identical since ActiveLang
carries the same surface.
Verified: svelte-check errors went 46 → 42 (the 4 form errors are gone;
remaining 42 are pre-existing in other components unrelated to the
cleanup). No form tests exist anymore (both were deleted in earlier
phases as casualties of the same import drift).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When src/uix/lib/types.ts went down with the rest of src/uix/lib/ in
phase A of the cleanup, the Layer and PartRef cross-layer primitives
went with it. Three files still imported them via `'../lib/types'`:
- src/uix/sema/types.ts
- src/uix/morfo/types.ts
- src/uix/morfo/compile.ts
Move the two types to src/uix/types.ts (top-level shared, sibling to
all UIX layers — no layer "owns" a cross-layer primitive), translate
the doc comment to English, and update the three importers to
`'../types'`. No semantic change.
Verified: svelte-check went from 49 errors to 46 (the 3 caused by the
deletion are gone); the remaining 46 are pre-existing and unrelated.
Vitest: morfo + eidos + sema = 136/136 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove src/uix/soma/components/form/components/form.svelte.test.ts
— same casualty as the auto-fields test removed in phase B: imports
from \$langs and \$lib/sium/* (both deleted).
- Update arts/session/ssr.ts JSDoc example: \$lib/app → \$active-app.
Note: form.svelte and form-auto-fields.svelte themselves still import
from \$lib/sium/* and \$langs. They will not compile against the new
ecosystem and need a rewrite or deletion as a separate decision —
flagged but not changed here.
Also note: src/arts/cache/test/engine-cache.test.ts fails with
"defineActiveCache is not a function". The test file is untracked
(was never committed); the failure predates this branch and is not
caused by the cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed in this commit:
- src/uix/air/ — old visual layer (~2.5k files)
- src/uix/terra/ — reference primitives (~765 files)
- src/lib/ — legacy root: actx, ling, logr, ext, glob,
util/dias, util/funcs, sium status notes,
uiux (~334 files). Replaced by src/arts/,
src/libs/, src/svrs/.
- src/uix/soma/components/form/components/form-auto-fields.svelte.test.ts
— only file in the codebase still importing
from \$uix/air. Form auto-fields will be
rebuilt with soma primitives if/when needed.
Verified post-deletion: zero matches for `from '\$lib(?!s)`, `\$glob`,
`\$langs`, `@/ling`, `@/logr`, `@/glob`, `@/actx`, `@/uiux`, `\$terra`,
`\$uix/(air|lib|terra)` across the remaining src/. The new ecosystem
(arts/, libs/, svrs/, uix/{active-uix, soma, sema, eidos, morfo}) is
self-contained.
Aliases in svelte.config.js / vite.config.ts still reference the
deleted paths — that's the next commit (phase C).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`src/uix/lib/` (13 files): orphaned DOM utility layer; replaced by
`src/libs/dom/` in the new ecosystem. Zero inbound imports.
`src/routes/` (459 files): test routes for the dead terra/air/soma demos,
plus a few stragglers under sema/eidos/sium. Demos will be re-authored
fresh under `web/routes/` once the SvelteKit config is updated.
No production code paths were touched — only the legacy reference
implementation and its test pages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consumes `compileMorfo(morfo).contracts.cssSelectors` to classify every
`[data-*]` selector in an eidos CSS file:
- morfo-backed: the runtime writes the attr; eidos reacts to it.
- eidos-only: the marker is present (`[data-toggle]`) but at least
one `data-*` is not declared on the morfo part. Legitimate design
tokens (`data-color`, `data-size`, `data-variant`) and drift cases
(`data-dialog-header` — a part the morfo no longer declares) both
land here; the lint tool reports them so authors can decide.
- invalid: the attr IS declared on the morfo, but with a value
outside the declared enum. Bug.
Also reports `unusedFromContract`: selectors the compiler promises
that no eidos rule consumes — the inverse direction of drift.
Two CLI entry points:
- `scripts/eidos-lint.ts <component>` — single-component report
- `scripts/eidos-lint-all.ts` — sweep across every eidos component
Initial sweep finds 0 invalid, 134 eidos-only, 82 unused contract
selectors across 11 components. Real drift hotspots: dialog/drawer
`header`/`footer` (eidos parts the morfos don't declare), popover's
`data-side × data-last-action` compounds, tooltip's
`data-state='delayed-open'`. Deliberate left as-is for the user to
triage — fixes belong in a separate commit.
Tests cover toggle (clean baseline), dialog (drift detector for
header/footer + dead `data-disabled`), and a synthetic invalid case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`compileMorfo(morfo)` returns a `CompiledMorfo` with pre-resolved plans,
dependency analysis, and frozen contracts. Memoised by morfo identity via
`WeakMap` so the runtime pays compile cost once per morfo (HMR-friendly).
What the compiled form replaces:
- `createAttrs(morfo)` walk → `parts.attrs` (kebab → marker)
- `findMorfoPart(parts, target)` linear search → `parts.byKebab` map
- `resolvePartAttrs()` source.kind branching every effect → split into
`staticAttrs` (literals + role hoisted at compile time) and
`dynamicAttrs[]` (`AttrPlan` with explicit `mode`)
- keyboard string parsing each tick → `KeyboardPlan` with `ParsedKey`
- `flattenMorfoParts` walk per `registerContract` call →
`contracts.dataAttrsByPart` + `contracts.cssSelectors`
- per-part `deps.{states,props,parts,needsTranslations}` for narrow
effects; aggregate `requiredSources` for cross-cutting audits
Mode taxonomy (`AttrMode`): `html-presence`, `aria-boolean`, `enum`,
`string`, `data-state-derived` — the runtime branches once on a
discriminated union instead of re-walking source/condition/values.
Tests cover: parts walking + tree links, static/dynamic split, keyboard
parse, actions, CSS selector contracts, required-source aggregation,
per-part deps, mapRef inner-source extraction, cache identity, frozen
output, duplicate-kebab errors. 28 tests, all green.
No runtime callers wired up yet — that's Phase 2 (kernel + runtime
consumes CompiledMorfo). Old `resolver.ts` / `create-attrs.ts` paths
remain functional for the existing runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single convenience method on `ActiveUix` so soma providers stop
duplicating the `dom: this.soma.dom, semantic: this.soma.semantic`
boilerplate at every `createMorfoRuntime` callsite.
Before (8 providers, identical lines):
this.runtime = createMorfoRuntime(dialogMorfo, {
dom: this.soma.dom, // ← duplicated everywhere
semantic: this.soma.semantic, // ← duplicated everywhere
states: { … },
props: { … },
events: { … }
})
After:
this.runtime = this.soma.uix.runtime(dialogMorfo, {
states: { … },
props: { … },
events: { … }
})
The implementation is a thin convenience — internally `uix.runtime()`
calls `createMorfoRuntime(morfo, { dom: this.dom, semantic: this.semantic,
…sources })`. No registry, no scope ids, no per-instance lifecycle to
track. Per-provider runtime ownership unchanged. The user explicitly
chose this minimal fix over a "single shared runtime + scope handles"
design after weighing the tradeoffs.
Migrated callsites: avatar, collapsible, dialog, drawer, popover,
switch, toast, toggle (8 providers, ~10 callsites including toast's
two — root + item). The `createMorfoRuntime` named import dropped from
each (only `MorfoRuntime` type kept where the field is annotated).
3 new tests on `active-uix.svelte.test.ts`:
- returns a MorfoRuntime with auto-injected services
- caller does not pass dom or semantic
- attach mode uses the external app's dom + semantic
Verified: vitest 17/17 active-uix (was 14), 211/211 UIX, smoke 68/68,
morfo-check 66/66.
Earlier design mistake: `createActiveUix(options)` was internally
calling `createActiveApp(...)` to compose the UIX services. That
violated the contract — active-uix was supposed to be either a
substitute for active-app (when no app exists) or a complement (when
an external app injects services).
Fix: standalone mode now instantiates each UIX service directly via
the canonical factories — no `active-app` involved:
- core: createEngineLogger, createEngineBus, createActiveTimers,
createActivePrefs (with empty schema)
- lang: createActiveLang(schema, defaultLocale, fallbackChain)
- dom: createActiveDom
- frontend: createActiveFrontend (auto-applies to <html>)
- format: createActiveFormat (clock from timers)
- semantic: new EngineSemantic(opts)
active-uix manages every owned lifecycle: dispose() tears them down
in reverse dependency order. Attach mode is unchanged — wraps an
external app, validates UIX services, doesn't touch app.dispose().
Type changes:
- `ActiveUix.app: ActiveApp | undefined`. Present only in attach
mode (it's the underlying app); `undefined` in standalone (no
app exists).
- `ActiveUix.bus: EngineBus` (generic envelope). Standalone gets
a fresh bus, attach gets the App's bus with its event map. UIX
components don't care about typed events; consumers that do
re-narrow at the call site.
- Removed `prefs?` and `extraServices?` from `ActiveUixOptions`.
Apps that need a custom prefs schema or services beyond UIX
should use attach mode (compose `createActiveApp` themselves).
New tests cover both modes: standalone (8 tests including service
disposal verification, idempotent dispose, format opt-out,
componentLangs registration toggle), attach (6 tests including
missing-services error, identity-equal service proxying, no
disposal of external app), and cross-mode parity (1 test).
Verified: vitest 15 files / 211 tests, smoke 68/68, morfo-check 66/66.
Decouples the UIX layer (soma / morfo runtime / sema / eidos) from
the App composition layer (`active-app`). UIX components no longer
import `\$active-app` directly — they read services through
`getActiveUix()`. Two boot paths produce the same `ActiveUix`:
- **Standalone** — `createActiveUix({ lang, semantic, … })`
bootstraps an `ActiveApp` internally with the UIX services plus
any `extraServices`. Owns the app — `dispose()` propagates.
- **Attach** — `attachActiveUix(externalApp)` wraps an app the
application composed itself (typically when it has services
beyond UIX: cache, session, http, …). The external app must
declare the UIX services; helper `defineUixServices(options)`
returns the slice ready to spread into
`createActiveApp({ services })`. Does NOT own the app.
In both cases components see only `ActiveUix`. They have no way to
tell which boot path was used.
Module:
src/uix/active-uix/
├── index.ts barrel ($active-uix)
├── types.ts ActiveUix, ActiveUixOptions, AttachActiveUixOptions
├── services.ts defineUixServices() + UIX_REQUIRED_SERVICES
├── active-uix.svelte.ts createActiveUix() + attachActiveUix() + ActiveUixImpl
├── components/uix.svelte <Uix uix={...}> wrapper (sets context + creates Soma)
└── svelte/context.svelte.ts setActiveUix() / getActiveUix() / tryGetActiveUix()
Surface exposed by `ActiveUix`:
- UIX services: lang, frontend, format (optional), dom, semantic
- Core: logger, bus, timers, prefs
- Config: somaPortalTo
- `app` escape hatch for advanced integration code (NOT for UIX
components)
Wiring done by active-uix at boot:
- `App.lang.extend('components', componentLangs)` — soma component
translations always available without per-app boilerplate.
- Frontend service auto-configured with `applyDom: true` +
`target: () => document.documentElement` so theme/dir/density
land on `<html>` automatically.
Migration:
- `Soma.create()` reads `getActiveUix()` instead of `getActiveApp()`.
Soma class no longer imports `\$active-app`.
- 3 layouts (test/eidos, test/sium, test/soma) replace
`createActiveApp + setActiveApp + <Soma>` with
`createActiveUix + <Uix>`. Boilerplate drops ~20 lines per layout.
- 7 sium pages replace `getActiveApp() + app.lang.setLocale(…)`
with `getActiveUix() + uix.lang.setLocale(…)`.
Validation:
- `attachActiveUix(app)` throws `ActiveUixMissingServiceError` if
the external app didn't declare lang/frontend/dom/semantic.
- `getActiveUix()` throws `ActiveUixNoContextError` if no `<Uix>`
ancestor mounted.
`\$active-uix` alias wired in svelte.config.js + vite.config.ts (both
client and server projects).
Verified: vitest 197/197, smoke 68/68, morfo-check 66/66, playwright
probe (4 routes including dialog with sound) zero page/console errors.
Follow-up to 4bf6dea4 — that commit added the type definitions to
`src/libs/reactive/types.ts` but the corresponding re-export from
`src/libs/reactive/index.ts` was lost during the staging shuffle.
Without this, `import { type ActiveProps } from '$libs/reactive'` would
fail on a fresh checkout even though the consumers across soma assume
those types are exported from the libs barrel (the previous commit
rewrote 79 of them to that import path).
Per architectural feedback: when relocating code to its correct layer,
do the migration completely — update every consumer directly and
delete the original. No re-export barrels at the old paths.
Removed shims:
- `src/uix/soma/attrs/` — was re-exporting `createAttrs` /
`registerContract` / `assertContract` / `getContract` from
`$uix/morfo` and the bool/data-attr formatters from `$libs/dom`.
All 66 importing files updated to import from the canonical
locations directly.
- `src/uix/soma/reactive/` — `reactive.svelte.ts` was a thin
re-export of `$libs/reactive`. The 79 consumers (every soma
provider + layers + types) now import primitives directly from
`$libs/reactive`. The `opts.ts` Props↔Opts bridge moved to
`src/uix/soma/provider/opts.ts` (its natural home — provider
plumbing, not reactive primitives).
- `attachRef` / `RefAttachment` re-export from
`src/uix/soma/provider/provider.svelte.ts` and from
`src/uix/soma/index.ts`. Both removed. Consumers
(`floating.svelte.ts`, `date-field-provider.svelte.ts`) now
import directly from `$libs/reactive`.
Also:
- Moved `ActiveProps<T>` / `StateProps<T>` types to
`src/libs/reactive/types.ts` — they're trivial generic helpers
over `Active<T>` / `State<T>` and shouldn't have lived in soma.
- Stripped soma's root barrel (`src/uix/soma/index.ts`) of every
re-export of libs/morfo concerns. It now only re-exports
actually-soma surfaces (Provider base, mergeProps, css helpers,
id, types, Soma class).
Result:
- `grep -r '\$soma' src/uix/morfo` → 0 matches
- `grep -r '\$uix' src/libs` → 0 matches
- `src/uix/soma/attrs/` and `src/uix/soma/reactive/` no longer exist
- Every import statement now reflects the real architectural layer
Verified: vitest 19/20 files / 214 tests (1 pre-existing air dead-
branch fail using `bun:test`), smoke 68/68, morfo-check 66/66.
The previous DOM consolidation commit left a wrong-direction edge —
`src/uix/morfo/runtime.svelte.ts` imported `createAttrs` from
`$soma/attrs` and `attachRef`/`RefAttachment` from `$soma/provider`.
Morfo is the declaration layer; soma is one of its consumers; morfo
must not depend on soma.
Moves:
- `createAttrs` (`src/uix/soma/attrs/create-attrs.ts`) →
`src/uix/morfo/create-attrs.ts`. It's a pure morfo→data-attr-name
derivation; its natural home is alongside the morfo declarations it
derives from. Soma is a consumer like any other.
- `contracts.ts` (registerContract / assertContract / getContract)
→ `src/uix/morfo/contracts.ts`. Same reasoning: contracts derive
from a morfo's `data: [{ values }]` enum.
- `attachRef` + `RefAttachment` (extracted from
`src/uix/soma/provider/provider.svelte.ts`) →
`src/libs/reactive/attach-ref.svelte.ts`. It bridges Svelte's
`{@attach}` system with the `State` reactive box; both ends are in
libs/reactive, so it lives there.
- Bool/data-attr formatters from `src/uix/soma/attrs/helpers.ts`
(boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef,
getDataOpenClosed, getDataChecked, getAriaChecked) →
`src/libs/dom/attrs.ts`. Pure DOM-attr value formatters with no
morfo dependency — they're libs/dom material.
Critical bug fix uncovered by the move:
`src/uix/soma/reactive/reactive.svelte.ts` was a parallel
implementation of the reactive primitive system, with its OWN
`ActiveSymbol` / `WritableSymbol` instances — different from the
ones in `$libs/reactive`. As soon as `attachRef` (now in libs)
tried to detect a soma-created `State` via `isState`, the guard
returned false and the function fell into the setter-fn branch,
throwing "ref is not a function" in 10+ form tests.
Fix: rewrite `soma/reactive/reactive.svelte.ts` as a thin re-export
of `$libs/reactive`. They now share the same symbol identities, so
every cross-layer guard works consistently.
Compatibility:
- `$soma/attrs` is now a backward-compat barrel that re-exports from
`$uix/morfo` (createAttrs, contracts) and `$libs/dom` (helpers).
Existing soma providers continue working unchanged.
- `$soma/provider/provider.svelte.ts` re-exports `attachRef` /
`RefAttachment` from libs for any importer that still uses the
legacy path.
Verified post-cleanup:
- `grep -r '$soma' src/uix/morfo` → 0 matches (no morfo→soma)
- `grep -r '$uix' src/libs` → 0 matches (no libs→uix)
- vitest 14 files / 197 tests
- smoke 68/68
- morfo-check 66/66
Three architectural cleanups in one cut:
1. **morfo runtime relocates to its own layer.** The MorfoRuntime + the
pure morfo→attrs resolver (and their tests) move from
`src/uix/soma/morfo/` → `src/uix/morfo/`. They were never
soma-specific — they interpret a morfo declaration on behalf of any
consumer. Soma was just the only consumer at the time. The new
barrel at `$uix/morfo` re-exports `createMorfoRuntime`,
`MorfoRuntime`, `MorfoRuntimeSources`, `RegisterPartOpts`,
`SourceMap`, `SemanticEmitter`, `TriggerOptions`, plus the resolver
helpers (`findMorfoPart`, `resolvePartAttrs`, `shouldEmitMorfoEntry`,
etc.).
2. **`src/uix/adom/` is gone.** The runtime pieces (`createActiveDom`,
`BodyScrollLock`, `DOMContext`, `RovingFocusGroup`, viewport) all
live at `$adom` (`src/arts/adom/`) which is the canonical
ecosystem-level ActiveDom. The `apply.ts` types and helpers
(`DomAttrValue`, `applyChange`, `removeAttrs`, `StructuralChange`)
live in `$libs/dom` and are re-exported from `$adom`. The 4 last
`$uix/adom` import sites updated to `$adom`.
3. **`src/uix/soma/dom/` is gone.** The 14 utilities (`isBrowser`,
`getDocument`, `isHTMLElement`, focus + tabbable helpers, etc.) all
exist as a superset in `$libs/dom`. The 12 soma consumers updated
their imports from `'../dom'` / `'$soma/dom'` → `'$libs/dom'`. No
functional change — purely import-path consolidation.
`src/uix/active-uix/` is preserved as an empty placeholder for the
future cross-layer composition class that will set up morfo + soma +
sema + eidos together.
The relocated runtime now imports cleanly:
- `ActiveDom`, `DomAttrValue` ← `$adom`
- `EngineSemantic`, sema helpers ← `$uix/sema`
- `Active`, `State` ← `$libs/reactive` (was `$soma/reactive`)
- `createAttrs` ← `$soma/attrs` (cross-layer; createAttrs is morfo
logic that lives in soma for now — slated for relocation in a
follow-up)
- `attachRef`, `RefAttachment` ← `$soma/provider/...` (idem)
Verified: vitest 14 files / 197 tests, smoke 68/68, morfo-check 66/66.
Updates the canonical semantic vocabulary to match the latest
formulation in src/docs/libro_semantica_completo.txt:
Families (7, was 6):
contact, commit, signal, handle, emerge, shift, sustain
- `alert` → `signal` (rename: same perceptual function — attention /
salience — but the book's name reflects the broader scope including
notify, warn, emphasize, announce).
- `shift` (NEW, transitional): frame change. Modal mount, navigation,
mode change. Differs from emerge in that it reorganizes planes
(heavier backdrop, deeper shadow) — "cruce de umbral" not just
"aparición". Per book rule, the frame doesn't carry intent on its
own; evaluation belongs to a companion `signal` event.
Intents (6, was 5):
neutral, affirm, fulfill, risk, threat, loss
- `loss` (NEW): consummated negative consequence (negative valence,
low activation, posterior). Differs from `threat` (anterior, high
activation, convokes action): loss registers, doesn't convoke. Per
book §11.5 "la amenaza no es la pérdida".
sema-map.ts perceptual values:
- `signal` family entry: kept the prior `alert` numbers verbatim
(semantics is the same — attention/salience) under the new name.
- `shift` family entry: motion 260ms ease-in-out 0.98→1, sound
pitch 500 ascending gain 0.18 (deliberately quieter than emerge's
0.20 to not compete with companion signal), presence with stronger
backdrop (0.5) + shadow (blur 32 y 12 op 0.2) emphasizing
plane reorganization.
- `loss` intent deltas: descending sound contour (-150 pitch),
contraction (scale.to -0.04, motion duration ×0.95 — brief but
grave), color toward purple-grave (hue replace 280, lower
saturation/intensity), withdrawn presence (backdrop 0.05, no glow).
ARIA `role="alert"` (W3C live region) is unrelated and untouched in
soma/announce, soma/toast and morfo announce/field/form. The toast
morfo gains `loss → role:'status' aria-live:'polite'` mapping —
loss is consummated so it shouldn't interrupt.
Verbs added per the book's worked examples: signal {warn, notify,
emphasize}, shift {enter-mode, exit-mode, navigate}, commit {save,
discard, delete}, contact {press, click, hover}, handle {pick, carry,
drop}, emerge {reveal, show, hide}.
Verified: vitest 9 files / 99 tests, smoke 68/68, morfo-check 66/66.
The air → eidos refactor stripped the `.air-accordion-*` class hooks but
left 5 rulesets with their selector empty (just whitespace before `{`).
The most visible damage was line 161:
[data-accordion-trigger][data-state='open'] {
transform: rotate(var(--eidos-accordion-indicator-rotation));
}
Empty selector after the trailing space → the rule matched the trigger
button itself instead of the intended `.air-accordion-trigger-indicator`
descendant. So expanding any item rotated its title 90deg.
The other four orphaned rules (trigger-main flex layout, trigger-label,
indicator size/transition, content-inner padding) were dormant noise but
also dropped — the morfo doesn't declare an Indicator part and the demo
doesn't render the inner wrapper, so there's nothing to style.
Content padding is now applied directly to `[data-accordion-content]`
since there's no inner wrapper. Animation-height includes padding, which
matches the natural scrollHeight measurement.
Verified:
- playwright probe: trigger `transform: none` in both closed and open
state (was `matrix(...)` rotating 90deg before)
- smoke 68/68
Diagnosis: clicking "open" on dialog/drawer/popover produced no sound
(or any semantic feedback). Cause: every overlay trigger was bypassing
`runtime.trigger(...)` with a direct `handleOpen()` because the morfo's
declared target part (overlay content) doesn't yet exist in the DOM
when the trigger fires — and `runtime.trigger` was throwing when the
target part had no registered ref.
Fix:
- `MorfoRuntime.trigger(name, opts?)` now accepts `{ fallbackTarget?:
HTMLElement }`. When the declared target part is unmounted, the
fallback is used as the visual signal target so the perceptual
signal still fires. Sound / vibra channels don't care about the
target; the visual channel writes its `data-event*` to the trigger
briefly, which is the natural locus of the user's action anyway.
- Dialog / Drawer / Popover triggers now go through `runtime.trigger`
with the trigger element as `fallbackTarget`. Each provider's
`events` map gains an `open` (or `present`) entry that runs
`handleOpen()` as the runtime's handler step — preserving the
prewrite → emit → handler order.
- Popover's toggle uses `close-dismiss` for the close direction so
both directions emit a perceptual signal, not just open.
Soma class: `soma.dom` and `soma.semantic` are now non-optional again
(matching pre-migration semantics — every layout declares both
services). `soma.format.*` slices stay optional.
Verified end-to-end with a Playwright probe that spies AudioContext
construction:
Open dialog → 2 oscillators (was 0)
Press Escape → +2 (was 2)
Click overlay → +2 (was 0)
Gates: vitest 7 files / 78 tests, smoke 68/68, morfo-check 66/66.
Restores the resolver-driven architecture that was deleted in the
channel-modules refactor (commit a0a1485b). Each signal now flows
through a single resolution that produces an `EffectiveSignature`, and
each channel reads its own slice — so the visual hold, the sound earcon,
and (future) motion / color / presence channels all share a single
family-level perceptual budget defined in `SEMA_MAP`.
New surfaces:
- `src/uix/sema/sema-map.ts` — typed perceptual map (port of the legacy
`sema-map.json`) with per-family `base` (motion / sound / color /
presence) + `activeChannels` and per-intent `deltas` using the
number / `{op:'add'}` / `{op:'multiply'}` / `{op:'replace'}` /
recursive-merge semantics from before.
- `src/uix/sema/resolver.ts` — pure `resolveSignature(signal)` that
returns an `EffectiveSignature`. Empty signature when family is
missing so channels skip rather than throw. Intent deltas only apply
to valenced families. Optional `soundPack` per-event sample URLs.
- `src/uix/sema/chans/sound.ts` — real `SoundChannel` (~290 lines, ported
from a0a1485b^). Single AudioContext + master GainNode, auto-unlock
for Safari/iOS via `click` / `touchstart` / `keydown` with capture,
ADSR-lite synthesis (sine + 5th → biquad lowpass → envelope, AM
modulator when roughness > 0.2), contour via `osc.detune`, optional
sample playback with `AudioBuffer` cache. Errors absorbed silently —
sema is ornamental.
Architectural changes:
- `Channel.handle(signal, effective)` — every channel now receives the
resolved signature alongside the signal. Channels can ignore it.
- `EngineSemantic.emit` resolves once per emit, dispatches both args.
- `VisualChannel` hold resolution chain is now (highest priority first):
1. `signal.hold`
2. `effective.motion.duration` ← NEW: from sema-map
3. family fallback table (SEMA_DURATIONS)
4. engine `defaultHold`
Behavior change: families with motion in the map (alert: 220ms,
contact: 80ms, commit: 180ms, …) now use those values instead of the
previous label-based 600ms / 120ms / 240ms fallbacks. The fallback
table remains as a safety net for families without motion (sustain).
- `EngineSemanticOptions` adds `sound?: true | false | SoundChannelOptions
| Channel`. Sound is opt-in (audible side effects). Layouts wire
`defineEngineSemantic({ sound: true })`.
Tests:
- New `resolver.test.ts` (7 tests) — locks down family base, intent
deltas, multiplicative ops, transitional-family no-op, deep-clone.
- `visual.test.ts` — every `handle` call now passes a stub
`EffectiveSignature`. Two new tests: motion.duration takes the hold,
and signal.hold overrides effective.motion.duration.
- `engine.test.ts` — `makeChannel` mock signature updated.
- `emit.test.ts` — alert family default test now expects 220ms (from
sema-map) instead of 600ms.
Verified:
- vitest: 6 files / 53 tests green
- smoke: 68/68 soma routes 200 OK
- morfo-check: 66/66 morfos validate
Replaces the legacy `$lib/ext/app` `App.create({ langs, presentation })`
composition pattern with the new ecosystem's `createActiveApp({ services
})` across all three test layouts (eidos / sium / soma) and rewires Soma
+ morfo runtime to consume the new surface.
What changed:
- Soma class (uix/soma/core/soma.svelte.ts) now reads from `getActiveApp()`
in $active-app context and adapts its historical names over the new
ecosystem: `soma.langs → app.lang`, `soma.presentation → app.frontend`,
`soma.dates/nums/money/units → app.format.{dates,numbers,currency,units}`.
All 12 soma component providers consume Soma unchanged.
- New context bridge: $active-app exports `setActiveApp(app)` /
`getActiveApp()` / `tryGetActiveApp()` (mirrors `setBus` / `getBus`
from $bus). Throws `AappNoContextError` (new code APP_ERR_NO_CONTEXT)
when no App is in scope.
- 3 layouts (test/eidos, test/sium, test/soma) replace
`App.create({ langs, presentation })` with `createActiveApp({ services:
{ lang, frontend, format, dom, semantic } })` + `setActiveApp(App)` and
call `App.lang.extend(...)` post-construction for runtime translation
modules.
- 7 sium test pages migrate `App.get()` → `getActiveApp()`,
`app.langs` → `app.lang`, `app.setLocale(x)` → `app.lang.setLocale(x)`.
- Morfo runtime exports a tiny `SemanticEmitter` type (`Pick<EngineSemantic,
'emit'>`) so consumers and tests don't have to satisfy the full
EngineSemantic surface.
- soma/core/langs.ts and sium plural import migrate `LangNode` /
`p` from `$lib/ext/lang/*` → `$libs/lang`.
After this commit no consumer outside `src/lib/` imports from
`$lib/ext/app`, `$lib/ext/lang`, or `$lib/ext/presentation`. The legacy
shims under `src/lib/ext/` remain for now (Phase 5 will delete them).
Verified:
- svelte-check: only 2 pre-existing errors (event.ts, runtime test)
- vitest: 6 files / 69 tests green (sema + morfo runtime)
- smoke: 68/68 soma routes 200 OK
- morfo-check: 66/66 morfos validate against demo DOM
- playwright: /test/sium/{login,booking,color} + /test/eidos/toggle
render full content with 0 page errors / 0 console errors
- eidos recipe check: dialog provider chain end-to-end OK
Adapts EngineSemantic to the AppServiceFactory contract so apps composed
with `createActiveApp` can declare semantic as a service:
services: {
semantic: defineEngineSemantic({ visual: { defaultHold: 400 } })
}
The factory lives in `src/uix/sema/` (not in `arts/active-app/service-
factories/`) because semantic coordination is a UIX-layer concern, not
generic app infrastructure like cache / auth / perm.
EngineSemantic itself stays unchanged — the factory just wraps it,
declares no core/service deps, and disposes the engine on teardown.
Aligns sema's engine class with the new ecosystem's Engine* / Active*
naming convention (per src/arts/README.md):
- Engine* — public methods over private state. No reactive surface.
- Active* — Engine* extended with $state + onChange listeners.
Sema's SemanticEngine is purely imperative: a channel registry + an
async dispatch. No $state, no listeners, no loading/lastError. So by
convention it must be Engine*, not Active*.
Renames (mechanical):
SemanticEngine → EngineSemantic
SemanticEngineOpts → EngineSemanticOptions (also aligned to
EngineSiumOptions / EngineHttpOptions name)
.destroy() → .dispose() (uniform with the rest
of the ecosystem; only one call site, in
engine.test.ts)
Touched code: engine.ts, exports.ts, emit.test.ts, engine.test.ts +
README.md (kept consistent). Also sweep src/lib/ext/app/{defaults,
app.svelte}.ts which still mention the old name in fallback wiring.
Air's docs (AIR_*.md) and the legacy CONTINUITY note kept untouched
(dead branch).
This commit is purely a rename. The next commit (Phase 4 part 2) adds
the defineEngineSemantic factory and migrates soma + the 3 layouts to
createActiveApp.
Verification: sema 44/44, smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 — mark legacy as deprecated:
- Add @deprecated JSDoc header to all 27 top-level barrels under
src/lib (ext/{app,lang,dates,money,nums,units,presentation},
{actx,ling,logr,glob,sium,util}, root index.ts).
- Add src/lib/README.md with the migration map: legacy module →
new equivalent under src/arts + src/libs + src/svrs.
- Zero behavior change. Only IDE strikethrough at usage sites.
Phase 1 — wire new ecosystem aliases in vite.config.ts and svelte.config.js:
- 18 new $arts/* aliases: $active-app, $adom, $auth, $bus, $cache,
$connection, $format, $frontend, $http, $lang, $logger, $orca,
$perm, $prefs, $session, $sium, $storage, $timer.
- $libs (src/libs), $locale (src/libs/locale), $svrs (src/svrs).
- $reactive rewired from src/lib/util/reactive to src/libs/reactive
(export surfaces verified compatible — same symbols, drop-in).
- Legacy aliases ($lib, $glob, $langs, @/ling, @/logr, @/glob,
@/actx, @/uiux) preserved for backwards compat during migration.
- SvelteKit auto-regenerates .svelte-kit/tsconfig.json paths from
svelte.config.js, so TS resolution updates without manual edits.
The migration plan (Phases 2-6) lives in src/lib/README.md:
Phase 2 — sweep src/lib/util/* imports to $libs/* (drop-in).
Phase 3 — morfo/schema.ts: 3 imports to $libs/standard-schema.
Phase 4 — soma's App.create() composition to createActiveApp with
explicit service slots (including semantic for sema). Only step
that changes architecture.
Phase 5 — sweep ~82 test pages to use new aliases.
Phase 6 — remove src/lib/{glob,ling,actx} after verifying no
consumers; investigate sium and util/{dates,times,timers}.
Verification: smoke 68/68, morfo-check 66/66. svelte-check baseline
unchanged for the alias rewire (no Cannot-find-module errors). The
2 dialog-provider possibly-undefined errors at lines 113-114 are
pre-existing (this.soma vs this.soma! after Soma.get() returning
Soma | undefined) — not introduced by this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Drawer's "show" semantic was missing from the morfo, so close paths
went straight to handleClose() without the prewrite → emit → handler
cadence. User flagged this — drawer DOES have a present/dismiss
semantic, equivalent in surface to Dialog.
Morfo (`morfo/components/drawer.ts`):
- Added `events[]` mirroring Dialog: present, close-save,
close-cancel, close-dismiss, close-dismiss-outside, close-after-fail
- Added `data-last-action` (saved/cancelled/dismissed/dismissed-outside/
failed) to Content's data so the prewrite has a declared slot
- scope: ['soma', 'sema'] now (sema participates via signal emit)
Eidos (`eidos/components/drawer.css`):
- Reserved a 3px transparent border on the LEADING edge of Content
per side (the edge facing the user, opposite to the slide-out
direction): right→border-left, left→border-right, top→border-bottom,
bottom→border-top
- Tint rules: combine [data-state='closed'] + [data-side] +
[data-last-action] so the right edge gets colored during the close
animation (saved → success, failed → danger, cancelled/dismissed →
neutral)
Soma (`soma/components/drawer/drawer-provider.svelte.ts`):
- DrawerProvider injects a MorfoRuntime (same pattern as Dialog soft-
migration). Registers content part. Wires events.{close-save,
close-cancel, close-dismiss, close-dismiss-outside, close-after-fail}
to handleClose().
- dismissWith(action) public method — same API as Dialog.dismissWith.
- Replaced 4 close paths with runtime.trigger():
Close.onclick → 'close-cancel'
Escape (modal) → 'close-dismiss'
Escape (non-modal) → 'close-dismiss'
Overlay.onclick → 'close-dismiss-outside'
Drag-to-dismiss (gesture) keeps direct handleClose() — the visible
slide-out IS the close, no 240ms hold makes sense.
- DrawerProvider class re-exported from $soma/components/drawer.
Demo (`test/eidos/drawer/+page.svelte`):
- New "Exit tinting by causa" section with Save/Cancel/Fail buttons
that call dismissWith() through a local _dismiss-button.svelte
(same pattern as Dialog's).
Verification (playwright):
save t=83ms lastAction=saved hold=240ms (commit family)
cancel t=119ms lastAction=cancelled hold=240ms (emerge)
fail t=127ms lastAction=failed hold=600ms (alert)
Smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous layout (`repeat(4, 1fr)` with 0.5rem gap) packed the four
sides in a horizontal row. Floating's collision detection saw the
tooltip on `side='right'` had no room (the next button was 8px away)
and shifted the placement, making the popover/tooltip appear far from
its trigger.
Switch to 2x2 grid with 4-7rem gaps and 3rem padding so each trigger
has enough surrounding space for the tooltip/popover to render at the
requested side without the floating layer flipping or shifting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates `air/components/tooltip/tooltip.css` (small — 31 lines, only
Content + sizes styled) and adds:
- Arrow fill (matches tooltip bg)
- Per-side entrance/exit animations keyed off [data-side] (Floating
layer) and [data-state]. Distance is smaller than Popover (2px vs
4px) — tooltip is lighter visually.
- State handling for tooltip's three-value [data-state]:
'closed' → exit animation
'delayed-open' → entrance animation (hover after openDelay)
'instant-open' → no animation (keyboard focus, deliberate
invocation; the user wants it now, not after a
slide)
Demo (test/eidos/tooltip): 4-side grid + sizes (sm/md/lg).
Verification: smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates `air/components/popover/popover.css` (small — 67 lines, only
Content + Close styled) and adds three eidos extensions:
1. Trigger envelope ([data-popover-trigger]) — air relied on the
Button recipe via .air-button class. Eidos applies a baseline
button-style envelope here, consuming Capa 2 directly.
2. Overlay fade ([data-popover-overlay][data-state='open'/'closed'])
— for modal popovers. The morfo declares Overlay as optional;
consumer renders it when modal=true.
3. Per-side entrance/exit animations keyed off [data-side]
(top/right/bottom/left) and [data-state]. Soma's Floating layer
writes data-side based on resolved placement. The popover slides
into place from the trigger edge — fade + small translate (4px)
+ scale (0.985 → 1). Air's "revelation" pattern: ~180ms entrance
/ 120ms exit.
Demo (test/eidos/popover): basic + 4-side grid. Each side button
anchors a Popover.Content with the matching `side` prop so the
animation direction matches placement.
Verification: smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates `air/components/drawer/drawer.css` to `eidos/components/
drawer.css` (mechanical: --air-* → --eidos-*, drop .air-* gates) and
adds the entrance/exit animations air's recipe was missing — air drove
drawer transitions through `air.semantic.contextIn/Out` Svelte
transitions, which don't exist in the morfo-driven path. Without them
the drawer snapped in/out instantly.
Per-side keyframes:
right → translateX(100%)
left → translateX(-100%)
top → translateY(-100%)
bottom → translateY(100%)
Plus overlay fade keyed off data-state. All ~240ms entrance / 180ms
exit (moderate / normal). The Presence layer waits for
getAnimations().finished so the content stays mounted during exit.
Demo (test/eidos/drawer):
- Modal section: 4 side buttons, render Drawer.Overlay (backdrop
+ focus trap, page non-interactive while open).
- Non-modal section: 4 side buttons, omit Drawer.Overlay + pass
modal={false}. No backdrop. Page behind stays interactive. Soma's
Dismissal layer auto-adjusts (interactOutsideBehavior default
flips from 'ignore' to 'close').
The Drawer morfo doesn't declare `events` yet, so no runtime.trigger()
cadence + no exit-tinting like Dialog. Recipe + animations only.
Verification (playwright probe):
top t=140ms translateY(-372) → t=406ms 0
right t=99ms translateX(452) → t=389ms 0
bottom t=94ms translateY(372) → t=390ms 0
left t=111ms translateX(-349) → t=428ms 0
Smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expose a programmatic dismiss method on DialogProvider that fires the
matching close-* event through the runtime, and use it from the eidos
Dialog demo to make the exit-tinting visible.
Soma:
- DialogProvider.dismissWith('save'|'cancel'|'fail'|'dismiss'|
'dismiss-outside') maps to the corresponding morfo event
(close-save, close-cancel, close-after-fail, close-dismiss,
close-dismiss-outside) and fires runtime.trigger().
- DialogProvider class re-exported from $soma/components/dialog so
consumers can call DialogProvider.get() inside a child component.
Demo (test/eidos/dialog):
- New "Exit tinting by causa" section with three buttons that close
the same dialog via three different events:
Save → close-save → border-top tinted success (240ms hold)
Cancel → close-cancel → border-top tinted neutral (240ms hold)
Fail → close-after-fail → border-top tinted danger (600ms hold)
The 600ms fail hold is sema's family='alert' default — the dialog
stays open longer for the user to register the failure before close.
- Uses a small _dismiss-button.svelte child component that captures
DialogProvider.get() at component init time. The inline onclick
pattern (Dialog.DialogProvider.get()?.dismissWith(...)) doesn't
work from the parent route's scope because Svelte's getContext is
init-time-only — the route is the parent of Dialog.Provider, not
a child. The _dismiss-button is rendered inside Dialog.Content so
its init-time context lookup resolves correctly.
Verification (playwright probe):
save t=144ms lastAction=saved hold=240ms
cancel t=116ms lastAction=cancelled hold=240ms
fail t=117ms lastAction=failed hold=600ms (full alert family)
Smoke 68/68.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inject a MorfoRuntime side-channel in DialogProvider that fires the
prewrite → semantic.emit → handler cadence for the close-* events
declared in the dialog morfo. The legacy provider machinery
(assertProps + resolveMorfoProps + Provider class hierarchy) stays —
runtime is alongside, not replacing.
Wiring:
- DialogProvider creates the runtime in its constructor with
`events.{close-save, close-cancel, close-dismiss,
close-dismiss-outside, close-after-fail}` all delegating to
handleClose(). The runtime gets dom + semantic from soma + state
sources for `open` + prop sources for `disabled`/`modal`.
- DialogContentProvider registers the content part with the runtime
when its constructor runs, so trigger() can resolve
`event.target = partRef('content')` to a real DOM node.
- DialogTriggerProvider.onclick keeps direct handleOpen() — the
'open' event in the morfo targets content, but content isn't
in the DOM until AFTER open flips. Chicken-and-egg.
- DialogCloseProvider.onclick → trigger('close-cancel').
- DialogContentProvider's dismissal callbacks:
onEscapeKeydown → trigger('close-dismiss')
onInteractOutside → trigger('close-dismiss-outside')
What this unlocks:
Each close path now writes `data-last-action` (saved/cancelled/
dismissed/dismissed-outside/failed) BEFORE flipping data-state,
visible during the exit animation. The dialog recipe's tint rules
in eidos finally fire in vivo.
Verification (playwright check-eidos-recipes):
@8ms post-click lastAction=cancelled state=open (prewrite)
@241ms lastAction=cancelled state=open (signal hold)
@378ms lastAction=cancelled state=closed (exit)
@493ms opacity=0.017 (exiting)
The 240ms wait between click and exit is sema's family='emerge' hold.
The user can override per-event via `morfo.events[].hold` if 240ms feels
too long for a dialog cancel. Smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Air's dialog relied on the Button recipe (`.air-button` class on
trigger). Eidos has no class composition, so the trigger was inheriting
only the archetype's cursor + focus ring, leaving it with the
browser-default button look.
Add a baseline envelope in dialog.css consuming Capa 2 tokens directly
(--eidos-color-{surface-raised, surface-overlay, border-default,
content-primary}, --eidos-control-height-md, --eidos-radius-md,
--eidos-space-3, --eidos-duration-fast). Hover and data-state='open'
both shift to surface-overlay. Disabled fades to 0.5.
Trigger isn't a separate component with its own Capa 3 tokens — it's
a part of Dialog using the system surface. Direct Capa 2 consumption
is appropriate here (same as archetype rules).
Verification: smoke 68/68, morfo-check 66/66; visual confirmed via
playwright screenshot — Open dialog/Open small/Open large render with
proper button envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the V1.5 hand-rolled motion/easing/tint/opacity/focus-ring
tokens with the air-translated equivalents already in eidos/themes/
base/_static.css and eidos/themes/base/light.css.
Substitutions applied to archetypes.css, events.css, components/
dialog.css:
--eidos-motion-fast → --eidos-duration-fast (120ms)
--eidos-motion-base → --eidos-duration-slow (320ms)
--eidos-easing-standard → --eidos-ease-default
--eidos-easing-decelerate → --eidos-ease-out
--eidos-easing-emphasized → --eidos-ease-spring
--eidos-tint-neutral → --eidos-color-neutral-element
--eidos-tint-affirm → --eidos-color-success-element
--eidos-tint-fulfill → --eidos-color-success-element
--eidos-tint-risk → --eidos-color-warning-element
--eidos-tint-threat → --eidos-color-danger-element
var(--eidos-opacity-hover) → 0.85 (single-site, archetype-internal)
var(--eidos-opacity-disabled) → 0.5 (single-site, archetype-internal)
Focus ring tokens (--eidos-focus-ring-{color,width,offset}) unchanged
— same names already exist in air's _static.css/light.css.
`affirm` and `fulfill` collapse to the same `success-element` because
air's intent vocabulary doesn't distinguish them. The morfo's
data-intent attr still preserves the distinction at the structural
layer; the visual mapping is just the same color.
Updated scripts/check-eidos-recipes.ts to navigate to the per-component
demo URLs (/test/eidos/{toggle,switch,dialog}) since the index page
no longer renders the recipes directly.
Verification: smoke 68/68, morfo-check 66/66. Playwright entrance/exit
curves intact (opacity interpolates over the configured durations).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure the demos to mirror /test/soma's pattern: index page plus
one demo per recipe, all under a shared layout that loads the eidos
CSS chain and the Soma + App scaffolding once.
test/eidos/
├── +layout.svelte Soma + App + sticky nav bar
├── +page.svelte index (links + foundation explanation)
├── toggle/+page.svelte colors / sizes / variants / states
├── switch/+page.svelte colors / sizes / disabled
├── collapsible/+page.svelte
├── toast/+page.svelte fire by intent (5 intents)
└── dialog/+page.svelte default + size variants
Each demo exercises the variants air's CSS exposes (data-color,
data-size, data-variant) by adding the attrs directly to the Provider
element — they're a-la-carte since the morfos don't declare them yet.
Future work: extend the morfos to declare these attrs as first-class
props.
Verification: all 6 routes return 200; smoke 68/68 (only covers
/test/soma — eidos routes not in scope), morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch (151 lines):
- mechanical: --air-* → --eidos-*, drop .air-* gates
- selectors match morfo as-is ([data-switch], [data-switch-thumb],
[data-state='checked'], [data-disabled])
- a-la-carte variants (data-color, data-size) dormant unless consumer adds
Collapsible (decorated parts only):
- mechanical translation
- dropped air's height-keyframes: they referenced
var(--terra-collapsible-content-height) (a JS-measured value from
the dead terra layer) and soma's Collapsible uses HTML `hidden`
(display:none) which kills CSS animations anyway. Left undecorated;
needs Presence layer integration in soma to enable real transitions.
Toast (substantive remap):
- selector remap due to part naming difference between air and morfo:
air [data-toast-toaster] → eidos [data-toast-viewport]
air [data-toast] (the card) → eidos [data-toast-item]
air [data-toast-message] → DROPPED (no equivalent — soma has
Title + Description direct, not a
separate "message" element)
air .air-toast-status → DROPPED (no status icon part)
air .air-toast-main → DROPPED (no content wrapper part)
- intent vocabulary remap (air data-type ↔ morfo data-intent):
error → threat
warning → risk
success → fulfill (and affirm — same palette)
info → neutral
- data-position on viewport stays a-la-carte (not in morfo)
Verification: smoke 68/68, morfo-check 66/66, focus rings + dialog
entrance/exit working per playwright.
Note: [data-dialog-trigger] has no explicit rule (air relied on
.air-button recipe). The trigger renders with browser defaults +
archetype interactive cursor. To be addressed in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the V1.5 hand-rolled recipes with the translated air originals
adapted to the morfo-driven architecture.
Mechanical translation:
- sed --air-* → --eidos-* (tokens) and --_air-* → --_eidos-* (private)
- drop `.air-{part}` class gates everywhere — eidos applies universally
to whatever data-attrs the morfo emits
Substantive adjustments per recipe:
Toggle:
- kept the existing morfo data-attrs ([data-toggle], [data-state='on'],
[data-disabled]) — match air's selectors directly
- air's a-la-carte variants ([data-color], [data-size], [data-variant],
[data-block], [data-icon-only]) stay dormant: not in toggle morfo,
only fire if a consumer adds them manually
- 229 lines of palette × variant × size variants vs 40 lines hand-rolled
Dialog:
- air's pattern: animation keyframes keyed off [data-state='open'/'closed']
(NOT Presence-layer markers); Soma's Presence waits getAnimations().
finished, so this works
- air ONLY animates the overlay — content snaps in/out instantly, sub-
perceptual. Added eidos-only @keyframes for content entrance/exit
(opacity + scale) so Presence has something to wait for and the user
can perceive the transition
- kept data-last-action exit tinting (border-top by causa during
closing animation) — air doesn't have this concept, dialog morfo
declares the prewrite that writes the attr before data-state flips
Verification:
- smoke 68/68, morfo-check 66/66
- playwright entrance: opacity 0 → 1 over ~120ms (air's overlay-in token)
- playwright exit: opacity 1 → 0 over ~280ms (air's overlay-out token)
- both interpolate, not sub-perceptual; user can tweak via tokens
Note: the existing eidos/tokens.css (legacy hand-rolled) and
eidos/components/{toast,switch,collapsible}.css (still hand-rolled)
continue to load alongside; phase 3 will translate the rest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates the foundation of air's visual system into eidos:
- eidos/contracts/ public token interfaces (4 root + 64 components)
- eidos/tokens/ per-component token values (62 components + motion)
- eidos/themes/base/ light + dark + _static + motion (4 files)
Mechanical translation: `--air-*` → `--eidos-*` everywhere, relative
imports preserved. The CSS is currently NOT wired into the legacy air/
folder, which stays for reference until phase 4 cleanup.
The `--eidos-*` prefix is documented in eidos/index.css with the two
reasons it earns its keep:
1. Authorship clarity at debug time (DevTools can identify the layer)
2. Override discipline (consumers know they're targeting public API)
Phase 1 only adds the foundation without touching component recipes.
The hand-rolled recipes (toast, dialog, toggle, switch, collapsible)
and the legacy hand-rolled tokens.css continue to load alongside the
new tree. Phase 2 will replace recipes with air-translated equivalents
and reconcile the duplicate motion/tint tokens.
Architectural note: air targeted terra and gated its CSS with
`.air-{component}` class on a wrapper. Eidos targets soma+morfo and
applies universally to the data-attrs the morfo declares — no class
gates, no Svelte wrappers. The morfo layer is the one that decides
which `data-*` attrs exist, which is the substantive shift between
the two systems.
Verification: smoke 68/68, morfo-check 66/66, focus rings + dialog
entrance/exit still working per Playwright check (curve unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hold of a component's event is now declared by the component itself,
not buried in sema's family fallback table.
Resolution precedence (in priority order):
1. signal.hold imperative, caller passes at emit time
2. morfo events[].hold declarative, component-level (this change)
3. sema family fallback 'emerge' → 'brief', 'alert' → 'noticed', ...
Sema's family table becomes the technical fallback when neither caller
nor morfo specifies. The numbers come from a single perceptual scale in
sema/durations.ts (subliminal/glimpse/brief/noticed/insistent/persistent),
so any change to the scale propagates to the family fallback automatically.
Public surface:
sema: SEMA_DURATIONS, SemaDurationLabel, SemaDurationSpec,
resolveSemaDuration() — exported from $uix/sema.
morfo: MorfoEvent.hold?: SemaDurationSpec — number or label.
soma: MorfoRuntime.trigger() reads event.hold, resolves the label →
ms via resolveSemaDuration(), passes it as signal.hold.
Tests:
- sema/durations.test.ts (4): scale values + resolver
- soma runtime (3 new): hold-less event sends no signal.hold (sema
falls back), numeric hold propagates, label hold resolves to ms.
Verification: sema 44/44, soma runtime 25/25, smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recipe was missing transitions on the Presence-layer markers
(data-starting-style / data-ending-style). The dialog snapped into
view and snapped out — duration 0ms is sub-perceptual.
Add fade + scale on Content (320ms = --eidos-motion-base = "brief")
and fade on Overlay. Both interpolate via the Presence lifecycle:
open → element mounts with [data-starting-style] briefly →
CSS shows opacity:0 + scale(0.96) →
marker removed next rAF →
transition runs to opacity:1 + scale(1)
close → [data-ending-style] applied →
CSS animates opacity:0 + scale(0.96) →
Presence waits getAnimations().finished →
element unmounts
Verification (Playwright):
@8ms entrance: opacity 0.028, scale 0.972 (interpolating)
@80ms exit: opacity 0.46, data-ending-style still present
Both opacity AND transform animate so getAnimations().finished has
something to wait for in either direction. Easings: standard for
opacity, decelerate for transform (pops in cleaner).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dialog overlay self-positions
Two bugs surfaced by Playwright verification at /test/eidos:
1) Focus ring: archetypes.css used `outline: ...` for the focus-visible
ring. The project's layout.css has a Tailwind layer/components rule
`*:not(body):not(.focus-override) { outline: none !important }` that
kills every outline. The eidos rule was never visible.
Switch to a two-layer box-shadow ring (inner transparent gap +
outer color). Box-shadow follows border-radius, so the ring also
looks right on pills (Switch) and rounded corners (Toggle, Dialog
buttons). Ring also stands out against accent-filled states because
it sits OUTSIDE the element, not on top of it.
2) Dialog overlay: the recipe relied on [data-archetype='overlay']
from archetypes.css for `position: fixed; inset: 0`, but Dialog isn't
migrated to MorfoRuntime yet — its provider uses legacy createAttrs
which doesn't read morfo's `archetype` field. The overlay element
had `position: static, height: 0`, so the backdrop was invisible.
Inline `position: fixed; inset: 0` directly in the recipe. Component
recipes shouldn't depend on archetype rules for foundational behavior;
archetype is a transversal extra, not a foundation.
Adds scripts/check-eidos-recipes.ts — Playwright script that opens
/test/eidos, tabs into Toggle/Switch, opens the dialog, prints
computed styles + saves screenshots to g:/tmp/eidos. Used to catch
both bugs; reproducible for future visual checks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ring color was hsl(220 90% 55% / 0.6) — same hue as the default
button accent at 60% alpha, so when the Toggle is in 'on' state or the
Switch is checked (both accent-filled), the ring against the active
background was almost invisible.
Bump to hsl(220 95% 45%) full opacity. Slightly darker than the accent
default, full alpha so it stands out against accent-filled states.
Note: `:focus-visible` only fires on keyboard focus (Tab), not click —
expected browser behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom property was added in V1 anticipating multiple recipe
consumers, but only Dialog uses last-action tinting. Premature
abstraction — single-use indirection that obscures what's happening.
Move the 5 selectors directly into dialog.css. events.css drops the
machinery (replaced with a 3-line note pointing to the recipe). If a
second consumer with the same exit-tint pattern shows up later, factor
out then — not before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second per-component recipe. Composes archetype interactive defaults
(trigger, overlay, close, content, focus-visible) with the visual
envelope (button shape, modal card, backdrop) and the asymmetric exit
tinting from events.css.
The recipe consumes `--_eidos-exit-tint` via a top accent stripe on
[data-dialog-content], so close-save tints fulfill, close-after-fail
tints threat, close-cancel/dismiss/dismissed-outside tint neutral. The
private var stays inside eidos — recipes consume it; consumers don't.
events.css extends the data-last-action coverage to dismissed and
dismissed-outside (both map to neutral tint, like cancelled).
Public CSS API: --eidos-dialog-{overlay-bg, overlay-z, content-bg,
content-radius, content-padding, content-shadow, content-max-width,
content-z, button-bg, button-bg-hover, button-border, button-border-hover}.
Default z-indices (overlay 50 / content 51) are sensible defaults the
integrator can override via tokens — Dialog without z-index would be
useless out of the box.
Test page (/test/eidos) gets a Dialog section so the recipe is visible
end-to-end.
Verification: smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes the save/restore patch (b66039ad) — that was a workaround
for a design flaw, not the fix. The flaw: VisualChannel was writing
`data-intent` on the same DOM target where the morfo already had
`data-intent` from `v.propRef('intent')`. Two owners of one attr name,
collapsed by the channel's cleanup. Toast lost its tinting after every
announce because the channel removed the structural attr.
Fix: rename the signal-bound attr to `data-event-intent`. The channel's
attrs all live under `data-event-*` (data-event, data-event-id, data-
event-phase, data-event-family, data-event-intent) — a clean namespace
that can never collide with state attrs (data-state, data-intent,
data-disabled, ...). The channel writes only its own namespace; the
runtime owns state.
CSS in eidos can read either with distinct semantics:
[data-event-intent='risk'] → reacts to a transient occurrence
[data-intent='risk'] → reacts to persistent component state
events.css announce rules now read data-event-intent.
The Toast recipe is unchanged — its [data-toast-item][data-intent='...']
selectors read structural state, which is now never overwritten.
Tests:
- visual.test.ts: rename data-intent → data-event-intent in all
assertions; replace the two save/restore tests with one that proves
state attrs (data-intent, data-state, data-disabled) survive the
full hold cycle untouched.
- emit.test.ts: rename data-intent → data-event-intent.
Verification: sema 40/40, smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The channel writes `data-intent` from `signal.intent` during the hold,
but `data-intent` is also the structural attr the morfo of components
like Toast.Item writes from `v.propRef('intent')`. Removing it on
cleanup erased the structural value — the toast lost its tinting after
the announce signal expired.
Fix: save the prior `data-intent` value before overwrite, restore it on
cleanup. General principle — a channel should clean only what it wrote.
Other signal attrs (`data-event*`, `data-event-family`) are signal-
specific so no collision.
Tests: structural data-intent survives the hold; mismatched signal vs
structural intent (edge case) restores the structural value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First per-component recipe in eidos/components/. Composes tokens +
archetypes + intent palette into a finished Toast visual:
- viewport stack layout (no positioning — integrator's call)
- card with intent-tinted left border (reuses --eidos-tint-*)
- absolute close button so layout doesn't depend on a header wrapper
- typography for title / description, secondary-button style for action
- swipe state (data-swipe='move'/'cancel') translates the card
- loading state adds an accent stripe via inset shadow
Toast.Item carries archetype='item' which sets cursor:pointer + user-
select:none in archetypes.css; the recipe overrides those because a
notification is passive, not selectable. Possible future morfo decision:
introduce a 'card' or 'notification' archetype.
The eidos test page drops its :global([data-toast-*]) scoped CSS — the
recipe now suffices. Stale '~13 ms / 1 rAF' text replaced with the
correct per-family hold (600ms for alert).
Wired via @import './components/toast.css' from eidos/index.css.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "data-event*" bullet still mentioned the old "~13ms" timing from
before the perceptual anchoring fix and the channel refactor. Updated
to reflect current behavior: VisualChannel writes the attrs for a
configurable hold (240ms emerge/commit/handle, 600ms alert/sustain by
default per family, configurable per signal or globally).
No code changes — single doc fix.
Reorganización estructural según el spec acordado: cada canal perceptivo
vive como módulo simétrico dentro de `chans/`. El engine queda mínimo
(registry + dispatch); la lógica de escribir `data-event*` al DOM,
mantener hold y retirar pasa al `VisualChannel`. La API pública que ven
los providers (`semantic.emit(signal)`) se mantiene idéntica.
Estructura nueva
src/uix/sema/
├── engine.ts registry + dispatch (ya no conoce DOM ni hold)
├── signal.ts SemanticSignal + nuevo campo opcional `id?`
├── exports.ts barrel actualizado (chans + drop publish + drop perception)
└── chans/
├── types.ts interfaz Channel { id, handle, dispose? }
├── visual.ts VisualChannel — escribe data-event*, hold, cleanup
├── visual.test.ts 9 tests del canal aislado
├── sound.ts SoundChannel placeholder (V1 no-op)
└── vibra.ts VibraChannel placeholder (V1 no-op)
Cambios al engine
- Constructor: `new SemanticEngine(opts?)`. `opts.visual` controla el
built-in (false / VisualChannelOptions / Channel custom). `opts.dom`
desaparece — ya no es responsabilidad del engine.
- Métodos: `register(channel)`, `getChannel(id)`, `emit(signal)`,
`destroy()`. Nada más.
- emit despacha a TODOS los canales registrados:
- Canales no-visuales (sound, vibra, futuros): fire-and-forget. Errores
se loguean pero no propagan (Sema es ornamental).
- Canal visual: el único cuya Promise se awaitea — comparte plano DOM
con el commit estructural posterior.
- emit genera el `id` de la ocurrencia y lo pasa a todos los canales,
garantizando coherencia cross-canal para tooling futuro.
Cambios al VisualChannel (lógica heredada del emit() anterior)
- 5 atributos: `data-event` + `data-event-id` + `data-event-phase`
(siempre) + `data-event-family` y `data-intent` (opcionales si vienen
en el signal). Los opcionales son la proyección al DOM de la metadata
semántica que Eidos consume.
- Hold defaults internos por familia (no exportados):
emerge/commit/handle: 240ms · alert/sustain: 600ms · contact: 120ms
Justificación técnica (rangos típicos de CSS transitions), no escala
perceptiva universal. Override per signal vía `signal.hold` o global
vía `new SemanticEngine({ visual: { defaultHold } })`.
- Semántica secuencial estricta: cleanup ANTES del resolve.
Eliminado
- `src/uix/sema/perception.ts` — escalas perceptivas no son concepto
cross-canal. La tabla de defaults por familia se movió al VisualChannel
como detalle interno con justificación técnica.
- `engine.publish()` y todo el sistema legacy:
- `SemanticEventDecl`, `SemanticComponentContract`, `SemanticPublishContext`,
`PublishedSemanticEvent`, `SemanticEventFilter`
- `onEvent()` y subscribers
- `applyPrewrites()` interno
- dependencia opcional de `ActiveDom` por construcción
- Tests de `publish()` reemplazados por tests del nuevo registry/dispatch.
Bootstrap (App + defaults)
- `src/lib/ext/app/app.svelte.ts:52` — `new SemanticEngine({ dom: this.dom })`
→ `new SemanticEngine()`
- `src/lib/ext/app/defaults.ts:45` — mismo cambio
Tests
- emit.test.ts ajustado: sin createActiveDom, sin SEMA_PERCEPTION/HOLD_DEFAULTS,
test "throws without dom" eliminado, números literales (240, 600).
Añadido test "resolves immediately when visual: false". 7 tests.
- engine.test.ts reescrito: register / getChannel / emit dispatch a múltiples
canales / id propagation / id override / fire-and-forget para non-visual /
error en canal no aborta dispatch / visual: false desactiva built-in /
custom Channel reemplaza built-in / destroy. 13 tests.
- chans/visual.test.ts nuevo: 5 attrs vs 3 / signal.hold respetado /
family default fallback / global default fallback / constructor defaultHold /
cleanup / sequential strict (resolve tras cleanup) / id field. 9 tests.
- 39 tests sema verdes en total.
Documentación
- src/uix/sema/README.md — reescrito alrededor del modelo de canales
- src/uix/eidos/events.css — comentario actualizado, referencia a chans/visual.ts
- src/uix/active_architecture.md — sección Sema reescrita
Componentes NO requieren cambios — la API `semantic.emit(signal)` es
idéntica externamente. Toast / runtime.trigger / providers funcionan igual.
Verificación
- 121/121 tests focused suite (sema 39 + adom + lib/dom + morfo + soma/morfo)
- 66/66 morfo-check
- 68/68 smoke routes
- svelte-check: 155 errors (baseline, sin nuevos)
Pendientes en doc del spec marcados como "no-objetivos":
- Implementación real de SoundChannel / VibraChannel (placeholders V1)
- Arbitraje propio del VisualChannel (deuda heredada del emit anterior)
- Cancelación si el target se desconecta durante el hold
Two architectural fixes surfaced by the eidos demo:
1. The previous default hold (1 rAF + 1 hold frame, ~30ms) was a
technical timing decision — invisible to humans. Signals must be
anchored to perceptual scales so they can actually be perceived.
2. The previous lifecycle resolved emit AFTER 1 rAF (caller's structural
change ran in parallel with cleanup, ~30ms later). That's an implicit
parallelism between event and state. The author has no way to say
"wait for the announcement to be perceived before changing state".
Sequential strict semantics (NEW)
- emit writes the signal, awaits the full author-specified hold, cleans
up, then resolves. State change happens AFTER cleanup — strictly
sequential.
- `signal.hold` is in milliseconds anchored to `SEMA_PERCEPTION` scales:
subliminal 50ms below conscious threshold
glimpse 120ms minimum perceptible without effort
brief 240ms comfortable acknowledgement
noticed 600ms sustained signal
insistent 1200ms demands attention
persistent 3000ms until acknowledged
- Defaults per family in `SEMA_HOLD_DEFAULTS`:
emerge: brief sustain: noticed
contact: glimpse commit: brief
alert: noticed handle: brief
- Authors override per event via `SemanticSignal.hold` based on:
- their CSS animation duration (so it completes before cleanup)
- the perceptual scale they want
- critical attention requirements (use insistent/persistent)
Files
- src/uix/sema/perception.ts (new) — SEMA_PERCEPTION + SEMA_HOLD_DEFAULTS
+ SEMA_OBSERVE_FRAME_MS constants with rationale anchored on Bloch's
law, Material/Apple HIG motion guidelines, toast UX literature.
- src/uix/sema/signal.ts — `hold` JSDoc updated to explain ms semantics +
defaults + override pattern.
- src/uix/sema/engine.ts — emit() refactored from rAF chain to
setTimeout(holdMs); cleanup happens BEFORE resolve (sequential strict);
`nextFrame()` helper removed; `resolveHoldMs(signal)` added with the
3-tier fallback (signal.hold → family default → SEMA_PERCEPTION.brief).
- src/uix/sema/exports.ts — re-exports SEMA_PERCEPTION + SEMA_HOLD_DEFAULTS
+ SEMA_OBSERVE_FRAME_MS + SemaPerception type.
- src/uix/sema/emit.test.ts — rewritten around fakeTimers + setTimeout
(no more rAF queue); new test verifies cleanup runs BEFORE resolve.
7/7 tests green.
- src/uix/sema/README.md — new "Duración del signal" section with the
full perception scale + per-family defaults tables + override pattern.
- src/uix/eidos/events.css — comment updated to explain the new hold
windows; rule `[data-event^='dismiss']` etc. clarified.
- src/uix/active_architecture.md — replaced "~13ms" prose with explicit
perception-anchored scales; added explanation of "perceptual by design,
not framerate".
- src/uix/soma/components/toast/toast-provider.svelte.ts — new $effect
that fires `present` + `announce` events when item ref attaches
(one-shot guarded), so toasts emit signals on appear. The flash on
/test/eidos is now visible thanks to (a) the perceptual hold and (b)
the wired events.
Channel parallelism note
- Hold is the DOM channel duration only.
- Future sound / vibra / motion engines subscribe to sema events
independently; each owns its own timing relative to the event id.
- Sema does not coordinate cross-channel sync — that's an integrator
decision per app.
Verification
- 22/22 sema tests green.
- 22/22 morfo runtime tests green.
- 66/66 morfo-check + 68/68 smoke (verified before commit).
- Probe shows announce signal on toast appear lasting ~600ms (alert
family default) — visible in DevTools attribute panel.
- svelte-check unchanged at 155.
Pre-existing failure in src/uix/air/internal/canvas/layout.test.ts
(`bun:test` import) is in the dead-branch air layer and unrelated.
First eidos code. Closes the cross-layer cycle that's been documented but
unproven: an actual CSS layer that consumes the morfo + sema contracts
and applies styles transversally without enumerating components.
src/uix/eidos/
- tokens.css — motion durations (quick/fast/base) + easings (standard /
emphasized / decelerate) + opacity scale (hover/disabled/faded) +
intent tints (neutral/affirm/fulfill/risk/threat) + focus ring tokens.
~30 lines. No theming dimension yet — when it arrives, these are the
base theme.
- archetypes.css — transversal selectors keyed by `data-archetype`.
Covers trigger, overlay, content, indicator, thumb, track, close,
action, item, option. Plus a universal `:focus-visible` ring rule that
applies to every archetype-classified element. Single attribute
selector specificity — components can override freely.
- events.css — reactions to perceptual signals during their ~13ms life.
`[data-event^="dismiss"]` fades out, `[data-event^="announce"]` pulses
with intent-tinted box-shadow, `[data-last-action]` tints exit
animation by causa.
- index.css — entrypoint that imports the above in cascade order.
Consumer: `import '$uix/eidos/index.css'`.
src/routes/test/eidos/+page.svelte (demo)
- Sets up Soma + App context inline (independent of /test/soma layout).
- Imports `$uix/eidos/index.css` and demonstrates the cross-layer story:
- Toggle + Switch buttons share trigger styles via `[data-archetype="trigger"]`
without per-component CSS.
- Collapsible expands/collapses with state-driven attrs.
- Toast intent buttons fire `data-event="announce" data-intent="risk|threat|affirm"`,
which eidos's events.css briefly tints via box-shadow during the signal window.
- Component-specific layout (positioning, switch thumb transform) lives in
the page's `<style>` block — eidos provides only the cross-component
baseline, NOT per-component recipes.
What this proves
- The runtime emits `data-archetype` correctly in browser (not just in
unit tests).
- A single CSS rule can target ALL triggers across all components
without enumeration. Add a new component with `archetype: 'trigger'`
and it inherits the styling automatically.
- Sema's `data-event*` signals live in the DOM long enough for CSS to
react (the ~13ms window measured by `scripts/probe-toast-dismiss.ts`
is observable as a brief box-shadow flash on toasts).
- The cross-layer cycle Morfo declara → Soma transcribe → Sema emite →
Dom aplica → Eidos lee runs end-to-end.
What V1 deliberately excludes
- Theming dimension (no `[data-theme]` overrides yet).
- Per-component recipes (each component will get its own CSS later).
- Keyframes / @starting-style / animation primitives.
- Sound / vibra (those are separate engines that subscribe to sema).
Verification
- /test/eidos returns 200; manual browser check shows triggers share
hover dim + focus ring across components, toasts flash intent tint on
appear.
- 68/68 smoke routes (smoke only scans /test/soma — eidos route is
outside that scope, manually verified).
- svelte-check unchanged at 155 (pure CSS additions).
Synthesizes the four-layer architecture (morfo · soma · sema · eidos) into
a single readable doc. Where the per-layer READMEs are operational
references, this is the "north star" for understanding the why and how
the pieces articulate.
Structure (14 sections):
1. The thesis in one line — four layers with explicit contracts
2. The problem solved — what conventional frameworks mix together
3. The four layers — Morfo (DNA), Soma (behavior), Sema (semantic
channel), Eidos (visual)
4. How they articulate — the transcription chain (six pieces, one
responsibility each)
5. The causal chain of an interaction — concrete walkthrough of a Toast
dismiss with timing
6. The primitives that pass between layers — DOM attrs catalog +
archetypes + verbs vocabularies
7. Hard rules — the 12 invariants that keep the system coherent,
including the 2-of-3 rule
8. The autoría / transcripción distinction — the lens for deciding what
lives where
9. What this architecture is NOT — explicit non-goals
10. Current state (2026-04-26) — what's built, designed, pending
11. Acknowledged risks — over-layering, name discipline, responsibility
invasion, lack of precedent
12. Why it may be worth it — explainability, less drift, validation,
extensibility
13. The summary phrase — "Morfo declara · MorfoRuntime transcribe ·
Provider aporta · Effects sincronizan · Semantic emite · Dom aplica
· Eidos lee"
14. For deeper reading — links to per-layer docs
Also adds a top-of-page pointer in src/uix/README.md so the vision doc is
the obvious entry point for new readers.
No code changes — pure documentation.
Closes the documentation loop on the cross-layer extension pass: morfo
now articulates between soma, sema, and (future) eidos. The "2-of-3 rule"
formalizes when an extension to morfo is justified vs when it should
stay as provider logic.
src/uix/eidos/README.md (new)
- Documents eidos's role and what it consumes from morfo + sema BEFORE
any code exists, so the contract is preparedly clean when implementation
starts.
- Catalogs which morfo fields eidos reads (parts, archetype, states,
data values, events, prewrite, focus, supportsNesting) and which it
ignores (computed state, runtime internals, layers).
- Documents the DOM-as-channel pattern: sema writes data-event* on emit;
eidos reacts to selectors like `[data-event^="dismiss"]`.
- Establishes the boundary with `air` (dead branch reference, not base).
src/uix/README.md (top-level)
- §8 Reglas de dependencia: adds the 2-of-3 rule table making the
morfo-extension contract explicit, plus a list of canonical vocabularies
(archetypes, verbs).
- §10 Reading order: includes eidos README + lib/dom + clarifies which
layers are dead branches.
src/uix/morfo/README.md
- New "Archetypes" section documenting the 24-verb vocabulary, the
Provider-as-trigger vs Provider-as-container distinction, and the rule
for adding new archetypes (≥2 components share the role).
- New "The 2-of-3 rule" section with the same table as the top-level,
listing which extensions did/didn't make it past the rule and why.
- `parts[].archetype` mentioned in the "What morfo contains" list.
src/uix/sema/README.md
- New "Vocabulario canónico de verbs" section listing SEMA_VERBS by
family and the `{verb}-{variant}` composite naming convention.
- Documents `validateEventName()` as advisory tooling.
src/uix/soma/SOMA_ARCHITECTURE.md
- partProps documentation now mentions data-archetype emission.
- New "Cross-layer hooks que soma emite por la regla 2-de-3" section
listing the data-* attrs soma writes that sema and eidos consume.
- Reading-order links updated.
No code changes — all docs.
Cross-component verb names that morfo `events[].name` should preferably
align with. Lets sema / sound / vibra engines subscribe by verb instead
of by component-specific event name, and lets eidos write transversal
selectors like `[data-event^="dismiss"]`.
Vocabulary (24 verbs, grouped by family alignment)
- emerge: present, dismiss, open, close, expand, collapse
- commit: commit, cancel, confirm, submit, reset, fail
- alert: announce, alert
- contact: activate, select, toggle
- handle: acknowledge, edit, drag, resize
- sustain: tick, progress
Composite event names follow `{verb}-{variant}` (e.g. `commit-save`,
`dismiss-outside`). The verb is the head; the rest is component-specific
nuance the validator extracts and reports separately.
`validateEventName(name)` returns `{ name, head, matchesCanonical, variant }`.
Advisory — doesn't reject morfos, just surfaces drift for tooling /
review (a future `sema:vocabulary` script can use it to flag composite
event names whose head isn't canonical).
Existing event names already align well
- Toast: `present`, `announce`, `dismiss` — all canonical bare verbs.
- Dialog: `open`, `close-save`, `close-cancel`, `close-dismiss`,
`close-dismiss-outside`, `close-after-fail` — all canonical heads
(`open`, `close`) with variants.
Tests: 10 unit tests covering vocabulary integrity, isSemaVerb guard,
validateEventName splitting/matching, and a snapshot of current dialog +
toast event names against the catalog (all match).
This is the second extension justified under the "2-of-3 rule": verbs
are consumed by sema (subscription routing) AND eidos (selector
generation), with the original morfo authoring as the third producer.
Bulk classification pass that gives every part a cross-component archetype
where one applies. Done with `scripts/catalog-archetypes.ts` — a one-shot
mechanical mapping based on a kebab → archetype table, plus three manual
overrides for components where Provider IS the interactive element.
Coverage
- 440 part declarations examined.
- ~265 received an archetype (provider, trigger, content, overlay, viewport,
item, indicator, thumb, track, label, title, description, close, action,
header, image, fallback, arrow, separator, group, input, segment).
- ~175 left without one — genuinely component-specific (Slider.Range,
PinInput.Segment internals, color-picker channel-slider, etc.).
Manual overrides (Provider IS the trigger)
- Toggle, Switch, Checkbox: their `Provider` part is a `<button>` that
the user actually interacts with, not a root container. Archetype set to
`'trigger'` instead of the default `'provider'` so eidos can style them
alongside Dialog.Trigger / Popover.Trigger / etc.
Script (scripts/catalog-archetypes.ts)
- Walks every `*.ts` under `src/uix/morfo/components/`.
- Inserts `archetype: '...'` directly after the matching `kebab:` line when
a mapping exists.
- Idempotent — skips parts that already declare an archetype.
- Indent-aware — only operates on part kebabs (≥2 tabs), never the morfo
root kebab. Bug discovered + fixed mid-pass when radio-group's root
kebab `'radio-group'` matched the `'radio-group' → 'group'` alias and
inserted at the wrong level (cleaned up by hand).
- Reports per-file counts + the list of unmapped kebabs at the end so
future passes can extend the mapping.
Tests
- Updated `omits data-archetype when not declared` test to use a tiny
inline morfo (Toggle's Provider now has archetype 'trigger', so it can't
be the "no archetype" fixture anymore).
Verification
- 22/22 runtime tests, full focused suite green.
- 66/66 morfo-check (every component still validates against its DOM —
archetype is additive, doesn't change emitted attrs apart from the new
`data-archetype="..."` which morfo-check doesn't enforce yet).
- 68/68 smoke routes.
- svelte-check unchanged at 155.
First step toward making morfo articulate the three layers (soma, sema,
eidos) instead of just serving soma. Archetype is a small canonical
vocabulary that lets a part declare its conceptual role beyond its
component-specific kebab name.
Articulation purpose:
- Eidos can style transversally — `[data-archetype=trigger] { ... }`
applies to every Trigger across all 60+ components without enumeration.
- Sema can map archetypes to canonical action verbs (`trigger` may fire
`activate`, `item` may fire `select`).
- Docs can categorize parts cross-component for discovery.
The "2-of-3 rule" justifies the extension: at least two layers consume it.
Vocabulary
24 archetypes covering the common cross-component roles:
provider, trigger, content, overlay, viewport, item, option, indicator,
thumb, track, label, title, description, close, action, header, image,
fallback, arrow, separator, group, input, segment, preview.
Designed to stay small. New archetype only added when at least two
existing components share the role. Genuinely-unique parts (Slider.Range,
PinInput.Segment) omit the field.
Implementation
- `MorfoPart.archetype?: MorfoArchetype` — optional union type.
- `ARCHETYPE_VOCABULARY` exported as `as const satisfies` array for
enumeration tooling.
- `partShallowSchema` (sium validator) gets `archetype: optional(union(...))`.
- MorfoRuntime caches `partMeta` in PartRegistration so `partProps` can
read `archetype` without re-walking the morfo tree per render.
- `runtime.partProps(part)` emits `data-archetype="..."` when declared.
Static identity (never mutates), so it ships through partProps not
through `dom.apply` — Svelte renders it once on first paint.
Tests
2 new tests:
- partProps emits data-archetype when morfo declares one (inline test
morfo to avoid coupling to catalog state).
- partProps omits data-archetype when not declared (Toggle, pre-catalog).
Verification
- 22/22 runtime tests green.
- svelte-check unchanged at 155.
- No morfo currently declares archetype yet — catalog pass follows in the
next commit. All existing morfos remain valid (field is optional).
First component of the bulk migration phase (step 2 of the 1-3-2 plan).
Pattern is now well-rehearsed across Toggle / Collapsible / Toast / Switch:
- morfo: add `value` sources to data entries that were enum-only
- provider: replace manual Opts interface with `OptsFromProps<...>`,
create runtime in constructor, register parts, drop manual data-* /
aria attrs from props (runtime emits via dom.apply)
- wrapper: replace per-prop `readableActive(() => x)` list with one
`bindProps({ ... })` call
- keep manual: HTML primitives (style, src, etc.) and event handlers
Avatar specifics
- 3 parts (provider/image/fallback) all share `data-status` from the root's
internal `status` State (idle/loading/loaded/error). morfo's `propRef('status')`
reads from runtime sources where the root provider exposes it.
- `status` is not a public Props field — it's an internal $state in the
wrapper. Manually intersected into `AvatarOpts` rather than derived from
AvatarProps.
- Image's preload logic, src/crossorigin/referrerpolicy passthrough, and
the style-based show/hide on each part stay in the provider's render —
none of these are morfo-managed.
- Wrapper cast uses `as unknown as Parameters<typeof X.create>[0]` for
parts whose Opts have only `id`+`ref` (TS can't extract the specific
fields from the generic `Record<string, Active<unknown>>` return of
`bindProps`). Toggle/Switch don't need this because their richer Opts
shape provides enough overlap.
Bulk migration paused at this point — Progress / Meter need a design
decision around `aria-label` priority logic (the legacy provider has
"consumer-passed wins over translated default", which conflicts with the
"dom.apply is the only writer of mutable attrs" rule). Either:
(a) Drop aria-label from those morfos and keep provider-managed (legacy
pattern preserved, morfo loses one declaration).
(b) Extend the resolver with a priority chain (propRef → translationRef
fallback) so the morfo can express "consumer wins, translation
otherwise".
Verification
- 66/66 morfo-check (Avatar passes; all data-status emitted by runtime).
- 68/68 smoke routes.
- svelte-check unchanged at 155.
V1.5 of MorfoRuntime — adds the keyboard dispatch piece and validates it
against Switch (the simplest component with a real `morfo.keyboard` block).
src/uix/soma/morfo/runtime.svelte.ts
- New `runtime.keydown(part, event): boolean` walks `partMeta.keyboard`,
matches `event.key` plus modifier flags against each entry's spec
("Enter", " ", "Shift+Tab", "Ctrl+A", …), and dispatches to the matching
handler from `sources.actions[entry.action]`. Returns whether a handler ran.
- New `sources.actions: Record<string, KeyboardActionHandler>` source map.
Handlers receive the raw `KeyboardEvent` so they can `preventDefault()`
selectively. The runtime never calls preventDefault on its own.
- Modifier matching is exact: `Shift+Tab` does NOT match plain Tab; `Tab`
does NOT match Shift+Tab. Each entry's `condition` (`prop-truthy` etc.)
is also honored.
- Key-spec parsing keeps the literal key untrimmed (the spacebar is `" "`,
trimming would collapse it to empty). Modifier segments are trimmed
individually.
Toast pilot — keyboard wiring NOT included
Toast Item declares `{ key: 'Escape', action: 'close' }` in morfo, but the
legacy Toast doesn't actually wire Escape either. Out of scope for this
commit; a follow-up can add `events.close` + the keydown listener on the
Item element.
Switch pilot — first consumer
- `morfo/components/switch.ts`: added `value` sources to `data-state` /
`data-disabled` (same pattern Toggle/Collapsible/Toast needed for the
runtime to manage them).
- `soma/components/switch/switch-provider.svelte.ts`:
- Manual `interface SwitchOpts` (10 lines) replaced with `OptsFromProps<
SwitchProps, ManagedKeys, 'checked'>` (5 lines).
- The custom `onkeydown` block (`if (e.key === KEYS.ENTER) preventDefault;
toggle()`) collapses to `(e) => this.runtime.keydown('provider', e)`.
The morfo's `Enter` and ` ` (spacebar) both dispatch to action `'toggle'`,
handled by `actions.toggle: (e) => { e.preventDefault(); this.toggle() }`.
- props derivation uses `runtime.partProps('provider')` for identity,
keeping only the HTML `disabled` attr and event handlers manual.
- `soma/components/switch/components/switch.svelte`: 8 lines of
`readableActive(() => x)` collapsed to a single `bindProps({ ... })` call
(the V2 helper from commit eb0f128c).
Tests
- 7 new keydown unit tests covering: no-keyboard part returns false, single-
key dispatch with original event passed through, multi-key for same action
(Enter and Space → toggle), no-match returns false, modifier strictness
(Tab vs Shift+Tab dispatch to different actions on dialog content),
unhandled action returns false, runtime never preventDefaults on its own.
Verification
- 20/20 runtime tests, 111+ soma tests green.
- 66/66 morfo-check (Switch passes; data-state and data-disabled now
emitted by the runtime via the morfo's value sources).
- 68/68 smoke routes.
- svelte-check unchanged at 155.
Eliminates the triplication that every component used to suffer:
1. Props (autoral, in types.ts) — public consumer API
2. Opts (mechanical) — provider's reactive contract
3. wrapper's `readableActive(() => x)` list (mechanical)
After V2 only (1) is hand-written. (2) is derived; (3) collapses to one
`bindProps({ ... })` call.
src/uix/soma/reactive/opts.ts (new)
- `OptsFromProps<P, Managed, StateKey>` type maps a Props interface to the
matching Opts: `id` + `ref` baked in; `Managed` selects which keys reach
the provider (HTML passthroughs stay out); `StateKey` is the bindable
subset (the rest become `Active`). `Exclude<P[K], undefined>` strips the
optionality the wrapper resolves via Svelte destructure defaults.
- `bindProps(config)` runtime helper: each entry is either a bare getter
(→ `readableActive`) or a `{ get, set }` object (→ `writableActive`).
Detection is structural (no marker function — avoids collision with
Svelte's `$bindable` rune in `<script>` blocks).
- `WritableSpec<T>` defaults to `unknown` for storage but
`PropsConfigEntry<T>` defaults to `any` for variance — heterogeneous bag
literals are bivariant under `any`, contravariant under `unknown`.
Toggle migration as the proof
- `morfo/components/toggle.ts`: rename `propRef('ariaLabel')` → `propRef('aria-label')`
so the morfo and the public Props key match. Bracket notation in the
provider handles the kebab-cased key.
- `soma/components/toggle/toggle-provider.svelte.ts`:
- `interface ToggleOpts { ... }` (15 lines hand-rolled) replaced by
`type ToggleOpts = OptsFromProps<ToggleProps, ManagedKeys, 'pressed'>` (10 lines)
- sources read `opts['aria-label'].current`
- `soma/components/toggle/components/toggle.svelte`:
- 11 lines of `name: readableActive(() => name)` collapsed to a single
`bindProps({ ... })` call where each line is just `name: () => name` or
`pressed: { get: () => pressed, set: (v) => (pressed = v) }`
Tests
- `opts.svelte.test.ts` — 4 unit tests verifying readable/writable
detection, mixed configs, and kebab-keyed entries (`'aria-label'`).
Verification
- 111/111 soma tests green.
- 66/66 morfo-check (Toggle's emitted DOM unchanged).
- 68/68 smoke routes.
- svelte-check unchanged at 155 (no new errors).
Recipe for migrating other components:
1. List `Managed` keys (everything in Props that isn't HTML passthrough).
2. List `StateKey` subset (anything bindable in the wrapper).
3. Replace the manual Opts interface with `OptsFromProps<...>`.
4. Wrap the wrapper's prop forwarding in `bindProps({ ... })`.
Commit B of the Toast pilot. The runtime.trigger() contract from commit A
now has its first real consumer: all three dismiss paths route through
`runtime.trigger('dismiss')`, which emits the perceptual signal one frame
before the toaster mutates state.
Multi-instance architecture validated
Toast is the first component with multiple per-instance subtrees: each
toast in the queue is its own scope with its own children. Two runtime
instances coexist per Toast tree:
ToastProvider (root)
└── runtime instance A — registers `viewport` only
ToastItemProvider (one per active toast in queue)
└── runtime instance B — registers `item` + its children
(`title`, `description`, `action`, `close`)
`partRef('item')` resolves to THIS toast's element because the lookup
happens on THIS Item's runtime, not a shared one. `runtime.trigger('dismiss')`
on Item-B targets Item-B's DOM element. Per-toast isolation by construction.
dismiss wired through runtime.trigger
Three call sites now go through the same path instead of calling
`toaster.dismiss(id)` directly:
- ToastCloseProvider.onclick (user clicks the × button)
- ToastItemProvider startTimer setTimeout callback (auto-dismiss)
- ToastItemProvider onpointerup (swipe gesture meets threshold)
The runtime fires `data-event="dismiss"` on the item, awaits one rAF for
CSS to observe it, then calls the dismiss handler that mutates
`toast.dismissing = true`. The item's effect re-derives `data-state="closed"`
from `isOpen = !dismissing` and writes it via dom.apply on the next tick.
intent-driven role / aria-live still works
The morfo's `mapRef(propRef('intent'), { neutral: 'status', risk: 'alert', … })`
for `role` and `aria-live` is resolved by the runtime per-Item: each toast
reads its own `intent` from `opts.toast.current.intent` via the per-Item
runtime sources. Risk/threat toasts get `role=alert` + `aria-live=assertive`;
the others get `role=status` + `aria-live=polite`. No code in the provider
selects this — the morfo declares it, the resolver computes it.
Per-part props
Action's `altText` is per-instance per-action — passed to the runtime via
`registerPart('action', { props: { altText: () => opts.altText.current } })`.
Validates that the runtime's per-part source override (introduced in
commit 8ede5353 but never exercised) works as designed.
Translation lookup
Close's `aria-label` resolves through `v.translationRef` against the Item
runtime's `translate` source (`soma.langs.ts`). Confirms translationRef
ergonomics through the runtime path.
What this commit does NOT do (deferred)
- `present` and `announce` events: declared in the morfo but not wired.
Would fire on Item mount; needs a small effect at the right phase.
- `runtime.keydown` for Item's `Escape` keyboard declaration: requires
the V2 runtime API.
- presence layer (data-starting-style/data-ending-style) still flows
through Svelte render — not migrated to dom.apply. Mixed model is OK
while the boundary is clearly between morfo-declared attrs (runtime)
and layer-declared transitions (Svelte).
Verification
- 84/84 focused unit tests green.
- 66/66 morfo-check (toast included).
- 68/68 smoke routes 200 OK.
- svelte-check unchanged at 155.
Commit A of the Toast pilot: introduce the trigger() contract end-to-end
without migrating any provider yet. Toast's actual migration follows in a
separate commit so that bugs (if any) can be attributed to one layer at a
time.
App layer
- New `AppSemantic` contract in `$lib/ext/app/types`: a single-method
interface (`emit(signal): Promise<void>`) implemented by SemanticEngine
and accepted by the App constructor.
- App now owns `app.semantic`, defaulting to a fresh SemanticEngine wired
to `app.dom` when the consumer doesn't pass one. Fallback App also gets
`fallbackSemantic` so context-less calls don't throw.
- Soma re-exposes it as `soma.semantic` so providers reach it the same
way they reach `soma.dom` / `soma.langs`.
MorfoRuntime
- New `runtime.trigger(eventName): Promise<void>` with the full sequence
the architecture closed:
1. apply `prewrite` attrs imperatively (transient markers)
2. await `semantic.emit({ target, name, family, intent? })` — resolves
the intent from `event.semantic.intent` (literal SemaIntent or
SemaIntentBinding via `resolveSemaIntent`)
3. invoke the provider's handler from `sources.events[name]`
4. effects on the affected parts re-derive structural attrs and
write them via `dom.apply` (automatic — not awaited here)
- New optional `sources.semantic` and `sources.events` on the runtime.
Without `semantic`, step 2 is skipped. Without an events handler for
the name, step 3 is skipped.
- `morfo.events.commits` is intentionally NOT executed by the runtime —
it remains descriptive metadata documenting what an external observer
will see (per the architecture's "state is the source of truth" rule).
Tests
- 8 new trigger unit tests using the toast and dialog morfos:
- throws on undeclared event
- throws when target part has no DOM
- emits with intent resolved from prop (toastMorfo.announce)
- falls back to declared default intent when prop missing
- omits intent for transitional families (toastMorfo.present)
- prewrite applied before semantic.emit (dialogMorfo.close-cancel)
- handler invoked only after semantic.emit resolves
- skips semantic.emit when no semantic source provided
Verification
- 84/84 focused unit tests green (full adom/sema/lib/dom/morfo/soma).
- 66/66 morfo-check.
- 68/68 smoke routes.
- svelte-check unchanged at 155 (no new errors introduced).
Next (commit B): migrate Toast to use the contract — wire close.onclick,
swipe gesture, auto-timer, and Escape keydown to runtime.trigger().
Second pilot of MorfoRuntime V1, validating the multi-part case left
unproven by Toggle. Collapsible has three providers (provider/trigger/
content), shares root state across them, and uses partRef cross-references
(`aria-controls` ↔ `aria-labelledby`).
Note on scope
The morfo doesn't declare `keyboard` — the Trigger is a `<button>` and
relies on native Enter/Space via onclick. So this pilot does NOT exercise
`runtime.keydown`; that's deferred to the next pilot with a real keyboard
contract (Tabs, Accordion, Listbox).
What this pilot validates
- Single runtime instance shared across three part-providers, created at
the root and accessed by children via the Provider context.
- Cross-part state source: all three parts read `open` and `disabled`
from the root provider's opts via `states` / `props` source maps.
- Cross-part id resolution: `parts.trigger`/`parts.content` getters read
from the root's reactive `triggerId`/`contentId` State; partRef on
Trigger and Content resolves once the corresponding sibling registers.
- Conditional emission with partRef: `aria-controls` (when Content present)
and `aria-labelledby` (when Trigger present) toggle correctly as siblings
mount/unmount.
- Effect timing across multiple parts: each part's effect runs in its own
component scope, cleans up on unmount.
Morfo update
Same fix as Toggle: data entries got explicit `value` sources. `data-state`
references `stateRef('open')`; `data-disabled` references `propRef` with
`prop-truthy` condition. Without this the runtime had no way to source
the values (the legacy provider hard-coded them).
Provider migration
- CollapsibleProvider creates the runtime in its constructor with shared
states/props/parts sources and registers the 'provider' part.
- CollapsibleTriggerProvider and CollapsibleContentProvider call
`provider.runtime.registerPart(...)` for their slot, and publish their
id to the root's State so partRef resolves.
- Each part's `props` derivation collapses from ~6 attrs (data-state,
data-disabled, role, aria-*, type, etc.) to just `runtime.partProps()`
+ the few primitive attrs not in the morfo (`disabled`, `onclick`,
`hidden`).
Verification
- 76/76 focused tests green.
- 66/66 morfo-check (Collapsible's emitted DOM matches the morfo).
- 68/68 smoke routes 200 OK.
- svelte-check unchanged.
Real bug surfaced by user testing the Toggle pilot: setting `invalid` to true
emitted `data-invalid` and `aria-invalid` correctly, but flipping it back to
false left the attrs on the DOM. The red border from CSS based on
`[data-invalid]` stayed lit.
Cause: `resolvePartAttrs` skipped declared attrs whose condition was falsy.
The map handed to `dom.apply` lacked the key, so dom.apply (which only
iterates the keys it receives) had no instruction to remove the attr from
the previous tick.
The function's JSDoc claimed it emitted `undefined` for non-matching attrs.
The implementation didn't match. Now it does:
- Conditional attrs with falsy condition are emitted as `undefined` →
`dom.apply` removes them from the DOM and Svelte ignores them at render.
- Data entries without a `value` source are still skipped entirely (they
are documentation in the morfo, not runtime-managed attrs).
Same fix covers all conditional attrs: data-disabled, data-readonly,
data-required, data-invalid, aria-disabled, aria-readonly, aria-required,
aria-invalid, aria-label — all toggled on/off cleanly now.
Regression test added covering the invalid true→false→true cycle.
Verification: 5/5 runtime tests, 76/76 focused suite, 66/66 morfo-check,
68/68 smoke.
First piloted slice of the closed architecture: a runtime that interprets
the morfo declaration and writes mutable attrs to the DOM via dom.apply,
removing the need for the provider to hand-roll resolveMorfoProps bindings
on every part.
src/uix/soma/morfo/
- resolver.ts — pure morfo → attrs resolver, extracted from Provider so
both legacy Provider (still in place) and the new runtime can share it
- runtime.svelte.ts — `createMorfoRuntime(morfo, sources)` with V1 surface:
- registerPart(part, opts) — registers per-instance id/ref/sources and
sets up a $effect that reapplies attrs whenever sources change
- partProps(part) — returns ONLY identity (id + marker + ref attachment),
never mutable attrs (those go through dom.apply)
- runtime.svelte.test.ts — 4 tests verifying initial application, reactive
reapplication, and unknown-part rejection
ARIA stateRef refinement
The shared resolver now stringifies ARIA stateRef values to 'true'/'false'.
Boolean false on aria-* must mean "explicitly unpressed/unexpanded", not
"attribute absent" — the previous boolean return wired into dom.apply's
`false → removeAttribute` semantics, which is correct for data-* presence
flags but wrong for ARIA. Distinct contracts; resolver decides at the
boundary, not dom.apply.
Toggle morfo
Added explicit `value` sources to all data entries. The legacy provider
hard-coded data-state etc.; the runtime needs the morfo to declare where
each attr's value comes from. Now data-state references stateRef('on') and
data-disabled / data-readonly / data-required / data-invalid reference
their corresponding propRef with prop-truthy conditions.
Toggle provider migration
- ToggleProvider creates a MorfoRuntime in its constructor with Soma's dom.
- registerPart('provider', { id, ref }) wires the runtime to the button.
- props derivation now returns only:
- runtime.partProps('provider') — id + data-toggle + ref attachment
- disabled (HTML attr; not in morfo by design — primitive button concern)
- onclick handler
- All morfo-declared attrs (data-state, aria-pressed, data-disabled,
aria-disabled, aria-label, etc.) are written by the runtime's effect via
dom.apply.
Verification
- Unit tests: 4/4 morfo runtime + full focused suite green.
- morfo:check: 66/66 — Toggle's emitted DOM matches the morfo declaration
end-to-end now that data-* sources are pinned.
- smoke: 68/68 routes 200 OK.
- svelte-check: no new errors.
V2 (deferred — not part of this pilot):
- runtime.keydown(part, event) for morfo.keyboard dispatch.
- runtime.trigger(eventName) — prewrite + semantic.emit + handler chain
(Toast will be the first component to exercise this).
- OptsFromProps + bindProps helper to absorb the props bridge boilerplate.
The minimum primitives the closed architecture needs. No MorfoRuntime yet,
no provider migration — just the two operations that everything else will
compose on top of.
adom (only writer of mutable attrs):
- types StructuralChange + DomAttrValue
- applyChange(change) writes attrs (string/number → setAttribute, true →
presence flag, false/null/undefined → removeAttribute)
- removeAttrs(target, names) targeted cleanup
- ActiveDom exposes apply() + remove() — same surface every consumer uses
sema (perceptual signal channel):
- new SemanticSignal type (runtime payload distinct from declaration-time
SemaEvent): { target, name, intent?, family?, hold? }
- SemanticEngine accepts dom by construction (DI; not imported hard from
$uix/adom inside sema)
- new emit(signal) async method with the agreed lifecycle:
1. write data-event* via dom.apply
2. await one rAF (CSS gets a frame to observe)
3. resolve Promise (caller can dom.apply structural change)
4. hold N frames (default 1, configurable per signal)
5. clear data-event* via dom.remove
- legacy publish() flow untouched for backward compatibility
Tests: 12/12 green; full adom+sema+lib/dom suite 50/50 green;
svelte-check delta: -12 errors (no regressions in touched files).
Reflect the architectural decisions reached on 2026-04-25 across the
layer-level READMEs:
- src/uix/README.md
- rewrite ADom section: no longer a "broker semántico"; only DOM mutation surface
- rewrite Sema section: vocabulary + EngineSemantic with Promise-returning emit
- new §2.bis "Cómo se ejecuta un componente": six-piece chain with disjoint
responsibilities (Morfo declares, Runtime transcribes, Provider supplies,
Effects sync, Semantic emits, Dom applies)
- update §8 dependency rules to match the closed design
- new one-line summary in §9
- src/uix/morfo/README.md
- new "How morfo gets executed" section: maps each morfo field to its runtime
executor; documents trigger() sequence and provider responsibilities
- src/uix/sema/README.md
- rewrite around the Promise contract: emit() resolves after 1 rAF
- document lifecycle (id → write signal → wait frame → resolve → hold → cleanup)
- error policy and the three composition scenarios with dom.apply
- src/uix/soma/SOMA_ARCHITECTURE.md
- new §3.bis "Arquitectura cerrada" introducing MorfoRuntime as the missing
piece between Morfo (declaration) and Provider (execution)
- documents API V1, three commit operations, trigger() sequence, operational
rules, and pilot order (Toggle → Collapsible → Toast → Dialog)
No code changes; this commit pins the architecture before implementation.
- Split lib/dom (pure) from adom (reactive runtime).
- Move resize-observer and responsive to plain .ts (no runes needed).
- Fold elements.ts utilities into core.ts.
- Tabbable helpers consolidated in tabbable.ts via npm `tabbable`.
- BodyScrollLock: explicit MANAGED_PROPERTIES tracking instead of stringly setAttribute.
- RovingFocusGroup: pure getTabIndex reader, separate initializeDefaultTabStop.
- DOMContext: drop redundant canUseDom, getRootNode without optional chaining.
- ActiveDom: viewport tracking moved to its own module, viewport readonly facade.
- New READMEs at lib/dom and adom documenting the layer pyramid.
Foundation commit before introducing MorfoRuntime.
Adds the full Sema specification at `src/uix/sema/sema-spec-v0.3.1.md`,
replacing the earlier `sema_pre.md` working draft. The spec is now the
source of truth for the perceptual layer — 6 families × 5 intents = 22
canonical events, 4 channels, the MorfoSema extension for cross-layer
action contracts, and the SemaPort protocol between headless and Sema.
## Key architectural decisions codified
- Tripartite resolution: morfo-sema (contract) / `.csem` (integrator
overrides) / `sema-map.json` (canonical vocabulary).
- `MorfoSema` minimal shape (7 fields, 5 optional with defaults).
- `SemaEventLabel` typed union — 22 events, closed set.
- `prewrite` makes the "data-last-action BEFORE data-state" convention
explicit and validated.
- `commits` declares structural effect, NOT preconditions — validity
remains the provider's responsibility.
- `keyboard.action` relaxed: only acquires Sema semantics when it matches
a name in `sema.actions[]`.
- `sound` disabled by default; opt-in only.
- Caps: 200 ms blocking / 80 ms under accessibility reductions.
- 4 arbitration regimes: `replace | collapse | lock | queue`.
## Cleanups
- `src/uix/sema/sema_pre.md` removed — superseded by v0.3.1.
- `src/uix/soma/components/accordion/audit_1.md` removed — historical
artifact, consolidated into the canonical audits at
`src/uix/soma/AUDIT_1.md` and the two recent `soma-audit-*.md` files.
## Next steps (from §14)
1. Extend `Morfo` type with `sema?: MorfoSema`.
2. Extend sium validator for the 7 new validations of §5.7.
3. Stub `SemaPort` (interface + no-op + test port).
4. Implement the engine runtime.
5. Implement `.csem` PostCSS plugin.
6. Define `sema-map.json` with the canonical values.
7. Validate end-to-end with 4 real components (Button, Input, Dialog, Toast).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The combined `/test/soma/forms` page covered three separate components
(Switch, Checkbox, RadioGroup) in one route. Each deserves its own demo
page — matching the convention of every other component having a
dedicated `/test/soma/{kebab}` route, and letting the permutation runner
actually exercise them (the runner navigates per morfo-kebab, not per
merged page, so the combined demo left the 3 morfos SKIPPED).
## New demos
- `/test/soma/switch` — basic + disabled variants. `data-perm-step="0"`
toggles the basic switch. Axes: `dir, disabled`.
- `/test/soma/checkbox` — basic + indeterminate + group variants. Steps
0-2 toggle the first checkbox + first two group items. Seeds
`?perm.checked=true` / `?perm.value=a,b`. Axes: `dir`.
- `/test/soma/radio-group` — active + disabled variants. Steps 0-2 select
each option. Seeds `?perm.value=option-2` / `?perm.orientation`.
Axes: `dir, orientation`.
Each demo reuses the CSS from the original combined page (via `:global`
selectors for `data-switch`, `data-checkbox`, `data-radio-group-item`).
All three have `aria-label` on the Provider — the DEV-mode A4 warning
added in batch 10 of audit 2026-04-21 requires it.
The combined `/test/soma/forms` page is kept in place (pre-existing
integration demo showing composition); future batches may decide
whether to drop it or repurpose it.
## Coverage delta
- Before: 63/66 demos · 131 variants · 202 steps.
- After: 66/66 demos · 139 variants · 228 steps. (smoke: 68 routes)
Coverage complete — every morfo now has at least one instrumented demo
under the permutation runner.
## Verification
- `npm run check`: 0 errors in new demo files.
- `npm run smoke`: 68/68 routes (was 65).
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 66/66 demos · 228 steps · 139 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bulk-instruments the remaining 49 demo pages with `<meta name="perm-axes"
content="dir" />`, bringing total coverage from 15/66 demos to 63/66.
Only checkbox, radio-group, and switch remain skipped — they have no
demo directory at `/test/soma/` (pre-existing gap, unrelated).
## Runner change
`scripts/permutation-check.ts` now allows meta-only demos (no
`data-perm-step` annotations) to participate: if a demo declares any
axis via the meta tag, the runner still visits its baseline + RTL
variants and runs the `[initial seed]` morfo validation at each, even
without a click cycle. This gives passive LTR/RTL coverage to demos
where a click-driven perm sequence doesn't apply naturally (leaf
components like Avatar, Progress, Meter; grid/list components; pickers
with inline controls).
Key change in `probeDemo()`: a demo is "instrumented" if EITHER the meta
tag OR at least one `data-perm-step` is present. Step-only demos get
the v1 click cycle at baseline; meta-only demos get the v3 axis matrix
with passive `[initial seed]` validation at each variant; declaring
both is the full treatment.
## Bulk instrumentation
For each of the 49 remaining demos, added:
```svelte
<svelte:head>
<title>… · Soma</title>
<meta name="perm-axes" content="dir" />
</svelte:head>
```
Baseline axis (`dir`) only. Demos that benefit from additional axes
(`orientation`, `disabled`, etc.) opt in incrementally — the bulk edit
doesn't touch their `$state` seeds, only adds the meta tag.
## Coverage delta
| Metric | Before | After |
|--------|-------:|------:|
| Instrumented demos | 15/66 | **63/66** |
| URL variants | 35 | **131** |
| Step validations | 106 | **202** |
~4× increase in URL variants visited per CI run. Most new demos
contribute 2 variants each (ltr + rtl), validating morfo under direction
flip. Fully-instrumented demos (tabs, toolbar, toggle-group, listbox)
still contribute 3-4 variants with click cycles.
## Deferred
- `context-menu` — right-click activation needs a `data-perm-mode='contextmenu'`
runner extension.
- `navigation-menu` — hover-driven, same as Tooltip. Tooltip was
instrumented via `data-perm-mode="focus"` + long settle; NavigationMenu
could follow the same pattern in a targeted batch.
- `color-picker` — complex pointer state machine on Area / ChannelSlider.
Meta-only covers the baseline structure.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 63/63 instrumented demos · 202 steps · 131 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `data-perm-step` + `<meta name="perm-axes">` + perms seed to:
- **accordion** — expand item-1 → switch to item-2. `?perm.value=item-1`
seeds the Provider's value array. `dir` axis.
- **listbox** — select apple (first) → select apricot. `?perm.orientation`
flips the layout, already wired. Items in a loop use conditional
`data-perm-step` by index. `dir,orientation` axes.
- **tree-view** — select readme → select license under the expanded docs
branch. `?perm.expanded=docs&perm.selected=readme` seed. `dir` axis.
- **combobox** — open via trigger (Combobox.Trigger). `dir` axis only —
typing / selection is left to v4 per-component value enumeration.
- **menubar** — open File menu → close via Escape (mode=key, 500ms settle).
`dir` axis.
- **toggle** — press Bold toggle (inside Field.Provider integration
example). `dir` axis.
- **toggle-group** — press left → press center (single mode). Added
`aria-label="Text alignment"` to silence the A4 DEV warning that now
fires (shipped in batch 10 of audit 2026-04-21). `?perm.value=bold`
preselects. `dir,orientation` axes.
## Deferred in this batch
- **context-menu** — needs `contextmenu` event (right-click). Requires a
`data-perm-mode='contextmenu'` extension to the runner. Future batch.
- **navigation-menu** — hover-driven only. Same future-batch treatment as
Tooltip has with `data-perm-mode="focus"` + long settle.
## Coverage delta
- Before: 8 demos · 19 variants · 62 steps.
- After: 15 demos · 35 variants · 106 steps.
~1.7× jump in a single batch. Tabs still leads with 4 variants (adds
`orientation` + `disabled` axes); ToggleGroup, Toolbar, Listbox each have
3 variants.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 15/15 demos · 106 steps · 35 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `data-perm-step` + `<meta name="perm-axes">` + perms seed to the
5 overlay demos sharing Dialog's open/close shape:
- alert-dialog — trigger open → cancel. `?perm.open=true` seeds.
`disabled` deliberately NOT in axes: disabling the Provider blocks
the Trigger click path the runner depends on.
- popover — trigger open → close button. `?perm.open=true` seeds open1.
- tooltip — focus trigger (mode=focus, 1s settle — tooltip opens after
700ms delay). No close step: tooltip closes on blur.
- drawer — trigger open → close button (500ms settle for exit animation).
`?perm.open=true` seeds basicOpen.
- dropdown-menu — trigger open → Escape close (mode=key, 500ms settle).
Second step targets the Menu.Content selector because the Content
only mounts after the Trigger step fires (runner handles dynamic
step discovery).
All five declare `dir` as the only axis (universal RTL variant). The
other orientation / disabled axes don't apply to these overlay shapes.
## Coverage delta
- Before: 3 instrumented demos · 9 variants · 34 steps.
- After: 8 instrumented demos · 19 variants · 62 steps.
2x jump in per-CI-run validations with zero infra changes — just applying
the v1-v3 convention to additional demos.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 8/8 demos · 62 steps · 19 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 ran every demo under a hard-coded `ltr` + `rtl` matrix. v3 lets each
demo declare which additional axes it honours — `orientation`, `disabled`,
`readonly`, `invalid`, `loading` — and the runner adds ONE variant per
declared axis (axis-at-a-time, not cartesian, so the matrix stays bounded
at O(N) instead of O(2^N)).
## Convention
```svelte
<svelte:head>
<meta name="perm-axes" content="dir,orientation,disabled" />
</svelte:head>
```
`dir` is universal (always runs). The other axes are opt-in: they only
produce variants when the demo both lists them in the meta AND wires the
corresponding state from `Perms.getOr(...)`. Without the meta, only
`ltr` + `rtl` run.
## Runner changes
- New `discoverAxes(base, morfoKebab)` pass: loads the baseline URL, reads
`<meta name="perm-axes">`, returns the declared axis list.
- `buildVariants(declaredAxes)` composes `ltr` + `rtl` + one `?perm.X=Y`
variant per declared axis from the `AXIS_FLIP` map.
- `[initial seed]` step validates morfo at each URL before any click,
so URL-seeded state alone is exercised (catches seed regressions even
when the click cycle never fires).
## Demo opt-in (this commit)
- `dialog` — `perm-axes="dir"` (no orientation / disabled / loading axes
apply to Dialog).
- `tabs` — `perm-axes="dir,orientation,disabled"` + wires `orientation` +
`disabled` to the first `Tabs.Provider`.
- `toolbar` — `perm-axes="dir,orientation"` + wires `orientation` to the
first `Toolbar.Provider`.
Result: 3 demos, 9 URL variants, 34 step validations per CI run
(v2 was 22 steps across 6 variants).
## Docs
`src/uix/morfo/PERMUTATION_RUNNER.md` v3 section: supported axis table,
3-step opt-in recipe, roadmap updated (v4 = morfo-inferred value cycling,
v5 = MutationObserver ordering for Sema).
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 3/3 demos · 34 steps · 9 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1 shipped click-based state cycles. v2 adds URL-seeded initial state so
the runner can reach any state the component exposes without walking a
click sequence, and runs every instrumented demo TWICE in CI (LTR + RTL)
to catch direction-specific regressions like the slider thumb transform
that landed this week.
## New test-route infrastructure
- `src/routes/test/soma/_perms.svelte.ts` — parses `?perm.X=Y` query
params into a typed `Perms` record (naive coercion: `'true'` → boolean,
digits → number, else string). Exposed via `Perms.getOr({})` context.
Underscore prefix so SvelteKit does not route it.
- `+layout.svelte` — parses perms once at mount, sets context, honours
`?perm.dir=rtl` and `?perm.locale=en` by routing through
`App.setDir` / `App.setLocale` so the existing control strip stays in
sync.
## Demo opt-in
Three demos seed initial state from URL params (v1 + v2 combined):
- `dialog` — `?perm.open=true` lands open.
- `tabs` — `?perm.value=tab-2` lands on that tab.
- `toolbar` — `?perm.format=bold,italic` + `?perm.align=center` preselect
toggles.
## Runner extension
`scripts/permutation-check.ts` now runs each instrumented demo under a
matrix of URL variants (currently `ltr` + `rtl`), validates morfo at the
URL-seeded state BEFORE any clicks (logged as `[initial seed]`), then
runs the v1 click cycle. Output format updated to show per-variant
blocks.
Result: 3 instrumented demos × 2 variants = 22 step validations per CI
run. RTL seeding reproduces the specific state machine the slider
`translate(50%, -50%)` bug needed to fail — had v2 been in place that
day, the regression would have fired before shipping.
## Docs
`src/uix/morfo/PERMUTATION_RUNNER.md` gains the v2 section (layout
plumbing, demo opt-in, URL variant table, `[initial seed]` step
semantics) and marks v1 + v2 as shipped 2026-04-22. v3 (morfo-inferred
per-component axis matrix) and v4 (MutationObserver ordering for Sema)
remain on the roadmap.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 3/3 demos · 22 steps across 2 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Why
`morfo:check` validates single-state DOM at page load; `smoke` catches
hydration-time errors. Neither ejercices state TRANSITIONS — and that is
where every reactivity bug we caught this week actually lived:
- toolbar A35 loop (mount-time, not caught by either)
- form A36 microtask loop (mount-time with onChange)
- slider RTL transform (static value, off-by-thumb-width)
All passed the existing CI. All would have failed a "click, re-validate"
pass.
## What
- **`scripts/permutation-check.ts`** — Playwright runner. For each demo,
collects `data-perm-step="N"` elements, executes them in ascending
order (re-discovering after each step so portal-gated controls get
counted), and re-validates the component's morfo + captures any
`pageerror` / `console.error` between steps.
- **`data-perm-*` authoring convention** — step index, human label, mode
(click / focus / type / key), settle delay, skip-validate flag. Demos
opt in incrementally; the runner SKIPs annotation-less pages without
failing.
- **`src/uix/morfo/PERMUTATION_RUNNER.md`** — full design doc covering
what it validates, the annotation convention, example, failure format,
and v2–v4 roadmap (URL-driven states → morfo-inferred cycles →
MutationObserver ordering for Sema).
- **COMPONENT_GUIDE A37 + checklist item 40** — instrumentation is now a
ship-gate rule; doc lists coverage targets (overlays / toggleables /
composite roving / forms / RTL) and explicit skips (alerts, file
pickers).
- **`npm run perm:check`** — pipeline entry, exit codes 0/1/2
parallelling `morfo:check`.
## Instrumented demos (v1 seed)
- dialog — 2 steps: open via trigger → close via Close button. Step 1 is
inside `{#if open}` + Portal; runner's dynamic re-discovery handles it.
- tabs — 3 steps: initial tab 1 → switch to tab 2 → switch to tab 3.
- toolbar — 3 steps: toggle bold / italic / underline GroupItems
(exercises the A35 pattern that previously looped).
Result: 8/8 permutations pass; 63 demos SKIPPED pending instrumentation.
## Verification
- `npm run check`: 0 errors in soma/morfo/scripts scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 3/3 instrumented demos, 8/8 permutations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- color demo now registers a ColorPicker-based widget inline so
kind:color fields render properly instead of showing [object Object]
in the fallback text input. Temporary — CDX-212 moves defaults to
Soma.services.sium.widgets where they belong.
- CDX-212 shard drafted with full v2.3 design (Soma services registry,
SiumService.widgets, 3-layer override: instance > subtree > global).
Includes all conversation context up to 2026-04-21 so tomorrow's
session resumes without paginating chat history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v2.2 demo sweep. All 8 sium test pages (booking, color, kitchen-sink,
login, settings, signup, survey, tree) now share:
- App-level langs: each page does `App.get().langs.extend('demo', {...})` to add
its own namespace. siumLangs + componentLangs already registered globally by
/test/sium/+layout.svelte. Locale switch goes through `app.setLocale()` so
Form.AutoFields sees translated sium.errors.* without per-page plumbing.
- Specimen-book design language: shared CSS in /test/sium/_design.css. Pages
contribute markup only — masthead with eyebrow + schema-name + shape-hint +
locale-switch, hairline rules, description, two-column .specimen layout with
numbered PLATE debug panels.
- No inline <style> blocks. No local createLangs. No ad-hoc helper functions.
Each demo went from ~98 v1-pattern lines (with 25 lines of inline CSS) to ~110
v2.2-pattern lines (zero CSS, all imports via canonical libs). Login was already
migrated as the pilot last turn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Slider thumb misalignment
`SliderThumbProvider.thumbStyle` used `translate(50%, -50%)` for both LTR
and RTL. For LTR that pushed the thumb RIGHT by half its width from the
`left: X%` anchor — visible symptoms in the time-picker / time-range-picker
demos:
- thumb never reached `left: 0` (centre at value=0 was offset by
thumbWidth/2)
- thumb overshot the track on the right edge at value=max
- drag felt off by the same amount throughout
Fix: use `translate(-50%, -50%)` for LTR (thumb centre aligns with anchor)
and keep `translate(50%, -50%)` for RTL (anchor is `right: X%`, thumb must
shift right by half its width to centre-align). Matches the logic already
used by `SliderTickProvider` which chose `tx = isRtl ? '50%' : '-50%'`.
Browser probe confirms: value=0 now centres at 0%, value=30/59 at ~50.8%,
style attribute reports `translate(-50%, -50%)` in LTR.
## soma README documents morfo
Added §4b "Morfo — contrato declarativo cross-layer" covering: why morfo
exists (6-place drift without it), how providers consume it
(`createAttrs(morfo)` + `registerContract(morfo)`), the mandatory
`as const satisfies Morfo` authoring pattern for literal-typed inference,
the 3-layer validation pipeline (schema / assertContract / morfo-check),
and the boundary (what does NOT go in morfo — props, prose, langs, state
machines, visual recipes). Updated the §4 structure listing so
`attrs/create-attrs.ts` shows the new signature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LinkPreview README: elevate the "intentionally hidden from AT" note to
its own `## Accessibility` section with the three reasons (duplicate
content, hover-only activation, focus stays on trigger). Consumers
reaching for an AT-visible preview are pointed at Popover / Tooltip.
- TagGroup `handleItemKeydown`: vertical nav now calls
`getDirectionalKeys(dir, 'vertical')` (returns UP/DOWN absolute) instead
of hardcoded `KEYS.ARROW_DOWN/UP`. Functionally identical — vertical
keys don't flip under RTL — but keeps the shape symmetric with the
horizontal branch and matches the convention used across the library.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## morfo: typed createAttrs
- `types.ts`: all array fields are now `readonly` so morfos can be authored
with `as const satisfies Morfo` without casts.
- `createAttrs` is a generic `<const M extends MorfoShape>` that infers the
emitted attr map from the morfo's literal shape. Typos like `attrs.trigerr`
are now compile errors; autocomplete lists every valid part.
- All 66 morfos migrated: `export const xMorfo = { ... } as const satisfies Morfo`.
- Two latent bugs the new typing caught: `dateRangeFieldAttrs.root` and
`timeRangeFieldAttrs.root` — legacy from the root→provider refactor, fixed
to `.provider`.
- Docs (morfo/README, COMPONENT_GUIDE) mandate `as const satisfies Morfo`;
the older `: Morfo` annotation widens literals and loses autocomplete.
## Audit batches 1–7 (soma-audit-2026-04-20)
Batch 1 — Keyboard a11y (A14/A12):
- Tabs: `isFallbackTabStop(el)` using `untrack` → first trigger gets
`tabindex=0` when no tab matches (WCAG 2.1.1 was failing).
- RatingGroup: `dir` prop + `getDirectionalKeys(dir, 'horizontal')` for RTL.
- Accordion P1-1/P1-6 dismissed: roving/labelledby already correct per APG.
Batch 2 — A33 reactive collections: Select `labelRegistry` and NavigationMenu
`triggerRefs` migrated to `SvelteMap`.
Batch 3 — A31 O(N²) fix: RadioGroup lifts `hasSelection` + `isFallbackTabStop`
to the provider with `untrack`-wrapped DOM walk. Per-item derivations drop
from O(N²) to O(1) per trigger.
Batch 4 — Morfo aria sync: toast / time-field / date-field / tabs /
tag-group / navigation-menu declare every aria the provider emits
(14 entries across 6 files).
Batch 5 — Accessible naming props: Tabs.List, ToggleGroup, TreeView,
NavigationMenu expose `aria-label?` / `aria-labelledby?` with explicit
"required unless labelledby is set" JSDoc.
Batch 6 — A30 cleanup: Checkbox `setLabelId` and 5 trigger-ref providers
(select/tooltip/link-preview/dropdown-menu/context-menu) migrated from
`$effect(() => parent.ref = opts.ref)` to the `Provider` base's
`onRefChange` callback (fires via attachRef inside `untrack`).
Batch 7 — A11 wrapper thinning: stepper/tree-view wrappers removed
`ctx.get()`, providers expose proxy derivations; time/date-range-field
inputs use `untrack(() => …)` for one-shot id registration; menubar
content's 40-line keyboard nav moved to `MenubarMenuProvider.onContentKeydown`;
color-picker area + channel-slider pointer state machines moved to the
providers with `onpointerdown/move/up` emitted via `props`.
form-auto-fields deferred with justification (reflective schema renderer,
not a state-holding wrapper).
## Form hang fix (A36)
`runValidate` entered the `isPromiseLike` branch even for sync schemas
because sium's Standard-Schema adapter declares `validate: async`. The
fallback `return { errors, issues }` read both reactive vars inside the
validation `$effect` while the `.then(...)` microtask wrote them
→ microtask-mediated loop, silent to Svelte's sync effect-depth guard.
Symptom: kitchen-sink hung the tab on mount with onChange / onBlur.
Fix: `return untrack(() => ({ errors, issues }))`. Regression locked by
two new 5s-timeout tests in `form-auto-fields.svelte.test.ts`.
Pattern codified as A36 in COMPONENT_GUIDE; CLAUDE.md count updated to
A1–A36 / 39 items.
## Verification
- `npm run check`: 0 errors in soma/morfo scope (sema demo pre-existing
errors untouched).
- `npx vitest run src/uix/soma src/uix/morfo`: all tests green
(94 + 7 = 101 tests, inc. 2 new form regression tests).
- `npm run smoke`: 65/65 routes.
- `npx tsx scripts/morfo-check.ts`: 66/66 morfos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A35 codifies the toolbar incident (2026-04-19): per-item `$effect` that
reads `opts.ref.current` and writes provider state read back by per-item
`props` $derived triggers `effect_update_depth_exceeded` via the
attachment reapply cycle. Register in the constructor (A30-style); use
`$effect` for cleanup only; wrap DOM walks in `untrack` when called from
`$derived`.
- COMPONENT_GUIDE: §A35 rule, checklist item 38
- CLAUDE.md: checklist count updated (A1–A35, 38 items), verification
mention of morfo-check + morfo kebab naming note
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Naming coherence across the part layer: the root part's `kebab` field now
matches its `name: 'Provider'`. The `createAttrs` special case still emits
`data-{component}` (no suffix) for the provider part — the DOM contract is
unchanged, only the authoring name changes.
Affected:
- createAttrs special-case: 'root' → 'provider'
- 66 morfos: kebab 'root' → 'provider'
- 77 soma provider files: `attrs.root` → `attrs.provider`, constructor
part arg 'root' → 'provider'
- Docs: A2 rule rewritten, morfo/README, soma/README example updates
- Test / schema / morfo-check: updated for new kebab
Toolbar fix: remove `itemsVersion` counter and per-item `$effect` blocks
that called `notifyItemsChanged()`. The pattern was an A30 violation —
each item's $effect read `ref.current`/`disabled` and wrote back to the
provider, which cascaded through `firstTabStop` → per-item `props`
$derived → DOM reattach → effect re-run, triggering
`effect_update_depth_exceeded` on page load. `isTabStop` now uses
`untrack` around the DOM walk so per-item `props` derivations only
depend on `lastFocusedElement`.
Verification: 7/7 morfo tests, 65/65 smoke, 66/66 morfo-check, no effect
loops detected across any demo page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each morfo now declares what its provider actually emits (via agent-based
extraction from provider code). Before: parts + data only. After: parts +
data + ARIA per part + keyboard per part + focus policy for overlays.
Contract source: provider's `readonly props = $derived.by(...)` blocks,
classified into the v.* value kinds — literal, stateRef, partRef, propRef,
translationRef. Conditions extracted from ternary / guard patterns.
Focus policies added on overlay components: popover, drawer, alert-dialog,
dropdown-menu, context-menu, select, date-picker, date-range-picker,
time-picker, time-range-picker, color-picker. Context-menu uses
return:'previous' (no discrete trigger). Tooltip, link-preview, combobox,
command omit focus (no trap).
Tag-group Link variant: morfo-check tolerates sibling parts sharing a
physical element (Link IS Item in the DOM) via allPartAttrs cross-scope.
README.md added — developer guide: authoring a morfo step-by-step, the
v.* builders, severity rules, validation layers, polymorphism handling
via defaultElement + role, Sema alignment (data-last-action, transition
markers).
Verify:
npm run check — 5 errs, all pre-existing in sium/examples.
vitest run src/uix/morfo — 7/7 pass.
morfo:check — 64/66 pass (2 flaky Playwright navigation timeouts,
re-run clean when isolated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 65 soma components now consume their morfo as the cross-layer contract.
createAttrs(morfo) and registerContract(morfo) overloads removed — the
Morfo form is now mandatory. Each component provider has a 3-line morfo
import + wire-up block instead of 20+ lines of inline declarations.
Additions:
sium/core/lazy.ts — lazy() combinator for recursive schemas (unblocks
morfo's eventual lazy part-tree validation; tests included).
sium/examples/ — booking, color, login, settings, signup, survey, tree
form demos.
Route demos at src/routes/test/sium/{booking,color,login,settings,
signup,survey,tree}.
Tooling:
morfo-check.ts — now tolerates sibling parts sharing a physical element
(Link variant of Item, etc.). Queries via allPartAttrs cross-scope.
morfo-vocabulary-check.ts — 28 WARN (legitimate per-component
vocabularies or subsets like switch missing "indeterminate"). All
reviewed, none are bugs.
Fixes caught by morfo-check:
table: data-sorted / data-pinned marked severity:'optional' (only
emitted when sorting/pinning active, not always).
tree-grid: data-expanded on Row severity:'optional' (branches only).
menubar: data-menubar-value declared on Trigger.
splitter: data-splitter-panel-id declared on Panel.
Verify:
npm run check — 0 morfo errors (4 pre-existing in sium/examples,
unrelated).
npx vitest run src/uix/morfo — 7/7 tests pass.
npm run smoke — 65/65 routes pass.
npm run morfo:check — 66/66 morfos validate against real DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Tier 3 (complete):
DateField, DatePicker, DateRangeField, DateRangePicker
Tier 4 (partial):
TimeField, TimePicker (TimeRangeField pending)
Core:
$lib/util/dias/segments.ts — pure domain primitives for segmented
date/time inputs (constants, guards, initializers, reconciliation,
content generation, Intl opts)
soma/external/dates re-exports everything (no façades inside soma)
soma/datetime/ slimmed to UI-only helpers (announcer, DOM segment nav,
SegmentState, KEYS predicates, description writers)
Conventions established (A23–A29):
A23 — never re-implement date helpers; extend dias
A24 — readonlySegments without value anchor logs warning
A25 — range components split readonly per-endpoint
A26 — contenteditable segments use onbeforeinput to block IME/paste
A27 — picker composition pattern with shared writableActive refs
A28 — time placeholders render as hh/mm/ss via dias/segments
A29 — demo pages are interactive testbeds, not static galleries
Demo pages: Material Design 3 styled, every public prop wired to a live
control, Field integration + state readout. Day-period uses real radio
group in 12h mode. Auto-anchor on range pickers keeps the end month
visible. 12h sliders map 0↔12 / 13-23↔1-11 correctly.
Docs: COMPONENT_GUIDE.md + SOMA_ARCHITECTURE.md updated with A23–A29,
checklist items 28–31, picker composition section, dias boundary,
directory structure note for soma/datetime/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**Date library reorganisation** (no new third-party dependency)
- New `src/lib/util/dias/` with `_vendor/` (Adobe Apache 2.0 vendored code,
ATTRIBUTION.md preserved) and a public facade split into `types`,
`values`, `calendars`, `queries`, `operations`, `parse`, `format`,
`index`. Consumers import only from `$lib/util/dias`.
- All date-related `Intl` consolidated in `dias/format.ts` —
`DateFormatter`, cache, `resolveDateOrder`, `resolveHourCycle`,
placeholders, defaults.
- `ext/dates` now a thin reactive wrapper delegating to `dias`.
- Aligned App-layer `HourCycle` to canonical numeric form `12 | 24`
(matches `Intl.DateTimeFormat`'s `hour12`). Removed string form
`'12h' | '24h'` from `ext/app/types`, `ext/dates`,
`ext/presentation/locale-defaults`, and their tests.
- Repointed every consumer: `soma/external/dates`,
`terra/external/dates`, eight `routes/test/*` pages.
- Legacy `src/lib/util/dates/` left untouched by request.
**Tier 3 components**
- `soma/components/calendar/` — 14 parts, single + multiple selection,
`MonthSelect`/`YearSelect`, ISO/simple week numbers,
`isDateHoliday` matcher, `data-weekend` flag, navigation live-
region, RTL flip, full ARIA, 27-item checklist verified.
- `soma/components/range-calendar/` — 14 parts, range selection with
live hover preview (novel vs ark/bits/radix/react-aria), `minDays`
/ `maxDays` / `allowSingleDay`, third-click deselects only the
clicked endpoint.
- Test pages at `/test/soma/calendar` and `/test/soma/range-calendar`
with seven demos each. Links added to the soma index under a new
"Dates" section.
- READMEs with anatomy, parts, props, ARIA, keyboard, data-attrs, and
a competitive comparison table.
**Command fixes**
- Resolved O(N^2) reactive cascade: introduced
`visibleSet: $derived<Set<string>>` on the root provider;
per-item `isVisible` is now an O(1) `has()` lookup instead of
calling back into the provider, which was re-running all items'
derivations on every `itemsVersion` bump.
- Added `dir` prop with fallback to `soma.presentation.getDir()`;
horizontal arrows flip in RTL via `getDirectionalKeys`; root
receives `dir` attribute.
**Doc updates (session outcomes)**
- `CLAUDE.md`: replaced obsolete `@/glob/lib` date references with
`$lib/util/dias`; added canonical date-layer section; reinforced
`Provider` export rule and `data-{component}[-{part}]` naming
(never `data-soma-*`); clarified translation convention.
- `src/uix/soma/COMPONENT_GUIDE.md`: new checklist items 26
(component README required) and 27 (grep for data-attr
consistency); new Common Mistakes 11 (`data-soma-*` prefix), 12
(wrong date module), 13 (string `HourCycle`).
- `src/uix/soma/SOMA_ARCHITECTURE.md`: clarified data-attr naming
(canonical emitter is `createAttrs`; validator checks enum
values only, not names); new section covering date types and
formatting through `$soma/external/dates` → `$lib/util/dias`;
documented numeric `HourCycle`.
**Infrastructure**
- `PrimitiveHeaderAttributes` and `PrimitiveSelectAttributes` added
to `$soma/types`.
- `getLastFirstDayOfWeek`, `getNextLastDayOfWeek` re-exported from
`$soma/external/dates`.
- `calendar.*` and `range-calendar.*` translation blocks added to
`$soma/core/langs.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Explains why soma wraps Svelte runes instead of using them directly:
compiler magic limitation, cross-class composition need, migration path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add App service compositor (langs, nums, money, dates, units, presentation)
- Add createSvelteLang() with reactivity for locale and schema changes
- Migrate 25 soma providers from getSoma()/translator.svelte to Soma.getOr()/langs.t()
- Add reactive dir resolution in all component wrappers
- Add dir HTML attribute to 12 root providers (accordion, tabs, slider, etc.)
- Fix slider RTL: range, thumb, tick use right/left based on dir
- Add table structural parts: Header, Body, Footer wrappers
- Implement ColumnPinning, ColumnSizing, Expanded in table-core
- Remove soma core formatters, translator.svelte, prop-resolvers (moved to App)
- Remove duplicate convenience getters (locale, dir, translate) from App/Soma
- Audit all 26 soma components, create audit docs with 84 issues found
- Update test page with pinning, sizing, expandable rows demos
Parts: Root, Item, Header, Trigger, Content
- Single/multiple mode via type prop
- Collapsible (single mode: click open item to close)
- Per-item and global disabled
- Keyboard: Arrow keys (orientation-aware), Home/End, loop option
- Presence on Content with data-starting/ending-style
- --soma-accordion-content-height/width CSS vars for animations
- forceMount on Content
- Header with level prop (aria-level)
- Auto-generated item values when omitted
- Demo page with 5 sections
Compared with Radix, Ark UI, Bits UI:
- Has collapsible + loop (Radix has collapsible, Bits has loop, soma has both)
- Has header level (like Bits)
- Has CSS animation vars (like all three)
- Has data-starting/ending-style (like Bits)
- Skipped ItemIndicator (Ark) — presentation concern for visual layer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Popover Content floating wrapper now includes pointer-events: auto
when preventScroll is active (same fix as Dialog).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Soma class is the framework's identity — belongs in core/, not loose in root
alongside README and architecture docs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
soma — new headless component system replacing terra:
- Provider base class with opt-in context injection
- Reactive system (state, readableActive, writableActive)
- Attrs system (createAttrs, contracts, helpers)
- Props system (mergeProps with on-prefix detection, no hardcoded event list)
- DOM utils (consolidated core, focus/, env)
- Keyboard, events, CSS, ID, config modules
- Layers: scroll, observers, visually-hidden, arrow, portal, text-selection,
dismissal (merged escape+dismissible), presence, focus-scope
- Collapsible component as first soma validation
air components:
- Pagination, Editable, PinInput (air-native), RatingGroup (air-native),
Stepper, FileUpload wrappers over terra
- Terra defaultContent refactor for children snippet pattern
- Avatar, Meter, Progress, Separator migrated to air-native
- Reactive utilities extracted to $lib/util/reactive (shared)
- $soma and $reactive aliases added
Architecture:
- soma is independent: no imports from terra or $lib/util/reactive
- Terra exports deprecated for migrated components
- README fundacional documenting all design decisions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Components:
- AlertDialog: Provider, Content (context transition), Action, Cancel + demo
- DropdownMenu: Provider (revelation sound), Content (floating), Item, Separator,
GroupHeading + checkbox/radio indicators via CSS pseudo-elements + demo
- ContextMenu: Provider, Content sharing dropdown-menu visual language + demo
Terra fix — menu variant registry:
- Add menuRegistryByVariant in menu.svelte.ts to track open menus per variant
- MenuMenuState.onOpen() closes all other menus of same variant before opening
- Prevents concurrent context-menus (or dropdown-menus) from being open simultaneously
- Registry lives in terra infrastructure, not air — clean architectural boundary
- Cleanup via onDestroyEffect when component unmounts
CSS fixes:
- DropdownMenu content: z-index: 50 for proper stacking
- Checkbox/radio items: visual indicators via ::before pseudo-elements
- Selectors duplicated for both dropdown-menu and context-menu data-attr prefixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- getAir() is now the single entry point — returns behavior, dom, translator, logger
- behavior.play() encapsulates soundEnabled check + preset resolution
- dom.resolve() resolves ResponsiveProp using current viewport/breakpoints
- internal/ has no knowledge of config/ (correct dependency direction)
- internal/ types stripped of Air prefix; public barrel re-exports with prefix
- context.ts defines TranslatorService locally (structurally compatible with TerraTranslator)
- button/text components updated to use only getAir(), no direct terra/config imports
- AudioContext fix: withRunningContext() resumes on first user gesture before playing
- AIR_IMPLEMENTATION_GUIDE updated: 12 semantics, correct APIs, current patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Command Dialog demo (⌘K style) with proper Dialog.Trigger
- Make all inline demos collapsible to prevent UI clutter
- Add keyboard shortcut (cmd+k / ctrl+k) for Command Dialog
- Add close button (✕) and onSelect handlers to close dialog
- Fix CSS import paths in air/ and terra/ test pages
- Fix various syntax errors in test pages (toggle, toggle-group, meter)
- Update Switch and RadioGroup contracts with missing attributes
- Fix missing ctx declarations in time-field and time-range-field
- Fix checkbox group import error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add DatiTimeFormat ('12h'|'24h') and DatiDateOrder aliases to dati_types.ts;
replace numeric HourCycle throughout dati_engine so actx and tests compile
- Add missing logger field to TerraConfigState.opts
- Fix percent NumberField: use edit-mode formatter on focus/commit/watch.pre
- Fix test pages: Toolbar.Group value type (string), Progress tuple cast
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- glob/numr_types: replace NumrConfig with rich NumrFormat (style, currency,
percent, unit, notation, compactDisplay, signDisplay, grouping, dir) using
semantic type names decoupled from Intl.NumberFormatOptions
- glob/numr_engine: internal numrFormatToIntlOpts adapter keeps Intl hidden
from consumers; mergeFormats/clampDecimals for safe instance+call merging
- glob/numr_engine.svelte: updated signature to accept NumrFormat
- terra/number-field: new headless primitive (Root, Label, Input, Increment,
Decrement) with spinbutton ARIA, locale-aware format/parse via createNumr,
direction via resolveDirectionProp, name prop for hidden form input
- terra/number-field types use NumrFormat from glob, no Intl dependency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrites the architecture guide to match the current directory layout:
- layers/ (was base/), config/ at top-level, dom/ as separate top-level
- utils/state/, utils/framework/, utils/collections/, utils/css/
- createTerraContext replaces new Context<T>()
- Adds Drawer as the full practical example, including the z-index note
for overlay stacking when both content and overlay share a Portal
Also adds z-index to drawer test page (.overlay: 50, .drawer: 51) so
content always renders on top of the overlay regardless of DOM insertion order.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Merge internal/ and shared/ into utilities/ with semantic subdirs:
floating/, focus/, keyboard/, datetime/, presence/, typeahead/
- Fix tooltip/popover positioning: wrapperProps.style was a plain object,
causing Svelte 5 spread to serialize it as "[object Object]" — wrapped
with styleToString() so CSS is applied correctly
- Fix WithChild<T,{},Ref> producing Snippet<[{}]> instead of Snippet:
use keyof check so empty SnippetProps yields plain Snippet (no args)
- Fix TooltipContentProps.children: switch to WithChildNoChildrenSnippetProps
so children is Snippet while child retains { open: boolean } props
- Fix field component: move field.svelte.ts to root dir, add exports.ts,
simplify index.ts, normalize import paths across all field components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Port Dialog component from uisv to acel (dialog.svelte.ts state machine)
- Rewrite alert-dialog as thin wrapper over acel Dialog
- Remove 4 redundant alert-dialog components (Trigger, Overlay, Title, Description)
now re-exported directly from dialog/components/
- Add Dialog export to acel/index.ts
- Fix double-namespace in button/index.ts
- Fix SPA config: ssr=false, prerender=false, adapter-static fallback
- Fix Snippet<[DialogContentSnippetProps]> children call with snippetProps
- Fix glob/lib/date TS errors: import type for verbatimModuleSyntax (×45)
- Fix TS7053 in manipulation.ts invertDuration and DateFormatter.ts
- Fix TS2322 hourCycle type in comn_utils.ts and DateFormatter.ts
- Recreate test pages: accordion, collapsible, alert-dialog, dialog (new), button (new)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add comprehensive library system for internationalization, logging,
formatting, and UI state management:
- ling: i18n library with translation keys, plural rules, and JSON serialization
- logr: logging system with levels, filters, history, and transports
- glob: number (numr), date (dati), currency (curr), and unit (unit) formatters
- actx: application context integrating all modules with Svelte 5 reactivity
- uiux: reactive UI state for theme, toasts, modals, menus, and breakpoints
Features:
- 247 passing tests across 4 test suites
- Svelte 5 runes ($state) for reactive context
- Lazy initialization for optional modules
- Type-safe configuration with TypeScript
- Locale-aware formatting using Intl APIs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>