/** * 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]` (valenced only) * 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?, motion?, sound?, color?, presence?, * 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 { SemaFamily, SemaIntent } from './types' import { resolveSemaDuration } from './durations' import { SEMA_MAP, SEMA_VALENCED_FAMILY_LIST, type ColorSignature, type DeltaOp, type DeltaValue, type HapticSignature, type MotionSignature, type PresenceSignature, type SemaChannelId, type SemaChannelSignatures, type SemaMap, type SemaSignatureOverride, type SoundSignature } from './sema-map' export type { ColorSignature, HapticSignature, MotionSignature, PresenceSignature, SemaChannelId, SemaChannelSignatures, SemaSignatureOverride, SoundSignature } from './sema-map' /** @deprecated kept for one cycle; use `SemaChannelId`. */ export type SemaActiveChannel = SemaChannelId /** * 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: SemaIntent | 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 any `motion.duration` (visual concern; lives in eidos * post-Phase 5 of the codex refactor). */ hold?: number motion?: MotionSignature sound?: SoundSignature color?: ColorSignature presence?: PresenceSignature 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() // — the engine has already stamped `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?: SemaIntent): 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 const next = deepClone(baseMap) as unknown as Record for (const [path, delta] of Object.entries(overrides)) { const parts = path.split('.') let cursor: Record = next for (let i = 0; i < parts.length - 1; i++) { const key = parts[i] const child = cursor[key] if (!isRecord(child)) { cursor[key] = {} } cursor = cursor[key] as Record } 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(cursor[leafKey], delta, 'replace') } return next as unknown as SemaMap }