Files
whatsapp-api-mockup/SPEC.md
T
urieljarethandClaude Opus 5.5 50a64f21e3 Render: chat pegado abajo, hojas sin desfase, separadores y fidelidad iOS
- scrollAbajo instantáneo + ResizeObserver 'pegado abajo' (imágenes tardías, pestaña oculta)
- Hoja de opciones busca la fila viva; re-render de su mensaje cierra la hoja
- Separadores de filas de la hoja, proporciones de lista, un solo separador entre grupos
- Home indicator sobre la hoja, composer oscuro #232626 con hairline, padding 7/12/6
- URLs sin puntuación final, www. -> https://, negrita/cursiva/tachado con reglas de WhatsApp
- Documento: 'tamaño • ext' o 'PDF'; transición de color al cambiar tema
- Presentación sin toolbar en escritorio (Esc para salir), hooks de zoom, móvil sin marco

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

23 KiB
Raw Blame History

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)

{
  "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.

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 (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); 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 (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 (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 a la izquierda
  • 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)
  • Atajos globales (ignorados en campos): P, Espacio, T, F, N, Esc, Ctrl+S, Ctrl+Z, ?; 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, demos, atajos, menú «⋯» (≤ 900 px), indicador de guardado
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)

<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…).