From 4ff9f0d27732bc98c490d927e646bbfbc7cd1056 Mon Sep 17 00:00:00 2001
From: dev
Date: Mon, 15 Jun 2026 15:19:08 +0200
Subject: [PATCH] =?UTF-8?q?feat(motion):=20modo=20(c)=20=E2=80=94=20compos?=
=?UTF-8?q?ici=C3=B3n=20sema=20+=20motion=20(opt-out=20visual=20en=20eidos?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/uix/eidos/MOTION_SERVICE_RFC.md | 23 +++++++++-----
src/uix/eidos/generated/base.css | 12 +++++++
src/uix/eidos/lib/render-css.ts | 11 ++++++-
src/uix/eidos/motion.test.ts | 14 +++++++++
src/uix/morfo/components/reveal.ts | 31 ++++++++++---------
web/routes/temas/animations/+layout.svelte | 8 +++--
.../temas/animations/reveal/+page.svelte | 9 ++++++
7 files changed, 83 insertions(+), 25 deletions(-)
diff --git a/src/uix/eidos/MOTION_SERVICE_RFC.md b/src/uix/eidos/MOTION_SERVICE_RFC.md
index 5ef4ffd6a..59b77d0e6 100644
--- a/src/uix/eidos/MOTION_SERVICE_RFC.md
+++ b/src/uix/eidos/MOTION_SERVICE_RFC.md
@@ -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)
diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css
index 7834b3ee5..839bb6a77 100644
--- a/src/uix/eidos/generated/base.css
+++ b/src/uix/eidos/generated/base.css
@@ -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;
diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts
index ded174cc7..e2d33c832 100644
--- a/src/uix/eidos/lib/render-css.ts
+++ b/src/uix/eidos/lib/render-css.ts
@@ -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;'])
]
}
diff --git a/src/uix/eidos/motion.test.ts b/src/uix/eidos/motion.test.ts
index ce588c659..9f4308425 100644
--- a/src/uix/eidos/motion.test.ts
+++ b/src/uix/eidos/motion.test.ts
@@ -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']`);
diff --git a/src/uix/morfo/components/reveal.ts b/src/uix/morfo/components/reveal.ts
index da35882e7..2933a69f0 100644
--- a/src/uix/morfo/components/reveal.ts
+++ b/src/uix/morfo/components/reveal.ts
@@ -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']
}
}
],
diff --git a/web/routes/temas/animations/+layout.svelte b/web/routes/temas/animations/+layout.svelte
index a5ab8b467..1fb24b564 100644
--- a/web/routes/temas/animations/+layout.svelte
+++ b/web/routes/temas/animations/+layout.svelte
@@ -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()
diff --git a/web/routes/temas/animations/reveal/+page.svelte b/web/routes/temas/animations/reveal/+page.svelte
index cd09af698..1d0773413 100644
--- a/web/routes/temas/animations/reveal/+page.svelte
+++ b/web/routes/temas/animations/reveal/+page.svelte
@@ -69,6 +69,15 @@
(Panel + Items) como data-animation-style — se nombra una vez, el sistema sabe dónde
(RFC §5).
+
+ Modo (c) — compone sema + motion (RFC Apéndice B): al abrir y al
+ cerrar, los eventos declaran channels: ['sound'] y suena un tono
+ emerge a la vez que corre la cascada. No pelean porque
+ eidos neutraliza su propia firma visual sobre las superficies coordinadas
+ (animation: none donde hay data-animation-style +
+ data-event-phase); la cascada es transition y sobrevive. El opt-out vive
+ en motion, sema intacto. (Sube el volumen; el audio arranca con el primer click.)
+
{#each STYLES as s (s.value)}