feat(motion): modo (c) — composición sema + motion (opt-out visual en eidos)

Un componente coordinado puede componer la cascada (motion) con sonido/háptico
(sema) en el mismo evento. El cabo: el meta-canal visual de sema estampa
data-event-* siempre que el evento tenga algún canal (channels != []), así que
channels:['sound'] arrastraba la firma visual genérica (present-rise), que pelea
con la cascada.

Solución EN MOTION (sema intacto): eidos neutraliza su PROPIA firma visual sobre
las superficies coordinadas. render-css.ts > renderCoordinatedPresetRules emite
`[data-animation-style='cascade-X'][data-event-phase='active'] { animation: none
!important }`. La firma usa `animation` -> muere; la cascada es `transition` ->
sobrevive. Aditivo: inerte hasta que un coordinado dispare un evento sema.

Ejemplo: Reveal pasa a modo (c) — open y close declaran channels:['sound']
(emerge suena, pitch 600) + expression:'family-default'; suena al abrir y al
cerrar a la vez que la cascada, sin pelea visual. Rail queda como modo (b) puro.
Layout de /temas/animations con events:{ sound: true }; la demo /reveal explica
el modo (c).

Honestidad: la versión inicial del RFC Apéndice B afirmaba que el modo (c)
componía "gratis" — falso (verificado en engine.ts/visual.ts). Corregido: el
Apéndice B documenta ahora el acoplamiento real y el opt-out en motion.

Tests: eidos/motion 25/25 (neutralización) · morfo 70/70 · morfo:vocabulary
limpio. Audio verificado en navegador (suena al abrir y cerrar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent e9fc493823
commit 4ff9f0d277

@ -755,13 +755,22 @@ percepción del componente **es** la cascada, no una firma genérica encima.
| Modo | Ejemplo | Feedback |
| -------------------------- | ------------------------------------- | ------------------------------------------------------------ |
| **(a) sema sola** | Button, Checkbox | la firma (`data-event-*`); sin coordinado |
| **(b) coordinado solo** | Reveal, Rail | la cascada; firma genérica silenciada (`channels: []`) |
| **(c) ambos compuestos** | un coordinado con `channels:['haptic']` | la cascada (visual) + un tap háptico/sonido (otra modalidad) |
El conflicto sólo existía en el **eje visual**; silenciar `channels: []` lo elimina, pero
sound/haptic siguen disponibles (modo c). Es ortogonalidad **por modalidad**, no apagar sema —
coherente con §10 (la firma cross-modal compone con la coordinación; el servicio no toca el engine
de sema).
| **(b) coordinado solo** | Rail | la cascada; firma genérica silenciada (`channels: []`) |
| **(c) ambos compuestos** | Reveal (`open` con `channels:['sound']`) | la cascada (visual) + sonido `emerge` — el opt-out visual lo da eidos (ver nota) |
El conflicto está en el **eje visual**, y silenciar `channels: []` lo elimina apagando TODO. El
modo (c) —componer la cascada con sound/haptic— tiene un matiz **verificado en el engine**: el
meta-canal visual estampa `data-event-*` **siempre que el evento tenga algún canal** (`channels ≠
[]`), sin mirar _cuál_. Así que un coordinado con `channels: ['sound']` obtiene el sonido **y** la
firma visual genérica (`present-rise`) — que pelearía con la cascada. **La solución vive en MOTION,
no en sema** (sema queda intacto): eidos emite, junto a cada preset coordinado, una regla que
**neutraliza su propia firma visual** sobre la superficie —
`[data-animation-style='cascade-X'][data-event-phase='active'] { animation: none !important }`
(`render-css.ts` → `renderCoordinatedPresetRules`). La firma usa `animation` → muere; la cascada es
`transition` → sobrevive. Es **aditivo** (inerte hasta que un coordinado dispare un evento sema) y
coherente con la doctrina: **eidos es el dueño del eje visual**, así que el opt-out de SU firma vive
en eidos. Resultado: un coordinado declara `channels: ['sound']` y compone sonido + cascada, **sin
tocar el engine de sema** (§10 sigue valiendo). Test en `eidos/motion.test.ts`.
### B.3 — Ejemplo con tokens: `Button` (modo a) y su composición (modo c)

@ -5190,6 +5190,10 @@
transform: translateX(-16px);
}
[data-animation-style='cascade-slide'][data-event-phase='active'] {
animation: none !important;
}
[data-animation-style='cascade-fade'] {
transition: opacity var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out));
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
@ -5203,6 +5207,10 @@
opacity: 0;
}
[data-animation-style='cascade-fade'][data-event-phase='active'] {
animation: none !important;
}
[data-animation-style='cascade-scale'] {
transition: opacity var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out)), transform var(--motion-cascade-duration, var(--duration-moderate)) var(--motion-cascade-ease, var(--ease-out));
transition-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms));
@ -5217,6 +5225,10 @@
transform: scale(0.9);
}
[data-animation-style='cascade-scale'][data-event-phase='active'] {
animation: none !important;
}
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid Highlight;

