sema: VisualChannel preserves structural data-intent on cleanup

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) <noreply@anthropic.com>
morfo-runtime
dev 5 months ago
parent 4f0108f839
commit b66039adb1

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

@ -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 {

Loading…
Cancel
Save

Powered by TurnKey Linux.