- 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]>
23 KiB
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):
text:textoimage:src,captionvideo:src,captionaudio:src(opcional),voz(bool: nota de voz),duracion("0:12")document:src(ignorado),caption,filename(con extensión; el icono depende de ella)sticker:src(WebP/imagen; sin burbuja de texto ni hora dentro)location:latitud,longitud,nombre,direccioncontact:nombre(formatted_name),telefono,email,empresatemplate:headerTipo(""|"texto"|"imagen"|"video"|"documento"),headerTexto,headerMedia,body,footer,botones:[ { "texto", "url" (opcional, ≤2 con url), "respuestas": [Mensaje…] } ]buttons(quick replies, máx 3):headerTipo(""|"texto"|"imagen"|"video"|"documento"),headerTexto,headerMedia,body,footer,botones:[ { "texto", "respuestas" } ]list:header,body,footer,boton(etiqueta ≤20),secciones:[ { "titulo", "filas": [ { "titulo", "descripcion", "respuestas" } ] } ]cta_url:headerMedia("" = sin header),body,footer,textoBoton,urlcarousel(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 enpagehide) ·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 conWAM.avisar) hasta el siguiente guardado correcto. WAM.resetear()→ void (borra LS, estado = demo con IDs desdem1)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 deextra(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íoWAM.horaAhora()→ "HH:MM" ·WAM.svgPlaceholder(texto)→ dataURIdata:image/svg+xml
js/render.js (dueño B) — pinta el chat, cero listeners de flujo
WAM.render()→ void:cerrarSheet()+renderHeader()+ (sicuenta.avisoCifrado !== false) dosli.wa-sistema(pastilla "Hoy" y aviso de cifrado de negocio) + TODOS los mensajes deestado.mensajesWAM.renderHeader()→ void: avatar/nombre/subtítulo/verificado en#chat-headerWAM.renderMensaje(msg, i)→ Nodeli.wa-row(+.gruposi cambia el emisor,.ultimosi es el último del grupo: solo ese lleva la cola de la burbuja, abajo) con todo el contenido del tipo y susdata-*WAM.anadirMensajeDOM(msg)→ void: append +scrollAbajo(); recalcula.grupo/.ultimocontra la última fila del DOM y reinserta losli.wa-sistemasi faltanWAM.setTema(tema)→ void:body[data-tema]+ claseoscuroen#pantallaWAM.setEscribiendo(visible)→ void: muestra/oculta#chat-escribiendo; subtítulo "escribiendo…" en verdeWAM.scrollAbajo()→ void ·WAM.abrirSheet(i | Mensaje)/WAM.cerrarSheet()→ void (hoja de lista en#pantalla: título =boton, filasbutton.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 llevandata-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).renderMensajede un mensaje cuya hoja está abierta la cierra (re-render parcial del editor), así la hoja nunca queda desfasada.scrollAbajo()es instantáneo (ignorascroll-behavior) y deja el chat "pegado abajo": mientras el usuario no suba (>60 px del fondo), un ResizeObserver sobre#chat-cuerpoy 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(yheaderTamanoen 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 conbody.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 esquemahttp(s):,mailto:otel:(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 vahiddenen 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 tarjetasWAM.renderTarjetas()→ void: una tarjeta colapsable por mensaje en#lista-mensajes(todos los campos del tipo)WAM.anadirMensaje(tipo, indice?)→ void: inserta enindice(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 alocadorWAM.crearAlocador)WAM.borrarMensaje(i)→ void: sinconfirm(); 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 filali.wa-row[data-id]afectada (usaWAM.renderUno(i)si existe; si no,renderMensaje+replaceChild), sin scrollWAM.aDataUrl(archivo)→ Promise (FileReader) ·WAM.exportarJSON()→ void (Blob +<a download>; nombre = slug de la demo guardada activa o decuenta.nombre, p. ej.cafe-aurora.json;wam-demo.jsonsi vacío)WAM.importarJSON(archivo)→ Promise (parse +normalizarsin 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-toastsfijo abajo a la izquierdaWAM.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-resety#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 conWAM.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.anadirMensajeDOMpara anunciar el mensaje en#wam-anuncios(sr-only) y quitaaria-livede#chat-cuerpo.
js/interactivity.js (dueño D) — flujos, typing, demo
WAM.resolverFlujo(el)→{ texto, respuestas }a partir de losdata-*del elemento clicadoWAM.flujo(texto, respuestas)→ Promise: burbuja OUT contexto(ticks auto sent→delivered→read, ~800 ms c/u), typing ON ~900 ms, luego por cadarespuestas: typing ON ~900 ms →anadirMensajeDOM→ scroll; typing OFFWAM.enviarLibre(texto)→ void: burbuja OUT del composer (horahoraAhora(), estadosent, ticks auto)WAM.flujo(texto, respuestas, cita?):citaopcional{nombre, titulo, extracto, miniatura, foto}→ bloque.wa-citadentro 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 eldata-idde 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-renderWAM.getVelocidad()/WAM.setVelocidad(0.5|1|2)(persistida enwam-mockup-v1-velocidad)WAM.demoActiva()→ bool · hook opcionalWAM.onDemoChange(activa)WAM.reproducirDemo()/WAM.detenerDemo()→ void: cancela flujos en curso, vacía#chat-cuerpoy revelaestado.mensajesuno a uno (pulsando solas las opcionesautoPulsar); detener vuelve al guion (~600 ms; mensajeinprecedido 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 encss/interactividad.css, D). Teclado en#pantalla: Enter/Espacio activan[data-flujo]/[data-ver-todas]/.sheet-cerrar; Escape cierra la hoja.#btn-resetpide 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 enbody[data-tema="oscuro"];body.presentacionoculta#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 conclip-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 prefijowam-.css/editor.css(C): prefijoed-(.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-campoes 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-altomedido 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
oscuroen#pantalla; editor/shell ya reaccionan porbody[data-tema](lo seteasetTema).
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://)
- Doble clic en
index.html→ la demo carga SIN ningún error ni warning de consola. - Se renderizan los 13 tipos del §6 con su estructura esperada (burbuja+cola, hora, ticks en OUT, botones apilados, sheet funcional, carousel desplazable).
- 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
respuestasencadenadas con scroll al fondo. #composer-input+ Enter/enviar → mensaje libre OUT con hora actual y ticks sent→delivered→read.- "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.
- F5 conserva TODO el estado (localStorage
wam-mockup-v1; nota: en file:// Chrome/Edge/Firefox lo comparten por origen file://);#btn-resetrestaura la demo. - Exportar descarga
<nombre-de-la-demo>.json(wam-demo.jsonsi no hay nombre); importar ese mismo archivo restaura un estado idéntico. - Tema alterna editor+chat; modo presentación oculta el editor y centra/escala el iPhone.
- Subir avatar o imagen por archivo funciona sin red (dataURL) y persiste tras recargar.
- Sin dependencias de red obligatorias: con
srcvacíos se usasvgPlaceholder(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…).