docs(arts): finish A1 drift sweep (format/timer/bus/connection/sium/prefs/http/motion/color/clipboard)

Second A1 batch — every fix verified against the cited code symbol, not the
plan (the plan repeatedly under- or mis-counted; verifying against code caught
both extra drift and already-fixed items):

- format: `unts/`->`units/` path typo; documented currency `formatAs`/`convertAs`
  and units `convertToDefault`; fixed the `$formats/currency` alias (plural, no
  such alias) -> `$format/currency` in format + currency READMEs.
- timer: FS diagram completed (11 -> the full 16 source files), "50 tests" -> 47,
  reschedule behaviour/errors reconciled (from a prior uncommitted session; all
  verified accurate against timer-handle.ts).
- bus: Layer-split diagram corrected (consts/errors/matching/diagnostics/contracts
  live in libs/bus, not arts/bus; only EngineBus/EngineBusOptions + engine/svelte
  in arts/bus; added silent-bus.ts); documented all 3 listener error modes
  (log-and-continue/throw/collect); fixed stale preset names -> the real
  cache-clear-on-*/connections-*/perm-invalidate-*/session-auto-refresh/standard.
- connection: `createEngineConnections()` -> `{ timers }` (required); documented
  the 6 missing ActiveConnections getters (13-getter surface); conn-chat demo
  (nonexistent script + npm task) -> real `/active/docs/conn`; fixed a paragraph
  that contradicted the required-timers reality.
- sium: documented `cssLength`/`cssValue`/`CSS_LENGTH_REGEX` refines (were absent);
  verified the "codes" item was already covered (siumLangs has all 26).
- prefs: `createPrefsStorageBridge({ prefs, storage, key })` -> `{ engine,
  storage, onError?, skipHydrate? }` (prefs->engine, key was phantom);
  `applyBrowserEnvironment(prefs,...)` -> `(engine,...)`;
  `watchBrowserEnvironment(prefs,...)` -> `(apply,...)` (first arg is a callback).
- http: `DEFAULT_RETRY`/`DEFAULT_TIMEOUT` -> `HTTP_DEFAULT_*`.
- motion: documented the missing `resolve`/`has`/`list`/`exit`; noted `JsDriver
  'svelte'` is reserved/unimplemented (README + engine-motion.ts JSDoc, which
  falsely listed it as running).
- color: types.ts JSDoc `ActiveEidos.setCssVariables` -> `applyColorScheme`; bare
  `THEMING.md` ref -> `../../uix/eidos/THEMING.md`.
- systemic (not in the plan): `defineActive*`/`defineEngine*` imported from
  `$active-app/services` (the schema-contract module, which does NOT export them)
  -> `$active-app/service-factories`, in clipboard + auth + storage + active-app.

arts:check: 0 errors, 0 warnings, 22 arts. Note: bus/connection/format/prefs/sium/
timer carried prior uncommitted arts-reconciliation edits from an earlier session,
folded in here (all coherent, same initiative).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 89c88a990b
commit ed151250c3

