/** * 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[-]` * 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 `` 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(/(?)[key]; if (delta === undefined) continue; const current = (next as unknown as Record)[key]; if (current === undefined) { if (isRecord(delta) && !isDeltaOp(delta)) { (next as unknown as Record)[key] = deepClone(delta); } continue; } (next as unknown as Record)[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>> ): 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)[channel as string]; if (!current) continue; (next as unknown as Record)[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): Partial { const out: Partial = {}; for (const key of Object.keys(base) as (keyof SemaChannelSignatures)[]) { const value = base[key]; if (value !== undefined && value !== null) { (out as Record)[key] = deepClone(value); } } return out; } function deepClone(value: T): T { return structuredClone(value); } function isRecord(value: unknown): value is Record { 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 = 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 | 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 { if (Array.isArray(value)) return [...value] as unknown as Record; 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)[key]; }