From b66039adb11ae952015caa7581ba13914f383840 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 27 Apr 2026 13:55:00 +0200 Subject: [PATCH] sema: VisualChannel preserves structural data-intent on cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel writes `data-intent` from `signal.intent` during the hold, but `data-intent` is also the structural attr the morfo of components like Toast.Item writes from `v.propRef('intent')`. Removing it on cleanup erased the structural value — the toast lost its tinting after the announce signal expired. Fix: save the prior `data-intent` value before overwrite, restore it on cleanup. General principle — a channel should clean only what it wrote. Other signal attrs (`data-event*`, `data-event-family`) are signal- specific so no collision. Tests: structural data-intent survives the hold; mismatched signal vs structural intent (edge case) restores the structural value. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/sema/chans/visual.test.ts | 47 +++++++++++++++++++++++++++++++ src/uix/sema/chans/visual.ts | 18 ++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/uix/sema/chans/visual.test.ts b/src/uix/sema/chans/visual.test.ts index bc012395d..59b276ad7 100644 --- a/src/uix/sema/chans/visual.test.ts +++ b/src/uix/sema/chans/visual.test.ts @@ -165,4 +165,51 @@ describe('VisualChannel', () => { const channel = new VisualChannel() expect(channel.id).toBe('visual') }) + + it('preserves a structural data-intent that existed before the signal', async () => { + // Toast.Item carries a structural data-intent from morfo (v.propRef('intent')). + // The channel must not erase it during cleanup. + target.setAttribute('data-intent', 'risk') + + const channel = new VisualChannel() + const promise = channel.handle({ + target, + name: 'announce', + family: 'alert', + intent: 'risk', + id: 'sig-1', + hold: 100 + }) + + expect(target.getAttribute('data-intent')).toBe('risk') + + vi.advanceTimersByTime(100) + await promise + + // signal attrs gone, structural data-intent restored + expect(target.hasAttribute('data-event')).toBe(false) + expect(target.getAttribute('data-intent')).toBe('risk') + }) + + it('restores a different prior data-intent when signal carries another value', async () => { + // Edge case: structural intent and signal intent disagree (would be unusual + // but the channel should still leave the structural value intact). + target.setAttribute('data-intent', 'neutral') + + const channel = new VisualChannel() + const promise = channel.handle({ + target, + name: 'announce', + intent: 'threat', + id: 'sig-2', + hold: 100 + }) + + expect(target.getAttribute('data-intent')).toBe('threat') + + vi.advanceTimersByTime(100) + await promise + + expect(target.getAttribute('data-intent')).toBe('neutral') + }) }) diff --git a/src/uix/sema/chans/visual.ts b/src/uix/sema/chans/visual.ts index 38adde157..630fe0abe 100644 --- a/src/uix/sema/chans/visual.ts +++ b/src/uix/sema/chans/visual.ts @@ -17,6 +17,11 @@ import type { Channel } from './types' * Los dos opcionales son proyección al DOM de la metadata semántica que * Eidos consume (`[data-intent="risk"]`, `[data-event-family="alert"]`). * + * `data-intent` es el único nombre que puede coincidir con un attr + * estructural ya escrito por el morfo del componente. El canal lo guarda + * antes de overwrite y lo restaura al limpiar (save/restore) — regla + * general: un canal solo debe limpiar lo que escribió. + * * Es un detalle de este canal, no API pública del engine: si en el futuro * otros canales (sound, vibra) quieren cosas distintas en sus respectivos * planos, las definirán por separado. @@ -69,7 +74,13 @@ export class VisualChannel implements Channel { const id = signal.id ?? '' - // Escribir atributos + // `data-intent` puede coincidir con un attr estructural ya escrito por + // el morfo del componente (p. ej. Toast.Item lleva `data-intent` desde + // `v.propRef('intent')`). Guardamos el valor previo y lo restauramos en + // cleanup para no borrar el attr estructural — el canal debe limpiar + // solo lo que escribió. + const priorIntent = signal.intent ? target.getAttribute('data-intent') : null + target.setAttribute('data-event', signal.name) target.setAttribute('data-event-id', id) target.setAttribute('data-event-phase', 'active') @@ -89,7 +100,10 @@ export class VisualChannel implements Channel { target.removeAttribute('data-event-id') target.removeAttribute('data-event-phase') if (signal.family) target.removeAttribute('data-event-family') - if (signal.intent) target.removeAttribute('data-intent') + if (signal.intent) { + if (priorIntent !== null) target.setAttribute('data-intent', priorIntent) + else target.removeAttribute('data-intent') + } } private resolveHoldMs(signal: SemanticSignal): number {