|
|
import type { EffectiveSignature } from '../resolver';
|
|
|
import type { SemanticSignal } from '../signal';
|
|
|
import { SEMA_DURATIONS, type SemaDurationLabel } from '../durations';
|
|
|
import { resolveHoldsByIntent } from '../holds';
|
|
|
import type { DomApplier } from '$adom';
|
|
|
import { DomSignalProjector, type SignalProjector } from '../projection';
|
|
|
import { semaDelay, type SemaTimerScheduler } from '../timers';
|
|
|
import type { Channel, ChannelPreparation } from './types';
|
|
|
import { SemaConfigError } from '../errors';
|
|
|
|
|
|
/**
|
|
|
* Canal visual — owns the DOM event projection + perceptual hold.
|
|
|
*
|
|
|
* `prepare()` projects `data-event-*` BEFORE cascade resolution so
|
|
|
* CSS-like cascade selectors can match the event surface. `handle()` then
|
|
|
* waits the resolved hold. The engine only orchestrates the generic channel
|
|
|
* lifecycle and calls the returned cleanup in `finally`.
|
|
|
*/
|
|
|
export interface VisualChannelOptions {
|
|
|
/**
|
|
|
* Default global de hold en ms cuando ni el signal ni la familia
|
|
|
* proporcionan uno. Default: 240 ms.
|
|
|
*
|
|
|
* El integrador puede subirlo si sus CSS transitions son más largas
|
|
|
* que el rango típico (150–300 ms para micro-interacciones,
|
|
|
* 200–400 ms para overlays).
|
|
|
*/
|
|
|
defaultHold?: number;
|
|
|
/** Projector used by `prepare()`. Required unless `dom` is provided. */
|
|
|
projector?: SignalProjector;
|
|
|
/** DOM writer used by the default `DomSignalProjector`. */
|
|
|
dom?: DomApplier;
|
|
|
/**
|
|
|
* Managed scheduler the hold runs on (`uix.timers`). Injected by the
|
|
|
* engine. When omitted the hold falls back to a raw `setTimeout` — unit
|
|
|
* tests only; production always wires `uix.timers`.
|
|
|
*/
|
|
|
timers?: SemaTimerScheduler;
|
|
|
}
|
|
|
|
|
|
const DEFAULT_HOLD_LABEL: SemaDurationLabel = 'brief';
|
|
|
|
|
|
/**
|
|
|
* Expression cap — the ABSOLUTE ceiling on how long the channel waits for
|
|
|
* the target's running animations to finish after the hold elapses, before
|
|
|
* unstamping. Canon of the channel (2026-07-06): the hold is a FLOOR of
|
|
|
* registration and the expression is its own budget (book cap. 4 §13 /
|
|
|
* cap. 12 §6 — "no todo evento termina cuando termina su animación"); this
|
|
|
* cap is NOT a perceptual value but an engineering guard against runaway /
|
|
|
* foreign animations (a loop on the same element would otherwise pin the
|
|
|
* projection forever). Deliberately a CONSTANT — deriving it from the hold
|
|
|
* (e.g. 2×hold) would re-couple the two budgets and re-create the very
|
|
|
* truncation bug this exists to kill. Value = the longest transitory
|
|
|
* built-in signature (announce-threat, 1000 ms) + comfortable margin; a
|
|
|
* design lint in the eidos suite fails if a transitory signature ever
|
|
|
* declares a duration above this.
|
|
|
*/
|
|
|
export const MAX_EXPRESSION_WAIT_MS = 1500;
|
|
|
|
|
|
export class VisualChannel implements Channel {
|
|
|
readonly id = 'visual';
|
|
|
|
|
|
private readonly defaultHold: number;
|
|
|
private readonly projector: SignalProjector;
|
|
|
private readonly timers: SemaTimerScheduler | undefined;
|
|
|
|
|
|
constructor(opts: VisualChannelOptions = {}) {
|
|
|
this.defaultHold = opts.defaultHold ?? SEMA_DURATIONS[DEFAULT_HOLD_LABEL];
|
|
|
this.timers = opts.timers;
|
|
|
if (opts.projector) {
|
|
|
this.projector = opts.projector;
|
|
|
} else {
|
|
|
if (!opts.dom) {
|
|
|
throw new SemaConfigError('VisualChannel requires a dom service or projector');
|
|
|
}
|
|
|
this.projector = new DomSignalProjector(opts.dom);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
prepare(signal: SemanticSignal): ChannelPreparation | undefined {
|
|
|
return this.projector.project(signal);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The hold this signal will observe — the REGISTRATION FLOOR, resolved by
|
|
|
* the one chain below. Public because the engine needs the same number to
|
|
|
* free the perceptual surface for a `regime: 'queue'` occurrence, and a
|
|
|
* second resolution path would be a second truth.
|
|
|
*
|
|
|
* Deliberately NOT the whole expression: `awaitExpression` waits for every
|
|
|
* running animation on the target (up to `MAX_EXPRESSION_WAIT_MS`), which
|
|
|
* is right for unstamping and wrong for queueing — measured on a real
|
|
|
* Toggle, the queued commit arrived **1.6 s** late because unrelated
|
|
|
* transitions kept the target busy. What the first occurrence is owed is
|
|
|
* its floor; what it does afterwards must not gate the next one.
|
|
|
*/
|
|
|
holdMsFor(signal: SemanticSignal, effective: EffectiveSignature): number {
|
|
|
return this.resolveHoldMs(signal, effective);
|
|
|
}
|
|
|
|
|
|
async handle(signal: SemanticSignal, effective: EffectiveSignature): Promise<void> {
|
|
|
const holdMs = this.resolveHoldMs(signal, effective);
|
|
|
if (holdMs <= 0) return;
|
|
|
// The hold runs on the managed scheduler (`uix.timers`) so it is
|
|
|
// cancellable on dispose, observable, and fake-clock-deterministic in
|
|
|
// tests — never a raw `setTimeout`. `semaDelay` falls back to one only
|
|
|
// when no scheduler was injected (direct unit construction).
|
|
|
await new Promise<void>((resolve) => {
|
|
|
semaDelay(this.timers, holdMs, resolve, { channel: 'visual', signal: signal.id });
|
|
|
});
|
|
|
// The hold is a FLOOR, never scissors: the expression completes.
|
|
|
await this.awaitExpression(signal);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The expression is never cut short (book cap. 4 §13 / cap. 12 §6 — the
|
|
|
* event's duración expresiva is its own budget; unstamping mid-animation
|
|
|
* cancels it, the truncation antipattern of cap. 32 §1: "una señal
|
|
|
* necesaria, por desaparecer demasiado pronto"). After the hold (the
|
|
|
* registration FLOOR) elapses, wait for the target's running animations
|
|
|
* to finish before the engine unstamps — capped by
|
|
|
* `MAX_EXPRESSION_WAIT_MS` (an absolute guard, so a loop or a foreign
|
|
|
* animation on the same element can never pin the projection).
|
|
|
*/
|
|
|
private async awaitExpression(signal: SemanticSignal): Promise<void> {
|
|
|
const target = signal.target;
|
|
|
if (!target || typeof target.getAnimations !== 'function') return;
|
|
|
const running = target.getAnimations().filter((a) => a.playState === 'running');
|
|
|
if (running.length === 0) return;
|
|
|
const finished = Promise.allSettled(running.map((a) => a.finished));
|
|
|
await new Promise<void>((resolve) => {
|
|
|
let done = false;
|
|
|
const settle = () => {
|
|
|
if (done) return;
|
|
|
done = true;
|
|
|
// Cancel the cap when the animations win the race — the old code
|
|
|
// left its callback sitting in the scheduler for up to
|
|
|
// MAX_EXPRESSION_WAIT_MS as a phantom entry (SEM-3). Cancelling
|
|
|
// an already-fired cap (the timeout path) is a harmless no-op.
|
|
|
cap.cancel();
|
|
|
resolve();
|
|
|
};
|
|
|
const cap = semaDelay(this.timers, MAX_EXPRESSION_WAIT_MS, settle, {
|
|
|
channel: 'visual',
|
|
|
signal: signal.id
|
|
|
});
|
|
|
void finished.then(settle);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Resolution chain (highest priority first):
|
|
|
* 1. `effective.hold` — already composed by the resolver from
|
|
|
* `signal.hold ?? resolveHoldsByIntent(family, intent)` (the single
|
|
|
* canonical hold source, holds.ts). Cascade rules can override it.
|
|
|
* 2. Defensive fallback: the same canonical table, consulted directly
|
|
|
* (covers a custom map whose resolver path didn't compose a hold).
|
|
|
* 3. Engine-wide `defaultHold`.
|
|
|
*
|
|
|
* The previous resolution path read a visual motion duration from the
|
|
|
* resolved signature. That coupling was the bug — animation timing and
|
|
|
* perceptual hold are different budgets and should evolve separately
|
|
|
* (which is also why `awaitExpression` exists: the hold never truncates
|
|
|
* the expression).
|
|
|
*/
|
|
|
private resolveHoldMs(signal: SemanticSignal, effective: EffectiveSignature): number {
|
|
|
if (typeof effective.hold === 'number') return effective.hold;
|
|
|
const canonical = resolveHoldsByIntent(signal.family, signal.intent)?.hold;
|
|
|
if (canonical !== undefined) {
|
|
|
return typeof canonical === 'number' ? canonical : SEMA_DURATIONS[canonical];
|
|
|
}
|
|
|
return this.defaultHold;
|
|
|
}
|
|
|
}
|