22 KiB
Guía de implementación — Semántica perceptiva en UIX
De la teoría del libro a la arquitectura del framework
Este documento traduce las decisiones del libro Semántica perceptiva de la interfaz a la arquitectura UIX (Morfo/Soma/Sema/Eidos). No repite la teoría — la convierte en contratos, vocabularios, reglas de resolución y convenciones técnicas.
Documento autoritativo para toda migración, wrapper nuevo o extensión de componente.
1. Vocabulario canónico corregido
1.1. Familias
7 familias. Sin excepciones.
export const SEMA_FAMILIES = [
'contact', // ¿el sistema ha sentido mi acción?
'commit', // ¿algo quedó fijado o tuvo consecuencia?
'signal', // ¿algo reclama mi atención?
'handle', // ¿estoy manipulando directamente un objeto?
'emerge', // ¿algo entró o salió del campo perceptivo?
'shift', // ¿cambió el marco operativo?
'sustain' // ¿esto sigue ocurriendo?
] as const;
Cambios respecto a la implementación actual:
| Antes | Ahora | Razón |
|---|---|---|
alert como familia |
signal con verbo alert |
alert es intensidad dentro de signal, no familia |
sin shift |
shift añadido |
emerge ≠ shift — dropdown ≠ modal |
sin loss |
loss como intent |
threat ≠ loss — amenaza ≠ pérdida consumada |
1.2. Intents
6 intents. Regiones evaluativas del espacio valencia/activación.
export const SEMA_INTENTS = [
'neutral', // sin carga evaluativa fuerte
'affirm', // confirmación positiva, baja activación
'fulfill', // resolución positiva, mayor activación
'risk', // problema corregible, negativo moderado
'threat', // amenaza activa, alta activación negativa
'loss' // pérdida consumada, consecuencia ya ocurrida
] as const;
1.3. Verbs por familia
export const SEMA_VERBS = {
contact: [
'press', 'tap', 'activate', 'focus', 'trigger', 'release'
],
commit: [
'select', 'toggle', 'save', 'submit', 'confirm', 'cancel',
'complete', 'fail', 'delete', 'restore', 'reset', 'discard',
'expire', 'acknowledge', 'set', 'remove', 'reorder'
],
signal: [
'announce', 'notify', 'warn', 'alert', 'emphasize', 'remind'
],
handle: [
'pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'
],
emerge: [
'present', 'dismiss', 'open', 'close', 'expand', 'collapse',
'reveal', 'hide'
],
shift: [
'enter-mode', 'exit-mode', 'navigate', 'route', 'step',
'return', 'context'
],
sustain: [
'start', 'progress', 'loading', 'waiting', 'syncing',
'processing', 'streaming', 'pending', 'retrying', 'end'
]
} as const;
Cambios de verbs:
| Antes | Ahora | Razón |
|---|---|---|
contact.select |
commit.select |
seleccionar fija estado = commit |
contact.toggle |
commit.toggle |
alternar fija estado = commit |
handle.acknowledge |
commit.acknowledge |
reconocer cierra algo = commit |
handle.edit |
shift.enter-mode |
editar cambia régimen = shift |
2. Sistema de 8 tokens de color
2.1. Los 8 valores
Dos ejes ortogonales:
Jerarquía (sin carga evaluativa):
primary— acción principalsecondary— acción secundaria
Intent (carga evaluativa):
neutral— sin juicio fuerteaffirm— confirmación suavefulfill— objetivo cumplidorisk— problema corregiblethreat— amenaza activaloss— pérdida consumada
2.2. Regla de resolución intent ↔ color
visualColor =
intent !== 'neutral' → intent // la carga evaluativa gana
intent === 'neutral' → color ?? 'neutral' // jerarquía si se pasó, neutral si no
Un componente recibe dos props ortogonales con prioridad clara:
intent(semántico) — siempre presente, defaultneutral. Decide firma sema y, si es evaluativo, también el color visual.color(jerárquico) — opcional, soloprimary | secondary. Override visual cuando NO hay carga evaluativa. Si pasascolor='primary'conintent='threat', el intent gana.
<Toggle /> <!-- data-color='neutral' -->
<Toggle color="primary" /> <!-- data-color='primary' -->
<Toggle intent="affirm" /> <!-- data-color='affirm' -->
<Toggle intent="threat" color="primary" /> <!-- data-color='threat' (intent gana) -->
2.3. Mapeo desde convención heredada
| Convención CSS | Token UIX | Nota |
|---|---|---|
| primary | primary |
jerarquía, no intent |
| secondary | secondary |
jerarquía, no intent |
| success | affirm o fulfill |
affirm = confirmación suave, fulfill = objetivo cumplido |
| warning | risk |
problema corregible |
| danger | threat o loss |
threat = antes, loss = después |
| info | signal.announce + neutral |
info no es intent, es función de atención |
2.4. Tokens CSS por theme
Cada theme define los 8 tokens:
:root {
--color-primary-element: ...;
--color-secondary-element: ...;
--color-neutral-element: ...;
--color-affirm-element: ...;
--color-fulfill-element: ...;
--color-risk-element: ...;
--color-threat-element: ...;
--color-loss-element: ...;
}
El theme decide hue/sat/lightness por marca. El componente solo declara qué token leer.
2.5. CSS en recipes
[data-color='primary'] { ... }
[data-color='secondary'] { ... }
[data-color='neutral'] { ... }
[data-color='affirm'] { ... }
[data-color='fulfill'] { ... }
[data-color='risk'] { ... }
[data-color='threat'] { ... }
[data-color='loss'] { ... }
Los selectores legacy (info, success, warning, danger) se eliminan.
3. Subset por componente
No todo componente acepta los 8 valores. Cada componente declara su subset permitido.
3.1. Tabla de subsets
| Componente | color | intent permitidos |
|---|---|---|
| Button (acción) | primary, secondary | neutral, affirm, fulfill, risk, threat, loss |
| Button (nav) | primary, secondary | neutral |
| Toggle / Switch | primary, secondary | neutral, affirm, risk, threat |
| Checkbox / Radio | primary, secondary | neutral, affirm |
| Input | — | neutral, risk |
| Select / Combobox | primary, secondary | neutral, affirm |
| Slider | primary | neutral, affirm |
| Alert inline | — | risk |
| Alert crítica | — | threat |
| Toast / Snackbar | — | neutral, affirm, risk |
| Toast con undo | — | loss |
| Banner | — | neutral, risk |
| Badge | — | neutral, risk |
| Modal | — | neutral, threat, risk |
| Spinner / Skeleton | — | no acepta intent |
| Progress bar | — | no acepta intent |
3.2. Regla de subset
Si un componente no admite loss, no aparece [data-color='loss'] en su recipe CSS. El intent se valida en TypeScript:
type ToggleIntent = Extract<SemanticIntent, 'neutral' | 'affirm' | 'risk' | 'threat'>;
3.3. Regla: el intent no nace del componente
El intent no nace del componente. Pero el componente debe poder recibirlo para expresarlo. La diferencia con la convención heredada es el orden:
- Convención: diseñador pinta botón de rojo → botón "es" danger → intent nace del color
- UIX: evento es commit.delete + loss → componente recibe loss → aplica firma perceptiva
3.4. Regla: la estética no contradice la semántica
La estética tiene libertad dentro del rango que la semántica permite. Un affirm puede ser verde esmeralda o azul suave — eso es estética. Pero un affirm no puede ser rojo con icono de alerta — eso es contradicción semántica.
4. Familias y componentes: quién expresa qué
4.1. emerge vs shift
| Componente | Familia | Razón |
|---|---|---|
| Dropdown | emerge.open | aparición local, no cambia marco |
| Popover | emerge.open | aparición anclada |
| Tooltip | emerge.present | información auxiliar |
| Accordion | emerge.expand | contenido contenido |
| Modal / Dialog | shift.enter-mode | cambia marco, captura foco, subordina fondo |
| Command Palette | shift.enter-mode | cambia régimen operativo |
| Edit mode | shift.enter-mode | cambia qué puede hacerse |
| Wizard step | shift.step | avanza en proceso |
| Route change | shift.navigate | nuevo contexto |
| Drawer (pesado) | shift.enter-mode | si bloquea fondo y captura foco |
| Drawer (ligero) | emerge.open | si no bloquea ni captura |
4.2. contact vs commit
contact = el sistema recibió mi gesto
commit = algo quedó fijado como consecuencia
Si la operación es instantánea (toggle, checkbox, select), el usuario percibe un solo evento: commit. El contact está implícito en el gesto. Se documenta como un solo evento semántico.
4.3. signal vs emerge
emerge = algo entra o sale del campo perceptivo
signal = algo reclama atención
Un toast que aparece es emerge.present. El mensaje dentro puede ser signal.notify + neutral o commit.save + affirm. La aparición no es la señal.
5. Morfo: declaración semántica del componente
5.1. Estructura del evento en Morfo
events: [
{
name: 'commit-toggle',
semantic: {
family: 'commit',
verb: 'toggle',
intent: undefined, // lo decide el provider según contexto
target: v.partRef('root'),
sequence: 'post' // la señal ocurre después del cambio de estado
}
}
]
5.2. Campo sequence (timing del evento)
sequence: 'pre' | 'coincident' | 'post'
| Valor | Cuándo usar | Ejemplo |
|---|---|---|
pre |
señal perceptiva antes del cambio estructural | emerge.dismiss (animar salida antes de cerrar) |
coincident |
señal durante el proceso | sustain.progress |
post |
señal después del resultado real | commit.save + affirm (confirmar después de guardar) |
No todo evento debe ser pre como el Toast dismiss. Contact debe ser post (inmediato, sin bloquear estado). Commit.save debe ser post (no celebrar antes de que exista resultado).
5.3. Capacidad semántica vs evento fijo
Morfo puede declarar capacidad semántica cuando el componente soporta varios eventos según contexto:
events: [
{
name: 'close',
semantic: {
allowedFamilies: ['shift', 'commit', 'emerge'],
defaultSemantic: { family: 'shift', verb: 'exit-mode' }
}
}
]
El provider concreta:
// Dialog con cambios sin guardar
semantic: { family: 'commit', verb: 'discard', intent: 'loss' }
// Dialog informativo
semantic: { family: 'shift', verb: 'exit-mode' }
6. Sema: holds y persistencia
6.1. Separar hold expresivo de persistencia semántica
hold: number // duración expresiva mínima del evento (ms)
persistence: 'transient' | 'untilAction' | 'untilFix' | 'stateBound'
| Tipo | Significado | Ejemplo |
|---|---|---|
transient |
desaparece tras hold | contact.press, commit.save + affirm |
untilAction |
persiste hasta que el usuario actúe | signal.alert + threat |
untilFix |
persiste hasta corrección | signal.warn + risk |
stateBound |
ligado al estado del proceso | sustain.progress |
6.2. Holds por familia e intent
const SEMA_HOLDS = {
contact: 120,
emerge: 180,
shift: 240,
commit: {
neutral: 200,
affirm: 180,
fulfill: 280,
risk: 240,
threat: 240,
loss: 240
},
signal: {
neutral: 240,
risk: 'untilFix',
threat: 'untilAction',
loss: 400
},
handle: {
pick: 120,
drop: 180
},
sustain: 'stateBound'
};
6.3. Sustain no tiene hold fijo
Sustain no es un evento transitorio. Es un estado que dura mientras dura el proceso. No se le asigna hold de 600ms ni de ningún valor fijo. Se gestiona como stateBound: el canal visual mantiene los atributos mientras el provider indique que el proceso sigue activo.
7. Canal visual: atributos DOM
7.1. Señales transitorias (Sema escribe, Eidos lee)
data-event="commit-toggle"
data-event-id="sig-42"
data-event-phase="active"
data-event-family="commit"
data-event-intent="affirm"
Viven durante el hold. Se limpian antes de resolver la Promise.
7.2. Estado persistente (Soma/Effects escriben, Eidos lee)
data-state="open"
data-color="primary"
data-intent="risk"
data-disabled
data-pressed
Viven mientras el estado sea verdadero. No son señales transitorias.
7.3. Regla: no mezclar transitorio y persistente
El canal visual nunca toca atributos de estado (data-state, data-intent, data-disabled). El estado lo gestiona el runtime. Razón: estado es persistente y señal es transitoria. Pisar el mismo nombre fuerza al canal a borrar estado al limpiar.
8. Composiciones simultáneas
8.1. V1: un solo evento activo por target
Mantener un solo data-event activo. Suficiente para:
- dismiss, open, press, complete, toggle, select
8.2. V2 (futuro): slots semánticos
Para composiciones simultáneas como:
shift.enter-mode + signal.alert + threat
sustain.progress + signal.warn + risk
handle.carry + signal.warn + threat
Añadir slots:
data-event-frame="shift.enter-mode"
data-event-signal="signal.alert"
data-event-intent="threat"
Así el modal (shift) no absorbe el intent de su contenido (signal).
9. Accesibilidad semántica en Morfo
9.1. Contrato a11y por evento
a11ySemantic: {
requiresPersistentTrace?: boolean;
requiresLiveRegion?: boolean;
requiresFocusMove?: boolean;
keyboardEquivalent?: boolean;
reducedMotionFallback?: 'state' | 'text' | 'focus' | 'none';
}
9.2. Ejemplos
// signal.warn + risk
a11ySemantic: {
requiresPersistentTrace: true,
reducedMotionFallback: 'text'
}
// signal.alert + threat
a11ySemantic: {
requiresPersistentTrace: true,
requiresLiveRegion: true,
requiresFocusMove: true
}
// handle (drag)
a11ySemantic: {
keyboardEquivalent: true
}
// commit.delete + loss
a11ySemantic: {
requiresPersistentTrace: true
}
10. Sound y Vibra
10.1. Principios
- Sound y Vibra reciben el signal directamente del engine, no leen DOM.
- Son fire-and-forget: no bloquean al caller.
- Son opcionales, proporcionales y nunca únicos.
- La semántica debe poder vivir sin ellos.
10.2. Configuración por familia/intent
sound: {
enabled: false, // opt-in global
allowFamilies: ['signal', 'commit'], // solo estas familias pueden sonar
allowIntents: ['fulfill', 'threat', 'loss'], // solo estos intents
muteFrequentEvents: true // silenciar contact frecuente
}
10.3. Eager-init
AudioContext se crea/resume en el primer user gesture (click/touch/keydown en document, capture phase). Convención aplicable a cualquier canal con restricción de "primera vez en gesture": audio, vibration, fullscreen, clipboard write.
11. Firma perceptiva por evento
Una firma perceptiva es el conjunto de decisiones de canal coordinadas alrededor de un evento.
Las dimensiones no son un conjunto cerrado. El framework ships con 5
canales canónicos (motion, sound, color, presence, haptic) y el
registry SemaChannelSignatures se extiende vía TypeScript declaration
merging cuando una app necesita a11y, voice, etc. Una family puede
no rellenar presence; otra puede aportar haptic. Una rule de
cascade puede silenciar motion para reducir estimulación. Los ejemplos
abajo enumeran las dimensiones relevantes para CADA evento — no una
lista canónica fija.
11.1. Ejemplo: commit.save + affirm
tiempo: breve
motion: asentamiento mínimo
forma: marca persistente
color: affirm (positivo discreto)
sonido: no por defecto
háptica: no por defecto
accesibilidad: texto o estado visible
11.2. Ejemplo: signal.alert + threat
presencia: dominante
forma: bloque crítico
color: threat (alta saliencia)
texto: acción clara
sonido: opcional, urgente
motion: entrada saliente
persistencia: hasta acción
accesibilidad: foco + live region + persistente
11.3. Ejemplo: commit.delete + loss
presencia: retirada + huella
forma: undo si existe
color: loss (grave/desaturado)
sonido: seco/grave opcional
motion: retirada/descenso
persistencia: huella
accesibilidad: anuncio + undo persistente + foco no perdido
no usar: alarma sostenida de threat
12. Arquetipos y familias frecuentes
Relación orientativa entre archetipos de Morfo y familias:
| Archetype | Familias frecuentes |
|---|---|
| trigger | contact, emerge, shift, commit |
| content | emerge, shift, signal |
| item | commit, handle, signal |
| overlay | shift |
| indicator | sustain, commit |
| thumb | handle |
| track | handle, sustain |
No como regla rígida, sino como documentación que ayuda a decidir qué eventos puede expresar un componente.
13. Doctrina del API: soma compound, eidos opinionado
13.1. Las dos capas exponen formas distintas
soma + morfo → composable universal — TODO caso (avanzado, raro, custom)
eidos → ergonomic opinionado del design system para el 90% case
Soma siempre es compound (Component.Provider, Component.Trigger,
Component.Content, …) por simetría: el desarrollador aprende un
patrón único, todas las partes son visibles, el contrato cross-part
(IDs, ARIA refs) queda explícito.
Eidos depende de la cardinalidad del componente:
- Single-part (Toggle, Switch): el
defaultflat colapsa el Provider en una sola llamada. Compound queda disponible para consumers que prefierenComponent.Provider. - Multi-part (Collapsible, Dialog, Drawer, Popover, Toast):
defaultflat auto-compone los parts a partir de snippets (e.g.triggersnippet →Triggerpart,children→Content). Compound (Component.Provider/.Trigger/.Content) queda disponible para casos avanzados.
13.2. Cuándo cada forma
| Caso | Forma recomendada |
|---|---|
| Disclosure simple | <Collapsible>{...}</Collapsible> (flat) |
| Trigger en header, content en sidebar | <Collapsible.Provider> + parts en sus subtrees |
| Múltiples triggers controlando el mismo content | compound — el flat solo permite uno |
| Content condicional por estado externo | compound — el flat siempre renderea Content |
| Toggle button básico | <Toggle> (flat) |
| Toggle dentro de un compound más grande | <Toggle.Provider> (compound) |
13.3. La virtud arquitectónica
Si la forma flat de eidos no encaja, el desarrollador baja a soma + morfo y compone libremente. No hay vendor lock-in en la capa visual: soma es la primitiva universal, eidos es la convención del design system. Cualquier consumer que necesite shapes que el flat no expresa (un Collapsible que vive en un portal, un Dialog con dos triggers, un Toast con animation custom) NO está bloqueado — recurre a $soma/components/{x} directamente.
Esto es la razón por la que eidos no impone su flat: lo ofrece como atajo del 90% case y deja la composición libre intacta abajo. El precio (un wrapper flat por componente multi-parte) es bajo; el beneficio (ergonomía + libertad) es alto.
13.4. Plantilla del flat wrapper para multi-part
<!-- eidos/components/{name}/{name}.svelte -->
<script lang="ts">
import * as Component from '$soma/components/{name}';
import type { ComponentFlatProps } from './types';
let {
/* bindable state */,
/* snippet slots, e.g. trigger, header */,
children,
...rest
}: ComponentFlatProps = $props();
</script>
<Component.Provider {...rest} bind:state>
<Component.Trigger>{#if trigger}{@render trigger()}{/if}</Component.Trigger>
<Component.Content>{@render children?.()}</Component.Content>
</Component.Provider>
Y el index.ts:
export { default } from './{name}.svelte'; // flat
export { default as Provider } from './{name}-provider.svelte'; // compound
export { default as Trigger } from './{name}-trigger.svelte';
export { default as Content } from './{name}-content.svelte';
Para single-part el default es directamente el provider envuelto:
// eidos/components/toggle/index.ts
export { default } from './toggle.svelte'; // flat = provider
export { default as Provider } from './toggle.svelte';
14. Plan de migración
14.1. Prioridad 1 — Vocabulario
- Renombrar
alert→signalen SEMA_FAMILIES - Añadir
shifta SEMA_FAMILIES - Añadir
lossa SEMA_INTENTS - Mover verbs: select/toggle a commit, acknowledge a commit, edit a shift
- Actualizar SEMA_VERBS con la tabla completa
14.2. Prioridad 2 — Tokens de color
- Añadir tokens:
secondary,affirm,loss - Renombrar:
success → fulfill,warning → risk,danger → threat - Eliminar:
info - Implementar regla de resolución intent ↔ color en providers
14.3. Prioridad 3 — Holds y persistencia
- Separar
holdexpresivo depersistencesemántica - Actualizar holds por familia/intent según tabla
- Sustain como
stateBound, no hold fijo
14.4. Prioridad 4 — Sequence timing
- Añadir campo
sequencea morfo events - Implementar
pre | coincident | posten runtime.trigger
14.5. Prioridad 5 — a11ySemantic
- Añadir contrato a11ySemantic a morfo events
- Implementar reducedMotionFallback, liveRegion, focusMove
14.6. Componentes por migrar
| Componente | CSS legacy | Wrapper | Subset color |
|---|---|---|---|
| toggle | — | ✅ piloto | primary, secondary, neutral, affirm, risk, threat |
| switch | switch.css | ⏳ | primary, secondary, neutral, affirm, risk, threat |
| dialog | dialog.css | ⏳ | neutral, risk, threat |
| drawer | drawer.css | ⏳ | neutral |
| popover | popover.css | ⏳ | neutral |
| toast | toast.css | ⏳ | neutral, affirm, risk, loss |
| checkbox | checkbox.css | ⏳ | primary, secondary, neutral, affirm |
| accordion | accordion.css | ⏳ | neutral |
| tabs | tabs.css | ⏳ | neutral |
| tooltip | tooltip.css | ⏳ | neutral |
15. Reglas invariantes
- El intent no nace del componente. Pero el componente debe poder recibirlo.
- La estética no contradice la semántica. Libertad dentro del rango que el intent permite.
- Color expresa intent, no lo define. El intent viene de la evaluación del evento.
- Emerge ≠ shift. Dropdown = emerge. Modal = shift.
- Threat ≠ loss. Antes de la consecuencia ≠ después de la consecuencia.
- Affirm ≠ fulfill. Confirmación suave ≠ objetivo cumplido.
- Info no es intent. Es signal.announce + neutral.
- Sustain no tiene hold fijo. Dura mientras dure el proceso.
- Los canales no-visuales no leen DOM. Reciben signal del engine.
- La semántica debe sobrevivir sin color, sin motion, sin sonido y sin háptica.
Documento derivado de "Semántica perceptiva de la interfaz" (Navarro Leal) y la arquitectura UIX (Morfo/Soma/Sema/Eidos).