You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/sema/resolver.ts

424 lines
15 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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];
}

Powered by TurnKey Linux.