Tighten sema channel and floating contracts

active-uix
dev 5 months ago
parent 8b195e38e2
commit 802ebce655

@ -35,7 +35,7 @@ import type {
IntentExpectedFamily,
IntentOptionalFamily
} from '../sema/types';
import type { SemaChannelId, SemaSignatureOverride } from '../sema/sema-map';
import type { SemaChannelId, SemaSignatureOverride } from '../sema/channels';
import type { SemaDurationSpec } from '../sema/durations';
// ── HTML element ──────────────────────────────────────────────────────────

@ -0,0 +1,102 @@
/**
* Sema channel contract.
*
* This is the single source for runtime channel ids, channel signatures and
* per-channel overrides. `sema-map.ts` owns data; this file owns the open
* channel type surface that both the map and event declarations consume.
*/
export interface SoundSignature {
pitch: number;
centroid: number;
roughness: number;
attack: number;
decay: number;
duration: number;
contour: 'flat' | 'ascending' | 'descending' | 'arc' | 'bell';
gain: number;
sampleUrl?: string;
}
/**
* Haptic / vibration feedback. Categorical `kind` lets future channels map
* to platform haptic primitives (iOS Haptic Engine, Android
* HapticFeedbackConstants) without breaking consumers; V1 of `HapticChannel`
* implements the Vibration API path with intensity-modulated durations.
*/
export interface HapticSignature {
/**
* Categorical pattern. Maps to system primitives where available; falls
* back to a Vibration API pattern derived from `duration` + `intensity`.
*/
kind: 'tick' | 'tap' | 'pulse' | 'thud' | 'success' | 'warning' | 'error';
/** 0..1. Modulates Vibration API duration; ignored on categorical-only platforms. */
intensity: number;
/** Base pulse duration in ms (single Vibration API pulse). */
duration: number;
/** Explicit on/off/on/off… pattern in ms. */
pattern?: readonly number[];
/** Delay relative to signal start (ms). */
delay?: number;
}
/**
* Canonical channel registry. Apps extend it via declaration merging:
*
* declare module '$uix/sema' {
* interface SemaChannelSignatures {
* a11y: A11ySignature;
* voice: VoiceSignature;
* }
* }
*/
export interface SemaChannelSignatures {
sound: SoundSignature;
haptic: HapticSignature;
}
/**
* Open string id of a channel. Known channels autocomplete in editors;
* arbitrary strings are accepted for channels registered with the engine but
* without a typed signature slice.
*/
export type SemaChannelId = keyof SemaChannelSignatures | (string & {});
/**
* Runtime channels are the things the engine actually dispatches to.
* Visual dimensions (`motion`, `color`, `presence`) are not channels.
*/
export type SemaRuntimeChannelId = 'visual' | 'sound' | 'haptic' | (string & {});
export type DeltaOp =
| { op: 'multiply'; factor: number }
| { op: 'replace'; value: number | string | boolean | null | readonly DeltaValue[] }
| { op: 'add'; value: number };
export type DeltaValue =
| number
| string
| boolean
| null
| DeltaOp
| readonly DeltaValue[]
| { [key: string]: DeltaValue };
/**
* Per-channel partial override applied at resolution time.
*/
export type SemaSignatureOverride = {
/**
* Replace the active-channel list for matching signals. Last-write-wins
* across the cascade. Empty array silences the signal entirely.
*/
channels?: readonly SemaChannelId[];
} & {
[K in keyof SemaChannelSignatures]?:
| Partial<SemaChannelSignatures[K]>
| Record<string, DeltaValue>;
};

@ -1,5 +1,5 @@
import type { EffectiveSignature } from '../resolver'
import type { HapticSignature } from '../sema-map'
import type { HapticSignature } from '../channels'
import type { SemanticSignal } from '../signal'
import type { Channel } from './types'