@ -28,7 +28,7 @@ import {
defineActiveLangs,
defineActiveSession,
defineEngineHttp
} from '$active-app/services';
} from '$active-app/service-factories';
import { applyStandardOrca } from '$active-app/presets';
const App = createActiveApp({
@ -51,11 +51,11 @@ applyStandardOrca(App);
`active-app` is layered to keep bundles small and the contract obvious.
| Layer | Path | Loaded when |
| ------------------------- | ---------------------- | ---------------------------------------------------------- |
| **Core** | `$active-app` | Always — every app needs `createActiveApp`. |
| **Service factories** | `$active-app/services` | The app declares any service in `services: { … }`. |
| **Orchestration presets** | `$active-app/presets` | The app opts into standard reactions or cherry-picks them. |
| Layer | Path | Loaded when |
| ------------------------- | ------------------------------- | ---------------------------------------------------------- |
| **Core** | `$active-app` | Always — every app needs `createActiveApp`. |
| **Service factories** | `$active-app/service-factories` | The app declares any service in `services: { … }`. |
| **Orchestration presets** | `$active-app/presets` | The app opts into standard reactions or cherry-picks them. |
Each layer is a separate barrel. An app that builds only the core never pulls
service factories or presets into its bundle.
@ -104,7 +104,7 @@ interface ActiveAppCore {
A service is anything an `AppServiceFactory` produces. Factories live in
`arts/active-app/service-factories/` and are exported from
`$active-app/services`.
`$active-app/service-factories`.
```ts
interface AppServiceFactory<TName, TCoreDeps, TServiceDeps, TInstance> {
@ -285,7 +285,7 @@ src/arts/active-app/
├── services.ts ← AppServiceFactory contract
├── service-builder.ts ← topology, lazy proxies, dispose
├── active-app.svelte.ts ← createActiveApp()
├── service-factories/ ← $active-app/services
├── service-factories/ ← $active-app/service-factories
│ ├── index.ts
│ ├── cache.ts
│ ├── clipboard.ts

@ -53,7 +53,7 @@ componer `http`, `cache` y `session`:
```ts
import { createActiveApp } from '$active-app';
import { defineActiveAuth, defineEngineHttp } from '$active-app/services';
import { defineActiveAuth, defineEngineHttp } from '$active-app/service-factories';
const App = createActiveApp({
services: {
@ -217,7 +217,7 @@ En cliente:
<!-- +layout.svelte -->
<script lang="ts">
import { createActiveApp } from '$active-app';
import { defineActiveAuth, defineEngineHttp } from '$active-app/services';
import { defineActiveAuth, defineEngineHttp } from '$active-app/service-factories';
let { data, children } = $props();

@ -52,51 +52,48 @@ events into app events. Consumers subscribe **only** to app events.
The model is **Domain Events + Integration Events** with an
**Anti-Corruption Layer**:
| Active term | DDD term |
| ------------------------------ | ----------------------------------- |
| Module events (`SESSION_EVENT_*`, …) | Domain events (private, iterable) |
| App events (`APP_EVENT_*`) | Integration events (public, stable) |
| Active term | DDD term |
| ----------------------------------------- | ------------------------------------ |
| Module events (`SESSION_EVENT_*`, …) | Domain events (private, iterable) |
| App events (`APP_EVENT_*`) | Integration events (public, stable) |
| `active-app/presets/*` or service bridges | Anti-corruption layer + event mapper |
| Per-consumer auto-reactions | Stateless process managers |
| Per-consumer auto-reactions | Stateless process managers |
If you have a DDD background the model maps 1:1.
## Layer split
```
arts/bus/ ← engine, framework-agnostic
types.ts EngineBus, BusEnvelope, BusListener,
BusPublishOptions, BusPublishResult,
EventPublisher, BusAnyListener
consts.ts BUS_*, listener error modes, diagnostics
engine-bus.ts createEngineBus()
no Svelte imports
errors.ts / matching.ts / diagnostics.ts
test/
libs/bus/ ← pure contracts (framework-agnostic, zero-dep)
types.ts BusEnvelope, BusListener, BusListenerErrorMode,
BusPublishOptions, BusPublishResult, BusListenerFailure,
EventPublisher, EventSubscriber, BusAnyListener, BusClock
consts.ts BUS_* literals, listener error modes, diagnostic events
errors.ts BusDisposedError + 4 siblings + guards
matching.ts event-name matching helpers
diagnostics.ts createBusDiagnostics()
arts/bus/ ← engine (framework-agnostic runtime)
types.ts EngineBus, EngineBusOptions (compose the libs contracts)
engine-bus.ts createEngineBus() — no Svelte imports
active-bus.svelte.ts reactive wrapper: createBusRecent()
{ lastEvent, count, clear, dispose }
silent-bus.ts no-op EngineBus (SSR / bus disabled)
arts/bus/svelte/ ← Svelte adapter
index.ts createSvelteEngineBus()
wraps listener invocation in untrack
arts/bus/active-bus.svelte.ts ← reactive wrappers (minimal surface)
createBusRecent() { lastEvent, count, clear, dispose }
index.ts createSvelteEngineBus() — wraps listeners in untrack
context.svelte.ts getBus / setBus via createContext
arts/active-app/events.ts ← APP_EVENT_* constants + payloads
+ APP_EVENT_RUNTIMES metadata
arts/bus/svelte/context.svelte.ts ← getBus / setBus via createContext
arts/active-app/presets/ ← orca reaction presets (wired by applyStandardOrca)
cache-clear-on-identity-change / cache-clear-on-revoke
connections-close-on-revoke / connections-reauth-on-identity-change
perm-invalidate-on-identity-change / session-auto-refresh / standard
arts/<module>/consts.ts ← module event constants
arts/<module>/types.ts ← module event payload types
arts/<module>/bus-helpers.ts ← typed publish/subscribe helpers
(the only path to call-site type safety)
arts/active-app/presets/ ← translators/reactions (anti-corruption layer)
identity-translator.ts module events → app events
permissions-translator.ts
tenant-translator.ts
connectivity-translator.ts
dispose-translator.ts
arts/<module>/bus-helpers.ts ← typed publish/subscribe helpers (call-site type safety)
```
**Hard rule**: `arts/bus/engine-bus.ts` does not import from `svelte`.
@ -108,64 +105,64 @@ engine must be testable without DOM or Svelte runtime.
```ts
export interface EngineBus {
publish<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
publishAsync<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): Promise<BusPublishResult<TType, TPayload>>;
/**
* Publish with `causationId` automatically set to `parent.id`. Use
* inside translators to keep the causation chain populated.
*/
publishCausedBy<TType extends string, TPayload>(
parent: BusEnvelope,
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
on<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): BusSubscription;
/**
* Sugar for `$effect`: returns the unsubscribe function directly so a
* Svelte component writes `$effect(() => Bus.subscribe(type, fn))`.
*/
subscribe<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): () => void;
onAny(listener: BusAnyListener<TEvents>, options?: BusListenOptions): BusSubscription;
once<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): BusSubscription;
listenerCount(type?: string): number;
_clearForTesting(type?: string): void;
dispose(): void;
publish<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
publishAsync<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): Promise<BusPublishResult<TType, TPayload>>;
/**
* Publish with `causationId` automatically set to `parent.id`. Use
* inside translators to keep the causation chain populated.
*/
publishCausedBy<TType extends string, TPayload>(
parent: BusEnvelope,
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
on<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): BusSubscription;
/**
* Sugar for `$effect`: returns the unsubscribe function directly so a
* Svelte component writes `$effect(() => Bus.subscribe(type, fn))`.
*/
subscribe<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): () => void;
onAny(listener: BusAnyListener<TEvents>, options?: BusListenOptions): BusSubscription;
once<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): BusSubscription;
listenerCount(type?: string): number;
_clearForTesting(type?: string): void;
dispose(): void;
}
export interface EventPublisher {
publish<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
publish<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
}
```
@ -173,15 +170,15 @@ export interface EventPublisher {
```ts
export interface BusEnvelope<TType extends string = string, TPayload = unknown> {
readonly id: string;
readonly type: TType;
readonly payload: TPayload;
readonly at: number; // epoch ms
readonly source: string;
readonly correlationId?: string;
readonly causationId?: string;
readonly context?: Readonly<Record<string, unknown>>;
readonly tags?: readonly string[];
readonly id: string;
readonly type: TType;
readonly payload: TPayload;
readonly at: number; // epoch ms
readonly source: string;
readonly correlationId?: string;
readonly causationId?: string;
readonly context?: Readonly<Record<string, unknown>>;
readonly tags?: readonly string[];
}
```
@ -197,18 +194,18 @@ carry those fields on every publish.
```ts
export interface EngineBusOptions {
readonly logger?: Logger;
readonly clock?: BusClock;
readonly idFactory?: () => string;
readonly maxListenersPerEvent?: number; // default 32
readonly maxReentrancyDepth?: number; // default 32
readonly listenerErrorMode?: BusListenerErrorMode;
/**
* Called for every listener invocation. The Svelte adapter wraps with
* `untrack`. Default: identity.
*/
readonly invokeListener?: (fn: () => void | Promise<void>) => void | Promise<void>;
readonly logger?: Logger;
readonly clock?: BusClock;
readonly idFactory?: () => string;
readonly maxListenersPerEvent?: number; // default 32
readonly maxReentrancyDepth?: number; // default 32
readonly listenerErrorMode?: BusListenerErrorMode;
/**
* Called for every listener invocation. The Svelte adapter wraps with
* `untrack`. Default: identity.
*/
readonly invokeListener?: (fn: () => void | Promise<void>) => void | Promise<void>;
}
```
@ -216,18 +213,34 @@ The engine never imports from `svelte`. The `invokeListener` hook is the
seam the Svelte adapter uses to wrap calls in `untrack` (see
[Svelte/SvelteKit Runtime Contract](#sveltesveltekit-runtime-contract)).
### Listener error modes
`listenerErrorMode` — the per-engine default (`EngineBusOptions`) or overridden
per `publish` (`BusPublishOptions`) — decides what happens when a listener throws.
The three constants live in `$libs/bus`:
| Mode | Behaviour |
| -------------------------------- | -------------------------------------------------------------------------------------- |
| `log-and-continue` (**default**) | emit a `bus.listener.failed` diagnostic per failure, run the remaining listeners |
| `throw` | run every listener, then throw an aggregated `BusAggregateListenerError` if any failed |
| `collect` | silent — no diagnostic, no throw; the caller reads the failures off the result |
Every mode runs **all** listeners and records the `BusListenerFailure[]` on
`BusPublishResult.errors`; they differ only in the side effect (diagnostic / throw
/ neither).
### Listener failure shape
`BusListenerFailure` carries enough context for distributed debugging:
```ts
export interface BusListenerFailure {
readonly listenerId?: string;
readonly type: string;
readonly envelopeId: string;
readonly correlationId?: string;
readonly causationId?: string;
readonly error: unknown;
readonly listenerId?: string;
readonly type: string;
readonly envelopeId: string;
readonly correlationId?: string;
readonly causationId?: string;
readonly error: unknown;
}
```
@ -237,8 +250,8 @@ export interface BusListenerFailure {
```ts
export type BusAnyListener<TEvents extends BusEventMap> = (
event: BusEventUnion<TEvents>,
context: BusListenerContext
event: BusEventUnion<TEvents>,
context: BusListenerContext
) => void | Promise<void>;
```
@ -283,12 +296,12 @@ export const SESSION_EVENT_REFRESHED = 'session.refreshed';
// arts/session/types.ts
export interface SessLifecyclePayload {
readonly event: SessionEvent;
readonly generation: number;
readonly identity: {
readonly from: SessionIdentityState;
readonly to: SessionIdentityState;
};
readonly event: SessionEvent;
readonly generation: number;
readonly identity: {
readonly from: SessionIdentityState;
readonly to: SessionIdentityState;
};
}
// arts/session/bus-helpers.ts
@ -299,36 +312,36 @@ import type { BusPublishOptions, EventPublisher } from '$libs/bus';
// identity actually transitions; `session.revoked` / `session.expired` /
// `session.refreshed` only for the matching lifecycle event.
export function publishSessLifecycleEvent(
bus: SessEventPublisher,
payload: SessLifecyclePayload,
options: BusPublishOptions = {}
bus: SessEventPublisher,
payload: SessLifecyclePayload,
options: BusPublishOptions = {}
): void {
if (payload.event === SESSION_EVENT_LIFECYCLE_INITIAL) return;
const opts = { source: SESSION_MODULE, ...options };
bus.publish(SESSION_EVENT_CHANGED, payload, opts);
if (payload.identity.from !== payload.identity.to) {
bus.publish(SESSION_EVENT_IDENTITY_CHANGED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_REVOKED) {
bus.publish(SESSION_EVENT_REVOKED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_EXPIRED) {
bus.publish(SESSION_EVENT_EXPIRED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_REFRESHED) {
bus.publish(SESSION_EVENT_REFRESHED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_INITIAL) return;
const opts = { source: SESSION_MODULE, ...options };
bus.publish(SESSION_EVENT_CHANGED, payload, opts);
if (payload.identity.from !== payload.identity.to) {
bus.publish(SESSION_EVENT_IDENTITY_CHANGED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_REVOKED) {
bus.publish(SESSION_EVENT_REVOKED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_EXPIRED) {
bus.publish(SESSION_EVENT_EXPIRED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_REFRESHED) {
bus.publish(SESSION_EVENT_REFRESHED, payload, opts);
}
}
// Subscriber: one event, one listener. `onSessChanged` covers every
// lifecycle transition; subscribe to `SESSION_EVENT_IDENTITY_CHANGED` /
// `SESSION_EVENT_REVOKED` / etc directly when you only care about a slice.
export function onSessChanged(
bus: EngineBus<SessEventMap>,
listener: (event: SessChangedEnvelope) => void | Promise<void>
bus: EngineBus<SessEventMap>,
listener: (event: SessChangedEnvelope) => void | Promise<void>
): BusSubscription {
return bus.on(SESSION_EVENT_CHANGED, (event) => listener(event as SessChangedEnvelope));
return bus.on(SESSION_EVENT_CHANGED, (event) => listener(event as SessChangedEnvelope));
}
```
@ -359,8 +372,8 @@ never a module singleton** (see Rule 1 below).
import { createSvelteEngineBus } from '$bus/svelte';
const Bus = createSvelteEngineBus({
logger: Logger,
clock: Timers.clock
logger: Logger,
clock: Timers.clock
});
const App = { Logger, Lang, Format, Dom, Storage, Http, Timers, Bus, Cache };
@ -414,11 +427,14 @@ import type { EngineBus } from '../types';
const BUS_CONTEXT = Symbol('arts.bus.context');
export function setBus(bus) { setContext(BUS_CONTEXT, bus); return bus; }
export function setBus(bus) {
setContext(BUS_CONTEXT, bus);
return bus;
}
export function getBus() {
const bus = getContext(BUS_CONTEXT);
if (!bus) throw new BusNoContextError();
return bus;
const bus = getContext(BUS_CONTEXT);
if (!bus) throw new BusNoContextError();
return bus;
}
```
@ -427,8 +443,8 @@ Root component sets it; descendants read it:
```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setBus } from '$bus';
setBus(App.bus);
import { setBus } from '$bus';
setBus(App.bus);
</script>
```
@ -443,14 +459,14 @@ function directly) inside `$effect`:
```svelte
<script lang="ts">
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
const Bus = getBus();
$effect(() =>
Bus.subscribe(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
// react
})
);
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
const Bus = getBus();
$effect(() =>
Bus.subscribe(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
// react
})
);
</script>
```
@ -476,10 +492,10 @@ import { untrack } from 'svelte';
import { createEngineBus, type EngineBusOptions } from '$bus';
export function createSvelteEngineBus(options: EngineBusOptions = {}) {
return createEngineBus({
...options,
invokeListener: (fn) => untrack(fn)
});
return createEngineBus({
...options,
invokeListener: (fn) => untrack(fn)
});
}
```
@ -523,18 +539,16 @@ Each `APP_EVENT_*` declares where it is allowed to fire:
```ts
// arts/active-app/events.ts
export const APP_EVENT_RUNTIMES: Readonly<Record<string, AppEventRuntime>> = {
[APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
[APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
};
export type AppEventRuntime =
| typeof APP_EVENT_RUNTIME_BOTH
| typeof APP_EVENT_RUNTIME_CLIENT;
export type AppEventRuntime = typeof APP_EVENT_RUNTIME_BOTH | typeof APP_EVENT_RUNTIME_CLIENT;
export function assertEventCanFire(type: string, where: 'server' | 'client'): void {
const runtime = APP_EVENT_RUNTIMES[type];
if (runtime && runtime !== 'both' && runtime !== where) {
throw new AappInvalidEventRuntimeError(type, runtime, where);
}
const runtime = APP_EVENT_RUNTIMES[type];
if (runtime && runtime !== 'both' && runtime !== where) {
throw new AappInvalidEventRuntimeError(type, runtime, where);
}
}
```
@ -549,16 +563,16 @@ strings; metadata lives in a parallel table.
Eight tests are required for the v0.1 gate:
| Test | What it verifies |
| -------------------------- | --------------------------------------------------------------------------------- |
| **SSR isolation** | A listener registered against request A's bus never sees request B's events. |
| **No singleton** | Importing the `bus` module twice yields no shared mutable state. |
| **Context isolation** | Two rendered app roots have different bus instances via `getBus()`. |
| **Svelte cleanup** | `$effect`-registered listener is unsubscribed when the component unmounts. |
| **Untrack** | Publishing during a `$effect` run does not create accidental reactive deps. |
| **Payload safety** | DEV-mode `structuredClone` check rejects non-cloneable payloads. |
| **Runtime guard** | Publishing a `client`-only event on the server throws in DEV. |
| **Flush test** | `flushSync(() => Bus.publish(...))` makes DOM updates observable synchronously. |
| Test | What it verifies |
| --------------------- | ------------------------------------------------------------------------------- |
| **SSR isolation** | A listener registered against request A's bus never sees request B's events. |
| **No singleton** | Importing the `bus` module twice yields no shared mutable state. |
| **Context isolation** | Two rendered app roots have different bus instances via `getBus()`. |
| **Svelte cleanup** | `$effect`-registered listener is unsubscribed when the component unmounts. |
| **Untrack** | Publishing during a `$effect` run does not create accidental reactive deps. |
| **Payload safety** | DEV-mode `structuredClone` check rejects non-cloneable payloads. |
| **Runtime guard** | Publishing a `client`-only event on the server throws in DEV. |
| **Flush test** | `flushSync(() => Bus.publish(...))` makes DOM updates observable synchronously. |
## Cross-module reactions live in orca, not on the bus
@ -601,10 +615,7 @@ applyStandardOrca(App);
Cherry-pick when the standard set is too aggressive:
```ts
import {
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange
} from '$active-app';
import { applyCacheClearOnIdentityChange, applyPermInvalidateOnIdentityChange } from '$active-app';
applyCacheClearOnIdentityChange(App);
applyPermInvalidateOnIdentityChange(App);
@ -627,20 +638,27 @@ type. v0.1 ships a minimal active wrapper:
```ts
// arts/bus/active-bus.svelte.ts
export function createBusRecent<TPayload>(bus: EngineBus, type: string) {
let lastEvent = $state<BusEnvelope<string, TPayload> | undefined>();
let count = $state(0);
const unsubscribe = bus.subscribe(type, (event) => {
lastEvent = event as BusEnvelope<string, TPayload>;
count += 1;
});
return {
get lastEvent() { return lastEvent; },
get count() { return count; },
clear() { lastEvent = undefined; count = 0; },
dispose: unsubscribe
};
let lastEvent = $state<BusEnvelope<string, TPayload> | undefined>();
let count = $state(0);
const unsubscribe = bus.subscribe(type, (event) => {
lastEvent = event as BusEnvelope<string, TPayload>;
count += 1;
});
return {
get lastEvent() {
return lastEvent;
},
get count() {
return count;
},
clear() {
lastEvent = undefined;
count = 0;
},
dispose: unsubscribe
};
}
```
@ -651,8 +669,8 @@ Surface stays minimal in v0.1: `lastEvent`, `count`, `clear()`,
Only one event survived the big-bang refactor:
| Constant | Meaning |
| ---------------------------- | ------------------------------------------------------ |
| Constant | Meaning |
| ---------------------------- | ------------------------------------------------------- |
| `APP_EVENT_DISPOSE_STARTING` | `App.dispose()` entered, last chance to flush listeners |
Everything else (identity changes, tenant switches, connectivity, cache
@ -706,7 +724,7 @@ import { flushSync } from 'svelte';
import { publishAppDisposeStarting } from '$active-app';
flushSync(() => {
publishAppDisposeStarting(Bus, { cause: 'test' });
publishAppDisposeStarting(Bus, { cause: 'test' });
});
expect(screen.getByText('disposing')).toBeVisible();
```
@ -718,8 +736,8 @@ For strict tests, throw on listener errors:
```ts
const Bus = createEngineBus({
listenerErrorMode: 'throw',
logger: testLogger
listenerErrorMode: 'throw',
logger: testLogger
});
```

@ -46,7 +46,7 @@ Si no hay writer ni `navigator.clipboard.writeText`, `writeText(...)` lanza
```ts
import { createActiveApp } from '$active-app';
import { defineActiveClipboard } from '$active-app/services';
import { defineActiveClipboard } from '$active-app/service-factories';
const App = createActiveApp({
services: {

@ -87,7 +87,7 @@ map; **`ActiveEidos.applyColorScheme(seed, opts)`** writes it as a managed style
introspection. Same math at build or runtime; only VALUES behind the frozen
`--color-{role}-{slot}` contract, so it touches **no component**. `opts`: `variant`
(tonal/vibrant/monochrome) + `temper` (intent cohesion) + per-role `overrides`. Full
spec: `../../uix/eidos/COLOR_ENGINE_RFC.md` §6.2 + `THEMING.md` §26.
spec: `../../uix/eidos/COLOR_ENGINE_RFC.md` §6.2 + `../../uix/eidos/THEMING.md` §26.
## Notes

@ -5,7 +5,7 @@
* APCA contrast, compositing-inverse alpha, and the seed → 12-step scale
* generator (template morph). No DOM, no reactive state — the SAME functions run
* at BUILD (eidos `render-css` emits static CSS) and at RUNTIME
* (`ActiveEidos.setCssVariables` for live / white-label theming). Because every
* (`ActiveEidos.applyColorScheme` for live / white-label theming). Because every
* decision (the APCA on-solid pick, the compositing-inverse alpha) is computed
* here in JS BEFORE a value is written, introspection and alpha fidelity are
* preserved in EVERY mode. See `src/uix/eidos/COLOR_ENGINE_RFC.md` §6.1.

@ -7,12 +7,12 @@ y diagnósticos estructurados.
La regla de nombres es importante:
| Concepto | Nombre |
| -------- | ------ |
| Raíz imperativa | `createEngineConnections()` / `EngineConnections` |
| Raíz reactiva | `createActiveConnections()` / `ActiveConnections` |
| Unidad individual | `Connection` |
| Topic lógico dentro de una conexión | `ConnectionChannel` |
| Concepto | Nombre |
| ----------------------------------- | ------------------------------------------------- |
| Raíz imperativa | `createEngineConnections()` / `EngineConnections` |
| Raíz reactiva | `createActiveConnections()` / `ActiveConnections` |
| Unidad individual | `Connection` |
| Topic lógico dentro de una conexión | `ConnectionChannel` |
No existe `EngineConnection` ni `ActiveConnection`. `Engine*` y `Active*`
quedan reservados para raíces de artefacto; una conexión individual no es una
@ -22,8 +22,11 @@ raíz, es una entidad gestionada por `EngineConnections`.
```ts
import { createEngineConnections, createWebSocketTransport } from '$connection';
import { createEngineTimers } from '$timer';
const Connections = createEngineConnections();
// `timers` es OBLIGATORIO: connection no construye su propio scheduler.
// En composición se pasa `App.timers`; standalone, `createEngineTimers()`.
const Connections = createEngineConnections({ timers: createEngineTimers() });
const Main = Connections.createConnection('main', {
transport: createWebSocketTransport({ url: () => '/realtime' }),
@ -53,14 +56,25 @@ tipados `Conn*`.
La capa activa añade estado derivado para UI:
```ts
const Connections = createActiveConnections();
// `timers` también es obligatorio aquí (se reenvía al engine interno).
const Connections = createActiveConnections({ timers: createEngineTimers() });
Connections.size;
Connections.states; // Record<name, ConnectionState>
// Nombres agrupados por estado (reactivos):
Connections.activeNames;
Connections.states;
Connections.connectedNames;
Connections.connectingNames;
Connections.reconnectingNames;
Connections.failedNames;
Connections.closedNames;
// Booleanos derivados:
Connections.allConnected;
Connections.anyConnected;
Connections.anyConnecting;
Connections.anyReconnecting;
Connections.anyFailed;
```
@ -147,9 +161,9 @@ coexisten. La regla práctica: usa el preset cuando uses `arts/orca` y
`arts/session`; usa `session` per-connection para casos standalone o
cuando la conexión vive fuera del ciclo App.
Cuando `Timers` no se inyecta, `createEngineConnections()` crea un scheduler
privado con el mismo logger. Los diagnósticos del scheduler salen bajo la
categoría `timer`; los de conexiones salen bajo `connection` o
`connection` **siempre** usa el `timers` inyectado — nunca construye su propio
scheduler (es un parámetro obligatorio). Los diagnósticos del scheduler salen bajo
la categoría `timer`; los de conexiones salen bajo `connection` o
`connection:<name>`.
## Estados
@ -449,16 +463,9 @@ para que el consumidor pueda decidir sin `try/catch` obligatorio.
## Página De Prueba
La documentacion actual vive en `/active`.
Incluye chat WebSocket real usando `scripts/conn-chat-server.mjs`:
```txt
npm run dev:conn-chat
```
La demo de ecosistema usa `connection` con transporte mock loopback para
probar integracion con `active-app`, `timer`, `logger`, `perm` y `cache`.
La demo interactiva vive en `/active/docs/conn` (chat WebSocket + ciclo de vida de
conexión/canal). La demo de ecosistema usa `connection` con transporte mock loopback
para probar la integracion con `active-app`, `timer`, `logger`, `perm` y `cache`.
## Testing

@ -152,13 +152,13 @@ override solo pivota el locale para esa entrada de
```ts
const nums = createEngineNumbers({ locale: 'es-ES' });
nums.format(1234.5); // '1234,5' (es-ES)
nums.format(1234.5, undefined, 'en-US'); // '1,234.5'
nums.getLocale(); // 'es-ES' (no muta)
nums.format(1234.5); // '1234,5' (es-ES)
nums.format(1234.5, undefined, 'en-US'); // '1,234.5'
nums.getLocale(); // 'es-ES' (no muta)
nums.formatCurrency(12.5, 'USD', undefined, 'en-US'); // '$12.50'
nums.formatPercent(0.5, undefined, 'en-US'); // '50%'
nums.formatCompact(1_500_000, undefined, 'en-US'); // '1.5M'
nums.formatPercent(0.5, undefined, 'en-US'); // '50%'
nums.formatCompact(1_500_000, undefined, 'en-US'); // '1.5M'
nums.formatUnit(20, 'kilometer', undefined, 'en-US'); // '20 km'
```
@ -183,7 +183,7 @@ formats.currency.getCurrency(); // defaultCurrency, porque no hay region
Para conversiones se inyecta un provider de rates:
```ts
import { createRates } from '$formats/currency';
import { createRates } from '$format/currency';
const rates = createRates({
initial: {
@ -200,13 +200,21 @@ const formats = createEngineFormat({
await formats.currency.convert(10, 'USD');
```
`format` / `convert` usan la moneda **activa**; sus variantes `As` toman la moneda
**explícita** (sin tocar la activa):
```ts
formats.currency.formatAs(10, 'JPY'); // formatea 10 como JPY
await formats.currency.convertAs(10, 'USD', 'GBP'); // USD → GBP explícito
```
Si falta un provider o un rate, `currency` puede emitir diagnosticos por el
logger inyectado. Las categorias y mensajes viven en constantes del submodulo.
## Units
`units` resuelve sistema por region y marca unidades por defecto en
`unts/unit-definitions.ts`.
`units/unit-definitions.ts`.
```ts
formats.units.getSystem(); // metric | imperial
@ -214,6 +222,7 @@ formats.units.getDefaultUnit('distance'); // kilometer | mile
formats.units.isDefaultUnit('mile', 'distance', 'imperial');
formats.units.formatDefault(20, 'distance');
formats.units.convert(1, 'mile', 'kilometer');
formats.units.convertToDefault(1, 'mile'); // → valor en la unidad por defecto del kind
```
El sistema tambien respeta auto/manual:
@ -238,9 +247,9 @@ formats.dates.getHourCycle();
formats.dates.formatDate(new Date());
formats.dates.formatTime(new Date());
formats.dates.formatDateTime(new Date());
formats.dates.formatRelative(-2, 'hour'); // "hace 2 horas"
formats.dates.formatRelative(3, 'day'); // "dentro de 3 días"
formats.dates.formatRelative(0, 'day'); // "hoy" (numeric:'auto')
formats.dates.formatRelative(-2, 'hour'); // "hace 2 horas"
formats.dates.formatRelative(3, 'day'); // "dentro de 3 días"
formats.dates.formatRelative(0, 'day'); // "hoy" (numeric:'auto')
```
`getHourCycle()` deriva del locale a traves de las utilidades de `days`; si el
@ -264,9 +273,9 @@ solo pivota el locale para esa entrada de `getCachedDateFormat`:
```ts
const dates = createEngineDates({ locale: 'es-ES' });
dates.formatDate(new Date()); // 25 may 2026 (es-ES)
dates.formatDate(new Date(), undefined, 'en-US'); // May 25, 2026
dates.getLocale(); // 'es-ES' (no muta)
dates.formatDate(new Date()); // 25 may 2026 (es-ES)
dates.formatDate(new Date(), undefined, 'en-US'); // May 25, 2026
dates.getLocale(); // 'es-ES' (no muta)
```
Reglas:
@ -315,12 +324,12 @@ no ha pasado `hourCycle` ni `hour12`**. Cualquier valor del caller gana:
```ts
const dates = createEngineDates({ locale: 'es-ES' }); // pref auto = 24h
dates.formatTime(sample); // '14:30' (24h por locale)
dates.formatTime(sample, { hourCycle: 'h12' }); // '2:30 p. m.' (caller gana)
dates.formatTime(sample); // '14:30' (24h por locale)
dates.formatTime(sample, { hourCycle: 'h12' }); // '2:30 p. m.' (caller gana)
dates.setHourCycle(12); // fija preferencia 12h
dates.formatTime(sample); // '2:30 p. m.'
dates.formatTime(sample, { hour12: false }); // '14:30' (caller gana)
dates.setHourCycle(12); // fija preferencia 12h
dates.formatTime(sample); // '2:30 p. m.'
dates.formatTime(sample, { hour12: false }); // '14:30' (caller gana)
```
Sin esta regla, un `<FormatDate hourCycle="h12" />` se convertía silenciosamente

@ -6,7 +6,7 @@ mediante factores cacheados.
La API publica sigue el mismo patron que el resto de artefactos:
```ts
import { createEngineCurrency, createRates } from '$formats/currency';
import { createEngineCurrency, createRates } from '$format/currency';
const rates = createRates({
initial: {
@ -46,7 +46,7 @@ src/arts/formats/currency/
locale:
```ts
import { createActiveCurrency } from '$formats/currency/active-currency.svelte';
import { createActiveCurrency } from '$format/currency/active-currency.svelte';
const active = createActiveCurrency({
localeSource: activeLang,

@ -14,13 +14,13 @@ const http = createEngineHttp({ baseUrl: 'https://api.example.com' });
const r = await http.get('/users/me', { schema: UserSchema });
if (r.ok) {
console.log(r.value.email);
console.log(r.value.email);
} else if (r.kind === 'http') {
console.error(`HTTP ${r.status}: ${r.statusText}`);
console.error(`HTTP ${r.status}: ${r.statusText}`);
} else if (r.kind === 'validation') {
console.error('Server returned an unexpected shape:', r.issues);
console.error('Server returned an unexpected shape:', r.issues);
} else {
console.error('Network failure:', r.error);
console.error('Network failure:', r.error);
}
```
@ -57,7 +57,7 @@ combination this artifact targets:
http/
├── index.ts Barrel exports
├── types.ts EngineHttp, HttpResult, HttpInit, HttpHooks, RetryConfig
├── consts.ts DEFAULT_RETRY, DEFAULT_TIMEOUT, NULL_BODY_STATUSES, ...
├── consts.ts HTTP_DEFAULT_RETRY, HTTP_DEFAULT_TIMEOUT, NULL_BODY_STATUSES, ...
├── engine-http.ts Factory createEngineHttp()
├── errors.ts HttpNetworkError / HttpTimeoutError / HttpAbortError /
│ HttpBodyValidationError + type guards
@ -81,7 +81,9 @@ http/
Configured in `svelte.config.js`:
```js
alias: { $http: 'src/arts/http' }
alias: {
$http: 'src/arts/http';
}
```
---
@ -92,22 +94,29 @@ Every method returns `Promise<HttpResult<T>>`:
```ts
type HttpResult<T> =
| { ok: true; value: T; response: Response }
| { ok: false; kind: 'http'; status; statusText; body; response: Response }
| { ok: false; kind: 'validation'; issues; response: Response }
| { ok: false; kind: 'network'; error };
| { ok: true; value: T; response: Response }
| { ok: false; kind: 'http'; status; statusText; body; response: Response }
| { ok: false; kind: 'validation'; issues; response: Response }
| { ok: false; kind: 'network'; error };
```
The `ok` discriminator matches Sium's. Pattern-match identically:
```ts
if (r.ok) {
use(r.value);
} else switch (r.kind) {
case 'http': toast(`Server returned ${r.status}`); break;
case 'validation': console.error(r.issues); break;
case 'network': toast('Offline?'); break;
}
use(r.value);
} else
switch (r.kind) {
case 'http':
toast(`Server returned ${r.status}`);
break;
case 'validation':
console.error(r.issues);
break;
case 'network':
toast('Offline?');
break;
}
```
The `response: Response` is preserved on the three branches that have one —
@ -138,7 +147,7 @@ Works with any Standard Schema vendor:
```ts
import { z } from 'zod';
const r = await http.get('/api/posts', {
schema: z.array(z.object({ id: z.string() }))
schema: z.array(z.object({ id: z.string() }))
});
```
@ -146,16 +155,16 @@ For `POST` / `PUT` / `PATCH`, validate the request body too:
```ts
await http.post('/api/users', {
body: payload,
bodySchema: CreateUserSchema, // pre-flight check; throws if invalid
schema: UserSchema // post-flight check on response
body: payload,
bodySchema: CreateUserSchema, // pre-flight check; throws if invalid
schema: UserSchema // post-flight check on response
});
```
When `bodySchema` rejects, the engine throws `HttpBodyValidationError`
synchronously. This is a programmer error (you sent the wrong shape), not
a runtime data condition — different from `kind: 'validation'` which is
about the *server's* response.
about the _server's_ response.
---
@ -165,26 +174,26 @@ about the *server's* response.
```ts
const http = createEngineHttp({
baseUrl: 'https://api.example.com',
headers: { Accept: 'application/json' },
fetch: globalThis.fetch, // override per-engine
timeout: 10_000, // per-attempt (ms)
totalTimeout: 30_000, // total budget covering all attempts (0 = off)
retry: {
limit: 2,
statusCodes: [408, 425, 429, 500, 502, 503, 504],
methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'], // idempotent
delay: (n) => 1000 * 2 ** (n - 1), // exponential
backoffLimit: 30_000,
jitter: false
},
hooks: {
beforeRequest: [],
beforeRetry: [],
afterResponse: [],
beforeError: []
},
logger: App.logger // wired automatically when used via App.http
baseUrl: 'https://api.example.com',
headers: { Accept: 'application/json' },
fetch: globalThis.fetch, // override per-engine
timeout: 10_000, // per-attempt (ms)
totalTimeout: 30_000, // total budget covering all attempts (0 = off)
retry: {
limit: 2,
statusCodes: [408, 425, 429, 500, 502, 503, 504],
methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'], // idempotent
delay: (n) => 1000 * 2 ** (n - 1), // exponential
backoffLimit: 30_000,
jitter: false
},
hooks: {
beforeRequest: [],
beforeRetry: [],
afterResponse: [],
beforeError: []
},
logger: App.logger // wired automatically when used via App.http
});
```
@ -196,7 +205,7 @@ mutates** the parent engine.
```ts
const scoped = http.with({ fetch: event.fetch }); // SvelteKit pattern
const apiV2 = http.with({ baseUrl: 'https://api.example.com/v2' });
const apiV2 = http.with({ baseUrl: 'https://api.example.com/v2' });
```
### Method shortcuts
@ -217,16 +226,16 @@ http.patch <S, B>(url, init?) → Promise<HttpResult<Out<S>>>
```ts
http.get('/users', {
schema: UserSchema, // validate response body
bodySchema: NewUserSchema, // (write methods only) validate body
body: { name: 'Ada' }, // any HttpBodyInit
headers: { 'X-Trace-Id': '...' }, // merged on top of engine defaults
search: { page: 2, q: 'ada' }, // appended to URL
fetch: event.fetch, // per-call fetch override
timeout: 5_000, // per-attempt (ms)
signal: abortCtrl.signal, // user signal — aborts cancel retry
retry: { limit: 0 }, // override retry policy (or `false`)
hooks: { beforeRequest: [trace] } // per-call hooks (engine first)
schema: UserSchema, // validate response body
bodySchema: NewUserSchema, // (write methods only) validate body
body: { name: 'Ada' }, // any HttpBodyInit
headers: { 'X-Trace-Id': '...' }, // merged on top of engine defaults
search: { page: 2, q: 'ada' }, // appended to URL
fetch: event.fetch, // per-call fetch override
timeout: 5_000, // per-attempt (ms)
signal: abortCtrl.signal, // user signal — aborts cancel retry
retry: { limit: 0 }, // override retry policy (or `false`)
hooks: { beforeRequest: [trace] } // per-call hooks (engine first)
});
```
@ -234,7 +243,7 @@ http.get('/users', {
## Retry policy
Defaults — *idempotent-by-default*: `GET`, `HEAD`, `PUT`, `DELETE`,
Defaults — _idempotent-by-default_: `GET`, `HEAD`, `PUT`, `DELETE`,
`OPTIONS` retry; `POST` and `PATCH` do not (unless `methods` is overridden).
Status codes: `408 425 429 500 502 503 504`.
@ -255,11 +264,11 @@ overrides the policy's `delay(attempt)`.
### Signal hierarchy (no surprises)
| Signal source | Reaction |
| ------------------------- | ---------------------------------------------- |
| User-provided `signal` | Aborts the current attempt; **no retry** |
| `totalTimeout` exceeded | Aborts the current attempt; **no retry** |
| Per-attempt `timeout` | Aborts the attempt; **retries** if budget left |
| Signal source | Reaction |
| ----------------------- | ---------------------------------------------- |
| User-provided `signal` | Aborts the current attempt; **no retry** |
| `totalTimeout` exceeded | Aborts the current attempt; **no retry** |
| Per-attempt `timeout` | Aborts the attempt; **retries** if budget left |
Internal classification uses `signal.reason` (an `HttpTimeoutError` with
`scope: 'attempt' | 'total'`, or an `HttpAbortError` for user signals) so
@ -286,30 +295,30 @@ been received cleanly.
```ts
const http = createEngineHttp({
hooks: {
beforeRequest: [
(ctx) => {
ctx.request.headers.set('X-Trace-Id', crypto.randomUUID());
}
],
beforeRetry: [
({ attempt, retryDelay, error }) => {
console.warn(`retry ${attempt}, waiting ${retryDelay}ms`, error);
}
],
afterResponse: [
({ response }) => {
metrics.observe('http.duration', response.headers.get('x-time'));
}
],
beforeError: [
({ response }) => {
if (response?.status === 401) {
return refreshAndRetry(); // returns a new Response or undefined
}
}
]
}
hooks: {
beforeRequest: [
(ctx) => {
ctx.request.headers.set('X-Trace-Id', crypto.randomUUID());
}
],
beforeRetry: [
({ attempt, retryDelay, error }) => {
console.warn(`retry ${attempt}, waiting ${retryDelay}ms`, error);
}
],
afterResponse: [
({ response }) => {
metrics.observe('http.duration', response.headers.get('x-time'));
}
],
beforeError: [
({ response }) => {
if (response?.status === 401) {
return refreshAndRetry(); // returns a new Response or undefined
}
}
]
}
});
```
@ -323,16 +332,16 @@ headers reflect the new value:
let token = await getToken();
const http = createEngineHttp({
headers: () => ({ Authorization: `Bearer ${token}` }),
hooks: {
beforeRetry: [
async ({ error, response }) => {
if (response?.status === 401) {
token = await refreshToken();
}
}
]
}
headers: () => ({ Authorization: `Bearer ${token}` }),
hooks: {
beforeRetry: [
async ({ error, response }) => {
if (response?.status === 401) {
token = await refreshToken();
}
}
]
}
});
```
@ -348,10 +357,10 @@ to the request via `event.fetch`:
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const api = App.http.with({ fetch }); // inherits cookies + relative URLs
const user = await api.get('/api/users/me', { schema: UserSchema });
if (!user.ok) throw error(401, 'Not authenticated');
return { user: user.value };
const api = App.http.with({ fetch }); // inherits cookies + relative URLs
const user = await api.get('/api/users/me', { schema: UserSchema });
if (!user.ok) throw error(401, 'Not authenticated');
return { user: user.value };
};
```
@ -359,8 +368,8 @@ Or per-call:
```ts
const r = await App.http.get('/api/users/me', {
fetch: event.fetch,
schema: UserSchema
fetch: event.fetch,
schema: UserSchema
});
```
@ -373,12 +382,12 @@ SvelteKit's automatic cookie forwarding and relative-URL resolution.
Five subclasses, all with stable literal `name` and exported type guards:
| Class | When | Surfaces as |
| --------------------------- | ------------------------------------------ | -------------------------- |
| `HttpNetworkError` | Wrapper for `fetch` rejection | `kind: 'network'` |
| `HttpTimeoutError` | Per-attempt or total timeout fired | `kind: 'network'` (final) |
| `HttpAbortError` | User signal aborted | `kind: 'network'` (final) |
| `HttpBodyValidationError` | `bodySchema` rejected the payload | **Thrown** (programmer err)|
| Class | When | Surfaces as |
| ------------------------- | ---------------------------------- | --------------------------- |
| `HttpNetworkError` | Wrapper for `fetch` rejection | `kind: 'network'` |
| `HttpTimeoutError` | Per-attempt or total timeout fired | `kind: 'network'` (final) |
| `HttpAbortError` | User signal aborted | `kind: 'network'` (final) |
| `HttpBodyValidationError` | `bodySchema` rejected the payload | **Thrown** (programmer err) |
Type guards: `isHttpNetworkError`, `isHttpTimeoutError`, `isHttpAbortError`,
`isHttpBodyValidationError`. Each carries stable `name` so they survive
@ -393,10 +402,10 @@ shared `Logger` injected automatically:
```ts
const App = createActiveApp({
http: {
baseUrl: 'https://api.example.com',
timeout: 10_000
}
http: {
baseUrl: 'https://api.example.com',
timeout: 10_000
}
});
await App.http.get('/users/me', { schema: UserSchema });
@ -413,8 +422,8 @@ Validation messages with the App's locale:
```ts
const r = await App.http.get('/api/users/me', { schema: UserSchema });
if (!r.ok && r.kind === 'validation') {
const sium = App.sium;
console.error(sium.resolveIssues(r.issues));
const sium = App.sium;
console.error(sium.resolveIssues(r.issues));
}
```
@ -428,13 +437,15 @@ The engine takes any `fetch`-compatible function. Stub it:
import { createEngineHttp } from '$http';
const http = createEngineHttp({
retry: { limit: 0 },
timeout: 0,
fetch: ((_url, _init) =>
Promise.resolve(new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' }
}))) as typeof fetch
retry: { limit: 0 },
timeout: 0,
fetch: ((_url, _init) =>
Promise.resolve(
new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' }
})
)) as typeof fetch
});
```
@ -445,10 +456,10 @@ Or queue several responses for retry scenarios — see
## Bundle profile
| Layer | Approx. size (min) |
| ------------------------------- | ------------------ |
| Engine + Retryer + helpers | ~5 KB |
| Errors + type guards | ~1 KB |
| Total (everything reached) | ~6 KB |
| Layer | Approx. size (min) |
| -------------------------- | ------------------ |
| Engine + Retryer + helpers | ~5 KB |
| Errors + type guards | ~1 KB |
| Total (everything reached) | ~6 KB |
Zero runtime dependencies. The `StandardSchemaV1` import is type-only.

@ -18,11 +18,11 @@ visual presets **data**. Neither imports the other — they meet at `uix.motion`
Motion occurs in two moments, each animable (one, the other, or both):
| | `--event` | `--state` |
|---|---|---|
| Attr | `data-event-*` (sema) | `data-state` (soma) |
| What | the perceptual **firma** during a signal's hold | the transition to/from a persistent condition |
| Registry | `signatures` (eidos generates CSS) | `presets` (named, per-component) |
| | `--event` | `--state` |
| -------- | ----------------------------------------------- | --------------------------------------------- |
| Attr | `data-event-*` (sema) | `data-state` (soma) |
| What | the perceptual **firma** during a signal's hold | the transition to/from a persistent condition |
| Registry | `signatures` (eidos generates CSS) | `presets` (named, per-component) |
`EngineMotion` runs the **`--state`** presets (CSS → settled; JS → driver). The
`--event` firma is CSS that eidos generates from `signatures`; the engine does
@ -37,10 +37,16 @@ motion.register('scale-fade', { driver: 'css', … }) // eidos registers presets
motion.register('pop', { driver: 'spring', enter: spring({ … }) })
motion.run(node, 'enter') // resolve node's data-animation-style + run
motion.enter(el, 'pop') // run a preset by name
motion.enter(el, 'pop') // run a preset by name (enter phase)
motion.exit(el, 'pop') // run a preset by name (exit phase)
motion.cancel(el) // cancel active JS motion on el
await motion.pending(el) // combined finished, for Presence
motion.dispose() // idempotent: cancel all + clear registry
// registry introspection
motion.has('pop') // is a preset registered?
motion.resolve('pop') // the StatePreset, or undefined
motion.list() // registered preset names
```
- **CSS preset** → declarative: a settled handle (the generated CSS + soma's
@ -62,6 +68,10 @@ motion.dispose() // idempotent: cancel all + clear registry
- **`rect`** — FLIP: measures first/last rects, animates the inverse delta via
WAAPI (layout / shared-element transitions).
`JsDriver` also declares `'svelte'`, but it is **reserved / not yet
implemented** — no driver dispatches it. The three drivers above are the whole
set today.
## The DOM port
This art imports no other art. The DOM dependency arrives **injected** and is
@ -73,7 +83,7 @@ typed by the structural `MotionDom` port (`requestFrame` / `cancelFrame` /
```ts
// active-app (attach path)
const App = createActiveApp({
services: { dom: defineActiveDom(), motion: defineEngineMotion() }
services: { dom: defineActiveDom(), motion: defineEngineMotion() }
});
// active-uix (standalone) creates it directly and exposes uix.motion.

@ -9,9 +9,10 @@
* - **CSS** presets are DECLARATIVE: a wrapper sets `data-animation-style`; the
* generated CSS animates on `data-state` and soma's `Presence` awaits it via
* `getAnimations()`. The engine does NOT drive them → settled handle.
* - **JS** presets (`waapi` / `spring` / `rect` / `svelte`) RUN here: the engine
* - **JS** presets (`waapi` / `spring` / `rect`) RUN here: the engine
* builds a `MotionContext`, invokes the preset's `MotionRun`, normalises the
* result to one handle, and tracks it per element for `cancel` / `pending`.
* (`JsDriver` also declares `'svelte'`, reserved / not yet implemented.)
*/
import type {
@ -22,86 +23,86 @@ import type {
MotionSide,
MotionState,
StatePreset
} from './types'
import { isCssStatePreset } from './types'
} from './types';
import { isCssStatePreset } from './types';
/** A handle that is already finished — for declarative (CSS) presets. */
const SETTLED_HANDLE: MotionHandle = Object.freeze({
finished: Promise.resolve(),
cancel() {}
})
});
const DEFAULT_DURATION = (): number => 240
const DEFAULT_EASE = (): string => 'cubic-bezier(0.4, 0, 0.2, 1)'
const DEFAULT_DURATION = (): number => 240;
const DEFAULT_EASE = (): string => 'cubic-bezier(0.4, 0, 0.2, 1)';
export interface EngineMotionOptions {
/** Injected DOM port (frame scheduling + reduced-motion). Optional in headless/tests. */
readonly dom?: MotionDom
readonly dom?: MotionDom;
/** Initial presets. Eidos registers its built-ins + theme presets at boot. */
readonly presets?: Readonly<Record<string, StatePreset>>
readonly presets?: Readonly<Record<string, StatePreset>>;
}
/** Per-run overrides. `dom` falls back to the engine's injected dom. */
export interface MotionRunOptions {
readonly dom?: MotionDom
readonly reduced?: boolean
readonly side?: MotionSide
readonly sourceRect?: DOMRect
readonly targetRect?: DOMRect
readonly duration?: (key: string) => number
readonly ease?: (key: string) => string
readonly dom?: MotionDom;
readonly reduced?: boolean;
readonly side?: MotionSide;
readonly sourceRect?: DOMRect;
readonly targetRect?: DOMRect;
readonly duration?: (key: string) => number;
readonly ease?: (key: string) => string;
}
export interface EngineMotion {
/** Register (or replace) a preset by name. */
register(name: string, preset: StatePreset): void
resolve(name: string): StatePreset | undefined
has(name: string): boolean
list(): string[]
register(name: string, preset: StatePreset): void;
resolve(name: string): StatePreset | undefined;
has(name: string): boolean;
list(): string[];
/** Run a preset by name. CSS → settled; JS → driver. */
enter(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle
exit(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle
enter(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle;
exit(el: HTMLElement, name: string, opts?: MotionRunOptions): MotionHandle;
/**
* Resolve the preset named by `el`'s `data-animation-style` and run it for
* `phase`. CSS / unknown → settled; JS → driver. Soma's `Presence` calls this
* (the engine reads the attr, so soma never learns the motion contract).
*/
run(el: HTMLElement, phase: 'enter' | 'exit', opts?: MotionRunOptions): MotionHandle
run(el: HTMLElement, phase: 'enter' | 'exit', opts?: MotionRunOptions): MotionHandle;
/** Cancel active JS-driven motion on `el`. */
cancel(el: HTMLElement): void
cancel(el: HTMLElement): void;
/** Combined `finished` of active JS-driven motion on `el`, for Presence to await. */
pending(el: HTMLElement): Promise<void>
pending(el: HTMLElement): Promise<void>;
/** Idempotent: cancels every active handle and clears the registry. */
dispose(): void
dispose(): void;
}
export function createEngineMotion(options?: EngineMotionOptions): EngineMotion {
const boundDom = options?.dom
const presets = new Map<string, StatePreset>()
const boundDom = options?.dom;
const presets = new Map<string, StatePreset>();
if (options?.presets) {
for (const [name, preset] of Object.entries(options.presets)) presets.set(name, preset)
for (const [name, preset] of Object.entries(options.presets)) presets.set(name, preset);
}
// Active JS-driven handles, per element. A plain Map (not WeakMap) so dispose
// can iterate; emptied sets are deleted so it doesn't leak detached elements.
const active = new Map<HTMLElement, Set<MotionHandle>>()
const active = new Map<HTMLElement, Set<MotionHandle>>();
// Physics state captured when a tracked run is cancelled, keyed by element, for a
// velocity-preserving handoff on reversal (§8.3): `cancel(el)` snapshots it, the
// NEXT `run(el, …)` consumes it as `ctx.handoff`. Only set when a cancel finds an
// in-flight handle that exposes `peek()` (i.e. a real interruption).
const handoffState = new Map<HTMLElement, MotionState>()
let disposed = false
const handoffState = new Map<HTMLElement, MotionState>();
let disposed = false;
function track(el: HTMLElement, handle: MotionHandle): void {
let set = active.get(el)
if (!set) active.set(el, (set = new Set()))
set.add(handle)
let set = active.get(el);
if (!set) active.set(el, (set = new Set()));
set.add(handle);
void handle.finished.finally(() => {
const s = active.get(el)
if (!s) return
s.delete(handle)
if (s.size === 0) active.delete(el)
})
const s = active.get(el);
if (!s) return;
s.delete(handle);
if (s.size === 0) active.delete(el);
});
}
// Take the physics state for a reversal handoff (§8.3): merge any state a preceding
@ -110,14 +111,14 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
// does not pre-cancel). Cancelling that in-flight run here also stops a second
// animation from stacking on the node.
function takeHandoff(el: HTMLElement): MotionState | undefined {
const pending = handoffState.get(el)
handoffState.delete(el)
const set = active.get(el)
if (!set || set.size === 0) return pending
const captured = captureHandoff(set)
for (const handle of [...set]) handle.cancel()
active.delete(el)
return captured ? { ...pending, ...captured } : pending
const pending = handoffState.get(el);
handoffState.delete(el);
const set = active.get(el);
if (!set || set.size === 0) return pending;
const captured = captureHandoff(set);
for (const handle of [...set]) handle.cancel();
active.delete(el);
return captured ? { ...pending, ...captured } : pending;
}
function runJs(
@ -126,23 +127,23 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
phase: 'enter' | 'exit',
opts?: MotionRunOptions
): MotionHandle {
const dom = opts?.dom ?? boundDom
const reduced = opts?.reduced ?? dom?.prefersReducedMotion.matches ?? false
const dom = opts?.dom ?? boundDom;
const reduced = opts?.reduced ?? dom?.prefersReducedMotion.matches ?? false;
// Reduced motion: 'instant' snaps (no JS run). Other policies run the
// MotionRun (it can read `ctx.reduced` and self-degrade) or rely on a CSS
// `fallback` the wrapper applies declaratively.
if (reduced && (preset.reduce ?? 'instant') === 'instant') return SETTLED_HANDLE
if (reduced && (preset.reduce ?? 'instant') === 'instant') return SETTLED_HANDLE;
const motionRun = phase === 'enter' ? preset.enter : preset.exit
const motionRun = phase === 'enter' ? preset.enter : preset.exit;
// A driver that schedules frames needs a dom. Without a run or a dom, settle.
if (!motionRun || !dom) return SETTLED_HANDLE
if (!motionRun || !dom) return SETTLED_HANDLE;
const controller = new AbortController()
const controller = new AbortController();
// Velocity-preserving handoff (§8.3): take any physics state to continue from —
// from a preceding explicit cancel() (the grouped/reversa path) AND/OR from a run
// still in flight on this element (the island path, which does not pre-cancel).
// Cancelling the in-flight run here also prevents a second animation stacking.
const handoff = takeHandoff(el)
const handoff = takeHandoff(el);
const ctx: MotionContext = {
moment: 'state',
phase,
@ -156,10 +157,10 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
targetRect: opts?.targetRect,
handoff,
signal: controller.signal
}
const handle = toHandle(motionRun(ctx), controller)
track(el, handle)
return handle
};
const handle = toHandle(motionRun(ctx), controller);
track(el, handle);
return handle;
}
function byName(
@ -168,63 +169,63 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
phase: 'enter' | 'exit',
opts?: MotionRunOptions
): MotionHandle {
const preset = presets.get(name)
const preset = presets.get(name);
// Unknown name OR a CSS preset → declarative (generated CSS + Presence). JS → run.
if (!preset || isCssStatePreset(preset)) return SETTLED_HANDLE
return runJs(el, preset, phase, opts)
if (!preset || isCssStatePreset(preset)) return SETTLED_HANDLE;
return runJs(el, preset, phase, opts);
}
return {
register(name, preset) {
presets.set(name, preset)
presets.set(name, preset);
},
resolve(name) {
return presets.get(name)
return presets.get(name);
},
has(name) {
return presets.has(name)
return presets.has(name);
},
list() {
return [...presets.keys()]
return [...presets.keys()];
},
enter(el, name, opts) {
return byName(el, name, 'enter', opts)
return byName(el, name, 'enter', opts);
},
exit(el, name, opts) {
return byName(el, name, 'exit', opts)
return byName(el, name, 'exit', opts);
},
run(el, phase, opts) {
const name = el.getAttribute('data-animation-style')
if (!name) return SETTLED_HANDLE
return byName(el, name, phase, opts)
const name = el.getAttribute('data-animation-style');
if (!name) return SETTLED_HANDLE;
return byName(el, name, phase, opts);
},
cancel(el) {
const set = active.get(el)
if (!set) return
const set = active.get(el);
if (!set) return;
// Snapshot the in-flight physics BEFORE cancelling, so the next run on this
// element can continue from the current position/velocity (§8.3). Only
// physics drivers expose `peek()`; if none do, no handoff is recorded.
const captured = captureHandoff(set)
if (captured) handoffState.set(el, captured)
for (const handle of [...set]) handle.cancel()
active.delete(el)
const captured = captureHandoff(set);
if (captured) handoffState.set(el, captured);
for (const handle of [...set]) handle.cancel();
active.delete(el);
},
pending(el) {
const set = active.get(el)
if (!set || set.size === 0) return Promise.resolve()
return Promise.all([...set].map((h) => h.finished)).then(() => {})
const set = active.get(el);
if (!set || set.size === 0) return Promise.resolve();
return Promise.all([...set].map((h) => h.finished)).then(() => {});
},
dispose() {
if (disposed) return
disposed = true
if (disposed) return;
disposed = true;
for (const set of active.values()) {
for (const handle of [...set]) handle.cancel()
for (const handle of [...set]) handle.cancel();
}
active.clear()
handoffState.clear()
presets.clear()
active.clear();
handoffState.clear();
presets.clear();
}
}
};
}
/**
@ -234,14 +235,14 @@ export function createEngineMotion(options?: EngineMotionOptions): EngineMotion
* then lets the next run restart cleanly from the preset's declared `from`.
*/
function captureHandoff(set: Set<MotionHandle>): MotionState | undefined {
let merged: Record<string, { x: number; v: number }> | undefined
let merged: Record<string, { x: number; v: number }> | undefined;
for (const handle of set) {
const state = handle.peek?.()
if (!state) continue
merged ??= {}
Object.assign(merged, state)
const state = handle.peek?.();
if (!state) continue;
merged ??= {};
Object.assign(merged, state);
}
return merged
return merged;
}
/**
@ -261,41 +262,41 @@ function toHandle(
controller: AbortController
): MotionHandle {
if (Array.isArray(result)) {
const anims = result as readonly Animation[]
const anims = result as readonly Animation[];
return {
finished: Promise.all(anims.map((a) => a.finished)).then(
() => {},
() => {}
),
cancel() {
controller.abort()
for (const a of anims) safeCancel(a)
controller.abort();
for (const a of anims) safeCancel(a);
}
}
};
}
const single = result as {
finished: Promise<unknown>
cancel: () => void
peek?: () => MotionState | undefined
}
finished: Promise<unknown>;
cancel: () => void;
peek?: () => MotionState | undefined;
};
return {
finished: Promise.resolve(single.finished).then(
() => {},
() => {}
),
cancel() {
controller.abort()
safeCancel(single)
controller.abort();
safeCancel(single);
},
// Forward the driver's `peek` (only `spring` has one) so the engine can capture
// its state on cancel for a reversal handoff (§8.3).
peek: single.peek ? () => single.peek!() : undefined
}
};
}
function safeCancel(a: { cancel?: () => void }): void {
try {
a.cancel?.()
a.cancel?.();
} catch {
// Already finished / detached — nothing to cancel.
}

@ -162,8 +162,8 @@ Adaptadores disponibles:
- `detectServerEnvironment(input)`
- `detectBrowserEnvironment(overrides?)`
- `applyBrowserEnvironment(prefs, overrides?)`
- `watchBrowserEnvironment(prefs, overrides?)`
- `applyBrowserEnvironment(engine, overrides?)`
- `watchBrowserEnvironment(apply, overrides?)` — `apply` recibe el patch de entorno (`(patch) => void`); `overrides` solo `{ matchMedia }`
Ejemplos de entorno:
@ -240,9 +240,10 @@ Morfo.
```ts
const bridge = createPrefsStorageBridge({
prefs,
storage,
key: 'active:prefs'
engine, // EnginePrefs
storage, // PrefsIntentStorage
onError: (error, op) => report(error, op), // opcional
skipHydrate: false // opcional (default false)
});
```

@ -360,6 +360,8 @@ uuid();
slug();
datetime(); // ISO 8601
ipv4();
cssLength(); // px/rem/em/%/vw/vh/vmin/vmax/ch no negativo (code 'custom'; regex CSS_LENGTH_REGEX)
cssValue(); // valor CSS más rico — hermano de cssLength, code 'css_value'
// numericos
finite(); // rechaza Infinity / -Infinity / NaN

@ -295,15 +295,15 @@ previous one and migrates any `onChange` listeners across the swap:
```svelte
<script lang="ts">
let userId = $state(1);
let userId = $state(1);
const profile = App.storage.dynamicEntry(
() => `user-${userId}:profile`,
() => ({ name: '', cart: [] as string[] })
);
const profile = App.storage.dynamicEntry(
() => `user-${userId}:profile`,
() => ({ name: '', cart: [] as string[] })
);
// userId = 2 → profile rebinds to 'user-2:profile' and .current
// reflects whatever lives there (or the default).
// userId = 2 → profile rebinds to 'user-2:profile' and .current
// reflects whatever lives there (or the default).
</script>
<input bind:value={profile.current.name} />
@ -328,7 +328,7 @@ native `BroadcastChannel` API. Zero polling, instant delivery, browser-wide:
import { withBroadcast, localAdapter, cookieAdapter, createActiveStorage } from '$storage';
const Storage = createActiveStorage({
adapter: withBroadcast(localAdapter, { channel: 'my-app' })
adapter: withBroadcast(localAdapter, { channel: 'my-app' })
});
```
@ -338,17 +338,13 @@ Useful pairings:
// Cookie that propagates across tabs the moment the value changes —
// browsers do not emit a native event for cookie mutations, broadcast
// fills the gap.
const session = withBroadcast(
cookieAdapter({ path: '/', maxAge: 3600 }),
{ channel: 'my-app:session' }
);
const session = withBroadcast(cookieAdapter({ path: '/', maxAge: 3600 }), {
channel: 'my-app:session'
});
// In-memory adapter shared between two EngineStorage instances (e.g.
// devtools panel + app), kept in sync without touching disk.
const ephemeral = withBroadcast(
createMemoryAdapter(),
{ channel: 'my-app:ephemeral' }
);
const ephemeral = withBroadcast(createMemoryAdapter(), { channel: 'my-app:ephemeral' });
```
Behavior:
@ -478,7 +474,7 @@ export const load: LayoutServerLoad = ({ cookies }) => {
<script lang="ts">
import { setContext, onDestroy } from 'svelte';
import { createActiveApp, cookieAdapter, localAdapter } from '$active-app';
import { defineActiveLangs, defineActiveStorage } from '$active-app/services';
import { defineActiveLangs, defineActiveStorage } from '$active-app/service-factories';
import { appLangs } from './langs';
let { data, children } = $props();

@ -73,7 +73,7 @@ Timers.cancelAll('connection:main');
- **Fake clock first-class.** Every operation flows through a
`TimerClock` interface. Tests inject a fake clock that advances
time deterministically — no `vi.useFakeTimers`, no real waits,
~50 race-safety tests run in <500 ms.
~47 race-safety tests run in <500 ms.
---
@ -87,12 +87,20 @@ timer/
│ Handle, Snapshot, Event, Clock, Backoff
├── errors.ts TimerDisposedError + 4 siblings + guards
├── clock.ts createSystemTimerClock()
├── fake-clock.ts createFakeTimerClock() — deterministic test clock
├── backoff.ts computeBackoffDelay() — pure helper
├── timer-validation.ts assertTimerDelay() + input guards
├── timer-entry.ts internal timer-entry record shape
├── timer-handle.ts public handle (key/active/cancel/reschedule)
├── timer-runner.ts fires due entries, (id,key,version)-guarded
├── timer-cancel.ts cancellation / replace teardown paths
├── timer-events.ts snapshot + event emission
├── diagnostics.ts catalogued diagnostic events (createTimerDiagnostics)
├── engine-timers.ts runes-free core, (id,key,version) guard
├── active-timers.svelte.ts runed wrapper — no $effect, no scheduling
├── active-timers.svelte.ts reactive wrapper — no $effect, no scheduling
└── test/
├── backoff.test.ts (9 tests)
├── engine-timers.test.ts (50 tests, fake clock)
├── engine-timers.test.ts (47 tests, fake clock)
└── active-timers.svelte.test.ts (7 browser tests)
```
@ -234,7 +242,8 @@ All schedulers return a `TimerHandle`:
handle.key // the key
handle.active // false after cancel/complete/dispose
handle.cancel(): boolean // true if it actually cancelled something
handle.reschedule(delayMs) // throws TimerInactiveError if not active
handle.reschedule(delayMs) // re-arm from now (validates delayMs);
// throws TimerInactiveError if inactive OR running
```
### Cancellation
@ -313,13 +322,13 @@ stop, they cancel explicitly from inside the task or use
## Errors
| Class | Thrown when | Type guard |
| ------------------------ | --------------------------------------- | -------------------------- |
| `TimerDisposedError` | mutator called after `dispose()` | `isTimerDisposedError` |
| `TimerInvalidKeyError` | key is not a non-empty string | `isTimerInvalidKeyError` |
| `TimerDuplicateKeyError` | key already exists, no `replace:true` | `isTimerDuplicateKeyError` |
| `TimerInvalidDelayError` | `delayMs` is non-finite or negative | `isTimerInvalidDelayError` |
| `TimerInactiveError` | `handle.reschedule()` on inactive entry | `isTimerInactiveError` |
| Class | Thrown when | Type guard |
| ------------------------ | ----------------------------------------------------- | -------------------------- |
| `TimerDisposedError` | mutator called after `dispose()` | `isTimerDisposedError` |
| `TimerInvalidKeyError` | key is not a non-empty string | `isTimerInvalidKeyError` |
| `TimerDuplicateKeyError` | key already exists, no `replace:true` | `isTimerDuplicateKeyError` |
| `TimerInvalidDelayError` | `delayMs` is non-finite or negative | `isTimerInvalidDelayError` |
| `TimerInactiveError` | `handle.reschedule()` on an inactive OR running entry | `isTimerInactiveError` |
Task failures at runtime are **not** thrown — they surface as
`TIMER_EVENT_FAILED` events on `onChange`. Reserve exceptions for

Loading…
Cancel
Save

Powered by TurnKey Linux.