@ -765,7 +765,16 @@ function renderCoordinatedPresetRules(name: string, preset: CoordinatedPreset):
renderBlock(`${sel}[data-ending-style]`, [
'transition-delay: calc((var(--motion-stagger-count, 1) - 1 - var(--motion-stagger-index, 0)) * var(--motion-stagger-each, 0ms));'
]),
renderBlock(`${sel}[data-starting-style], ${sel}[data-ending-style]`, offDecls)
renderBlock(`${sel}[data-starting-style], ${sel}[data-ending-style]`, offDecls),
// A coordinated surface OPTS OUT of sema's generic VISUAL firma: if it ALSO fires a
// sema event (so `data-event-*` is stamped — the engine stamps it for any non-empty
// `channels`), the generic `present-rise`/`dismiss-fade` keyframe would fight the
// cascade. Neutralise it HERE — eidos owns the visual axis, so the opt-out lives in
// MOTION, never in sema. `animation: none` kills the firma's keyframe; the cascade is
// a `transition`, so it survives. `!important` because an intent-specific firma
// selector (3 attrs) outranks this 2-attr one. This is the modo (c) enabler: a
// coordinated component can compose sound/haptic with the cascade (RFC Apéndice B).
renderBlock(`${sel}[data-event-phase='active']`, ['animation: none !important;'])
]
}

