fix dependency direction: morfo no longer imports from soma

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
morfo-runtime
dev 5 months ago
parent 9b07cead6b
commit d9b6521020

@ -0,0 +1,34 @@
/**
* DOM attribute value helpers — pure boolean → string formatters used to
* emit `data-*` and ARIA attributes. No reactivity, no DOM, no morfo
* dependency. Generic enough that any consumer (soma providers, eidos
* tooling, plain HTML emitters) can reuse them.
*/
export function boolToStr(condition: boolean): 'true' | 'false' {
return condition ? 'true' : 'false'
}
export function boolToEmptyStrOrUndef(condition: boolean): '' | undefined {
return condition ? '' : undefined
}
export function boolToTrueOrUndef(condition: boolean): true | undefined {
return condition ? true : undefined
}
export function getDataOpenClosed(condition: boolean): 'open' | 'closed' {
return condition ? 'open' : 'closed'
}
export function getDataChecked(condition: boolean): 'checked' | 'unchecked' {
return condition ? 'checked' : 'unchecked'
}
export function getAriaChecked(
checked: boolean,
indeterminate: boolean
): 'true' | 'false' | 'mixed' {
if (indeterminate) return 'mixed'
return checked ? 'true' : 'false'
}

@ -0,0 +1,10 @@
export * from './apply'
export * from './attrs'
export * from './consts'
export * from './core'
export * from './errors'
export * from './focus'
export * from './locale'
export * from './resize-observer'
export * from './responsive'
export * from './tabbable'

@ -0,0 +1,49 @@
/**
* Svelte attachment ↔ State box bridge.
*
* `attachRef` returns an attachment object that, when applied to a DOM
* node via Svelte's `{@attach}` system, stores the node reference into
* either a reactive `State<T | null>` box (preferred) or a plain
* `(v: T | null) => void` setter, and unsets it on unmount.
*
* Sits in `libs/reactive` because it bridges two cross-cutting
* primitives — Svelte's attachment API and the `State` reactive box —
* neither of which belongs to a specific UIX layer.
*/
import { untrack } from 'svelte'
import { createAttachmentKey } from 'svelte/attachments'
import { isState } from './guards'
import type { State } from './types'
type RefSetter<T> = (v: T) => void
export type RefAttachment<T extends EventTarget = HTMLElement> = {
[key: symbol]: (node: T) => (() => void) | void
}
export function attachRef<T extends EventTarget = Element>(
ref: State<T | null> | RefSetter<T | null>,
onChange?: (v: T | null) => void
): RefAttachment<T> {
return {
[createAttachmentKey()]: (node: T) => {
if (isState(ref)) {
ref.current = node
untrack(() => onChange?.(node))
return () => {
if ('isConnected' in node && node.isConnected) return
ref.current = null
onChange?.(null)
}
}
ref(node)
untrack(() => onChange?.(node))
return () => {
if ('isConnected' in node && node.isConnected) return
ref(null)
onChange?.(null)
}
}
}
}

@ -0,0 +1,19 @@
// ─── Types ────────────────────────────────────────────────────────────────────
export type { Active, State, Getter, MaybeActiveOrGetter, Source, Flattened } from './types.ts';
// ─── Symbols (needed for advanced consumers extending the system) ─────────────
export { ActiveSymbol, WritableSymbol } from './symbols.ts';
// ─── Guards ───────────────────────────────────────────────────────────────────
export { isActive, isState } from './guards.ts';
// ─── Core primitives ──────────────────────────────────────────────────────────
export { state } from './state.svelte.ts';
export { readableActive, writableActive } from './derived.svelte.ts';
export { readonly } from './readonly.svelte.ts';
// ─── Utilities ────────────────────────────────────────────────────────────────
export { toValue, normalize, flatten, autoReset } from './utils.svelte.ts';
// ─── Svelte attachment ↔ State bridge ────────────────────────────────────────
export { attachRef, type RefAttachment } from './attach-ref.svelte.ts';

