Files
whatsapp-api-mockup/SPEC.md
T
urieljarethandClaude Opus 5.5 3509a77d62 Editor y toolbar: jerarquía, tarjetas legibles, foco seguro y autoPulsar estable
- Toolbar: «Nueva demo» primaria junto a la marca (N); Tema como icono;
  «Volver al ejemplo Café Aurora» (peligro) solo dentro del menú «⋯»;
  etiquetas de Nueva demo y Presentación visibles entre 900 y 1100 px.
- Tarjetas: flecha de dirección en el número (↙/↗), badge con icono
  (solo icono en editor estrecho), resumen en 2.ª línea al abrir, hueco
  de ↑/↓ reservado (sin saltos al pasar el ratón), container queries.
- Borrar (mensaje, botón, fila, tarjeta, respuesta) nunca deja el foco
  en otro botón ✕.
- autoPulsar sigue al elemento al mover/borrar/arrastrar; opción
  inexistente visible y aviso; casilla «Se pulsa sola» por elemento,
  chip «▶ auto» y select arriba del formulario.
- Deshacer/rehacer conservan el tema; el tema marca «Guardando…».
- Escenarios: siempre punto de deshacer, un único aviso con acción y
  foco al nombre de la cuenta; chips de tipos en el diálogo Demos,
  rejilla de 4 columnas en >= 1280 y sin nombre precargado.
- Paleta sin estado seleccionado, «+» en hover/foco, chip de destino
  y «+ Otro» solo tras el primer alta.
- Avisos abajo al centro sobre la vista previa; tel: válido en
  plantillas (máx. 1); interruptor «Mostrar cabecera» fijo (role=switch).
- Teclado: enlace «Saltar a la vista previa» y G / F6; atajos con ⌘ en
  Mac; etiqueta «Velocidad»; «Repetir desde el inicio».
- Demo vacía: herramientas ocultas y pista dentro del chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:32:22 -06:00