@ -76,6 +76,20 @@ describe('eidos motion — CSS generation', () => {
expect(css).not.toContain("[data-animation-style='cascade-slide'][data-state=");
});
it('coordinated surfaces opt OUT of the sema visual firma (modo c enabler)', () => {
// If a coordinated surface ALSO fires a sema event, the engine stamps data-event-*
// and eidos's generic firma (present-rise…) would fight the cascade. eidos
// neutralises its OWN firma here — motion owns the visual axis, sema is untouched:
// animation:none kills the firma keyframe; the cascade is a transition, so it
// survives. This lets a coordinated component compose sound/haptic.
expect(css).toMatch(
/\[data-animation-style='cascade-slide'\]\[data-event-phase='active'\] \{\s*animation: none !important;/
);
for (const name of ['cascade-slide', 'cascade-fade', 'cascade-scale']) {
expect(css).toContain(`[data-animation-style='${name}'][data-event-phase='active'] {`);
}
});
it('emits the Material transition presets (shared-axis + fade-through)', () => {
expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`);
expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`);

@ -21,16 +21,16 @@ import { v } from '../types';
export const revealMorfo = {
name: 'Reveal',
kebab: 'reveal',
// 'sema' because the morfo declares events (open/close) — they participate in the
// sema layer even though `channels: []` silences their perceptual surface.
scope: ['soma', 'sema'],
// The reveal's perception IS the coordinated cascade (eidos transitions on the
// Presence's data-starting/ending-style) — NOT a generic sema signature. So the
// open/close events carry `channels: []` (no perceptual surface) and expression is
// 'none': otherwise the generic emerge `present-rise`/`dismiss-fade` signature
// fires on the event and FIGHTS the coordinated motion (a real bug the demo hit).
// Doctrine: a coordinated-motion component silences the generic sema signal.
expression: 'none',
// Modo (c) — composición sema + motion (RFC Apéndice B). `open` declares
// `channels: ['sound']`: it PLAYS an `emerge` sound (sema) WHILE the coordinated
// cascade runs (motion). They don't fight because eidos NEUTRALISES its own generic
// VISUAL firma over coordinated surfaces (`render-css.ts`: `[data-animation-style][
// data-event-phase='active'] { animation: none !important }`) — the firma's keyframe
// dies, the cascade's `transition` survives. The opt-out lives in MOTION, never in
// sema. Both `open` and `close` declare `channels: ['sound']` — emerge sounds on
// appear AND on dismiss; eidos neutralises the visual firma either way (cascade survives).
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/',
texts: {
label: '#?components.reveal.label|Reveal'
@ -42,11 +42,12 @@ export const revealMorfo = {
family: 'emerge',
verb: 'open',
target: v.partRef('panel'),
// State is set in the soma handler → `post`, so a (future) perceptual
// hold never blocks the functional open (CLAUDE.md sequencing doctrine).
// State is set in the soma handler → `post`, so the perceptual hold never
// blocks the functional open (CLAUDE.md sequencing doctrine).
sequence: 'post',
// No perceptual surface — the coordinated cascade IS the feedback.
channels: []
// Modo (c): the sound plays WITH the cascade. eidos neutralises the generic
// VISUAL firma over the coordinated surface, so only sound + cascade land.
channels: ['sound']
}
},
{
@ -56,7 +57,9 @@ export const revealMorfo = {
verb: 'close',
target: v.partRef('panel'),
sequence: 'post',
channels: []
// Modo (c) también al cerrar — emerge sounds on dismiss too (symmetry); the
// exit cascade (data-ending-style) runs while the visual firma is neutralised.
channels: ['sound']
}
}
],

@ -23,9 +23,11 @@
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
// Engine present so portal components (Dialog / Popover / Drawer) hydrate;
// sound + haptic off to keep the visual-motion demo quiet.
events: { sound: false, haptic: false }
// `sound: true` so the modo (c) demo (Reveal — RFC Apéndice B) is audible: opening
// the reveal plays an `emerge` sound WHILE the cascade runs (eidos neutralises the
// generic visual firma over the coordinated surface, so they compose, not fight).
// haptic stays off. The sound also makes each Button's contact press audible.
events: { sound: true, haptic: false }
})
setActiveUix(uix)
Soma.create()

@ -69,6 +69,15 @@
(Panel + Items) como <code>data-animation-style</code> — se nombra una vez, el sistema sabe dónde
(RFC §5).
</p>
<p class="lede">
<strong>Modo (c) — compone sema + motion</strong> (RFC Apéndice B): al <strong>abrir y al
cerrar</strong>, los eventos declaran <code>channels: ['sound']</code> y suena un tono
<code>emerge</code> <strong>a la vez</strong> que corre la cascada. No pelean porque
<strong>eidos neutraliza su propia firma visual</strong> sobre las superficies coordinadas
(<code>animation: none</code> donde hay <code>data-animation-style</code> +
<code>data-event-phase</code>); la cascada es <code>transition</code> y sobrevive. El opt-out vive
en <strong>motion</strong>, sema intacto. (Sube el volumen; el audio arranca con el primer click.)
</p>
<div class="actions">
<div class="picker" role="group" aria-label="Estilo de animación">
{#each STYLES as s (s.value)}

Loading…
Cancel
Save

Powered by TurnKey Linux.