25 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Branch note (
active-uix): the legacy layerssrc/lib/,src/uix/terra/,src/uix/air/and the oldsrc/routes/test tree were removed during the UIX refactor. The new ecosystem lives insrc/arts/,src/libs/,src/svrs/, andsrc/uix/{active-uix,soma,sema,eidos,morfo}. Demos are being re-authored underweb/routes/from scratch.
Build / Test Commands
npm run dev # Start dev server (routes resolve from web/routes)
npm run build # Production build (static via adapter-static)
npm run test # Run all tests once
npm run test:unit # Run tests in watch mode
npx vitest run src/uix/morfo/compile.test.ts # Run a single test file
npx vitest run -t "describe name" # Run tests matching a pattern
npm run check # Type check with svelte-check
npm run lint # Check formatting (Prettier)
npm run format # Auto-format
Code Style
- Tabs for indentation, single quotes, no trailing commas, 100 char print width (Prettier).
- Comments in English. (Some legacy Spanish comments remain — don't add new ones.)
- Svelte 5 runes mode is enforced globally (
svelte.config.jsdynamicCompileOptions). - DOM event handlers: lowercase (
onclick,onfocus). User callbacks: camelCase (onComplete,onInteractOutside). - State:
$state()without#. Derived:readonly x = $derived.by(...).
Path Aliases
Aliases are kept in sync between svelte.config.js and vite.config.ts. Source
of truth is vite.config.ts (single aliases const reused by both top-level
resolve and the server-test project).
| Alias | Target |
|---|---|
@ |
src/ |
$active-app, $adom, $auth, $bus, $cache, $connection, $format, $frontend, $http, $lang, $logger, $orca, $perm, $prefs, $session, $sium, $storage, $timer |
src/arts/{name} |
$libs, $locale, $reactive |
src/libs, src/libs/locale, src/libs/reactive |
$svrs |
src/svrs/ |
$uix, $active-uix, $soma |
src/uix, src/uix/active-uix, src/uix/soma |
Vitest Two-Project Structure
vite.config.ts defines two test projects:
- client: Browser tests via Playwright for
*.svelte.{test,spec}.{js,ts}. - server: Node environment for
*.{test,spec}.{js,ts}(excludes svelte tests).
Architecture
The UIX is layered around a declarative contract (morfo) that the other layers
consume. Each consumer reads the morfo via compileMorfo(morfo), never by
walking the raw declaration.
arts/ → runtime artifacts: active-app, adom, perm, prefs, ...
libs/ → pure helpers (zero-dep): reactive, days, dom, locale, ...
svrs/ → server-authoritative engines
uix/morfo → declarative contract: parts, data-attrs, ARIA, keyboard,
events with semantic family/intent. Compiler at compile.ts
emits CompiledMorfo (cached by WeakMap).
uix/sema → semantic engine + channels (visual / sound / vibra).
The visual channel writes data-event* during a hold window.
State attrs (data-state, data-disabled, ...) are NEVER
touched by sema — they belong to the morfo runtime.
uix/soma → headless component layer. Each provider consumes morfo +
sema via `runtime.trigger(eventName)` which sequences:
prewrite → semantic.emit → handler → effect-driven state attrs.
uix/eidos → CSS layer that selects against the data-attrs the morfo
contract promises. lint.ts validates a CSS file against
compileMorfo(morfo).contracts.cssSelectors.
uix/active-uix → composition root. Two boot modes:
- createActiveUix(options) — standalone (owns services)
- attachActiveUix(activeApp) — attach to external app
morfo (src/uix/morfo/)
Single source of truth for a component. A Morfo declares:
parts[]— kebab + archetype +data+aria+ optionalkeyboardevents[]— name +targetpartRef +semantic+ optionalprewrite+commitsfocus— initial / trap / return / restore (for overlay components)
compileMorfo(morfo) returns a CompiledMorfo with pre-resolved AttrPlan[],
KeyboardPlan[], ActionPlan[], and contracts.cssSelectors (the closed set
of selectors eidos is allowed to use). Cached by morfo identity.
eidos linter
scripts/eidos-lint.ts <component> and scripts/eidos-lint-all.ts classify every
[data-*] selector in eidos CSS as morfo-backed / eidos-only / invalid. Use
this to detect drift between the morfo contract and the eidos rules.
Key Conventions
- Never modify the morfo declaration files without updating the eidos selectors and provider sources that depend on them.
- Read before acting. When told to read a file, read it. Don't paraphrase.
- Verify before reporting done. Run
npm run checkand the relevant vitest scope. UI claims need a browser check; if you can't run a browser, say so. - No backward-compat shims when relocating code. Update consumers and delete the old path; don't leave a re-export.
- No re-export façades between layers (e.g. soma must not re-export dias —
consume
$libs/daysdirectly). - Provider naming: child providers reference the parent as
provider, neverroot. The exported component is<Xxx.Provider>.createAttrsstill uses the part kebab'provider'internally. - Data-attr naming:
data-{component}(provider) anddata-{component}-{kebab}(sub-parts). Neverdata-soma-*. The compiler emits these viacompiled.parts.attrs; runtime queries match exactly. - CSS token naming (eidos recipes): bare-prefixed by component, never
by layer. Public tokens:
--{component}-…(e.g.--dialog-content-bg,--toggle-radius-md). Internal recipe tokens:--_{component}-…. Never--eidos-…,--air-…,--terra-…,--soma-…. Migratingair-baselines means stripping theair-prefix, not replacing it. - DOM helpers:
$adomis the single public surface for DOM-related functionality at component / soma level. It re-exports all of$libs/domwholesale, plus the reactivecreateActiveDomruntime and stateful primitives (BodyScrollLock,DOMContext,RovingFocusGroup). Components and soma NEVER import from$libs/domdirectly — consume$adom. Onlyarts/adom/*(the implementation),arts/frontend(peer service fallback), and tests of$libs/dommay reach into it. This includes responsive types (ResponsiveProp<T>,Breakpoint,Breakpoints) — they travel with the runtime, so they live in$adom. - Iframe / popup correctness: when you have a
uix.dom, preferuix.dom.getDocument(node?)anduix.dom.getWindow(node?)over the bare functions of the same name. The instance methods respect the active-dom'stargetWindow; the bare functions fall back to the globaldocument/window, which is wrong in iframe / popup / happy-dom-test contexts.
Critical Rules
- NEVER delete files without explicit instruction ("delete", "remove", "borra", "elimina"). If ambiguous, ASK first.
- NEVER create fake translators / mocked services in tests for components.
Use real instances composed via
createActiveUixorattachActiveUix. - NEVER skip hooks (
--no-verify) or bypass signing unless explicitly asked. If a hook fails, fix the root cause. $reactivesurvives,$libdoes not. The$reactivealias points to the currentsrc/libs/reactive; the singular$libalias was removed in the cleanup phase and any code that resurfaces it is a regression.
Reference Documents
Architecture overview and motivations: src/uix/active_architecture.md.
Per-layer references:
src/uix/morfo/README.mdsrc/uix/sema/README.mdsrc/uix/soma/SOMA_ARCHITECTURE.md+src/uix/soma/COMPONENT_GUIDE.mdsrc/uix/eidos/README.md+src/uix/eidos/components/README.md
The canonical guide for sema vocabulary, morfo event shape, color
tokens, persistence and a11ySemantic lives in
src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md.
That document is authoritative — when in doubt about families, intents,
verbs, color subsets per component, or holds, that's the source.
Sema cascade selectors must use the typed builder
Cascade rules in src/uix/sema/components/*.ts MUST construct their
selector via semaSelector(morfo, partKebab, matchers?) from
$uix/morfo. Never hand-write selector strings that target morfo-
emitted attrs ([data-{component}], [data-{component}-{part}],
[data-event="..."]). The helper:
- Compile-error if
partKebabdoesn't exist onmorfo.parts. - Compile-error if
matchers.eventNamedoesn't matchmorfo.events. - Type-checks
eventFamily/eventIntentagainst the canonical sema unions. - Output is the same CSS string the cascade rule needs — zero runtime cost beyond concatenation.
Hand-written selectors in cascade rules are an architecture violation: when morfo renames a part or event, the strings go stale silently. The helper wires the cascade to the morfo's contract so renames break at the type level.
// good
import { semaSelector } from '$uix/morfo'
import { dialogMorfo } from '$uix/morfo/components/dialog'
{
selector: semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' }),
haptic: { kind: 'tap' }
}
// bad — string drifts silently if morfo renames
{
selector: '[data-dialog-content][data-event-family="commit"]',
haptic: { kind: 'tap' }
}
Sema: intent policy per family
The doctrinal stance on intent declaration is encoded as a policy const,
SEMA_FAMILY_POLICY in src/uix/sema/types.ts:
{
contact: { intentPolicy: 'allowed' }, // intent OPTIONAL — neutral default
commit: { intentPolicy: 'expected' }, // intent REQUIRED — every consummation evaluates
signal: { intentPolicy: 'expected' }, // intent REQUIRED — alarms inherently evaluative
handle: { intentPolicy: 'allowed' }, // intent OPTIONAL — drag/scrub usually neutral
emerge: { intentPolicy: 'optional' }, // intent OPTIONAL — Dialog confirming threat declares
shift: { intentPolicy: 'optional' },
sustain: { intentPolicy: 'optional' }
}
Each entry is an object so future per-family policy fields (sequence default, allowed channels, hold preferences, gesture phases, …) live alongside without restructuring consumers.
Type derivation: SemaEvent and MorfoEventSemantic discriminate over this
policy. Editing the const reshapes the discriminated union: 'expected' →
intent REQUIRED at compile-time; 'allowed' / 'optional' → optional.
Runtime enforcement: validateSemaEvent throws when an 'expected' family
declares an event without intent. 'optional' families pass silently when
intent is absent.
This replaces the prior valenced/transitional split as the gatekeeper for intent. The valenced/transitional distinction still exists as a family classification but no longer dictates intent rules — the policy const does. Real-world UX needs (a Dialog opening to confirm a destructive action carries threat in its very emergence) made the strict rule counter-productive.
Sema: open channel registry + flat CSS-style cascade
The perceptual layer is NOT 4-channel-closed. The framework ships 5
canonical channels (motion, sound, color, presence, haptic)
declared in SemaChannelSignatures. Apps add more via TypeScript
declaration merging.
The semantic tokens are data-event-*: the engine stamps data-event,
data-event-family, data-event-intent, data-event-phase, data-event-id
on signal.target BEFORE the cascade resolves. Cascade rules use CSS
selectors that target those tokens directly — same surface that eidos CSS
already reads. Engine owns stamp/unstamp; VisualChannel only owns the hold.
Cascade rules are flat (no overrides: { eventLabel: ... } middle
layer): a single selector + per-channel deltas, like a CSS rule.
{
selector: '[data-dialog-content][data-event-intent="threat"]',
sound: { sampleUrl: '/sounds/dialog-fail.wav' },
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] }
}
Resolution cascade (each layer overrides the previous):
1. SEMA_MAP.families[F].base — per-channel signatures
2. SEMA_MAP.intents[I].deltas — valenced families only;
numbers ADD (modificadores)
3. SEMA_MAP.soundPack[event-label] — sample URLs
4. signal.overrides + signal.channels — per-event morfo overrides;
numbers REPLACE
5. engineOpts.overrides.runtime — path-based globals; REPLACE
6a. engineOpts.components[].cascade — per-component packs
(sema/components/{name}.ts)
6b. engineOpts.overrides.cascade — app-level rules; appended
AFTER packs so they win
on equal specificity
Per-component packs live in src/uix/sema/components/{name}.ts —
symmetric to morfo / soma / eidos. Each exports a Sema with
cascade rules + optional preloadSamples (WAVs decoded at boot).
Override semantics — numbers:
- intent.deltas (capa 2): numbers ADD (modificadores compositivos).
- overrides (capas 4, 5, 6): numbers REPLACE (
color: red, not "add red"). - Use
{ op: 'add' }/{ op: 'multiply' }/{ op: 'replace' }for explicit semantics either way.
intent.deltas owns the perceptual signature of the intent — the
sound primitives pitch, gain, contour (and the haptic intensity
when shaped per-intent) come from capa 2. Cascade rules (capas 6a/6b)
MUST NOT override these primitives — doing so flattens the perceptual
difference between risk / threat / loss (or affirm / fulfill).
Cascade rules add character (haptic.kind, haptic.pattern,
sound.sampleUrl, motion.easing, presence.backdrop) — not the
intent's evaluative profile. If a per-component cascade rule needs to
shift a numeric primitive (e.g. close-* in emerge goes descending),
use { op: 'add', value: -150 } so it composes on top of the intent
rather than erasing it.
HapticChannel (V1, real — Vibration API) is opt-in via
new EngineSemantic({ haptic: true }). Sound and haptic both default
off because they have audible / tactile side effects.
Canonical sources: src/uix/sema/sema-map.ts (SEMA_MAP value +
SemaChannelSignatures registry + Sema), src/uix/sema/resolver.ts
(cascade implementation + CSS specificity), src/uix/sema/stamp.ts
(stamp/unstamp), src/uix/sema/components/*.ts (per-component packs),
src/uix/sema/README.md.
Eidos drift defense — types over lint
Selector drift between morfo's emitted attrs and the layers that style / react to them (eidos CSS, sema cascade) is defended at compile time via typed builders, not at lint time.
- Sema cascade rules (
sema/components/*.ts) consumesemaSelector(morfo, partKebab, matchers?)from$uix/morfo. Renames in morfo break the cascade at type-check time. See "Sema cascade selectors must use the typed builder" above. - Eidos plain
.cssrecipes are still raw CSS today — there is no CSS-side typed builder. For those,scripts/eidos-lint.tsremains as an opt-in safety net that classifies each[data-*]selector asmorfo-backed/eidos-only/invalid. It is not the architectural contract; the contract is the morfo declaration.
Rule: when a layer can consume the morfo via TypeScript (anything in
.ts / .svelte), it MUST use the typed builder. Lint is for the
remaining surface (plain CSS recipes) until those gain a builder of
their own. Hand-written morfo-targeting selector strings in TypeScript
files are an architecture violation, not a lint warning.
Session hand-off — 2026-05-09 (selector discipline + lint reframing)
- Typed selector builder applied in
src/uix/sema/components/dialog.ts. Every cascade rule now constructs its selector viasemaSelector(dialogMorfo, 'content', matchers?)instead of hand-writing strings. The helper lives insrc/uix/morfo/selectors.tsand is exported from$uix/morfo. - Eidos-lint reframed from "architectural drift mechanism" to "opt-in safety net for plain CSS recipes". The architectural mechanism is compile-time typing. The lint script + scripts continue to work unchanged but are no longer the doctrinal source of safety.
- Intent.deltas signature ownership documented — cascade rules MUST
NOT override
pitch/gain/contouronsound, because doing so collapses the per-intent perceptual difference. Captured both inline in the dialog cascade (NOTE comment on the intent block) and in the "Override semantics — numbers" rule above. - Documentation updated:
src/uix/morfo/README.md— new "Typed selector builder —semaSelector" section.src/uix/eidos/README.md— replaced "Linter" section with "Defensa contra drift de selectores" framing the two-tier defense (compile-time builder for TS/Svelte, opt-in lint for plain CSS).src/uix/sema/README.md— already documented the builder + the cascade discipline + the SEMA_FAMILY_POLICY shape (from the prior refactor).
Verification at hand-off: npm run check reports 0 errors, 2
pre-existing Svelte warnings unrelated to this work. npm run test
reports 1883/1883 tests green across 154 files (~22s).
Session hand-off — 2026-05-08 (post-canon alignment)
The codebase has been aligned to the canonical guide. Most recent architectural move:
- Runtime relocated + renamed — what was
MorfoRuntimeinsrc/uix/morfo/runtime.svelte.tsis nowSomaRuntimeinsrc/uix/soma/runtime.svelte.ts. Reason: morfo must remain pure declarative TypeScript (DNA); the runtime imports Svelte runes,$adom,$uix/sema, and$libs/reactive— those are soma's territory. Per the doctrine "morfo declares, soma executes" the runtime now lives where it conceptually belongs. Active-uix'suix.runtime(morfo, sources)factory stays unchanged; it importscreateSomaRuntimefrom$soma. - Sema is now genuinely ornamental —
ActiveUix.semanticreturnsEngineSemantic | undefined(no longer throws).SomaRuntime.trigger()skips theemitstep and the target-resolution check when no engine is present. Components stay functional in SSR / headless tests / audio-disabled environments.
The earlier swept phases (still in effect):
- Sema verbs (
src/uix/sema/verbs.ts): restructured to family-keyedRecord.select/toggle/acknowledgemoved tocommit;editremoved fromhandle(now expressed asshift.enter-mode). New verbs added per the canon (tap, focus, release, complete, restore, remind, route, step, syncing, etc.).validateEventNamerecognises both{verb}-{variant}and{family}-{verb}shapes. - Morfo event shape (
src/uix/morfo/types.ts):targetmoved insidesemantic; added optionalverb(advisory, validated againstSEMA_VERBS[family]) andsequence: 'pre' | 'coincident' | 'post'. All 22 events across toggle/toast/popover/drawer/dialog refactored to the new shape. - Color tokens (
src/uix/eidos/themes/base/{light,dark}.css): doctrinal 8 tokens —primary, secondary, neutral, affirm, fulfill, risk, threat, loss. Renamedsuccess → fulfill,warning → risk,danger → threat(same hex). New primitives forsecondary(slate-blue),affirm(teal-mint, low activation),loss(deep violet-grave, posterior).infopalette deleted entirely. Legacy aliases in_static.cssdeleted (clean cut, no transition). 29 consumers (recipe CSS + tokens) migrated to doctrinal names.
Earlier in the session:
- Toggle is the eidos pilot (
src/uix/eidos/components/toggle/): recipe + Svelte wrapper + types + index + README. The pattern is documented insrc/uix/eidos/components/README.md. - Eidos has a shared lib at
src/uix/eidos/lib/types.ts. First type isSize('xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'full'). Components narrow withExtract<Size, 'sm' | 'md' | 'lg'>per the per-component-subset doctrine. - No
Eidosprefix on types. The path$uix/eidos/components/{x}already conveys layer. Public types areToggleProps,ToggleVariant,ToggleSize. - API doctrine: soma stays compound (
Toggle.Provider); eidos exports bothdefaultandProviderfor single-part components so<Toggle>works for the ergonomic case while<Toggle.Provider>stays available. - SoundChannel eager-init landed in
src/uix/sema/chans/sound.tsto fix the autoplay race: the AudioContext is created + resumed synchronously on the first user gesture (capture-phase listener ondocument). - Demo page (
web/routes/toggle/+page.svelte) has the live preview rendered ALWAYS above the tablist (so Sema-tab Play buttons can fire on the real toggle) and the motion preview amplifies scale ×8 visually only — doctrinal values stay in the<dl>.
Mandatory checklist before any eidos component migration:
- Recover the air baseline from git:
git ls-tree -r 0a391408^ --name-only | Select-String "air.*{name}" git show 0a391408^:src/uix/air/components/{name}/types.ts git show 0a391408^:src/uix/air/components/{name}/{name}.svelte - Inventory air's props, slots, effects, perceptual emits.
- Build a comparison table: feature × (air had it | canon allows it |
eidos exposes it). Flag each gap as
regression,enhanced,dropped-by-canon, ornuevo. - Present the table BEFORE coding. Get explicit per-gap decision.
- Only then write the wrapper.
Switch was migrated WITHOUT this discipline and shipped a regression
(no ResponsiveProp<Size> for breakpoint-aware sizing). Don't repeat.
Next concrete steps:
- Migrate
collapsible → dialog → drawer → popover → toast → avatarto the eidos wrapper pattern. For each: walk the air-baseline checklist above first. Toggle / switch are templates for the subdirectory shape, but the per-component prop surface must come from air's recovery, not from copying toggle's props. - Persistence + holds-by-intent (guide §6) — defer until the
first concrete
signal.warn/signal.alertconsumer appears. Today every signal istransientwith a numeric hold. Need to addpersistence: 'transient' | 'untilAction' | 'untilFix' | 'stateBound'toSemanticSignal+ the holds-by-intent table per the canon. - a11ySemantic per event (guide §9) — defer until there's a reduced-motion runtime helper + live-region helper to consume the contract.
- Polymorphic events (guide §5.3,
allowedFamilies+defaultSemantic) — defer; no current component needs polymorphism. - The docs site's topbar prefs (sound mute toggle, theme switcher)
are wired up but the sound mute does NOT yet drive
SoundChannel.masterGain. Wiring belongs on layout.svelte's$effect.
Baseline npm run check shows 39 pre-existing errors in test files
unrelated to this work. Tests are 217/217 green across uix.
Behavioral Guidelines
These reduce common LLM coding mistakes. They bias toward caution over speed — for trivial tasks, use judgment.
1. Think before coding
Don't assume. Don't hide confusion. Surface tradeoffs.
- State assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
2. Simplicity first
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
3. Surgical changes
Touch only what you must. Clean up only your own mess.
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it — don't delete it.
When your changes create orphans, remove the imports / variables / functions your changes made unused. Don't remove pre-existing dead code unless asked.
4. Goal-driven execution
Define success criteria. Loop until verified.
- "Add validation" → "Write tests for invalid inputs, then make them pass."
- "Fix the bug" → "Write a test that reproduces it, then make it pass."
- "Refactor X" → "Ensure tests pass before and after."
Strong success criteria let you loop independently. Weak criteria require constant clarification.