296 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPEC — WhatsApp Cloud API Mockup (demo visual 100 % estática)
Web estática: `index.html` + `css/*.css` + `js/*.js`. Sin frameworks, sin build, sin módulos ES,
sin `fetch()` de archivos locales; funciona abriendo `index.html` por `file://` con doble clic.
Único global: `window.WAM` (cada JS hace `window.WAM = window.WAM || {}` y añade propiedades).
Estado único y vivo en `WAM.estado`; tras toda mutación: `WAM.guardar()` + re-render.
Bases: `docs/research-api.md` (payloads y límites) y `docs/research-ui.md` (colores, burbujas, marco). UI en español.
## 1. Modelo de datos (`WAM.estado`, JSON serializable → localStorage / export)
```json
{
"version": 1,
"tema": "claro",
"cuenta": {
"nombre": "Café Aurora",
"avatar": "",
"subtitulo": "en línea",
"verificado": true,
"avisoCifrado": true
},
"mensajes": [ ]
}
```
Sobre común de CADA mensaje (`Mensaje`): `{ "id": "m1", "dir": "in"|"out",
"tipo": "<ver union>", "hora": "10:24" (texto literal HH:MM), "estado": "sent"|"delivered"|"read" }`.
`estado` solo se renderiza si `dir:"out"` (ticks abajo-derecha); por defecto `"sent"`.
Campos EXACTOS por `tipo` (los que faltan no aplican; `respuestas` = array de `Mensaje` completos
con `dir:"in"`, anidados, NO se renderizan en el chat: los consume la interactividad):
1. `text`: `texto`
2. `image`: `src`, `caption`
3. `video`: `src`, `caption`
4. `audio`: `src` (opcional), `voz` (bool: nota de voz), `duracion` ("0:12")
5. `document`: `src` (ignorado), `caption`, `filename` (con extensión; el icono depende de ella)
6. `sticker`: `src` (WebP/imagen; sin burbuja de texto ni hora dentro)
7. `location`: `latitud`, `longitud`, `nombre`, `direccion`
8. `contact`: `nombre` (formatted_name), `telefono`, `email`, `empresa`
9. `template`: `headerTipo` (""|"texto"|"imagen"|"video"|"documento"), `headerTexto`, `headerMedia`,
`body`, `footer`, `botones`: `[ { "texto", "url" (opcional, ≤2 con url), "respuestas": [Mensaje…] } ]`
10. `buttons` (quick replies, máx 3): `headerTipo` (""|"texto"|"imagen"|"video"|"documento"),
`headerTexto`, `headerMedia`, `body`, `footer`, `botones`: `[ { "texto", "respuestas" } ]`
11. `list`: `header`, `body`, `footer`, `boton` (etiqueta ≤20), `secciones`:
`[ { "titulo", "filas": [ { "titulo", "descripcion", "respuestas" } ] } ]`
12. `cta_url`: `headerMedia` ("" = sin header), `body`, `footer`, `textoBoton`, `url`
13. `carousel` (2–5 cards): `body`, `cards`:
`[ { "media", "texto", "textoBoton", "url", "respuestas" } ]`
Convenciones: `src`/`avatar`/`headerMedia`/`media` = dataURL o URL remota; `""` → placeholder SVG
de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `<a target="_blank">`
(cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`.
`WAM.normalizar(o)` rellena campos faltantes por tipo y devuelve el estado válido (import/upgrade).
Campo opcional `autoPulsar` (entero ≥ 0) en `template`/`buttons`/`list`/`carousel` (también en respuestas
anidadas): índice del botón, de la fila aplanada de la lista o de la card que «Reproducir demo» pulsa sola
~1 s después de revelar el mensaje. `normalizar` lo omite si no es válido. El editor lo mantiene al
reordenar o borrar botones / filas / cards (sigue al elemento; se quita si se borró el pulsado).
**Guion frente a sesión.** `estado.mensajes` es el guion (persistente). Lo que se pulsa o escribe en el
teléfono es la sesión: `WAM.sesion` (array en memoria, IDs `s1`, `s2`…), pintada con `anadirMensajeDOM`
sin tocar el estado ni localStorage. Todo `WAM.render()` vuelve al guion: vacía la sesión, cierra la hoja y
cancela demo y flujos (interactivity.js envuelve `WAM.render`).
## 2. Contratos de módulos (namespace único `WAM`) e IDs DOM compartidos
### js/store.js (dueño D) — estado, persistencia, demo
- `WAM.CLAVE = "wam-mockup-v1"` · `WAM.estado` (objeto vivo)
- `WAM.datosDemo()` → estado nuevo de la demo (§6) · `WAM.cargar()` → estado (LS o demo; normaliza)
- `WAM.guardar()` → void (JSON a localStorage; la 1.ª llamada escribe al instante y las de los 250 ms
siguientes se agrupan; se vacía en `pagehide`) · `WAM.guardarYa()` → bool (escritura inmediata)
- Error de guardado (cuota llena / LS bloqueado): llama UNA vez al hook opcional `WAM.onErrorGuardar(texto, error)`
(main.js lo pinta con `WAM.avisar`) hasta el siguiente guardado correcto.
- `WAM.resetear()` → void (borra LS, estado = demo con IDs desde `m1`)
- `WAM.normalizar(o)` → estado (acepta un array de mensajes como raíz; IDs duplicados o inválidos se
reasignan; vacía URLs con esquema no permitido; respuestas string → text; conserva campos extra primitivos)
- `WAM.generarId()` → string (nunca repite un ID ya entregado aunque aún no esté en el estado)
- `WAM.crearAlocador(extra?)` → `function tomar()` que reparte IDs únicos frente al estado, a los ya
entregados y a los de `extra` (para clonar mensajes con respuestas anidadas)
- `WAM.urlSegura(url, "enlace"|"media")` → bool (enlace: http(s)/mailto/tel/sin esquema; media: http(s)/blob/
data:image|video|audio/sin esquema)
- `WAM.nuevoMensaje(tipo)` → Mensaje vacío
- `WAM.horaAhora()` → "HH:MM" · `WAM.svgPlaceholder(texto)` → dataURI `data:image/svg+xml`
### js/render.js (dueño B) — pinta el chat, cero listeners de flujo
- `WAM.render()` → void: `cerrarSheet()` + `renderHeader()` + (si `cuenta.avisoCifrado !== false`) dos
`li.wa-sistema` (pastilla "Hoy" y aviso de cifrado de negocio) + TODOS los mensajes de `estado.mensajes`
- `WAM.renderHeader()` → void: avatar/nombre/subtítulo/verificado en `#chat-header`
- `WAM.renderMensaje(msg, i)` → Node `li.wa-row` (+ `.grupo` si cambia el emisor, `.ultimo` si es el último
del grupo: solo ese lleva la cola de la burbuja, abajo) con todo el contenido del tipo y sus `data-*`
- `WAM.anadirMensajeDOM(msg)` → void: append + `scrollAbajo()`; recalcula `.grupo`/`.ultimo` contra la
última fila del DOM y reinserta los `li.wa-sistema` si faltan
- `WAM.setTema(tema)` → void: `body[data-tema]` + clase `oscuro` en `#pantalla`
- `WAM.setEscribiendo(visible)` → void: muestra/oculta `#chat-escribiendo`; subtítulo "escribiendo…" en verde
- `WAM.scrollAbajo()` → void · `WAM.abrirSheet(i | Mensaje)` / `WAM.cerrarSheet()` → void (hoja de lista en
`#pantalla`: título = `boton`, filas `button.sheet-fila[data-sec][data-fila]`, botón `.sheet-cerrar`)
- `WAM.abrirOpciones(i | Mensaje, li?)` → void: hoja "Todas las opciones" de un mensaje con >3 botones
(la abre el propio B al pulsar `[data-ver-todas]`). Sus filas de respuesta llevan `data-opcion="<j>"`
y B reenvía el clic al `[data-btn="<j>"]` oculto de la burbuja, que atiende la delegación normal de main.js.
- `WAM.tick(estado)` → string SVG inline (✓ / ✓✓ del color de la hora; ✓✓ leído azul iOS `#027bfc`)
- Botones del teléfono: `<button type="button" class="qr-btn">` (Enter/Espacio nativos); URLs solo con
esquema `http(s):`, `mailto:` o `tel:` (otro esquema → botón inerte `.qr-btn-inactiva`); `tel:` pinta
el icono de llamada. Con >3 botones se ven 2 + "Ver todas las opciones" y el resto va `hidden` en la fila.
### js/editor.js (dueño C) — formularios; listeners SOLO dentro de `#panel-editor` e inputs propios
- `WAM.renderEditor()` → void: formulario de cuenta + repinta tarjetas
- `WAM.renderTarjetas()` → void: una tarjeta colapsable por mensaje en `#lista-mensajes` (todos los campos del tipo)
- `WAM.anadirMensaje(tipo, indice?)` → void: inserta en `indice` (defecto: al final) con texto de ejemplo y hora =
anterior + 1 min; abre su tarjeta y enfoca su primer campo · `WAM.moverMensaje(i, delta)` · `WAM.duplicarMensaje(i)`
(ids del clon y de sus respuestas con un solo alocador `WAM.crearAlocador`)
- `WAM.borrarMensaje(i)` → void: sin `confirm()`; toast «Mensaje borrado · Deshacer» (6 s)
- `WAM.setCampo(ruta, valor, opts?)` → void (ruta `"mensajes.3.body"`; muta + guardar + `WAM.render()`).
`opts.diferido` (edición tecla a tecla): el chat se repinta en el siguiente frame y SOLO la fila
`li.wa-row[data-id]` afectada (usa `WAM.renderUno(i)` si existe; si no, `renderMensaje` + `replaceChild`), sin scroll
- `WAM.aDataUrl(archivo)` → Promise<dataURL> (FileReader) · `WAM.exportarJSON()` → void (Blob + `<a download>`;
nombre = slug de la demo guardada activa o de `cuenta.nombre`, p. ej. `cafe-aurora.json`; `wam-demo.json` si vacío)
- `WAM.importarJSON(archivo)` → Promise<void> (parse + `normalizar` sin reservar los IDs del estado vivo + guardar +
re-render total; deja punto de deshacer)
- `WAM.toast(texto, {tipo:"error", ms, accion:{texto, fn}})` → fn cerrar: aviso `#ed-toasts` fijo abajo, centrado
sobre la vista previa. Solo hay UN aviso con acción a la vista: uno nuevo con acción retira los anteriores con acción
- `WAM.enfocarEnChat(id)` → bool: centra y resalta 1,2 s (`.ed-resaltado`) la burbuja en `#chat-cuerpo`; lo usa el
editor al abrir/enfocar una tarjeta · `WAM.abrirTarjeta(id)` → bool: abre y enfoca la tarjeta (desde el chat)
- Validación propia (no pública): chip ⚠ por tarjeta, caja «La Cloud API rechazaría…» y `#ed-avisos-global`;
chip ↳ con botones/filas/cards con y sin respuesta.
### js/escenarios.js (C) — datos, sin DOM
- `WAM.ESCENARIOS` → `[{ id, nombre, descripcion, datos() }]` (en blanco, restaurante, e-commerce, clínica,
inmobiliaria, soporte, cobranza; textos ficticios) · `WAM.datosEscenario(id)` → estado crudo (se normaliza al usarlo)
### js/ux.js (C) — capa de UX de la app; `WAM.initUX()` lo llama `renderEditor()` una sola vez
- Historial: `WAM.puntoDeshacer()` (instantánea antes de mutar, máx. 30; los strings/dataURL se comparten),
`WAM.deshacer()`, `WAM.rehacer()`, `WAM.puedeDeshacer()`; `#btn-deshacer` / `#btn-rehacer`, Ctrl+Z / Ctrl+Mayús+Z
fuera de campos de texto
- `WAM.confirmarReemplazo("reset"|"importar", ejecutar)`: `<dialog>` con «Exportar antes» (main.js lo usa en
`#btn-reset` y `#btn-importar`; sin mensajes no pregunta). Reset deja punto de deshacer.
- `WAM.abrirDemos(seccion?)`: escenarios + demos guardadas (`wam-demos` = índice, `wam-demo-<id>` = estado,
`wam-demo-activa`) con guardar/abrir/renombrar/duplicar/borrar · `WAM.nombreDemoActiva()` · `WAM.abrirAtajos()`
- `WAM.marcarEditado()` → indicador `#estado-guardado` («Guardando…» → «Guardado · hace N s»; rojo con
`WAM.onErrorGuardar`, que ux.js sustituye por toast con «Exportar JSON»)
- `WAM.setVistaMovil("editor"|"vista")` (≤ 900 px; `body.vista-movil`, `wam-ui-vista`) · `WAM.recordarEnfoque(id)`
- Deshacer / rehacer conservan el tema actual (el tema no forma parte del historial de contenido); cambiar el
tema pasa por `WAM.marcarEditado()`. Cargar escenario / demo / Café Aurora deja SIEMPRE punto de deshacer.
- Atajos globales (ignorados en campos): P, Espacio, T, F, N, G, Esc, Ctrl+S, Ctrl+Z, ?; F6 también en campos;
G / F6 y el enlace `#saltar-vista` mueven el foco entre el editor y `#chat-cuerpo`; Alt+↑/↓ mueve en el editor.
Alt+clic o doble clic en una burbuja abre su tarjeta. Envuelve `WAM.anadirMensajeDOM` para anunciar el mensaje
en `#wam-anuncios` (sr-only) y quita `aria-live` de `#chat-cuerpo`.
### js/interactivity.js (dueño D) — flujos, typing, demo
- `WAM.resolverFlujo(el)` → `{ texto, respuestas }` a partir de los `data-*` del elemento clicado
- `WAM.flujo(texto, respuestas)` → Promise: burbuja OUT con `texto` (ticks auto sent→delivered→read, ~800 ms c/u),
typing ON ~900 ms, luego por cada `respuestas`: typing ON ~900 ms → `anadirMensajeDOM` → scroll; typing OFF
- `WAM.enviarLibre(texto)` → void: burbuja OUT del composer (hora `horaAhora()`, estado `sent`, ticks auto)
- `WAM.flujo(texto, respuestas, cita?)`: `cita` opcional `{nombre, titulo, extracto, miniatura, foto}` → bloque
`.wa-cita` dentro de la burbuja OUT. La burbuja sale al instante; las respuestas de flujos simultáneos se
encolan. El indicador «escribiendo…» es un contador de referencias.
- `WAM.resolverFlujo(el)` → `{ texto, respuestas, cita, msg, tipo }`; resuelve el mensaje por el `data-id` de la
fila (válido para mensajes de sesión); etiquetas vacías = las del render («Botón n», «Ver más», «Opción»);
fila de lista: `titulo + "
" + descripcion`.
- `WAM.mensajeDeElemento(el)` → Mensaje · `WAM.abrirLista(msg)` → abre la hoja (`abrirSheet(i | msg)`)
- `WAM.marcarUsado(el)` → clase `.usado` (gris, sin más clics) · `WAM.reiniciarChat()` (= `render()`)
- `WAM.sesionAGuion()` → n: añade la sesión al guion (acción explícita) + guardar + re-render
- `WAM.getVelocidad()` / `WAM.setVelocidad(0.5|1|2)` (persistida en `wam-mockup-v1-velocidad`)
- `WAM.demoActiva()` → bool · hook opcional `WAM.onDemoChange(activa)`
- `WAM.reproducirDemo()` / `WAM.detenerDemo()` → void: cancela flujos en curso, vacía `#chat-cuerpo` y revela
`estado.mensajes` uno a uno (pulsando solas las opciones `autoPulsar`); detener vuelve al guion
(~600 ms; mensaje `in` precedido de typing ~700 ms; cancelable)
- `WAM.espera(ms, grupo?)` → Promise cancelable; grupo `"flujo"` (defecto) o `"demo"`; la duración se divide
por la velocidad (todo timer pasa por aquí para poder detener)
### js/main.js (dueño D) — bootstrap
- `WAM.avisar(texto, {tipo:"info"|"error", duracion?})` → aviso flotante `#wam-aviso` (estilos por CSSOM)
- Controles de `#barra-movil`: `#sel-velocidad`, `#btn-reiniciar`, `#btn-reproducir` (estilos en `css/interactividad.css`, D).
Teclado en `#pantalla`: Enter/Espacio activan `[data-flujo]`/`[data-ver-todas]`/`.sheet-cerrar`; Escape cierra la hoja.
`#btn-reset` pide confirmación. Enlaces del chat con esquema no permitido no se abren.
- `WAM.init()` → void (se ejecuta al final del body): `estado = cargar()`; `setTema`; `render()`; `renderEditor()`;
delegación de clics en `#chat-cuerpo` (ver data-*), submit de `#composer`, `#btn-reproducir`, toolbar
(`#btn-tema`, `#btn-presentacion`, `#btn-exportar`, `#btn-importar`→`#importar-archivo`, `#btn-reset`)
### IDs DOM (el HTML lo escribe A; el contenido lo rellena B o C en runtime)
| ID | Rellena | Papel |
|---|---|---|
| `app`, `panel-editor`, `panel-movil`, `toolbar` | A | shell de dos columnas y barra superior |
| `btn-tema`, `btn-presentacion`, `btn-exportar`, `btn-importar`, `btn-reset`, `importar-archivo` (hidden) | A/C | toolbar; main (D) los enlaza |
| `editor-cuenta` | C | form identidad: `cuenta-nombre`, `cuenta-subtitulo`, `cuenta-verificado`, `cuenta-avatar-vista`, `cuenta-avatar-archivo`, `cuenta-avatar-url` |
| `nuevo-tipo` (paleta `role=toolbar` de los 13 tipos: un clic AÑADE), `nuevo-tipo-valor` (hidden), `btn-anadir` («+ Otro …»), `ed-destino-alta`, `lista-mensajes` | C | alta por paleta (el último tipo usado vive en `#nuevo-tipo-valor` y `data-valor`) y tarjetas de mensajes |
| `bloque-cuenta` (`<details>`), `cuenta-resumen`, `ed-avisos-global`, `btn-ver-telefono` | C | cuenta plegable, contador de avisos, salto a la vista previa (≤ 900 px) |
| `btn-deshacer`, `btn-rehacer`, `btn-demos`, `btn-atajos`, `btn-mas` + `tb-menu`, `estado-guardado` | C (ux.js) | toolbar: historial, «Nueva demo» (primario, junto a la marca), atajos, menú «⋯» (siempre visible; `#btn-reset` «Volver al ejemplo Café Aurora» solo vive dentro del menú; ≤ 900 px agrupa además Exportar / Importar / Atajos), indicador de guardado |
| `saltar-vista` (enlace), `ed-lista-herr`, `body[data-wam-vacia]` | C | salto de teclado a la vista previa; herramientas de lista (ocultas sin mensajes); demo vacía → pista en el chat |
| `tabs-movil`, `tab-editor`, `tab-vista` | C (ux.js) | pestañas Editor / Vista previa (≤ 900 px) |
| `barra-movil`, `btn-reproducir` | A | barra sobre el teléfono (Reproducir demo) |
| `iphone`, `pantalla` (class `wa`), `isla`, `statusbar` ("9:41" + señal/wifi/batería SVG), `home-indicator` | A | marco iPhone 393×852 |
| `chat-header` (`chat-avatar`, `chat-nombre`, `chat-subtitulo`, `chat-verificado`), `chat-cuerpo`, `chat-escribiendo` | A estructura / B contenido | header del chat, scroll de mensajes, typing |
| `composer` (form), `composer-input`, `composer-enviar` | A estructura / D lógica | campo "Mensaje" + enviar |
| `sheet`, `sheet-fondo`, `sheet-titulo`, `sheet-opciones` | B crea/desstruye | modal de selección de lista |
Contrato de clics B→D (delegación en `#chat-cuerpo`): B estampa en cada elemento interactivo
`data-flujo="<índice del mensaje>"` y además `data-btn` (template/buttons), `data-sec`+`data-fila` (list),
`data-card` (carousel). D resuelve con `resolverFlujo(el)` y llama `flujo()`. Los `<a>` de URL no llevan flujo.
### Convención CSS
- `css/base.css` (A): reset, layout grid de `#app`, `.toolbar`, variables `--wam-*` (fondo, panel, texto,
borde, acento) con overrides en `body[data-tema="oscuro"]`; `body.presentacion` oculta `#panel-editor`.
- `css/iphone.css` (A): `#panel-movil`, `.iphone` (393×852, radio 55, negro), `.pantalla`, `.island`
(126×37, top 11), `.statusbar` (54 pt), `.home-indicator` (139×5).
- `css/whatsapp.css` (B): todo bajo `.wa`; variables `--wa-*` del research-ui y overrides `.wa.oscuro`;
clases: `.wa-row .in .out .bubble .meta .tick .tick-read`, cola con `clip-path` (abajo, en `.ultimo`), `.qr-btns .qr-btn .qr-ico`, `.wa-sistema`,
`.tpl-*`, `.cta-*`, `.carousel .card`, `.doc-* .audio-* .loc-* .contact-* .sticker-*`, `.sheet*`;
keyframes de typing con prefijo `wam-`.
- `css/editor.css` (C): prefijo `ed-` (`.ed-seccion .ed-campo .ed-tarjeta .ed-btn .ed-fila .ed-badge`,
`.ed-chip .ed-toast .ed-seg .ed-validacion`, `#chat-cuerpo .wa-row.ed-resaltado`). `.ed-campo` es un `<div>` con
`<label for>` solo sobre la etiqueta (un label envolvente activaba el primer botón de la barra de formato).
- `css/base.css`: además, layout de una columna con pestañas ≤ 900 px (escala del teléfono con el ancho completo y
`--wam-toolbar-alto` medido por ux.js), `.wam-dlg*` (diálogos), `.wam-sr`, `--wam-primario` (#008069, AA con blanco).
- `css/interactividad.css` (D): `.wa-cita*` (respuesta citada), `.qr-btn.usado`, `.wam-pulsando`, controles de `#barra-movil`.
- Tema: B alterna `oscuro` en `#pantalla`; editor/shell ya reaccionan por `body[data-tema]` (lo setea `setTema`).
## 3. Orden exacto de scripts en index.html (todos al final del `<body>`, sin `defer`/`module`)
```html
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/iphone.css">
<link rel="stylesheet" href="css/whatsapp.css">
<link rel="stylesheet" href="css/editor.css">
<link rel="stylesheet" href="css/interactividad.css">
…
<script src="js/store.js"></script> <!-- define WAM.CLAVE/estado/normalizar; NO toca DOM al cargar -->
<script src="js/render.js"></script> <!-- define funciones de render; NO toca DOM al cargar -->
<script src="js/editor.js"></script> <!-- define funciones del editor; NO toca DOM al cargar -->
<script src="js/interactivity.js"></script> <!-- define flujos/typing/demo; NO toca DOM al cargar -->
<script src="js/escenarios.js"></script> <!-- datos de escenarios; NO toca DOM al cargar -->
<script src="js/ux.js"></script> <!-- WAM.initUX (lo invoca renderEditor); NO toca DOM al cargar -->
<script src="js/main.js"></script> <!-- única ejecución: WAM.init() -->
```
Ningún archivo lee `WAM.estado` en carga (solo dentro de funciones) → el orden garantiza cero
`undefined` y cero errores por `file://`.
## 4. División de trabajo (archivos sin solapar)
**A — shell:** `index.html` + `css/base.css` + `css/iphone.css`. HTML completo con TODOS los IDs de la
tabla (contenedores del editor y del chat vacíos), `<body data-tema="claro">`, `#pantalla class="wa"`,
grid de dos columnas, toolbar, marco iPhone con Dynamic Island y status bar. Sin JS propio.
**B — chat:** `css/whatsapp.css` + `js/render.js`. Render de los 13 tipos con el aspecto de WhatsApp iOS
según `docs/referencias/README.md` (burbuja 17 px con radio 18 y cola abajo solo en la última del grupo,
media inset 4 px, template/buttons/list/cta_url en UNA burbuja con separador y botones con icono verde,
hoja inferior iOS, carrusel sin puntos, nota de voz con avatar, documento en panel gris, contacto con
"Mensaje | Guardar contacto", "Hoy" + aviso de cifrado), ticks SVG, indicador "escribiendo…", claro/oscuro
(negro neutro). Sin listeners de flujo; solo presentación: cierre de la hoja, "Ver todas las opciones" y
el reenvío del clic de sus filas al botón oculto de la burbuja.
**C — editor:** `css/editor.css` + `js/editor.js`. Formulario de cuenta (nombre, avatar por archivo vía
`aDataUrl` o por URL, subtítulo, verificado), alta por la paleta de tarjetas `#nuevo-tipo` (una tarjeta con
icono por tipo; el valor elegido se guarda en `#nuevo-tipo-valor`), tarjetas por mensaje con TODOS los
campos del §1 (incluye sub-editor de `botones`/`secciones`/`cards` y sus `respuestas`), reordenar
(drag & drop por asa o flechas ↑/↓)/duplicar/borrar, exportar/importar JSON. Mutación solo vía
`setCampo` + `guardar` + `render`.
**D — núcleo:** `js/store.js` + `js/interactivity.js` + `js/main.js`. Demo por defecto (§6), localStorage,
`normalizar`, delegación de clics del chat, composer, `flujo` con typing y retrasos, ticks automáticos,
reproducir/detener demo, toggle tema/presentación/export/import/reset, bootstrap.
Regla de convivencia: la única API entre módulos son las funciones `WAM.*` y los IDs/data-* de esta SPEC.
Prohibido `document.getElementById` de IDs ajenos a tu panel salvo los listados aquí.
## 5. Criterios de aceptación (Chrome/Edge, abriendo por `file://`)
1. Doble clic en `index.html` → la demo carga SIN ningún error ni warning de consola.
2. Se renderizan los 13 tipos del §6 con su estructura esperada (burbuja+cola, hora, ticks en OUT,
botones apilados, sheet funcional, carousel desplazable).
3. Clic en quick reply / fila de lista / botón de plantilla / botón de card → burbuja OUT con ese texto,
indicador "escribiendo…" ~900 ms y llegada de las `respuestas` encadenadas con scroll al fondo.
4. `#composer-input` + Enter/enviar → mensaje libre OUT con hora actual y ticks sent→delivered→read.
5. "Reproducir demo" vacía el chat y lo revela paso a paso (~600 ms/mensaje, typing antes de cada IN);
pulsar de nuevo lo detiene.
6. F5 conserva TODO el estado (localStorage `wam-mockup-v1`; nota: en file:// Chrome/Edge/Firefox lo
comparten por origen file://); `#btn-reset` restaura la demo.
7. Exportar descarga `<nombre-de-la-demo>.json` (`wam-demo.json` si no hay nombre); importar ese mismo archivo
restaura un estado idéntico.
8. Tema alterna editor+chat; modo presentación oculta el editor y centra/escala el iPhone.
9. Subir avatar o imagen por archivo funciona sin red (dataURL) y persiste tras recargar.
10. Sin dependencias de red obligatorias: con `src` vacíos se usa `svgPlaceholder` (las URLs remotas
de la demo son opcionales y su ausencia degrada al placeholder).
## 6. Demo por defecto (D, en `datosDemo()`): "Café Aurora" ☕ — recorre los 13 tipos
IN text saludo · OUT text cliente · IN image (caption "Nuevo horario") · IN location (la cafetería) ·
IN contact (barista) · IN document (menu.pdf) · IN audio voz 0:12 · IN sticker · IN template
(header imagen, body confirmación de reserva, footer, botones "Confirmar"/"Cambiar" con respuestas) ·
IN buttons (body "¿Qué te muestro?", botones "Horarios"/"Promociones"/"Hablar" — 1-2 respuestas cada uno) ·
IN list (botón "Ver opciones", secciones Bebidas/Postres, filas con descripción y respuestas) ·
IN cta_url ("Abrir tienda") · IN carousel (3 cards "Los favoritos" con botón y respuestas).
Medias: URLs de picsum.photos con seed fija; textos cortos españoles creíbles. Verificados los límites
del research-api (≤3 quick replies, títulos 20/24, body 1024, footer 60…).