/** * Eidos — reactions to perceptual signals (`data-event*`). * * The VisualChannel of Sema writes signal attrs under the `data-event-*` * namespace during a configurable hold window: * - data-event (event name, e.g. 'announce', 'dismiss') * - data-event-id (occurrence id from the engine) * - data-event-phase ('active') * - data-event-family (optional, e.g. 'signal') * - data-event-intent (optional, e.g. 'loss') * * The channel never touches state attrs (`data-state`, `data-intent`, * `data-disabled`, ...) — those are owned by the runtime/morfo. Read * `data-event-intent` for signal-bound intent reactions; read * `data-intent` for the persistent state of a component. * * Hold defaults per family (internal to chans/visual.ts): * - emerge / commit / handle: 240ms * - signal / shift / sustain: 600ms * - contact: 120ms * Authors override per signal via `signal.hold`, or globally via * `new EngineSemantic({ visual: { defaultHold } })`. * * Even with the configured hold, prefer `animation: @keyframes` over * `transition` for the visible response: the animation runs to completion * independent of the signal's lifetime, so it works correctly with custom * holds and won't reverse mid-flight if the signal is cleared early. * * Rule of thumb: * - Use `animation: @keyframes` for visible reactions to signals. * - Use `transition` for ongoing state attrs (data-state, data-disabled). */ /* ── Keyframes ──────────────────────────────────────────────────────────── */ @keyframes eidos-announce-pulse-neutral { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 20% { box-shadow: 0 0 0 var(--focus-ring-width) var(--color-neutral-element); } } @keyframes eidos-announce-pulse-affirm { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 20% { box-shadow: 0 0 0 var(--focus-ring-width) var(--color-fulfill-element); } } @keyframes eidos-announce-pulse-fulfill { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 20% { box-shadow: 0 0 0 var(--focus-ring-width) var(--color-fulfill-element); } } @keyframes eidos-announce-pulse-risk { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 15% { box-shadow: 0 0 0 4px var(--color-risk-element); } 30% { box-shadow: 0 0 0 2px var(--color-risk-element); } } @keyframes eidos-announce-pulse-threat { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 10% { box-shadow: 0 0 0 6px var(--color-threat-element); } 35% { box-shadow: 0 0 0 3px var(--color-threat-element); } } @keyframes eidos-dismiss-fade { to { opacity: 0; transform: translateX(8%); } } @keyframes eidos-present-rise { from { opacity: 0; transform: translateY(8px); } } /* contact family — brief squeeze on press, ~120ms */ @keyframes eidos-press-squeeze { 0% { transform: scale(1); } 40% { transform: scale(0.96); filter: brightness(0.96); } 100% { transform: scale(1); } } /* commit family — small settle bump as the new state lands, ~240ms */ @keyframes eidos-commit-settle { 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); } 30% { box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.32); } 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); } } /* ── Base: any event signal during its active phase ────────────────────── * Hint to the compositor; cleared automatically when the signal disappears. */ [data-event][data-event-phase='active'] { will-change: opacity, transform, box-shadow; } /* ── Dismiss family — fade + slide out (animation runs 320ms) ──────── */ [data-event^='dismiss'][data-event-phase='active'], [data-event^='close'][data-event-phase='active'] { animation: eidos-dismiss-fade var(--duration-slow) var(--ease-default) forwards; } /* ── Present / open — rise into place ─────────────────────────────── */ [data-event^='present'][data-event-phase='active'], [data-event^='open'][data-event-phase='active'] { animation: eidos-present-rise var(--duration-slow) var(--ease-out); } /* ── Announce — intent-tinted pulse (visible 600-1000ms) ──────────── * Reads data-event-intent (signal-bound), NOT data-intent (state) — * the channel writes only its own namespace. */ [data-event^='announce'][data-event-phase='active'][data-event-intent='neutral'] { animation: eidos-announce-pulse-neutral 600ms var(--ease-default); } [data-event^='announce'][data-event-phase='active'][data-event-intent='affirm'] { animation: eidos-announce-pulse-affirm 600ms var(--ease-default); } [data-event^='announce'][data-event-phase='active'][data-event-intent='fulfill'] { animation: eidos-announce-pulse-fulfill 600ms var(--ease-default); } [data-event^='announce'][data-event-phase='active'][data-event-intent='risk'] { animation: eidos-announce-pulse-risk 800ms var(--ease-spring); } [data-event^='announce'][data-event-phase='active'][data-event-intent='threat'] { animation: eidos-announce-pulse-threat 1000ms var(--ease-spring); } /* ── Press / contact — quick squeeze acknowledgement ───────────────── * Fired on any control whose morfo declares a `contact` event (toggle, * switch, button…). Brief (~120ms), non-valenced. The sema visual * channel writes data-event-family='contact' during the hold. */ [data-event-family='contact'][data-event-phase='active'] { animation: eidos-press-squeeze 140ms var(--ease-default); } /* ── Commit — consolidation pulse on the landing of a new state ────── * Fired by morfos that declare a `commit` event (toggle press, switch * change, dialog close-save, etc.). The pulse uses the indigo accent; * intent variants below tint by valence. */ [data-event-family='commit'][data-event-phase='active'] { animation: eidos-commit-settle 260ms var(--ease-out); } [data-event-family='commit'][data-event-intent='fulfill'][data-event-phase='active'] { animation: eidos-announce-pulse-fulfill 320ms var(--ease-out); } [data-event-family='commit'][data-event-intent='affirm'][data-event-phase='active'] { animation: eidos-announce-pulse-affirm 320ms var(--ease-out); } [data-event-family='commit'][data-event-intent='threat'][data-event-phase='active'] { animation: eidos-announce-pulse-threat 400ms var(--ease-spring); } /* `data-last-action` tinting lives in component recipes (e.g. * dialog.css), not here. If a future second consumer appears with the * same exit-tint pattern, factor out then — not before. */