|
|
/**
|
|
|
* resolveSignature(signal, opts) — turns a `SemanticSignal` into the
|
|
|
* per-channel `EffectiveSignature` used by every channel.
|
|
|
*
|
|
|
* Resolution cascade (each layer overrides the previous):
|
|
|
*
|
|
|
* 1. FAMILY base — `SEMA_MAP.families[signal.family].base`
|
|
|
* 2. INTENT deltas — `SEMA_MAP.intents[signal.intent]` when present
|
|
|
* 3. soundPack URL — `SEMA_MAP.soundPack[<family>-<intent>]`
|
|
|
* 4. MORFO overrides — `signal.overrides` / `signal.channels`
|
|
|
* (from the morfo event, copied by SomaRuntime)
|
|
|
* 5. RUNTIME overrides — `options.runtimeMap` already has
|
|
|
* `engineOpts.overrides.runtime` baked in by the
|
|
|
* engine via `applyMapOverrides()` at construction
|
|
|
* 6. CASCADE rules — `options.cascade` — CSS-style rules matched
|
|
|
* against `signal.target` AFTER the engine has
|
|
|
* stamped `data-event-*` on it (see `stamp.ts`).
|
|
|
* Rules are flat: `{ selector, priority?,
|
|
|
* channels?, sound?, haptic? }`. The selector is
|
|
|
* matched natively via `target.matches()` /
|
|
|
* `target.closest()`.
|
|
|
*
|
|
|
* CSS-like specificity orders cascade rules: ascending order — later
|
|
|
* applied wins. Authors can override with `priority?: number` (replaces
|
|
|
* the computed specificity entirely).
|
|
|
*
|
|
|
* Pure function — `resolveSignature` reads from `SEMA_MAP` (or a passed
|
|
|
* map) and an optional immutable `cascade` array. Runtime path overrides
|
|
|
* are baked into the map up-front by the engine via `applyMapOverrides`,
|
|
|
* so this function never mutates anything.
|
|
|
*/
|
|
|
|
|
|
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';
|
|
|
|
|
|
export type {
|
|
|
HapticSignature,
|
|
|
SemaChannelId,
|
|
|
SemaChannelSignatures,
|
|
|
SemaSignatureOverride,
|
|
|
SoundSignature
|
|
|
} from './sema-map';
|
|
|
|
|
|
/**
|
|
|
* Single cascade rule. Identical shape to a CSS rule — a selector that
|
|
|
* matches the target's data-attrs (state from morfo + event tokens
|
|
|
* stamped by the engine) and a flat block of per-channel deltas.
|
|
|
*
|
|
|
* {
|
|
|
* selector: '[data-dialog-content][data-event-intent="threat"]',
|
|
|
* sound: { sampleUrl: '/sounds/dialog-fail.wav' },
|
|
|
* haptic: { kind: 'error' }
|
|
|
* }
|
|
|
*
|
|
|
* No `overrides: { eventLabel: ... }` middle layer — semantic event
|
|
|
* identity is matched via `[data-event="..."]`,
|
|
|
* `[data-event-family="..."]` or `[data-event-intent="..."]` directly in
|
|
|
* the selector.
|
|
|
*/
|
|
|
export interface SemaCascadeRule extends SemaSignatureOverride {
|
|
|
selector: string;
|
|
|
/**
|
|
|
* Override CSS-computed specificity. When omitted, specificity is
|
|
|
* computed from the selector (IDs × 100, attrs / classes /
|
|
|
* pseudo-classes × 10, elements × 1). Use `priority` for cases that
|
|
|
* need to win without selector gymnastics — typically accessibility
|
|
|
* (`priority: 100+`) or experimental cohort overrides.
|
|
|
*/
|
|
|
priority?: number;
|
|
|
}
|
|
|
|
|
|
export interface SemaResolveOptions {
|
|
|
map?: SemaMap;
|
|
|
cascade?: readonly SemaCascadeRule[];
|
|
|
}
|
|
|
|
|
|
export interface EffectiveSignature {
|
|
|
family: SemaFamily | undefined;
|
|
|
intent: Intent | undefined;
|
|
|
activeChannels: readonly SemaChannelId[];
|
|
|
/**
|
|
|
* Resolved hold duration in ms — how long `data-event-*` tokens live
|
|
|
* on the target. Computed from `signal.hold ?? family.hold`. Distinct
|
|
|
* from visual motion timing, which lives in Eidos recipes.
|
|
|
*/
|
|
|
hold?: number;
|
|
|
sound?: SoundSignature;
|
|
|
haptic?: HapticSignature;
|
|
|
}
|
|
|
|
|
|
/** Empty signature returned when input has no family — channels skip. */
|
|
|
const EMPTY_SIGNATURE: EffectiveSignature = {
|
|
|
family: undefined,
|
|
|
intent: undefined,
|
|
|
activeChannels: []
|
|
|
};
|
|
|
|
|
|
export function resolveSignature(
|
|
|
signal: SemanticSignal,
|
|
|
mapOrOptions: SemaMap | SemaResolveOptions = SEMA_MAP
|
|
|
): EffectiveSignature {
|
|
|
const options: SemaResolveOptions = isResolveOptions(mapOrOptions)
|
|
|
? mapOrOptions
|
|
|
: { map: mapOrOptions };
|
|
|
const map = options.map ?? SEMA_MAP;
|
|
|
|
|
|
if (!signal.family) return EMPTY_SIGNATURE;
|
|
|
|
|
|
const familyEntry = map.families[signal.family];
|
|
|
if (!familyEntry) return EMPTY_SIGNATURE;
|
|
|
|
|
|
// Layer 1 — family base
|
|
|
// `hold` is resolved up-front: per-call `signal.hold` wins, otherwise
|
|
|
// the family-declared `hold` from `SEMA_MAP`. Cascade rules can
|
|
|
// override `hold` directly via the `hold` field on the rule (treated
|
|
|
// the same as any other channel-less primitive in `applyOverride`).
|
|
|
const familyHoldMs = resolveSemaDuration(familyEntry.hold);
|
|
|
const initialHold = signal.hold ?? familyHoldMs;
|
|
|
let signature: EffectiveSignature = {
|
|
|
family: signal.family,
|
|
|
intent: signal.intent,
|
|
|
activeChannels: [...familyEntry.activeChannels],
|
|
|
...(initialHold !== undefined ? { hold: initialHold } : {}),
|
|
|
...cloneBase(familyEntry.base)
|
|
|
};
|
|
|
|
|
|
// Layer 2 — intent deltas. Applied whenever the signal carries an
|
|
|
// intent, regardless of family. The doctrine originally restricted
|
|
|
// this to valenced families (contact / commit / signal / handle) but
|
|
|
// real UX needs intent on transitional events too: a `<Dialog
|
|
|
// intent="threat">` should sound threatening even on the `open` event
|
|
|
// (emerge family). SomaRuntime forwards the consumer's intent prop
|
|
|
// when the morfo doesn't declare its own, and the resolver applies
|
|
|
// the deltas to whichever channels the family's base populated.
|
|
|
if (signal.intent) {
|
|
|
const intentEntry = map.intents[signal.intent];
|
|
|
if (intentEntry) {
|
|
|
signature = applyChannelDeltas(signature, intentEntry.deltas);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Layer 3 — soundPack URL by canonical event label
|
|
|
const eventLabel = composeEventLabel(signal.family, signal.intent);
|
|
|
const sampleUrl = map.soundPack[eventLabel];
|
|
|
if (sampleUrl && signature.sound) {
|
|
|
signature.sound.sampleUrl = sampleUrl;
|
|
|
}
|
|
|
|
|
|
// Layer 4 — per-event morfo overrides (carried by the signal)
|
|
|
if (signal.channels !== undefined) {
|
|
|
signature.activeChannels = [...signal.channels];
|
|
|
}
|
|
|
if (signal.overrides) {
|
|
|
signature = applyOverride(signature, signal.overrides);
|
|
|
}
|
|
|
|
|
|
// Layer 6 — CSS-style cascade. Rules are matched via target.matches()
|
|
|
// — channel prepare hooks have already projected `data-event-*` on the
|
|
|
// target, so selectors that read those tokens match correctly.
|
|
|
if (options.cascade && options.cascade.length > 0 && signal.target) {
|
|
|
const matched = collectCascadeMatches(options.cascade, signal.target);
|
|
|
for (const rule of matched) {
|
|
|
signature = applyOverride(signature, ruleAsOverride(rule));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return signature;
|
|
|
}
|
|
|
|
|
|
// ── Cascade matching (CSS-like) ───────────────────────────────────────────
|
|
|
|
|
|
function collectCascadeMatches(
|
|
|
rules: readonly SemaCascadeRule[],
|
|
|
target: HTMLElement
|
|
|
): SemaCascadeRule[] {
|
|
|
const indexed = rules.map((rule, index) => ({
|
|
|
rule,
|
|
|
index,
|
|
|
score: rule.priority ?? cssSpecificity(rule.selector)
|
|
|
}));
|
|
|
|
|
|
const matched = indexed.filter(({ rule }) => safeMatches(target, rule.selector));
|
|
|
|
|
|
// Ascending — last applied wins.
|
|
|
matched.sort((a, b) => {
|
|
|
if (a.score !== b.score) return a.score - b.score;
|
|
|
return a.index - b.index;
|
|
|
});
|
|
|
|
|
|
return matched.map(({ rule }) => rule);
|
|
|
}
|
|
|
|
|
|
function safeMatches(target: HTMLElement, selector: string): boolean {
|
|
|
if (selector === '*') return true;
|
|
|
try {
|
|
|
return target.matches(selector) || target.closest(selector) !== null;
|
|
|
} catch {
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Approximate CSS specificity. IDs × 100, attribute selectors / classes /
|
|
|
* pseudo-classes × 10, element / pseudo-element × 1. Doesn't model the
|
|
|
* full CSS algorithm (combinators, `:where()`, `:is()`) but suffices for
|
|
|
* the selector vocabulary cascade rules use in practice.
|
|
|
*/
|
|
|
function cssSpecificity(selector: string): number {
|
|
|
let score = 0;
|
|
|
const idMatches = selector.match(/#[\w-]+/g);
|
|
|
score += 100 * (idMatches?.length ?? 0);
|
|
|
const attrMatches = selector.match(/\[[^\]]+\]/g);
|
|
|
score += 10 * (attrMatches?.length ?? 0);
|
|
|
const classMatches = selector.match(/\.[\w-]+/g);
|
|
|
score += 10 * (classMatches?.length ?? 0);
|
|
|
// Pseudo-classes — exclude pseudo-elements (::after etc).
|
|
|
const pseudoMatches = selector.match(/(?<!:):[\w-]+(?:\([^)]*\))?/g);
|
|
|
score += 10 * (pseudoMatches?.length ?? 0);
|
|
|
return score;
|
|
|
}
|
|
|
|
|
|
function ruleAsOverride(rule: SemaCascadeRule): SemaSignatureOverride {
|
|
|
const { selector: _s, priority: _p, ...override } = rule;
|
|
|
void _s;
|
|
|
void _p;
|
|
|
return override as SemaSignatureOverride;
|
|
|
}
|
|
|
|
|
|
// ── Override application (layers 4 + 6) ───────────────────────────────────
|
|
|
//
|
|
|
// Overrides use REPLACE-by-default for primitive leaves (numbers, strings,
|
|
|
// booleans). Matches CSS intuition: `sound: { gain: 0.4 }` SETS gain to
|
|
|
// 0.4. To explicitly add, use `{ op: 'add', value: 0.1 }`.
|
|
|
|
|
|
function applyOverride(
|
|
|
signature: EffectiveSignature,
|
|
|
override: SemaSignatureOverride
|
|
|
): EffectiveSignature {
|
|
|
const next: EffectiveSignature = deepClone(signature);
|
|
|
|
|
|
if (override.channels !== undefined) {
|
|
|
next.activeChannels = [...override.channels];
|
|
|
}
|
|
|
|
|
|
for (const key of Object.keys(override)) {
|
|
|
if (key === 'channels') continue;
|
|
|
const delta = (override as Record<string, unknown>)[key];
|
|
|
if (delta === undefined) continue;
|
|
|
const current = (next as unknown as Record<string, unknown>)[key];
|
|
|
if (current === undefined) {
|
|
|
if (isRecord(delta) && !isDeltaOp(delta)) {
|
|
|
(next as unknown as Record<string, unknown>)[key] = deepClone(delta);
|
|
|
}
|
|
|
continue;
|
|
|
}
|
|
|
(next as unknown as Record<string, unknown>)[key] = applyLeaf(
|
|
|
current,
|
|
|
delta as DeltaValue,
|
|
|
'replace'
|
|
|
);
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
}
|
|
|
|
|
|
// ── Channel deltas (layer 2 — intent) ─────────────────────────────────────
|
|
|
//
|
|
|
// Intent deltas use ADD-by-default for numbers — they're modifiers
|
|
|
// composed on top of the family base. Strings/arrays still replace, ops
|
|
|
// take explicit precedence.
|
|
|
|
|
|
function applyChannelDeltas(
|
|
|
signature: EffectiveSignature,
|
|
|
deltas: Partial<Record<SemaChannelId, Record<string, DeltaValue>>>
|
|
|
): EffectiveSignature {
|
|
|
const next: EffectiveSignature = deepClone(signature);
|
|
|
for (const channel of next.activeChannels) {
|
|
|
const delta = deltas[channel];
|
|
|
if (!delta) continue;
|
|
|
const current = (next as unknown as Record<string, unknown>)[channel as string];
|
|
|
if (!current) continue;
|
|
|
(next as unknown as Record<string, unknown>)[channel as string] = applyLeaf(
|
|
|
current,
|
|
|
delta,
|
|
|
'add'
|
|
|
);
|
|
|
}
|
|
|
return next;
|
|
|
}
|
|
|
|
|
|
// ── Internals ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
function isResolveOptions(value: unknown): value is SemaResolveOptions {
|
|
|
return isRecord(value) && !('families' in value && 'intents' in value);
|
|
|
}
|
|
|
|
|
|
function composeEventLabel(family: SemaFamily, intent?: Intent): string {
|
|
|
return intent ? `${family}-${intent}` : family;
|
|
|
}
|
|
|
|
|
|
function cloneBase(base: Partial<SemaChannelSignatures>): Partial<SemaChannelSignatures> {
|
|
|
const out: Partial<SemaChannelSignatures> = {};
|
|
|
for (const key of Object.keys(base) as (keyof SemaChannelSignatures)[]) {
|
|
|
const value = base[key];
|
|
|
if (value !== undefined && value !== null) {
|
|
|
(out as Record<string, unknown>)[key] = deepClone(value);
|
|
|
}
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
function deepClone<T>(value: T): T {
|
|
|
return structuredClone(value);
|
|
|
}
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
|
}
|
|
|
|
|
|
function isDeltaOp(value: unknown): value is DeltaOp {
|
|
|
return isRecord(value) && typeof value.op === 'string';
|
|
|
}
|
|
|
|
|
|
function applyLeaf(
|
|
|
base: unknown,
|
|
|
delta: DeltaValue,
|
|
|
numberMode: 'add' | 'replace' = 'add'
|
|
|
): unknown {
|
|
|
if (typeof delta === 'number') {
|
|
|
if (numberMode === 'replace') return delta;
|
|
|
return typeof base === 'number' ? base + delta : delta;
|
|
|
}
|
|
|
if (typeof delta === 'string' || typeof delta === 'boolean' || delta === null) {
|
|
|
return delta;
|
|
|
}
|
|
|
if (Array.isArray(delta)) {
|
|
|
return [...delta] as unknown;
|
|
|
}
|
|
|
if (isDeltaOp(delta)) {
|
|
|
if (delta.op === 'replace') {
|
|
|
return Array.isArray(delta.value) ? [...delta.value] : delta.value;
|
|
|
}
|
|
|
if (typeof base !== 'number') return base;
|
|
|
if (delta.op === 'multiply') return base * delta.factor;
|
|
|
return base + delta.value;
|
|
|
}
|
|
|
if (!isRecord(delta)) return base;
|
|
|
if (!isRecord(base)) {
|
|
|
return deepClone(delta);
|
|
|
}
|
|
|
|
|
|
const out: Record<string, unknown> = deepClone(base);
|
|
|
for (const [key, nextDelta] of Object.entries(delta)) {
|
|
|
out[key] = applyLeaf(out[key], nextDelta as DeltaValue, numberMode);
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
// ── Runtime path-based map mutation (layer 5) ─────────────────────────────
|
|
|
|
|
|
/**
|
|
|
* Apply path-based runtime overrides to the SemaMap, producing a new map
|
|
|
* without mutating the canonical one. Used by the engine at construction.
|
|
|
*/
|
|
|
export function applyMapOverrides(
|
|
|
baseMap: SemaMap,
|
|
|
overrides: Record<string, DeltaValue> | undefined
|
|
|
): SemaMap {
|
|
|
if (!overrides || Object.keys(overrides).length === 0) return baseMap;
|
|
|
let next: unknown = baseMap;
|
|
|
for (const [path, delta] of Object.entries(overrides)) {
|
|
|
next = applyPathOverride(next, path.split('.'), delta);
|
|
|
}
|
|
|
return next as unknown as SemaMap;
|
|
|
}
|
|
|
|
|
|
function applyPathOverride(root: unknown, parts: string[], delta: DeltaValue): unknown {
|
|
|
if (parts.length === 0) return applyLeaf(root, delta, 'replace');
|
|
|
const next = cloneContainer(root);
|
|
|
let cursor = next;
|
|
|
let sourceCursor = root;
|
|
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
|
const key = parts[i];
|
|
|
const sourceChild = readChild(sourceCursor, key);
|
|
|
const child = cloneContainer(sourceChild);
|
|
|
cursor[key] = child;
|
|
|
cursor = child;
|
|
|
sourceCursor = sourceChild;
|
|
|
}
|
|
|
|
|
|
const leafKey = parts[parts.length - 1];
|
|
|
// Runtime path overrides REPLACE primitive leaves (designer setting
|
|
|
// `'families.commit.base.sound.pitch': 850` means SET to 850, not
|
|
|
// add). Use `{ op: 'add' }` if explicit additive semantics are needed.
|
|
|
cursor[leafKey] = applyLeaf(readChild(sourceCursor, leafKey), delta, 'replace');
|
|
|
return next;
|
|
|
}
|
|
|
|
|
|
function cloneContainer(value: unknown): Record<string, unknown> {
|
|
|
if (Array.isArray(value)) return [...value] as unknown as Record<string, unknown>;
|
|
|
if (isRecord(value)) return { ...value };
|
|
|
return {};
|
|
|
}
|
|
|
|
|
|
function readChild(value: unknown, key: string): unknown {
|
|
|
if (!isRecord(value) && !Array.isArray(value)) return undefined;
|
|
|
return (value as Record<string, unknown>)[key];
|
|
|
}
|