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