Files
whatsapp-api-mockup/SPEC.md
T
urieljarethandClaude Opus 5.5 4fe8f73959 Aplicar pendientes tras la integración
- render.js: botones y cards con urlBloqueada se pintan inertes.
- interactivity.js: sinFormato con la misma regla de marcadores que render.js; cita de documento con icono y nombre.
- Zoom manual (Ajustar / 100 % / 125 %) en #barra-movil, persistido en localStorage.
- Contador «Paso N/M» con estilo en iphone.css en lugar de estilos en línea.
- store/editor: campos opcionales tamano y headerTamano; editar la URL retira urlBloqueada;
  urlValida acepta URLs con forma de dominio; importar avisa de lo recortado.
- README y SPEC actualizados.

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

321 lines
26 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`.
Botón de plantilla o card con URL no permitida: `normalizar` deja `url: ""` y añade `urlBloqueada: true`
(sigue siendo un botón de enlace, inerte `.qr-btn-inactiva` en render.js; nunca un quick reply con flujo;
`resolverFlujo` lo ignora). Editar a mano el campo URL en el editor retira la marca.
`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).
Lo recortado (más de 500 mensajes, 10 botones/secciones/filas/cards, 20 respuestas o 12 niveles) queda en
`WAM.ultimosRecortes` y en `estado.__recortes` (no enumerable): `{ mensajes, mensajesOriginales, botones,
secciones, filas, cards, respuestas, profundidad }`.
- `WAM.describirRecortes(r?)` → string en español para el toast de importar («Se importaron 500 de 600
mensajes; se recortaron 3 respuestas…») o `""` si no se recortó nada (por defecto `WAM.ultimosRecortes`)
- `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
(busca la fila viva en el momento del clic). `renderMensaje` de un mensaje cuya hoja está abierta la cierra
(re-render parcial del editor), así la hoja nunca queda desfasada.
- `scrollAbajo()` es instantáneo (ignora `scroll-behavior`) y deja el chat "pegado abajo": mientras el usuario
no suba (>60 px del fondo), un ResizeObserver sobre `#chat-cuerpo` y sus filas vuelve a bajar cuando algo
crece (imágenes que decodifican tarde, pestaña Vista previa que pasa a visible).
- Documento: campo opcional `tamano` (y `headerTamano` en la cabecera de plantilla) → subtítulo
"243 KB • pdf"; sin él, solo la extensión en mayúsculas ("PDF").
- Hooks de CSS (css/iphone.css): `body[data-wam-zoom="100"|"125"]` fija la escala del teléfono (sin
atributo = ajuste automático), solo ≥ 901 px y fuera de Presentación. Lo pone `#sel-zoom` (ux.js,
`WAM.setZoom(v)`, persistido en `localStorage["wam-zoom"]`); en ≤ 520 px el teléfono va sin marco salvo
con `body.con-marco`.
- `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) + `puntoDeshacer` + guardar +
`marcarEditado` + re-render
- Hora de la sesión: las salientes de un flujo y sus respuestas usan la hora del último mensaje visible
+ 1 min (línea temporal coherente con el guion); el composer libre usa `WAM.horaAhora()`. Los IDs de sesión
(`s1`, `s2`…) saltan los que ya existen en el guion.
- `WAM.getVelocidad()` / `WAM.setVelocidad(0.5|1|2)` (persistida en `wam-mockup-v1-velocidad`)
- `WAM.demoActiva()` → bool · hook opcional `WAM.onDemoChange(activa)` (también se llama en cada paso)
- `WAM.progresoDemo()` → `{ paso, total }` (mensajes del guion revelados / total; `{0, 0}` sin demo).
main.js lo pinta en `#demo-paso` («Paso 3/12», `aria-live`) dentro de `#barra-movil`
- `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-zoom` (ux.js), `#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…).