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/chans/visual.ts

175 lines
7.5 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.

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;
}
}

Powered by TurnKey Linux.