@ -1,13 +1,17 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { SoundChannel } from './sound';
import { SoundChannel, type SoundChannelDom } from './sound';
function createDocHarness() {
function createDomHarness() {
const cleanup = vi.fn();
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
} satisfies Pick<Document, 'addEventListener' | 'removeEventListener'>;
cleanup,
dom: {
getDocument: vi.fn(() => document),
listen: vi.fn(() => cleanup)
} satisfies SoundChannelDom
};
}
function createAudioHarness() {
@ -31,25 +35,25 @@ function createAudioHarness() {
describe('SoundChannel', () => {
it('does not register global unlock listeners in the constructor', () => {
const doc = createDocHarness();
const { dom } = createDomHarness();
const audio = createAudioHarness();
const channel = new SoundChannel({
doc,
dom,
audioContextFactory: audio.factory
});
expect(doc.addEventListener).not.toHaveBeenCalled();
expect(dom.listen).not.toHaveBeenCalled();
expect(audio.factory).not.toHaveBeenCalled();
channel.dispose();
});
it('primes the AudioContext synchronously from prepare when sound is allowed', () => {
const doc = createDocHarness();
const { cleanup, dom } = createDomHarness();
const audio = createAudioHarness();
const channel = new SoundChannel({
doc,
dom,
audioContextFactory: audio.factory,
masterGain: 0.4
});
@ -64,22 +68,25 @@ describe('SoundChannel', () => {
expect(audio.ctx.createGain).toHaveBeenCalledTimes(1);
expect(audio.gainNode.gain.value).toBe(0.4);
expect(audio.resume).toHaveBeenCalledTimes(1);
expect(doc.addEventListener).toHaveBeenCalledTimes(3);
expect(doc.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), true);
expect(doc.addEventListener).toHaveBeenCalledWith('touchstart', expect.any(Function), true);
expect(doc.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), true);
expect(dom.getDocument).toHaveBeenCalledTimes(1);
expect(dom.listen).toHaveBeenCalledWith(
document,
['click', 'touchstart', 'keydown'],
expect.any(Function),
true
);
channel.dispose();
expect(doc.removeEventListener).toHaveBeenCalledTimes(3);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(audio.close).toHaveBeenCalledTimes(1);
});
it('does not prime the AudioContext for explicit non-sound channel lists', () => {
const doc = createDocHarness();
const { dom } = createDomHarness();
const audio = createAudioHarness();
const channel = new SoundChannel({
doc,
dom,
audioContextFactory: audio.factory
});
@ -90,7 +97,7 @@ describe('SoundChannel', () => {
});
expect(audio.factory).not.toHaveBeenCalled();
expect(doc.addEventListener).not.toHaveBeenCalled();
expect(dom.listen).not.toHaveBeenCalled();
channel.dispose();
});

@ -30,13 +30,23 @@ import type { Logger } from '$libs/logger';
type AudioContextCtor = new () => AudioContext;
export interface SoundChannelDom {
getDocument(node?: Element | Window | Node | Document | null): Document;
listen(
target: EventTarget,
event: string | readonly string[],
handler: EventListener,
options?: boolean | AddEventListenerOptions
): () => void;
}
export interface SoundChannelOptions {
/** Override the AudioContext factory (tests / non-browser environments). */
audioContextFactory?: () => AudioContext | null;
/** Override the global fetch (for sample loading in tests). */
fetchFn?: typeof fetch;
/** Override document for unlock listener registration (tests). */
doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>;
/** DOM service used for global unlock listener registration. */
dom?: SoundChannelDom;
/** Master gain multiplier applied on top of every signature's `gain`. Default 1. */
masterGain?: number;
/** Optional diagnostics logger. When omitted, audio failures stay silent. */
@ -59,7 +69,7 @@ export class SoundChannel implements Channel {
private readonly sampleCache = new Map<string, AudioBuffer>();
private readonly fetchFn?: typeof fetch;
private readonly audioContextFactory?: () => AudioContext | null;
private readonly doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>;
private readonly dom?: SoundChannelDom;
private readonly masterGainValue: number;
private readonly logger: Logger | undefined;
private teardownUnlock?: () => void;
@ -68,7 +78,7 @@ export class SoundChannel implements Channel {
this.fetchFn =
opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined);
this.audioContextFactory = opts.audioContextFactory;
this.doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined);
this.dom = opts.dom;
this.masterGainValue = opts.masterGain ?? 1;
this.logger = opts.logger;
}
@ -199,22 +209,14 @@ export class SoundChannel implements Channel {
}
private setupUnlockListener(): void {
if (!this.doc || this.teardownUnlock) return;
if (!this.dom || this.teardownUnlock) return;
const events = ['click', 'touchstart', 'keydown'] as const;
const unlock = () => {
this.audioCtx?.resume().catch(() => {});
};
for (const eventName of events) {
this.doc.addEventListener(eventName, unlock, true);
}
this.teardownUnlock = () => {
for (const eventName of events) {
this.doc?.removeEventListener(eventName, unlock, true);
}
};
this.teardownUnlock = this.dom.listen(this.dom.getDocument(), events, unlock, true);
}
private async synthesize(ctx: AudioContext, sig: SoundSignature): Promise<void> {

@ -26,11 +26,12 @@ import type { Channel, ChannelPreparation } from './chans/types';
import type { DomApplier } from '$libs/dom';
import type { Logger } from '$libs/logger';
import { HapticChannel, type HapticChannelOptions } from './chans/haptic';
import { SoundChannel, type SoundChannelOptions } from './chans/sound';
import { SoundChannel, type SoundChannelDom, type SoundChannelOptions } from './chans/sound';
import { VisualChannel, type VisualChannelOptions } from './chans/visual';
import { SemaDuplicateChannelError } from './errors';
import { applyMapOverrides, resolveSignature, type SemaCascadeRule } from './resolver';
import { SEMA_MAP, type DeltaValue, type SemaMap, type Sema } from './sema-map';
import { SEMA_MAP, type SemaMap, type Sema } from './sema-map';
import type { DeltaValue } from './channels';
import type { SemanticSignal } from './signal';
import type { SignalProjector } from './projection';
@ -81,7 +82,7 @@ export interface EngineSemanticOptions {
* In `active-uix` this is `uix.dom`, so sema projects event attrs through
* the same DOM owner as soma.
*/
dom?: DomApplier;
dom?: DomApplier | (DomApplier & SoundChannelDom);
}
export class EngineSemantic {
@ -125,9 +126,17 @@ export class EngineSemantic {
}
if (opts.sound !== undefined && opts.sound !== false) {
const soundChannel: Channel = isChannel(opts.sound)
? opts.sound
: new SoundChannel({ ...(opts.sound === true ? {} : opts.sound), logger: opts.logger });
let soundChannel: Channel;
if (isChannel(opts.sound)) {
soundChannel = opts.sound;
} else {
const soundOptions = opts.sound === true ? {} : opts.sound;
soundChannel = new SoundChannel({
...soundOptions,
dom: soundOptions.dom ?? (isSoundChannelDom(opts.dom) ? opts.dom : undefined),
logger: opts.logger
});
}
this.register(soundChannel);
// Pre-decode the WAVs declared by component packs so the first
@ -246,3 +255,12 @@ function isChannel(value: unknown): value is Channel {
typeof (value as Channel).handle === 'function'
);
}
function isSoundChannelDom(value: unknown): value is SoundChannelDom {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as SoundChannelDom).getDocument === 'function' &&
typeof (value as SoundChannelDom).listen === 'function'
);
}

@ -64,21 +64,23 @@ export {
resolveSignature,
applyMapOverrides,
type EffectiveSignature,
type HapticSignature,
type SemaCascadeRule,
type SemaChannelId,
type SemaChannelSignatures,
type SemaResolveOptions,
type SemaSignatureOverride,
type SoundSignature
type SemaResolveOptions
} from './resolver';
export type {
HapticSignature,
SemaChannelId,
SemaChannelSignatures,
SemaSignatureOverride,
SemaRuntimeChannelId,
SoundSignature
} from './channels';
export {
SEMA_MAP,
type SemaMap,
type FamilyMapEntry,
type IntentMapEntry,
type Sema,
type SemaRuntimeChannelId
type Sema
} from './sema-map';
export { stampEventAttrs, unstampEventAttrs } from './stamp';

@ -34,18 +34,16 @@ import type { SemanticSignal } from './signal';
import type { Intent } from '../intent';
import type { SemaFamily } from './types';
import { resolveSemaDuration } from './durations';
import {
SEMA_MAP,
SEMA_VALENCED_FAMILY_LIST,
type DeltaOp,
type DeltaValue,
type HapticSignature,
type SemaChannelId,
type SemaChannelSignatures,
type SemaMap,
type SemaSignatureOverride,
type SoundSignature
} from './sema-map';
import { SEMA_MAP, SEMA_VALENCED_FAMILY_LIST, type SemaMap } from './sema-map';
import type {
DeltaOp,
DeltaValue,
HapticSignature,
SemaChannelId,
SemaChannelSignatures,
SemaSignatureOverride,
SoundSignature
} from './channels';
export type {
HapticSignature,
@ -53,7 +51,7 @@ export type {
SemaChannelSignatures,
SemaSignatureOverride,
SoundSignature
} from './sema-map';
} from './channels';
/**
* Single cascade rule. Identical shape to a CSS rule — a selector that

@ -22,126 +22,15 @@
import type { Intent } from '../intent';
import type { SemaFamily, SemaValencedFamily } from './types';
import type { SemaDurationSpec } from './durations';
// ── Channel signature types ────────────────────────────────────────────────
export interface SoundSignature {
pitch: number;
centroid: number;
roughness: number;
attack: number;
decay: number;
duration: number;
contour: 'flat' | 'ascending' | 'descending' | 'arc' | 'bell';
gain: number;
sampleUrl?: string;
}
/**
* Haptic / vibration feedback. Categorical `kind` lets future channels map
* to platform haptic primitives (iOS Haptic Engine, Android
* HapticFeedbackConstants) without breaking consumers; V1 of `HapticChannel`
* implements the Vibration API path with intensity-modulated durations.
*/
export interface HapticSignature {
/**
* Categorical pattern. Maps to system primitives where available; falls
* back to a Vibration API pattern derived from `duration` + `intensity`.
*
* tick — micro acknowledgement (drag step, focus crossing).
* tap — discrete decision confirmation (commit).
* pulse — alert / call to attention (signal).
* thud — heavy descending feedback (loss).
* success — positive confirmation (affirm / fulfill intents).
* warning — moderate negative consequence (risk intent).
* error — strong negative consequence (threat intent).
*/
kind: 'tick' | 'tap' | 'pulse' | 'thud' | 'success' | 'warning' | 'error';
/** 0..1. Modulates Vibration API duration; ignored on categorical-only platforms. */
intensity: number;
/** Base pulse duration in ms (single Vibration API pulse). */
duration: number;
/**
* Explicit on/off/on/off… pattern in ms. Wins over `kind` + `duration`
* when present; passed straight to `navigator.vibrate(pattern)`.
*/
pattern?: readonly number[];
/**
* Delay relative to signal start (ms). Anchors the haptic to the
* perceptually correct moment of the signal — `contact` fires at 0
* (synchronous to the press), `commit` after the visual settles
* (~60ms), `signal` immediately for alerts.
*/
delay?: number;
}
// ── Channel registry (open via declaration merging) ────────────────────────
/**
* Canonical channel registry. Each key is a channel id; each value is the
* per-channel signature shape. Apps extend it via declaration merging:
*
* declare module '$uix/sema' {
* interface SemaChannelSignatures {
* a11y: A11ySignature;
* voice: VoiceSignature;
* }
* }
*
* Channels without a typed signature (pure side-effect, no slice on the
* `EffectiveSignature`) just register a `Channel` with the engine — they
* do not need an entry here.
*/
export interface SemaChannelSignatures {
sound: SoundSignature;
haptic: HapticSignature;
}
/**
* Open string id of a channel. Known channels (keys of
* `SemaChannelSignatures`) autocomplete in editors; arbitrary strings are
* accepted for channels registered with the engine but without a typed
* signature slice. The `(string & {})` trick prevents TS from collapsing
* the union to plain `string` and losing autocomplete.
*/
export type SemaChannelId = keyof SemaChannelSignatures | (string & {});
/**
* Phase 6 of the codex refactor (`refactorizacion_codex.md`):
* `SemaRuntimeChannelId` is the FORWARD-LOOKING type for `channels`
* lists. It enumerates the channels that the engine actually dispatches
* to at runtime — `'visual'` (DOM projection + hold), `'sound'`, `'haptic'`,
* plus future channels (`'a11y'`, `'voice'`) added via declaration merging.
*
* Visual dimensions (`motion`, `color`, `presence`) are not channels.
* They live in Eidos; Sema dispatches real runtime channels only.
*
* Use this type when adding new code that lists CHANNELS (the things
* the engine dispatches to), not when listing dimensions of an
* EffectiveSignature.
*/
export type SemaRuntimeChannelId = 'visual' | 'sound' | 'haptic' | (string & {});
import type {
DeltaValue,
SemaChannelId,
SemaChannelSignatures,
SemaSignatureOverride
} from './channels';
// ── Map shape ──────────────────────────────────────────────────────────────
export type DeltaOp =
| { op: 'multiply'; factor: number }
| { op: 'replace'; value: number | string | boolean | null | readonly DeltaValue[] }
| { op: 'add'; value: number };
export type DeltaValue =
| number
| string
| boolean
| null
| DeltaOp
| readonly DeltaValue[]
| { [key: string]: DeltaValue };
export interface FamilyMapEntry {
/** Per-channel base signatures. Channels not present default to undefined. */
base: Partial<SemaChannelSignatures>;
@ -168,29 +57,6 @@ export interface IntentMapEntry {
deltas: Partial<Record<SemaChannelId, Record<string, DeltaValue>>>;
}
/**
* Per-channel partial override applied at resolution time. Used by:
* - layer 4: per-event morfo overrides (in `SemaEvent.overrides`)
* - layer 6: cascade rule overrides (`SemaCascadeRule.overrides[eventLabel]`)
*
* Numeric leaf values add by default; `{ op: 'replace' | 'multiply' | 'add' }`
* for explicit semantics; arrays replace atomically. Apps that augment
* `SemaChannelSignatures` automatically gain typed keys here.
*/
export type SemaSignatureOverride = {
/**
* Replace the active-channel list for matching signals. Last-write-wins
* across the cascade — the rule applied LAST decides. Empty array
* silences the signal entirely (no channel activates). Omit to leave
* `activeChannels` untouched.
*/
channels?: readonly SemaChannelId[];
} & {
[K in keyof SemaChannelSignatures]?:
| Partial<SemaChannelSignatures[K]>
| Record<string, DeltaValue>;
};
/**
* Per-component sema declaration — sibling to `Morfo`. Lives in
* `src/uix/sema/components/{name}.ts` alongside the component's morfo,

@ -8,7 +8,7 @@
* propia modalidad.
*/
import type { SemaChannelId, SemaSignatureOverride } from './sema-map'
import type { SemaChannelId, SemaSignatureOverride } from './channels'
import type { Intent } from '../intent'
import type { SemaFamily } from './types'

@ -8,7 +8,7 @@
import type { Intent } from '../intent';
import type { PartRef } from '../types';
import type { SemaChannelId, SemaSignatureOverride } from './sema-map';
import type { SemaChannelId, SemaSignatureOverride } from './channels';
// ── Core domain ────────────────────────────────────────────────────────────

@ -23,20 +23,7 @@ import { ElementSize, watch } from 'runed';
import { useFloating } from './use-floating.svelte';
import type { Measurable, UseFloatingReturn } from './types';
export const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const;
export const ALIGN_OPTIONS = ['start', 'center', 'end'] as const;
const OPPOSITE_SIDE: Record<Side, Side> = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
};
export type Side = (typeof SIDE_OPTIONS)[number];
export type Align = (typeof ALIGN_OPTIONS)[number];
export type Boundary = Element | null;
import { OPPOSITE_SIDE, type Align, type Boundary, type Side } from './placement';
// ── FloatingProvider ─────────────────────────────────────────────────────────

@ -5,12 +5,16 @@ export {
FloatingArrow,
FloatingAnchor,
type FloatingContentOpts,
type Side,
type Align,
type Boundary,
getSideFromPlacement,
getAlignFromPlacement
} from './floating.svelte';
export {
SIDE_OPTIONS,
ALIGN_OPTIONS,
type Side,
type Align,
type Boundary
} from './placement';
// ── Floating engine ──────────────────────────────────────────────────────────
export type {

@ -0,0 +1,13 @@
export const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const;
export const ALIGN_OPTIONS = ['start', 'center', 'end'] as const;
export type Side = (typeof SIDE_OPTIONS)[number];
export type Align = (typeof ALIGN_OPTIONS)[number];
export type Boundary = Element | null;
export const OPPOSITE_SIDE: Record<Side, Side> = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
};

@ -12,7 +12,7 @@ import type {
} from '$libs/reactive';
import type { Arrayable, Direction, StyleProperties } from '$soma/types';
import type { Snippet } from 'svelte';
import type { Align, Boundary, Side } from './floating.svelte.js';
import type { Align, Boundary, Side } from './placement';
// ─── Shared ───────────────────────────────────────────────────────────────────

Loading…
Cancel
Save

Powered by TurnKey Linux.