@ -1,12 +1,15 @@
/**
* Data contract system for soma components.
* Data contract system. Contracts are derived from a component's morfo
* via `registerContract(morfo)`; `assertContract` validates emitted
* `data-*` values against the contract in development (no-op in
* production).
*
* Contracts are derived from a component's morfo via `registerContract(morfo)`.
* `assertContract` validates emitted `data-*` values against the contract
* in development; it is a no-op in production.
* Lives in morfo because the contract IS a derivation of the morfo
* declaration. Any consumer (soma providers, eidos validators, future
* tooling) imports it from `$uix/morfo`.
*/
import type { Morfo, MorfoPart } from '$uix/morfo';
import type { Morfo, MorfoPart } from './types';
/** Internal shape of a registered contract. Not consumer-facing. */
interface DataAttrRecord {

@ -1,4 +1,4 @@
import type { Morfo, MorfoPart } from '$uix/morfo';
import type { Morfo, MorfoPart } from './types';
/**
* Creates data-attribute names for a soma component from its morfo.

@ -54,3 +54,12 @@ export {
shouldEmitMorfoEntry,
type MorfoBindings
} from './resolver';
// morfo → data-attr name derivation. Soma providers use this to know
// which attrs to emit; eidos tooling can use it to generate selectors.
export { createAttrs, type AttrsOf, type AttrsReturn } from './create-attrs';
// Data contract validation derived from a morfo. `registerContract`
// caches the lookup table; `assertContract` warns in dev when emitted
// `data-*` values violate the morfo's declared `values` enum.
export { registerContract, getContract, assertContract } from './contracts';

@ -39,9 +39,8 @@ import { resolveSemaDuration, resolveSemaIntent, type EngineSemantic } from '$ui
export type SemanticEmitter = Pick<EngineSemantic, 'emit'>;
import type { Morfo, MorfoEvent, MorfoPart } from './types';
import { createAttrs } from '$soma/attrs';
import { attachRef, type RefAttachment } from '$soma/provider/provider.svelte';
import type { Active, State } from '$libs/reactive';
import { createAttrs } from './create-attrs';
import { attachRef, type RefAttachment, type Active, type State } from '$libs/reactive';
import {
findMorfoPart,

@ -1,27 +0,0 @@
export function boolToStr(condition: boolean): 'true' | 'false' {
return condition ? 'true' : 'false';
}
export function boolToEmptyStrOrUndef(condition: boolean): '' | undefined {
return condition ? '' : undefined;
}
export function boolToTrueOrUndef(condition: boolean): true | undefined {
return condition ? true : undefined;
}
export function getDataOpenClosed(condition: boolean): 'open' | 'closed' {
return condition ? 'open' : 'closed';
}
export function getDataChecked(condition: boolean): 'checked' | 'unchecked' {
return condition ? 'checked' : 'unchecked';
}
export function getAriaChecked(
checked: boolean,
indeterminate: boolean
): 'true' | 'false' | 'mixed' {
if (indeterminate) return 'mixed';
return checked ? 'true' : 'false';
}

@ -1,5 +1,25 @@
export { createAttrs, type AttrsReturn } from './create-attrs';
export { assertContract, registerContract, getContract } from './contracts';
/**
* Backward-compat barrel. The actual helpers now live in:
*
* - `$uix/morfo` — `createAttrs`, `registerContract`, `assertContract`,
* `getContract` (morfo derivations).
* - `$libs/dom` — `boolToStr`, `boolToEmptyStrOrUndef`, `boolToTrueOrUndef`,
* `getDataOpenClosed`, `getDataChecked`, `getAriaChecked` (pure DOM
* attribute formatters).
*
* New code should import from those canonical homes. This file exists
* only to avoid churning existing soma providers.
*/
export {
createAttrs,
type AttrsOf,
type AttrsReturn,
registerContract,
getContract,
assertContract
} from '$uix/morfo';
export {
boolToStr,
boolToEmptyStrOrUndef,
@ -7,4 +27,4 @@ export {
getDataOpenClosed,
getDataChecked,
getAriaChecked
} from './helpers';
} from '$libs/dom';

@ -71,11 +71,10 @@
* ```
*/
import { untrack } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import { assertContract, createAttrs, registerContract } from '../attrs';
import { assertContract, createAttrs, registerContract } from '$uix/morfo';
import { attachRef, type RefAttachment } from '$libs/reactive';
import type { SomaContext } from './context';
import { isState, type Active, type State } from '../reactive';
import type { Active, State } from '$libs/reactive';
import type {
Morfo,
MorfoPart,
@ -86,39 +85,8 @@ import type {
MorfoAriaEntry
} from '$uix/morfo';
// ---- Ref attachment ----
type RefSetter<T> = (v: T) => void;
export type RefAttachment<T extends EventTarget = HTMLElement> = {
[key: symbol]: (node: T) => (() => void) | void;
};
export function attachRef<T extends EventTarget = Element>(
ref: State<T | null> | RefSetter<T | null>,
onChange?: (v: T | null) => void
): RefAttachment<T> {
return {
[createAttachmentKey()]: (node: T) => {
if (isState(ref)) {
ref.current = node;
untrack(() => onChange?.(node));
return () => {
if ('isConnected' in node && node.isConnected) return;
ref.current = null;
onChange?.(null);
};
}
ref(node);
untrack(() => onChange?.(node));
return () => {
if ('isConnected' in node && node.isConnected) return;
ref(null);
onChange?.(null);
};
}
};
}
// Re-export for downstream consumers (legacy import path).
export { attachRef, type RefAttachment };
/** Minimal opts contract — every provider has id. ref is optional (roots without DOM). */
export interface ProviderOpts {

@ -1,27 +1,30 @@
// ── Symbols ──────────────────────────────────────────────────────────────────
/** Marks any reactive container (readable or writable). */
export const ActiveSymbol = Symbol('active');
/** Marks a writable reactive container (State). */
export const WritableSymbol = Symbol('writable');
// ── Types ────────────────────────────────────────────────────────────────────
/** Readonly reactive container. Backed by $state or $derived. */
export type Active<T> = {
readonly [ActiveSymbol]: true;
readonly current: T;
};
/** Mutable reactive container. Always backed by $state. */
export type State<T> = Omit<Active<T>, 'current'> & {
readonly [WritableSymbol]: true;
current: T;
};
/**
* Backward-compat re-export. The reactive primitive system now lives in
* `$libs/reactive` so that `attachRef` (also in libs) and any future
* cross-layer consumer see the SAME `ActiveSymbol` / `WritableSymbol`
* identities. Two parallel copies of these symbols would silently break
* the `isState` / `isActive` guards, which is what initially motivated
* the consolidation.
*
* New code should import from `$libs/reactive` directly. This file is a
* thin shim until the in-tree imports finish migrating.
*/
/** Zero-argument getter function. */
export type Getter<T> = () => T;
export {
ActiveSymbol,
WritableSymbol,
type Active,
type State,
type Getter,
isActive,
isState,
state,
readableActive,
writableActive,
autoReset
} from '$libs/reactive';
import type { Active, State } from '$libs/reactive';
/** Wraps all properties of T as Active<T[K]>. */
export type ActiveProps<T> = {
@ -32,114 +35,3 @@ export type ActiveProps<T> = {
export type StateProps<T> = {
[K in keyof T]: State<T[K]>;
};
// ── Guards ───────────────────────────────────────────────────────────────────
function isObject(value: unknown): value is Record<PropertyKey, unknown> {
return value !== null && typeof value === 'object';
}
/** Returns true if value is any reactive container (readable or writable). */
export function isActive(value: unknown): value is Active<unknown> {
return isObject(value) && ActiveSymbol in value;
}
/** Returns true if value is a writable State container. */
export function isState(value: unknown): value is State<unknown> {
return isActive(value) && WritableSymbol in value;
}
// ── Core primitives ──────────────────────────────────────────────────────────
/**
* Creates a mutable reactive container backed by Svelte's $state.
* Pass the container by reference to preserve reactivity across function boundaries.
*/
export function state<T>(): State<T | undefined>;
export function state<T>(initialValue: T): State<T>;
export function state<T>(initialValue?: T): State<T | undefined> {
let current = $state<T | undefined>(initialValue);
return {
[ActiveSymbol]: true,
[WritableSymbol]: true,
get current() {
return current;
},
set current(v: T | undefined) {
current = v;
}
};
}
/**
* Creates a readonly reactive container backed by $derived.by.
*/
export function readableActive<T>(getter: () => T): Active<T> {
const value = $derived.by(getter);
return {
[ActiveSymbol]: true,
get current() {
return value;
}
};
}
/**
* Creates a writable reactive container backed by a getter/setter pair.
* The getter is tracked via $derived.by; the setter lets external code push new values.
*/
export function writableActive<T>(getter: () => T, setter: (v: T) => void): State<T> {
const value = $derived.by(getter);
return {
[ActiveSymbol]: true,
[WritableSymbol]: true,
get current() {
return value;
},
set current(v: T) {
setter(v);
}
};
}
// ── Auto-reset ───────────────────────────────────────────────────────────────
interface AutoResetOptions<T> {
afterMs?: number;
onChange?: (value: T) => void;
}
/**
* Creates a State that auto-resets to its default value after a timeout.
* Useful for typeahead search buffers.
*/
export function autoReset<T>(defaultValue: T, options: AutoResetOptions<T> = {}): State<T> {
const { afterMs = 5_000, onChange } = options;
let timeout: ReturnType<typeof setTimeout> | null = null;
let value = $state(defaultValue);
function scheduleReset() {
return setTimeout(() => {
value = defaultValue;
onChange?.(defaultValue);
}, afterMs);
}
$effect(() => () => {
if (timeout !== null) clearTimeout(timeout);
});
return writableActive(
() => value,
(v) => {
value = v;
onChange?.(v);
if (timeout !== null) clearTimeout(timeout);
timeout = scheduleReset();
}
);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.