Files
whatsapp-api-mockup/SPEC.md
T

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