Compare commits

..
13 Commits
Author SHA1 Message Date
urieljarethandClaude Opus 5.5 29d5f6dbdc Tests: un reintento para tolerar fallos intermitentes por carga
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:16:56 -06:00
urieljarethandClaude Opus 5.5 46d47797b4 Escala del iPhone con la altura real de la barra de vista previa (sin recorte a 1440x900)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:14:07 -06:00
urieljarethandClaude Opus 5.5 875ae9ad67 Deshacer con demo activa, avisos obsoletos, hoja de opciones y foco en hojas
- Deshacer/rehacer guardan y reponen «wam-demo-activa» con cada instantánea
  ({estado, activa}); «Actualizar» pide un segundo clic si el contenido cambió
  de cuenta o de número de mensajes respecto a la demo guardada.
- WAM.puntoDeshacer cierra los avisos con acción: su «Deshacer» ya no
  desharía el cambio que anuncian.
- «Todas las opciones» replica botonAccion: URL bloqueada o no válida se
  pinta como fila inerte (aria-disabled, sin data-opcion) en el grupo de
  enlaces.
- Hojas inferiores: enfocan la primera fila (o la X), retienen Tab dentro del
  panel y devuelven el foco al cerrar (origen, fila repintada o
  #chat-cuerpo); no roban el foco durante «Reproducir demo».
- Tests e2e de regresión en tests/e2e/regresiones.spec.mjs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:08:46 -06:00
urieljarethandClaude Opus 5.5 1176003b42 Tests: cubrir scroll inicial, zoom, JSON malicioso, recortes, contador de pasos, horas e IDs de sesión, formato, citas y documentos
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:46:11 -06:00
urieljarethandClaude Opus 5.5 17e672e45f Corregir el escape de la regla de marcadores en sinFormato
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:46:10 -06:00
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
urieljarethandClaude Opus 5.5 a645056c00 Tests: abrir el menú «⋯» antes de pulsar Restablecer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:35:02 -06:00
urieljarethandClaude Opus 5.5 b042329f93 Integrar rama de lógica: contador de pasos, horas coherentes, IDs de sesión y URLs bloqueadas
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:33:21 -06:00
urieljarethandClaude Opus 5.5 2f1c606129 Integrar rama de editor y toolbar: jerarquía, foco seguro y autoPulsar estable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:33:16 -06:00
urieljarethandClaude Opus 5.5 13b11a0227 Integrar rama de render: chat pegado abajo, hojas sin desfase y fidelidad iOS
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:33:16 -06:00
urieljarethandClaude Opus 5.5 3509a77d62 Editor y toolbar: jerarquía, tarjetas legibles, foco seguro y autoPulsar estable
- Toolbar: «Nueva demo» primaria junto a la marca (N); Tema como icono;
  «Volver al ejemplo Café Aurora» (peligro) solo dentro del menú «⋯»;
  etiquetas de Nueva demo y Presentación visibles entre 900 y 1100 px.
- Tarjetas: flecha de dirección en el número (↙/↗), badge con icono
  (solo icono en editor estrecho), resumen en 2.ª línea al abrir, hueco
  de ↑/↓ reservado (sin saltos al pasar el ratón), container queries.
- Borrar (mensaje, botón, fila, tarjeta, respuesta) nunca deja el foco
  en otro botón ✕.
- autoPulsar sigue al elemento al mover/borrar/arrastrar; opción
  inexistente visible y aviso; casilla «Se pulsa sola» por elemento,
  chip «▶ auto» y select arriba del formulario.
- Deshacer/rehacer conservan el tema; el tema marca «Guardando…».
- Escenarios: siempre punto de deshacer, un único aviso con acción y
  foco al nombre de la cuenta; chips de tipos en el diálogo Demos,
  rejilla de 4 columnas en >= 1280 y sin nombre precargado.
- Paleta sin estado seleccionado, «+» en hover/foco, chip de destino
  y «+ Otro» solo tras el primer alta.
- Avisos abajo al centro sobre la vista previa; tel: válido en
  plantillas (máx. 1); interruptor «Mostrar cabecera» fijo (role=switch).
- Teclado: enlace «Saltar a la vista previa» y G / F6; atajos con ⌘ en
  Mac; etiqueta «Velocidad»; «Repetir desde el inicio».
- Demo vacía: herramientas ocultas y pista dentro del chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:32:22 -06:00
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
urieljarethandClaude Opus 5.5 c0b7d0c2ce Demo con contador de pasos e icono de detener, horas de sesión coherentes, IDs de sesión sin choques, URLs bloqueadas inertes, recortes de importación contados y sesionAGuion con deshacer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:09:42 -06:00
18 changed files with 1886 additions and 226 deletions
+12 -4
View File
@@ -34,7 +34,8 @@ En el bloque **Cuenta** configuras lo que se ve en la cabecera del chat:
- **Subtítulo** (p. ej. «en línea»); cuando el chat «está escribiendo» se cambia temporalmente por *escribiendo…* en verde.
- **Avatar**: súbelo desde un archivo (se convierte a dataURL) o pega una URL/dataURL; también puedes quitarlo (se usa un placeholder SVG).
- **Cuenta verificada**: activa o desactiva la insignia azul junto al nombre.
- **Tema**: claro u oscuro (también con el botón *Tema* de la barra superior; se guarda con el resto del estado).
El **tema** (claro u oscuro) ya no está en Cuenta: se cambia con el botón *Tema* de la barra superior o con la tecla **T**, y se guarda con el resto del estado.
### Añadir, editar y reordenar mensajes
@@ -42,7 +43,8 @@ En el bloque **Cuenta** configuras lo que se ve en la cabecera del chat:
- Cada mensaje es una **tarjeta colapsable** con un resumen; haz clic para desplegar sus campos.
- En cada tarjeta puedes cambiar los campos comunes: **Dirección** (Entrada/Salida), **Hora** (texto literal `HH:MM`, se muestra tal cual) y, solo en los salientes, **Estado (ticks)**: Enviado ✓ / Entregado ✓✓ / Leído ✓✓ (celeste).
- Acciones por tarjeta: **↑ / ↓** reordenar, **⧉** duplicar (con IDs regenerados) y **✕** borrar (pide confirmación).
- Todo cambio se guarda al instante y se refleja en el teléfono. El botón **Restablecer** de la barra superior borra `localStorage` y restaura la demo «Café Aurora».
- Todo cambio se guarda al instante y se refleja en el teléfono. En el menú **⋯** de la barra superior, **Volver al ejemplo Café Aurora** restaura la demo de ejemplo (pide confirmación y se puede deshacer).
- Los botones, filas y tarjetas sin URL tienen la casilla **Se pulsa sola en la demo**: durante *Reproducir demo* se pulsa automáticamente (la cabecera plegada muestra el chip «▶ auto»).
### Campos por tipo de mensaje
@@ -51,7 +53,7 @@ En el bloque **Cuenta** configuras lo que se ve en la cabecera del chat:
| Texto | texto (admite formato WhatsApp: `*negrita*`, `_cursiva_`, `~tachado~`, `` ` ```monoespaciado``` ` ` y URLs clicables) |
| Imagen / Vídeo | archivo o URL + caption opcional |
| Audio | archivo opcional, casilla **nota de voz** (waveform + micro) y duración (`0:12`) |
| Documento | nombre de archivo con extensión (define el color del icono: pdf, docx, xlsx…), caption y archivo opcional |
| Documento | nombre de archivo con extensión (define el color del icono: pdf, docx, xlsx…), tamaño opcional («243 KB • pdf»), caption y archivo opcional |
| Sticker | imagen/WebP (se muestra sin burbuja ni hora) |
| Ubicación | latitud, longitud, nombre y dirección (el mapa es un gráfico SVG con pin) |
| Contacto | nombre, teléfono, email y empresa |
@@ -91,11 +93,17 @@ Si un botón no tiene respuestas, al pulsarlo solo sale la burbuja con su texto.
## Modo demo (columna derecha)
- **Reproducir demo**: vacía el chat y revela los mensajes uno a uno (~600 ms por mensaje, con «escribiendo…» antes de cada mensaje entrante), como si la conversación ocurriera en directo. Pulsar de nuevo (**■ Detener**) lo cancela.
- **Reproducir demo**: vacía el chat y revela los mensajes uno a uno (~600 ms por mensaje, con «escribiendo…» antes de cada mensaje entrante), como si la conversación ocurriera en directo. Mientras corre se ve el contador «Paso N/M»; pulsar de nuevo (**■ Detener**) lo cancela.
- **Velocidad**: el selector junto al botón ajusta la reproducción (0,5x, 1x, 2x).
- **Zoom** (solo escritorio): *Ajustar* calcula el tamaño del teléfono según el hueco; *100 %* y *125 %* lo fijan y el panel hace scroll. Se recuerda en este navegador.
- **Repetir desde el inicio** (antes «Reiniciar chat»): descarta lo pulsado y escrito en el teléfono y vuelve al guion desde el primer mensaje.
- **Nueva demo** (botón de la barra superior o tecla **N**): abre los escenarios de ejemplo y tus demos guardadas.
- **Deshacer / Rehacer**: botones de la barra superior o **Ctrl/⌘ + Z** y **Ctrl/⌘ + Shift + Z**. Cargar un escenario, una demo o el ejemplo deja siempre un punto de deshacer.
- **Mensajes libres**: escribe en el campo *Mensaje* del composer y envía (Enter o botón): sale una burbuja con la **hora actual** y ticks automáticos ✓ → ✓✓ → ✓✓ azul.
- **Hoja de lista (sheet)**: al pulsar el botón de un mensaje de tipo *Lista* se abre la hoja inferior con las secciones y filas; toca una fila para disparar su flujo, o toca el fondo oscuro para cerrarla.
- **Modo presentación**: oculta el editor y deja solo el iPhone centrado, perfecto para pantallazos o para proyectar. Se alterna con el botón *Presentación* de la barra superior.
- **Tema**: alterna claro/oscuro en toda la app (editor y chat).
- **Teclado**: el enlace **Saltar a la vista previa** (visible al tabular) y las teclas **G** / **F6** alternan el foco entre editor y teléfono. El diálogo *Atajos* lista el resto.
---
+40 -9
View File
@@ -51,10 +51,14 @@ con `dir:"in"`, anidados, NO se renderizan en el chat: los consume la interactiv
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.
~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`
@@ -72,7 +76,12 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
(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)
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)
@@ -95,7 +104,18 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
`#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.
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
@@ -115,7 +135,8 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
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 a la izquierda
- `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`;
@@ -136,7 +157,10 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
- `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.
- 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`.
@@ -154,9 +178,15 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
" + 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.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)`
- `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)
@@ -165,7 +195,7 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
### 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).
- 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()`;
@@ -180,7 +210,8 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`).
| `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 |
| `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 |
+146 -12
View File
@@ -187,9 +187,43 @@ body {
border-color: var(--wam-acento);
color: var(--wam-acento-hover);
}
#btn-reset:hover { color: var(--wam-peligro); border-color: var(--wam-peligro); }
#btn-mas { display: none; }
/* «Nueva demo»: punto de partida (botón primario junto a la marca) */
.tb-btn-primario {
margin-left: 4px;
background: var(--wam-primario);
border-color: var(--wam-primario);
color: #fff;
}
.tb-btn-primario:hover { background: var(--wam-primario-hover); border-color: var(--wam-primario-hover); }
.tb-kbd {
display: inline-block;
min-width: 18px;
padding: 1px 5px;
border: 1px solid rgba(255, 255, 255, .45);
border-radius: 5px;
font: 600 11px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: center;
opacity: .9;
}
/* Acción destructiva: siempre dentro del menú «⋯», al final */
.tb-btn-peligro { color: var(--wam-peligro); }
.tb-btn-peligro:hover { color: var(--wam-peligro); border-color: var(--wam-peligro); background: rgba(185, 58, 46, .08); }
.tb-menu-sep { display: none; }
.tb-solo-menu { display: none; }
/* Escritorio: el grupo secundario va en línea y «⋯» despliega
«Volver al ejemplo Café Aurora» como un menú de una opción. */
#btn-reset { display: none; }
.toolbar.tb-menu-abierto #btn-reset {
display: inline-flex;
position: absolute;
top: calc(100% + 6px);
right: 10px;
z-index: 40;
min-height: 40px;
padding: 10px 14px;
border-color: var(--wam-borde);
box-shadow: var(--wam-sombra);
}
/* --- Editor (columna izquierda · cascarón; contenido: editor.js) -------- */
#panel-editor {
@@ -375,6 +409,20 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
.wam-esc-nombre { font-size: 14px; font-weight: 700; }
.wam-esc-desc { font-size: 12.5px; line-height: 1.4; color: var(--wam-texto-suave); }
.wam-esc-meta { font-size: 11.5px; font-weight: 600; color: var(--wam-acento-hover); }
.wam-esc-tipos { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.wam-esc-tipo {
padding: 1px 7px;
border: 1px solid var(--wam-borde);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--wam-texto-suave);
background: var(--wam-panel-alt);
}
@media (min-width: 1280px) {
.wam-dlg-grande { width: min(1040px, calc(100vw - 64px)); max-height: min(92vh, 860px); }
.wam-dlg-grande .wam-esc-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* Demos guardadas */
.wam-demos-lista { list-style: none; display: grid; gap: 6px; }
@@ -429,8 +477,12 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
.tb-guardado-rel { display: none; }
}
@media (max-width: 1100px) {
.toolbar-acciones .tb-btn > span:not(.tb-solo-menu) { display: none; }
.toolbar-acciones .tb-btn { padding: 7px 9px; }
/* Iconos sin texto, salvo en «Nueva demo» y «Presentación» */
.toolbar-acciones .tb-grupo-sec .tb-btn > span:not(.tb-solo-menu) { display: none; }
.toolbar-acciones .tb-grupo-sec .tb-btn { padding: 7px 9px; }
.toolbar.tb-menu-abierto #btn-reset > span { display: inline; }
.toolbar.tb-menu-abierto #btn-reset { padding: 10px 14px; }
.tb-kbd { display: none; }
}
/* --- Ventana estrecha (≤ 900 px): una columna con pestañas -------------- */
@@ -489,9 +541,12 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
.tab-movil:focus-visible { outline-offset: -4px; }
/* «Ver teléfono»: botón flotante dentro del editor */
/* centrado: nunca tapa las acciones (derecha) ni el asa (izquierda) de
las tarjetas; #lista-mensajes reserva su hueco al final (editor.css) */
.ed-ver-telefono {
position: fixed;
right: 16px;
left: 50%;
transform: translateX(-50%);
bottom: calc(var(--wam-tabs-alto) + 14px + env(safe-area-inset-bottom, 0px));
z-index: 25;
display: inline-flex;
@@ -515,10 +570,15 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
.toolbar-logo { width: 30px; height: 30px; }
.toolbar h1 { font-size: 14px; }
.tb-guardado { padding: 4px 8px; margin-left: 2px; }
.tb-guardado-txt { display: none; }
.tb-guardado[data-estado="error"] .tb-guardado-txt { display: inline; }
#btn-mas { display: inline-flex; }
/* el texto sigue disponible para lectores de pantalla (y en el title) */
.tb-guardado-txt {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.tb-guardado[data-estado="error"] .tb-guardado-txt { position: static; width: auto; height: auto; margin: 0; clip: auto; }
.tb-sep-sec { display: none; }
.toolbar-acciones #btn-presentacion > span { display: none; }
#btn-presentacion { padding: 7px 9px; }
.tb-grupo-sec {
display: none;
position: absolute;
@@ -536,15 +596,26 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
box-shadow: var(--wam-sombra);
}
.toolbar.tb-menu-abierto .tb-grupo-sec { display: flex; }
.tb-grupo-sec .tb-btn {
.toolbar-acciones .tb-grupo-sec .tb-btn {
justify-content: flex-start;
border-color: transparent;
padding: 10px 12px;
min-height: 40px;
}
.toolbar-acciones .tb-grupo-sec .tb-btn > span,
.tb-grupo-sec .tb-solo-menu { display: inline; }
.toolbar-acciones .tb-grupo-sec .tb-btn > span:not(.tb-solo-menu),
.toolbar-acciones .tb-grupo-sec .tb-solo-menu { display: inline; }
.tb-grupo-sec .tb-btn:hover { border-color: transparent; }
.tb-grupo-sec .tb-btn-peligro:hover { background: rgba(185, 58, 46, .08); }
.tb-menu-sep { display: block; height: 1px; margin: 4px 6px; background: var(--wam-borde); }
/* dentro del menú desplegable «Volver al ejemplo» es una fila más */
#btn-reset,
.toolbar.tb-menu-abierto #btn-reset {
display: inline-flex;
position: static;
padding: 10px 12px;
box-shadow: none;
border-color: transparent;
}
/* Escala del teléfono con el ancho completo (sin restar el editor) y la
altura real de la toolbar + barra de vista previa + pestañas. */
@@ -566,10 +637,19 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
}
}
@media (max-width: 560px) {
#btn-demos > span { display: none; }
#btn-demos { padding: 7px 9px; }
}
@media (max-width: 520px) {
/* la pestaña ya dice «Vista previa»: se gana una fila para el teléfono */
#barra-movil .barra-titulo { display: none; }
#barra-movil { justify-content: center; }
#barra-movil .barra-etq-txt {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
}
@media (max-width: 420px) {
@@ -577,6 +657,60 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
.toolbar-marca { flex: 1; }
}
/* --- Enlace «Saltar a la vista previa» (visible solo con el foco) ------ */
.wam-saltar {
position: fixed;
left: 12px;
top: 8px;
z-index: 200;
padding: 10px 14px;
border-radius: 10px;
background: var(--wam-primario);
color: #fff;
font-size: 14px;
font-weight: 700;
text-decoration: none;
transform: translateY(-160%);
transition: transform .15s ease;
}
.wam-saltar:focus { transform: none; outline: 2px solid #fff; outline-offset: -4px; }
#chat-cuerpo:focus { outline: none; }
#chat-cuerpo:focus-visible { outline: 2px solid var(--wam-acento); outline-offset: -3px; }
#lista-mensajes:focus { outline: none; }
/* --- Etiqueta de la velocidad en la barra de la vista previa ---------- */
#barra-movil .barra-etq {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--wam-texto-suave);
cursor: pointer;
}
#barra-movil #btn-reiniciar { display: inline-flex; align-items: center; gap: 6px; }
#barra-movil #btn-reiniciar svg { flex: none; }
/* --- Chat vacío: pista dentro del teléfono -----------------------------
body[data-wam-vacia] lo pone js/editor.js cuando la demo no tiene
mensajes (así no parpadea al arrancar «Reproducir demo»). */
body[data-wam-vacia] #chat-cuerpo:not(:has(> .wa-row))::after {
content: "Añade mensajes desde el editor";
display: block;
width: max-content;
max-width: 80%;
margin: 16px auto;
padding: 6px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, .92);
color: #54656f;
font-size: 12.5px;
text-align: center;
box-shadow: 0 1px .5px rgba(11, 20, 26, .13);
}
body[data-wam-vacia] .pantalla.oscuro #chat-cuerpo:not(:has(> .wa-row))::after { background: #182229; color: #8696a0; }
body.presentacion[data-wam-vacia] #chat-cuerpo:not(:has(> .wa-row))::after { content: "Sin mensajes todavía"; }
/* Destinos táctiles más grandes */
@media (pointer: coarse) {
.tb-btn { min-height: 40px; }
@@ -585,5 +719,5 @@ button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f2
/* --- Accesibilidad: menos movimiento ------------------------------------ */
@media (prefers-reduced-motion: reduce) {
#chat-cuerpo { scroll-behavior: auto; }
.tb-btn, .wam-btn-anadir, .wam-esc { transition: none; }
.tb-btn, .wam-btn-anadir, .wam-esc, .wam-saltar { transition: none; }
}
+156 -28
View File
@@ -276,6 +276,7 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
.ed-cab-principal {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
align-items: center;
gap: 6px;
@@ -293,14 +294,22 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
flex: 0 0 auto;
min-width: 18px;
height: 18px;
display: inline-grid;
place-items: center;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 1px;
padding: 0 5px 0 4px;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--ed-acento-hondo);
background: var(--ed-acento-suave);
border-radius: 999px;
}
/* Dirección: flecha ↙ entrada / ↗ salida (además del color) */
.ed-num-dir { font-size: 11px; line-height: 1; }
.ed-num.ed-num-in { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
.ed-num.ed-num-resp { padding: 0 5px; }
.ed-dir {
flex: 0 0 auto;
@@ -321,19 +330,28 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
}
.ed-badge {
flex: 0 0 auto;
padding: 1px 6px;
flex: 0 1 auto;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px 1px 4px;
overflow: hidden;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
white-space: nowrap;
color: var(--wam-texto, var(--ed-texto-suave));
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 999px;
}
.ed-badge .ed-ico { flex: none; display: inline-grid; place-items: center; color: var(--wam-texto-suave, var(--ed-texto-suave)); }
.ed-badge .ed-ico svg { display: block; width: 13px; height: 13px; }
.ed-badge-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.ed-resumen {
flex: 1;
flex: 1 1 60px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -341,6 +359,26 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
font-size: 12px;
color: var(--wam-texto, var(--ed-texto-suave));
}
/* Tarjeta abierta: el resumen baja a su propia línea bajo la cabecera
(así siempre se lee qué mensaje se está editando) */
.ed-tarjeta { --ed-acc-w: 146px; } /* + ↑ ↓ ⧉ ✕ (26 px) + huecos */
.ed-tarjeta.ed-anidada { --ed-acc-w: 88px; } /* ↑ ↓ ✕ */
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab { align-items: flex-start; }
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab > .ed-asa { margin-top: 2px; }
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab > .ed-cab-principal { flex-wrap: wrap; row-gap: var(--ed-acc-h, 8px); overflow: visible; }
/* la 2.ª línea pasa por debajo de las acciones: usa todo el ancho de la tarjeta */
.ed-tarjeta.ed-abierta > .ed-tarjeta-cab > .ed-cab-principal > .ed-resumen {
order: 10;
flex: 0 0 calc(100% + var(--ed-acc-w)); /* tamaño exterior = 100 %: fuerza la 2.ª línea */
max-width: none;
margin-right: calc(-1 * var(--ed-acc-w));
white-space: normal;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
padding-left: 2px;
}
.ed-acciones {
flex: 0 0 auto;
@@ -825,15 +863,22 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
font-size: 12.5px;
}
.ed-paleta .ed-tipo-card .ed-tipo-card-e { flex: none; overflow: visible; }
.ed-paleta .ed-tipo-card.ed-seleccionada {
/* último tipo usado: marca sutil (la paleta añade al hacer clic) */
background: var(--wam-panel, var(--ed-panel));
color: var(--wam-texto, var(--ed-texto));
font-weight: 500;
box-shadow: none;
border-color: var(--ed-seleccion);
}
/* Sin estado «seleccionado»: cada clic añade. El «+» aparece con hover/foco. */
.ed-paleta .ed-tipo-card:hover { background: var(--ed-seleccion-suave); }
.ed-tipo-mas {
display: inline-block;
width: 0;
overflow: hidden;
font-weight: 800;
line-height: 1;
opacity: 0;
transition: width 0.12s ease, opacity 0.12s ease;
}
.ed-paleta .ed-tipo-card:hover .ed-tipo-mas,
.ed-paleta .ed-tipo-card:focus-visible .ed-tipo-mas { width: 0.7em; opacity: 1; }
@media (hover: none) {
.ed-tipo-mas { width: 0.7em; opacity: 0.55; }
}
.ed-alta-pie {
display: flex;
flex-wrap: wrap;
@@ -845,6 +890,52 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
font-size: 12px;
color: var(--wam-texto-suave, var(--ed-texto-suave));
}
/* Chip visible del destino del próximo alta */
.ed-destino-alta.ed-destino-chip {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 26px;
padding: 2px 10px;
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 999px;
background: var(--ed-fondo-suave);
font-weight: 600;
color: var(--wam-texto, var(--ed-texto));
}
.ed-destino-alta.ed-destino-chip::before {
content: "↧";
font-weight: 700;
color: var(--ed-seleccion);
}
.ed-destino-alta.ed-destino-dentro {
border-color: var(--ed-seleccion);
background: var(--ed-seleccion-suave);
color: var(--ed-seleccion);
padding-right: 3px;
}
.ed-destino-quitar {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.ed-destino-quitar:hover { background: rgba(43, 107, 228, 0.18); }
.ed-destino-quitar:focus-visible { outline: 2px solid var(--ed-seleccion); outline-offset: 1px; }
.ed-destino-cambio { animation: ed-destino-cambio 0.9s ease-out; }
@keyframes ed-destino-cambio {
0% { box-shadow: 0 0 0 3px var(--ed-seleccion-suave), 0 0 0 1px var(--ed-seleccion); }
100% { box-shadow: 0 0 0 0 transparent; }
}
.ed-enlace {
padding: 0;
border: 0;
@@ -955,20 +1046,43 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
}
.ed-chip-aviso { color: var(--ed-ambar); background: rgba(180, 83, 9, 0.12); }
.ed-chip-flujo { color: var(--ed-acento-hondo); background: var(--ed-acento-suave); }
.ed-chip-auto { color: var(--ed-seleccion); background: var(--ed-seleccion-suave); }
/* «Se pulsa sola en la demo»: select arriba del formulario y casilla por elemento */
.ed-campo-auto { max-width: 320px; }
.ed-check-auto {
justify-self: start;
padding: 3px 8px 3px 6px;
border: 1px dashed var(--ed-seleccion);
border-radius: 999px;
font-size: 12px;
color: var(--ed-seleccion);
}
.ed-check-auto input { accent-color: var(--ed-seleccion); }
.ed-check-auto:has(input:checked) { border-style: solid; background: var(--ed-seleccion-suave); font-weight: 600; }
.ed-chip-flujo-incompleto { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
.ed-num-resp { font-size: 12px; }
/* Las flechas ↑/↓ se muestran al abrir, al pasar el ratón o con el foco
dentro (siguen en el orden de Tab: el foco las hace visibles) */
dentro. Su hueco se reserva siempre (visibility, no display): el hover no
provoca saltos de layout ni recorta el resumen. Con el foco en la
cabecera se vuelven visibles y entran en el orden de Tab. */
.ed-tarjeta { position: relative; }
.ed-tarjeta:not(.ed-abierta):not(:hover):not(:focus-within) > .ed-tarjeta-cab .ed-mov {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
padding: 0;
border: 0;
visibility: hidden;
}
/* Editor estrecho (contenido < 400 px, p. ej. ventanas de 1024 y 1280): en plegadas las
flechas no ocupan sitio (se reordena con el asa o Alt+↑/↓) y aparecen
al abrir la tarjeta, cuando el resumen ya va en su propia línea. */
.ed-bloque-mensajes { container-type: inline-size; container-name: ed-mensajes; }
@container ed-mensajes (max-width: 400px) {
.ed-tarjeta:not(.ed-abierta) > .ed-tarjeta-cab .ed-mov { display: none; }
}
/* Muy estrecho (editor < 380 px, contenido ~330): la etiqueta de tipo queda solo como icono */
@container ed-mensajes (max-width: 330px) {
.ed-tarjeta-cab .ed-badge-txt { display: none; }
.ed-tarjeta-cab .ed-badge { padding: 2px 4px; }
}
.ed-acciones .ed-btn-mini { min-width: 26px; min-height: 26px; padding: 2px 6px; }
.ed-btn-mini:disabled { opacity: 0.35; }
@@ -1033,16 +1147,23 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
.ed-vacio-acciones { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 4px; }
/* ---------- Toasts (fuera del scroll del panel) ---------- */
/* Abajo, centrados sobre la columna de la vista previa: no tapan las
tarjetas ni sus acciones mientras se trabaja en el editor. */
.ed-toasts {
--ed-col-editor: clamp(340px, 34vw, 480px); /* = columna del editor (base.css) */
position: fixed;
left: 16px;
left: calc(var(--ed-col-editor) + (100vw - var(--ed-col-editor)) / 2);
bottom: 16px;
z-index: 1000;
display: grid;
justify-items: center;
gap: 8px;
max-width: min(440px, calc(100vw - 32px));
width: max-content;
max-width: min(440px, calc(100vw - var(--ed-col-editor) - 32px));
transform: translateX(-50%);
pointer-events: none;
}
body.presentacion .ed-toasts { left: 50%; max-width: min(440px, calc(100vw - 32px)); }
.ed-toast {
display: flex;
align-items: center;
@@ -1090,7 +1211,14 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
to { opacity: 1; transform: none; }
}
@media (max-width: 900px) {
.ed-toasts { bottom: calc(var(--wam-tabs-alto, 58px) + 70px); }
.ed-toasts {
left: 50%;
max-width: min(440px, calc(100vw - 32px));
bottom: calc(var(--wam-tabs-alto, 58px) + 64px + env(safe-area-inset-bottom, 0px));
}
body.vista-movil .ed-toasts { bottom: calc(var(--wam-tabs-alto, 58px) + 12px + env(safe-area-inset-bottom, 0px)); }
/* hueco al final de la lista para el botón flotante «Ver teléfono» */
#lista-mensajes { padding-bottom: 56px; }
}
/* ---------- Enlace con la vista previa ---------- */
@@ -1108,6 +1236,8 @@ body.presentacion #chat-cuerpo .wa-row.ed-resaltado { outline: 0; }
/* ---------- Destinos táctiles ---------- */
@media (pointer: coarse) {
.ed-tarjeta { --ed-acc-w: 176px; --ed-acc-h: 14px; }
.ed-tarjeta.ed-anidada { --ed-acc-w: 106px; }
.ed-btn-mini,
.ed-acciones .ed-btn-mini { min-width: 32px; min-height: 32px; }
.ed-fmt-btn { min-width: 32px; height: 32px; }
@@ -1116,14 +1246,12 @@ body.presentacion #chat-cuerpo .wa-row.ed-resaltado { outline: 0; }
}
@media (max-width: 480px) {
/* cabecera de tarjeta: la dirección se lee en el color del número */
.ed-tarjeta-cab .ed-dir { display: none; }
.ed-num.ed-num-in { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
.ed-paleta-grupo { grid-template-columns: 1fr; gap: 4px; }
.ed-paleta-titulo { padding-top: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ed-toast, .ed-tarjeta.ed-destello { animation: none; }
.ed-toast, .ed-tarjeta.ed-destello, .ed-destino-cambio { animation: none; }
.ed-tipo-mas { transition: none; }
.ed-bloque-cuenta > .ed-cuenta-sumario::before, .ed-seg-op span { transition: none; }
}
+10
View File
@@ -69,6 +69,12 @@
margin-right: 4px;
opacity: .8;
}
.wa .wa-cita-doc {
display: inline-block;
vertical-align: -2px;
margin-right: 4px;
color: var(--wa-text-meta);
}
.wa .wa-cita-mini {
flex: none;
width: 56px;
@@ -100,6 +106,7 @@
justify-content: flex-end;
}
#btn-reiniciar,
#sel-zoom,
#sel-velocidad {
height: 34px;
padding: 0 12px;
@@ -112,8 +119,11 @@
font-weight: 600;
cursor: pointer;
}
#sel-zoom,
#sel-velocidad { padding: 0 8px; }
#btn-reiniciar:hover,
#sel-zoom:hover,
#sel-velocidad:hover { border-color: var(--wam-acento, #00a884); }
#btn-reiniciar:focus-visible,
#sel-zoom:focus-visible,
#sel-velocidad:focus-visible { outline: 2px solid var(--wam-acento, #00a884); outline-offset: 2px; }
+89 -5
View File
@@ -21,11 +21,11 @@
@supports (scale: calc(1vh / 1px)) {
:root {
/* 150 px = toolbar + barra-movil + aire; 352 px = editor mínimo + aire.
/* toolbar + barra-movil (alturas reales, js/ux.js) + aire; 352 px = editor mínimo + aire.
px/px → número (requisito de scale()); min/max solo comparan números. */
--wam-escala: max(.3, min(
1,
(100vh - 150px) / 852px,
(100vh - var(--wam-toolbar-alto, 57px) - var(--wam-barra-alto, 48px) - 44px) / 852px,
(100vw - 352px) / 393px
));
}
@@ -43,6 +43,45 @@
que el teléfono crezca (en 1280×720 pasa de .67 a ~.73). */
body.presentacion #barra-movil { display: none; }
/* Presentación en escritorio (ratón + teclado): captura limpia, sin toolbar.
Se sale con Esc o P (js/ux.js); una pista flotante lo recuerda. */
@media (min-width: 901px) and (hover: hover) and (pointer: fine) {
body.presentacion #toolbar { display: none; }
body.presentacion #panel-movil { position: relative; }
body.presentacion #panel-movil::before {
content: "Esc para salir";
position: absolute;
top: 12px;
right: 16px;
padding: 5px 10px;
border-radius: 999px;
background: rgba(11, 20, 26, .55);
color: #fff;
font-size: 12px;
font-weight: 600;
pointer-events: none;
opacity: .75;
z-index: 5;
}
@supports (scale: calc(1vh / 1px)) {
body.presentacion {
--wam-escala: max(.3, min(1, (100vh - 32px) / 852px, (100vw - 64px) / 393px));
}
}
}
/* Zoom manual (lo fija js/ux.js con body[data-wam-zoom]; sin atributo =
"Ajustar", el cálculo automático). Con zoom fijo el panel hace scroll.
Solo en escritorio y fuera de Presentación (ahí manda el ajuste). */
@media (min-width: 901px) {
html body[data-wam-zoom="100"]:not(.presentacion) { --wam-escala: 1; }
html body[data-wam-zoom="125"]:not(.presentacion) { --wam-escala: 1.25; }
body[data-wam-zoom="100"]:not(.presentacion) #panel-movil,
body[data-wam-zoom="125"]:not(.presentacion) #panel-movil { overflow: auto; }
body[data-wam-zoom="100"]:not(.presentacion) .iphone-escena,
body[data-wam-zoom="125"]:not(.presentacion) .iphone-escena { margin: 0 auto; }
}
/* --- Columna de vista previa (#panel-movil) ---------------------------- */
#panel-movil {
min-width: 0;
@@ -61,18 +100,32 @@ body.presentacion #barra-movil { display: none; }
#barra-movil {
flex: none;
width: 100%;
max-width: 560px;
max-width: 820px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
/* Contador «Paso 3/12» de la demo (lo crea js/main.js junto al botón) */
#barra-movil .demo-paso {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--wam-texto-suave);
white-space: nowrap;
}
#barra-movil .demo-paso[hidden] { display: none; }
/* El zoom manual solo tiene sentido con marco y ventana de escritorio */
@media (max-width: 900px) {
#barra-movil .barra-zoom { display: none; }
}
#barra-movil .barra-titulo {
font-size: 12px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
white-space: nowrap;
color: var(--wam-texto-suave);
}
#btn-reproducir {
@@ -185,7 +238,7 @@ body.presentacion #barra-movil { display: none; }
height: 5px;
border-radius: 3px;
background: #fff; /* en oscuro */
z-index: 45;
z-index: 70; /* encima de la hoja inferior (.sheet z 60), como en iOS */
pointer-events: none;
}
#pantalla:not(.oscuro) .home-indicator { background: #000; } /* en claro, negro */
@@ -202,7 +255,7 @@ body.presentacion #barra-movil { display: none; }
overflow-x: hidden;
list-style: none;
padding: 8px 0 14px;
scroll-behavior: smooth;
scroll-behavior: auto; /* el suave solo en enfocarEnChat (behavior explícito) */
overscroll-behavior: contain;
}
@@ -241,6 +294,37 @@ body.presentacion #barra-movil { display: none; }
/* --- Modo presentación: el iPhone se centra y respira más --------------- */
body.presentacion #panel-movil { padding: 14px 20px 22px; }
/* --- Móvil real (≤ 520 px): sin marco ----------------------------------
En el propio teléfono del consultor sobra el iPhone dibujado: la pantalla
ocupa el ancho y el alto disponibles a escala 1 (sin isla, status bar ni
indicador home duplicados). body.con-marco lo desactiva. */
@media (max-width: 520px) {
html body:not(.con-marco) { --wam-escala: 1; }
body:not(.con-marco) #panel-movil { padding-left: 0; padding-right: 0; }
body:not(.con-marco) #barra-movil { padding: 0 16px; margin-bottom: 8px; }
body:not(.con-marco) .iphone-escena {
flex: 1 1 auto;
width: 100%;
height: auto;
min-height: 420px;
margin: 0;
}
body:not(.con-marco) .iphone {
width: 100%;
height: 100%;
border-radius: 0;
transform: none;
box-shadow: none;
background: transparent;
}
body:not(.con-marco) .pantalla { border-radius: 0; padding-top: 0; }
body:not(.con-marco) .island,
body:not(.con-marco) .statusbar,
body:not(.con-marco) .home-indicator { display: none; }
body:not(.con-marco) .wa #composer { padding-bottom: 8px; }
body:not(.con-marco) .wa .sheet-panel { padding-bottom: 12px; }
}
/* --- Accesibilidad: menos movimiento ------------------------------------- */
@media (prefers-reduced-motion: reduce) {
#btn-reproducir { transition: none; }
+28 -14
View File
@@ -69,7 +69,7 @@
--wa-chrome: #121313;
--wa-chrome-text: #ffffff;
--wa-header-icon: #ffffff;
--wa-compose-bar: #121313;
--wa-compose-bar: #232626; /* barra [medido]; el header sí es #121313 */
--wa-compose-field: #393c3c; /* [medido] */
--wa-compose-border: transparent;
--wa-separator: rgba(255, 255, 255, .10);
@@ -106,7 +106,6 @@
/* Reset de <button> dentro del chat (botones de flujo accesibles) */
.wa button.qr-btn,
.wa button.sheet-fila,
.wa button.sheet-cerrar,
.wa button.contact-accion {
font: inherit;
@@ -116,6 +115,16 @@
-webkit-appearance: none;
appearance: none;
}
/* Las filas de la hoja conservan su separador (lista-hoja-inferior.png) */
.wa button.sheet-fila {
font: inherit;
letter-spacing: inherit;
border: 0;
border-bottom: .5px solid var(--wa-sheet-sep);
background: none;
-webkit-appearance: none;
appearance: none;
}
/* ---------- 4. Header del chat (iOS: chevron negro, avatar 36, iconos de línea) ---------- */
.wa #chat-header {
@@ -127,7 +136,7 @@
background: var(--wa-chrome);
color: var(--wa-chrome-text);
border-bottom: .5px solid var(--wa-separator);
transition: background-color .2s ease;
transition: background-color .2s ease, color .2s ease;
}
.wa .wa-back { width: 22px; flex: none; display: flex; color: var(--wa-chrome-text); padding-left: 4px; }
.wa .wa-avatar,
@@ -139,6 +148,7 @@
flex: none;
background: var(--wa-separator);
}
.wa #chat-avatar { position: relative; z-index: 1; }
.wa .wa-header-tit { flex: 1; min-width: 0; }
.wa .header-title {
font-size: 17px;
@@ -199,14 +209,14 @@
position: relative;
z-index: 0; /* contexto: la cola queda bajo el contenido */
max-width: 78%;
padding: 6px 12px 7px;
padding: 7px 12px 6px; /* 7 arriba / 6 abajo [medido, README §2.2] */
border-radius: 18px;
background: var(--wa-bubble-in);
font-size: 17px;
line-height: 22px;
overflow-wrap: break-word;
word-break: break-word;
transition: background-color .2s ease;
transition: background-color .2s ease, color .2s ease;
}
.wa .out .bubble { background: var(--wa-bubble-out); }
@@ -615,7 +625,7 @@
background: var(--wa-bubble-in);
display: flex;
flex-direction: column;
transition: background-color .2s ease;
transition: background-color .2s ease, color .2s ease;
}
.wa .card-media {
display: block;
@@ -737,15 +747,17 @@
margin-left: 16px;
padding-left: 0;
}
.wa .sheet-opciones > .sheet-seccion:first-child { padding-top: 12px; }
.wa .sheet-opciones > .sheet-seccion:first-child { padding-top: 30px; }
.wa .sheet-grupo { margin: 0; }
.wa .sheet-grupo + .sheet-grupo { margin-top: 18px; border-top: .5px solid var(--wa-sheet-sep); }
/* "Todas las opciones": un único separador (con sangría) entre grupos */
.wa .sheet-grupo + .sheet-grupo { margin: 0 0 0 16px; border-top: .5px solid var(--wa-sheet-sep); }
.wa .sheet-grupo + .sheet-grupo > .sheet-fila { margin-left: 0; width: 100%; }
.wa .sheet-fila {
display: flex;
align-items: center;
gap: 12px;
min-height: 52px;
padding: 12px 16px 12px 0;
padding: 17px 16px 17px 0;
margin: 0 0 0 16px;
width: calc(100% - 16px);
border-bottom: .5px solid var(--wa-sheet-sep);
@@ -757,10 +769,12 @@
-webkit-tap-highlight-color: transparent;
}
.wa .sheet-fila:active { background: rgba(0, 0, 0, .05); }
.wa .sheet-fila-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.wa .sheet-fila.qr-btn-inactiva:active { background: none; }
.wa .sheet-fila-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0; }
.wa .sheet-fila-titulo { font-size: 17px; line-height: 22px; }
.wa .sheet-fila-desc { font-size: 15px; line-height: 20px; letter-spacing: 0; color: var(--wa-sheet-sub); }
.wa .sheet-fila-btn { color: var(--wa-action); min-height: 48px; }
.wa .sheet-fila-desc { font-size: 15px; line-height: 18px; letter-spacing: 0; color: var(--wa-sheet-sub); }
.wa .sheet-fila-btn,
.wa button.sheet-fila-btn { color: var(--wa-action); min-height: 48px; padding-top: 12px; padding-bottom: 12px; border-bottom: 0; }
.wa .sheet-fila-btn .sheet-fila-titulo { color: var(--wa-action); }
.wa .sheet-fila .qr-ico { flex: none; display: flex; color: var(--wa-action); }
.wa .sheet-pie {
@@ -780,8 +794,8 @@
gap: 12px;
padding: 6px 12px 34px 10px; /* 34 pt de safe area bajo el home indicator */
background: var(--wa-compose-bar);
border-top: 0;
transition: background-color .2s ease;
border-top: .5px solid var(--wa-separator); /* hairline como el del header */
transition: background-color .2s ease, color .2s ease;
}
.wa #composer .attach,
.wa #composer .cmp-icono {
+47 -23
View File
@@ -16,6 +16,9 @@
</head>
<body data-tema="claro">
<!-- Teclado: salta las tarjetas del editor directo al teléfono (atajo G / F6) -->
<a id="saltar-vista" class="wam-saltar" href="#chat-cuerpo">Saltar a la vista previa</a>
<div id="app">
<!-- ============ Barra superior de la app (shell A) ============ -->
@@ -28,6 +31,12 @@
<h1>WhatsApp Cloud API</h1>
<span class="toolbar-subtitulo">Mockup visual · demo estática</span>
</div>
<!-- Punto de partida del consultor: escenarios por industria y demos guardadas -->
<button id="btn-demos" class="tb-btn tb-btn-primario" type="button" title="Nueva demo: escenarios por industria y demos guardadas (N)" aria-keyshortcuts="N">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.6"/><path d="M17 14v6M14 17h6"/></svg>
<span>Nueva demo</span>
<kbd class="tb-kbd" aria-hidden="true">N</kbd>
</button>
<!-- Estado del guardado local (lo pinta js/ux.js) -->
<span id="estado-guardado" class="tb-guardado" data-estado="ok" title="Guardado en este navegador">
<span class="tb-guardado-punto" aria-hidden="true"></span>
@@ -44,24 +53,18 @@
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/></svg>
</button>
<span class="tb-separador" aria-hidden="true"></span>
<button id="btn-tema" class="tb-btn" type="button" title="Alternar tema claro / oscuro (T)" aria-label="Tema oscuro" aria-pressed="false">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 2.6a9.4 9.4 0 1 0 0 18.8 9.4 9.4 0 0 0 0-18.8zm0 16.9V4.5a7.5 7.5 0 0 1 0 15z"/></svg>
<span>Tema</span>
</button>
<button id="btn-presentacion" class="tb-btn" type="button" title="Modo presentación: oculta el editor (P · Esc para salir)" aria-pressed="false">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" fill-rule="evenodd" aria-hidden="true"><path d="M3 4.5h18a1 1 0 0 1 1 1V16a1 1 0 0 1-1 1h-6.4v2.1h1.9a.9.9 0 1 1 0 1.8H7.5a.9.9 0 1 1 0-1.8h1.9V17H3a1 1 0 0 1-1-1V5.5a1 1 0 0 1 1-1zm8 3.4v5.7l4.6-2.85z"/></svg>
<span>Presentación</span>
</button>
<button id="btn-tema" class="tb-btn tb-btn-icono" type="button" title="Alternar tema claro / oscuro (T)" aria-label="Tema oscuro" aria-pressed="false">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 2.6a9.4 9.4 0 1 0 0 18.8 9.4 9.4 0 0 0 0-18.8zm0 16.9V4.5a7.5 7.5 0 0 1 0 15z"/></svg>
</button>
<span class="tb-separador tb-sep-sec" aria-hidden="true"></span>
<!-- «⋯»: en ventana estrecha agrupa las acciones secundarias -->
<button id="btn-mas" class="tb-btn tb-btn-icono" type="button" aria-label="Más acciones" aria-expanded="false" aria-controls="tb-menu" title="Más acciones">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="19" cy="12" r="1.9"/></svg>
</button>
<!-- Acciones secundarias: en línea en escritorio; en ventana estrecha
forman el menú «⋯». «Volver al ejemplo» (destructiva) vive siempre
dentro del menú «⋯», al final y con estilo de peligro. -->
<div id="tb-menu" class="tb-grupo-sec">
<button id="btn-demos" class="tb-btn" type="button" title="Nueva demo: escenarios por industria y demos guardadas (N)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.6"/><path d="M17 14v6M14 17h6"/></svg>
<span>Demos</span>
</button>
<button id="btn-exportar" class="tb-btn" type="button" title="Exportar la demo a JSON (Ctrl+S)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 3.2c.5 0 .9.4.9.9v8.2l2.7-2.7a.9.9 0 1 1 1.3 1.3l-4.2 4.2a.9.9 0 0 1-1.3 0L7.2 11a.9.9 0 1 1 1.3-1.3l2.7 2.7V4.1c0-.5.4-.9.9-.9zM4.9 18.5h14.2a.9.9 0 1 1 0 1.8H4.9a.9.9 0 1 1 0-1.8z"/></svg>
<span>Exportar</span>
@@ -71,15 +74,19 @@
<span>Importar</span>
</button>
<input type="file" id="importar-archivo" accept="application/json,.json" hidden>
<button id="btn-reset" class="tb-btn" type="button" title="Restablecer la demo de ejemplo (pide confirmación)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 5V2.8a.7.7 0 0 0-1.13-.55L6.9 5.2a.7.7 0 0 0 0 1.1l3.97 2.95A.7.7 0 0 0 12 8.7V6.5a5.5 5.5 0 1 1-5.5 5.5H4.75A7.25 7.25 0 1 0 12 5z"/></svg>
<span>Restablecer</span>
</button>
<button id="btn-atajos" class="tb-btn tb-btn-icono" type="button" title="Atajos de teclado (?)" aria-label="Atajos de teclado">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="6" width="19" height="12" rx="2.2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7.5 14h9"/></svg>
<span class="tb-solo-menu">Atajos</span>
</button>
<span class="tb-menu-sep" aria-hidden="true"></span>
<button id="btn-reset" class="tb-btn tb-btn-peligro" type="button" title="Sustituye la demo actual por el ejemplo Café Aurora (pide confirmación y se puede deshacer)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 5V2.8a.7.7 0 0 0-1.13-.55L6.9 5.2a.7.7 0 0 0 0 1.1l3.97 2.95A.7.7 0 0 0 12 8.7V6.5a5.5 5.5 0 1 1-5.5 5.5H4.75A7.25 7.25 0 1 0 12 5z"/></svg>
<span>Volver al ejemplo Café Aurora</span>
</button>
</div>
<button id="btn-mas" class="tb-btn tb-btn-icono" type="button" aria-label="Más acciones" aria-expanded="false" aria-controls="tb-menu" title="Más acciones">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="19" cy="12" r="1.9"/></svg>
</button>
</div>
</header>
@@ -103,19 +110,20 @@
<div class="wam-alta">
<!-- Paleta de los 13 tipos (la rellena js/editor.js): un clic AÑADE el mensaje.
El último tipo usado queda en #nuevo-tipo-valor (hidden) y en data-valor. -->
<div id="nuevo-tipo" class="ed-paleta" role="toolbar" aria-label="Añadir un mensaje: elige el tipo"></div>
<div id="nuevo-tipo" class="ed-paleta" role="toolbar" aria-label="Añadir un mensaje: cada tipo añade uno"></div>
<input type="hidden" id="nuevo-tipo-valor" value="text">
<div class="ed-alta-pie">
<span id="ed-destino-alta" class="ed-destino-alta" aria-live="polite"></span>
<button id="btn-anadir" class="wam-btn-anadir" type="button">+ Otro «Texto»</button>
<!-- «Repetir último»: aparece tras el primer alta de la sesión -->
<button id="btn-anadir" class="wam-btn-anadir" type="button" hidden>+ Otro «Texto»</button>
</div>
</div>
<div class="ed-lista-herr">
<div class="ed-lista-herr" id="ed-lista-herr">
<button type="button" class="ed-btn ed-btn-mini" data-acc="horas-secuencia" title="Reparte las horas desde la del primer mensaje, +1 min por mensaje">Horas en secuencia</button>
<button type="button" class="ed-btn ed-btn-mini" data-acc="plegar-todo">Plegar todo</button>
</div>
<!-- Una tarjeta colapsable por mensaje la pinta js/editor.js (C) -->
<div id="lista-mensajes"></div>
<div id="lista-mensajes" tabindex="-1" aria-label="Tarjetas de mensajes"></div>
</section>
<!-- Solo en ventana estrecha: salta a la vista previa del mensaje en edición -->
@@ -130,12 +138,28 @@
<div id="barra-movil">
<span class="barra-titulo">Vista previa</span>
<div class="barra-acciones">
<select id="sel-velocidad" title="Velocidad de la reproducción" aria-label="Velocidad de la reproducción">
<label class="barra-etq barra-zoom" for="sel-zoom" title="Tamaño del teléfono en pantalla">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2M11 8.5v5M8.5 11h5"/></svg>
<span class="barra-etq-txt">Zoom</span>
</label>
<select id="sel-zoom" class="barra-zoom" title="Tamaño del teléfono en pantalla">
<option value="" selected>Ajustar</option>
<option value="100">100 %</option>
<option value="125">125 %</option>
</select>
<label class="barra-etq" for="sel-velocidad" title="Velocidad de la reproducción">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 13V9M10 2.8h4"/></svg>
<span class="barra-etq-txt">Velocidad</span>
</label>
<select id="sel-velocidad" title="Velocidad de la reproducción">
<option value="0.5">0,5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
</select>
<button id="btn-reiniciar" type="button" title="Descartar lo pulsado y escrito en el teléfono y volver al guion">Reiniciar chat</button>
<button id="btn-reiniciar" type="button" title="Descarta lo pulsado y escrito en el teléfono y vuelve al guion desde el primer mensaje">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/></svg>
<span>Repetir desde el inicio</span>
</button>
<button id="btn-reproducir" type="button" title="Reproducir la demo paso a paso (clic de nuevo para detener)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8.4 5.6v12.8c0 .8.9 1.3 1.6.9l10-6.4c.6-.4.6-1.4 0-1.8l-10-6.4c-.7-.4-1.6.1-1.6.9z"/></svg>
<span>Reproducir demo</span>
@@ -199,7 +223,7 @@
</header>
<!-- Scroll de mensajes (estructura A · filas li.wa-row de B) -->
<ul id="chat-cuerpo" aria-live="polite"></ul>
<ul id="chat-cuerpo" aria-live="polite" tabindex="-1" aria-label="Conversación"></ul>
<!-- Indicador "escribiendo…" (estructura A · contenido y animación de B) -->
<div id="chat-escribiendo"></div>
+227 -36
View File
@@ -110,6 +110,7 @@
var ultimaAbierta = null; // id top-level abierto más reciente (posición de alta)
var insertarTras = null; // id tras el que insertar (botón «+» de una tarjeta)
var ultimoTipo = "text";
var yaAnadido = false; // ¿se añadió algo desde la paleta en esta sesión?
var ultimoEnlazado = null; // id resaltado en la vista previa
var tResalte = 0;
var contadorIds = 0; // ids únicos para <label for>
@@ -244,9 +245,9 @@
return false;
}
/* Aviso flotante (toast) abajo a la izquierda, fuera de la zona con
scroll del panel. opts: { tipo: "error", ms, accion: { texto, fn } }.
Devuelve una función que lo cierra. */
/* Aviso flotante (toast) abajo, centrado sobre la vista previa (fuera de
la lista de tarjetas). opts: { tipo: "error", ms, accion: { texto, fn } }.
Solo hay un aviso con acción a la vez. Devuelve una función que lo cierra. */
function contenedorToasts() {
var c = document.getElementById("ed-toasts");
if (!c) {
@@ -280,14 +281,24 @@
var x = h("button", { type: "button", class: "ed-toast-cerrar", "aria-label": "Cerrar aviso", text: "×" });
x.addEventListener("click", cerrar);
t.appendChild(x);
// Como mucho 2 a la vista: primero se retiran los que no tienen acción
var hijos = Array.prototype.slice.call(c.children);
// Un aviso con acción nuevo invalida los anteriores con acción: su
// «Deshacer» ya no desharía lo que dice (se deshace de uno en uno).
if (opts.accion && typeof opts.accion.fn === "function") {
hijos = hijos.filter(function (x) {
if (!x.querySelector(".ed-toast-accion")) return true;
if (typeof x.__cerrar === "function") x.__cerrar(); else if (x.parentNode) x.parentNode.removeChild(x);
return false;
});
}
// Como mucho 2 a la vista: primero se retiran los que no tienen acción
var sinAccion = hijos.filter(function (x) { return !x.querySelector(".ed-toast-accion"); });
while (hijos.length >= 2) {
var quitar = sinAccion.length ? sinAccion.shift() : hijos[0];
hijos.splice(hijos.indexOf(quitar), 1);
if (quitar.parentNode) quitar.parentNode.removeChild(quitar);
}
t.__cerrar = cerrar;
c.appendChild(t);
timer = setTimeout(cerrar, opts.ms || (esError ? 7000 : 3000));
return cerrar;
@@ -439,7 +450,10 @@
if (obj[k] === null || obj[k] === undefined) obj[k] = /^\d+$/.test(partes[i + 1]) ? [] : {};
obj = obj[k];
}
obj[partes[partes.length - 1]] = valor;
var clave = partes[partes.length - 1];
obj[clave] = valor;
// Editar la URL a mano retira la marca de enlace bloqueado (store.js)
if (clave === "url" && obj.urlBloqueada) delete obj.urlBloqueada;
if (typeof WAM.guardar === "function") WAM.guardar();
marcarEditado();
if (opts && opts.diferido) { programarRender(String(ruta)); return; }
@@ -500,14 +514,51 @@
if (car2) car2.scrollLeft = sl;
}
// Reemplaza un array completo vía setCampo (mutación contractual) y repinta tarjetas
/* --- autoPulsar (SPEC §1): índice del elemento que «Reproducir demo»
pulsa solo. Botones (template/buttons) y tarjetas (carousel) usan su
índice en el array; las listas, el índice aplanado de todas sus filas. */
function pulsablesDe(m) {
if (!m || typeof m !== "object") return [];
if (m.tipo === "list") {
var out = [];
(m.secciones || []).forEach(function (s) { ((s && s.filas) || []).forEach(function (f) { out.push(f); }); });
return out;
}
if (m.tipo === "carousel") return Array.isArray(m.cards) ? m.cards : [];
if (m.tipo === "template" || m.tipo === "buttons") return Array.isArray(m.botones) ? m.botones : [];
return [];
}
function tieneAuto(m) { return !!m && typeof m.autoPulsar === "number" && isFinite(m.autoPulsar) && m.autoPulsar >= 0; }
// Ruta del mensaje dueño de una lista de botones / cards / secciones / filas
function rutaMensajeDe(rutaArr) {
var r = String(rutaArr || "");
var m = /^(.*)\.secciones\.\d+\.filas$/.exec(r) || /^(.*)\.(?:botones|cards|secciones)$/.exec(r);
return m ? m[1] : null;
}
// Reemplaza un array completo vía setCampo (mutación contractual) y repinta tarjetas.
// Si el array es de botones / cards / secciones / filas, autoPulsar sigue
// al elemento pulsado (se mueve con él; se quita si se borró).
function mutarArray(ruta, fn) {
var arr = rutaGet(ruta);
if (!Array.isArray(arr)) { aviso("Estructura no encontrada: " + ruta, "error"); return null; }
var copia = fn(arr.slice());
if (!copia) return null; // validación rechazada (fn ya avisó)
snap();
var rutaMsg = rutaMensajeDe(ruta);
var dueno = rutaMsg ? rutaGet(rutaMsg) : null;
var pulsado = tieneAuto(dueno) ? pulsablesDe(dueno)[dueno.autoPulsar] : undefined;
WAM.setCampo(ruta, copia);
if (pulsado !== undefined) {
var nuevo = pulsablesDe(rutaGet(rutaMsg)).indexOf(pulsado);
if (nuevo !== dueno.autoPulsar) {
var padre = rutaGet(rutaMsg);
if (nuevo < 0) delete padre.autoPulsar; else padre.autoPulsar = nuevo;
WAM.setCampo(rutaMsg, padre);
}
}
WAM.renderTarjetas();
return copia;
}
@@ -816,7 +867,8 @@
snap();
WAM.setCampo("mensajes", copia);
WAM.renderTarjetas();
// Foco a la tarjeta vecina (la siguiente o, si era la última, la anterior)
// Foco a la CABECERA de la tarjeta vecina (la siguiente o, si era la
// última, la anterior), nunca a su botón borrar; sin tarjetas, la paleta.
var vecino = copia[i] || copia[i - 1];
if (vecino) enfocarTarjeta(vecino.id, false);
else { var pal = paletaFoco(); if (pal) pal.focus(); }
@@ -929,6 +981,8 @@
} finally {
WAM.estado = previo;
}
// Se lee ya: cualquier normalización posterior sobrescribe el resumen
var recortes = typeof WAM.describirRecortes === "function" && datos ? WAM.describirRecortes(datos.__recortes) : "";
if (!datos || !Array.isArray(datos.mensajes)) throw new Error("sin mensajes");
snap();
WAM.estado = datos;
@@ -943,7 +997,7 @@
if (typeof WAM.render === "function") WAM.render();
WAM.renderEditor();
var n = WAM.estado.mensajes.length;
WAM.toast("Demo importada: " + n + " mensaje" + (n === 1 ? "" : "s"),
WAM.toast(recortes ? recortes + "." : "Demo importada: " + n + " mensaje" + (n === 1 ? "" : "s"),
typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
var primero = listaNodo() && listaNodo().querySelector(".ed-cab-principal");
if (primero) primero.focus();
@@ -989,7 +1043,18 @@
------------------------------------------------------------------ */
function largo(v) { return String(v === undefined || v === null ? "" : v).length; }
function vacioTxt(v) { return !String(v === undefined || v === null ? "" : v).trim(); }
function urlValida(v) { return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(String(v || "").trim()); }
// Con esquema http(s) o con forma de dominio («www.cafe.com», «cafe.com/menu»):
// render.js abre estas últimas con https://, igual que WhatsApp.
function urlValida(v) {
var s = String(v || "").trim();
return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(s) ||
/^www\.[^\s]+$/i.test(s) || /^[\w-]+(\.[\w-]+)+(:\d+)?([\/?#][^\s]*)?$/.test(s);
}
// Botón de llamada de plantilla: tel: con al menos 3 dígitos (espacios, guiones y paréntesis permitidos)
function telValido(v) {
var s = String(v || "").trim();
return /^tel:\+?[\d\s().-]+$/i.test(s) && (s.replace(/\D/g, "").length >= 3);
}
function validar(m, prefijo, profundidad) {
var av = [];
@@ -1032,12 +1097,16 @@
lim(m.footer, 60, "El pie");
var bt = m.botones || [];
if (bt.length > LIMITES.botonesPlantilla) add("Máximo " + LIMITES.botonesPlantilla + " botones");
var conUrl = bt.filter(function (b) { return b && !vacioTxt(b.url); }).length;
// tel:+… es el botón de llamada (PHONE_NUMBER de la API): máximo 1 y
// no cuenta entre los 2 de URL
var conTel = bt.filter(function (b) { return b && telValido(b.url); }).length;
var conUrl = bt.filter(function (b) { return b && !vacioTxt(b.url) && !telValido(b.url); }).length;
if (conUrl > 2) add("Máximo 2 botones con URL (" + conUrl + ")");
if (conTel > 1) add("Máximo 1 botón de llamada tel: (" + conTel + ")");
bt.forEach(function (b, i) {
if (vacioTxt(b.texto)) add("Botón " + (i + 1) + ": sin texto");
else lim(b.texto, 25, "Botón " + (i + 1));
if (!vacioTxt(b.url) && !urlValida(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://…)");
if (!vacioTxt(b.url) && !urlValida(b.url) && !telValido(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://… o tel:+34…)");
});
break;
}
@@ -1110,6 +1179,13 @@
}
default: break;
}
// «Se pulsa sola» apunta a una opción que ya no existe o que abre una URL
if (tieneAuto(m) && ["template", "buttons", "list", "carousel"].indexOf(m.tipo) >= 0) {
var objAuto = pulsablesDe(m)[m.autoPulsar];
var nAuto = "«Se pulsa sola en la demo»: la opción " + (m.autoPulsar + 1);
if (!objAuto) add(nAuto + " ya no existe (la demo no pulsará nada)");
else if (!vacioTxt(objAuto.url)) add(nAuto + " abre una URL y la demo no la pulsará");
}
if ((profundidad || 0) < 6) {
origenesRespuesta(m).forEach(function (o) {
o.respuestas.forEach(function (r) {
@@ -1150,6 +1226,16 @@
"aria-label": av.length + (av.length === 1 ? " aviso" : " avisos")
}, [h("span", { "aria-hidden": "true", text: "⚠ " + av.length })]));
}
// ▶ auto: la demo pulsa sola una opción de este mensaje
if (tieneAuto(m)) {
var objA = pulsablesDe(m)[m.autoPulsar];
if (objA && vacioTxt(objA.url)) {
var etA = objA.texto || objA.titulo || objA.textoBoton || ("opción " + (m.autoPulsar + 1));
var tA = "En «Reproducir demo» se pulsa sola: «" + etA + "»";
frag.appendChild(h("span", { class: "ed-chip ed-chip-auto", title: tA, "aria-label": tA },
[h("span", { "aria-hidden": "true", text: "▶ auto" })]));
}
}
var f = contarFlujos(m);
if (f.total) {
var txt = "↳ " + f.con + (f.sin ? "/" + f.total : "");
@@ -1188,6 +1274,8 @@
if (!cab) return;
var res = cab.querySelector(".ed-resumen");
if (res) res.textContent = resumenDe(m);
var cp = cab.querySelector(".ed-cab-principal");
if (cp) cp.title = (t.classList.contains("ed-abierta") ? "Plegar" : "Desplegar") + " · " + resumenDe(m);
var chips = cab.querySelector(".ed-chips");
if (chips) {
while (chips.firstChild) chips.removeChild(chips.firstChild);
@@ -1477,7 +1565,8 @@
btnMini("✕", "Quitar " + o.etiqueta.toLowerCase(), "del-boton", { "data-ruta": ruta, "data-idx": i }, true)
]),
edCampo("Texto", ruta + "." + i + ".texto", b.texto, { limite: o.maxTexto, placeholder: o.ejemplo || "" }),
o.conUrl ? edCampo("URL (si la pones, abre el enlace en vez de responder)", ruta + "." + i + ".url", b.url, { placeholder: "https://…" }) : null,
o.conUrl ? edCampo("URL (si la pones, abre el enlace en vez de responder; tel:+34… llama)", ruta + "." + i + ".url", b.url, { placeholder: "https://…" }) : null,
edCheckAuto(rutaMensajeDe(ruta), i, b),
edRespuestas(ruta + "." + i + ".respuestas", b.respuestas, o.raiz)
]);
});
@@ -1503,9 +1592,13 @@
function edSeccionesEd(ruta, secciones, raiz) {
var lista = Array.isArray(secciones) ? secciones : [];
var rutaMsgLista = rutaMensajeDe(ruta);
var base = 0; // índice aplanado de la primera fila de la sección (autoPulsar)
var hijos = lista.map(function (s, si) {
var filas = Array.isArray(s.filas) ? s.filas : [];
var rutaFilas = ruta + "." + si + ".filas";
var offset = base;
base += filas.length;
var filaEls = filas.map(function (f, fi) {
return h("div", { class: "ed-item", "data-drag-ruta": rutaFilas, "data-idx": fi }, [
h("div", { class: "ed-sub-cab" }, [
@@ -1516,6 +1609,7 @@
]),
edCampo("Título", rutaFilas + "." + fi + ".titulo", f.titulo, { limite: 24 }),
edCampo("Descripción", rutaFilas + "." + fi + ".descripcion", f.descripcion, { limite: 72 }),
edCheckAuto(rutaMsgLista, offset + fi, f),
edRespuestas(rutaFilas + "." + fi + ".respuestas", f.respuestas, raiz)
]);
});
@@ -1572,6 +1666,7 @@
edCampo("Texto del botón", ruta + "." + ci + ".textoBoton", c.textoBoton, { limite: 20 }),
edCampo("URL (opcional)", ruta + "." + ci + ".url", c.url, { placeholder: "https://…" })
]),
edCheckAuto(rutaMensajeDe(ruta), ci, c),
edRespuestas(ruta + "." + ci + ".respuestas", c.respuestas, raiz)
]);
});
@@ -1598,10 +1693,14 @@
var caja = h("div", { class: "ed-header-bloque" });
caja.appendChild(h("div", { class: "ed-sub-cab" }, [
h("span", { class: "ed-sub-titulo", text: "Cabecera" }),
// Etiqueta fija (no se invierte con el estado): el interruptor dice si se muestra
h("label", { class: "ed-switch" }, [
h("input", { type: "checkbox", "data-rol": "header-toggle", "data-ruta": ruta, checked: tipo !== "" }),
h("input", {
type: "checkbox", role: "switch", "aria-checked": tipo !== "" ? "true" : "false",
"data-rol": "header-toggle", "data-ruta": ruta, checked: tipo !== ""
}),
h("span", { class: "ed-switch-pista" }, [h("span", { class: "ed-switch-nudo" })]),
h("span", { class: "ed-switch-texto", text: tipo === "" ? "Mostrar cabecera" : "Ocultar cabecera" })
h("span", { class: "ed-switch-texto", text: "Mostrar cabecera" })
])
]));
if (tipo !== "") {
@@ -1628,6 +1727,9 @@
caja.appendChild(edCampo("Nombre del documento (con extensión; define el icono)", ruta + ".headerTexto", m.headerTexto, {
max: 80, contar: true, placeholder: "catalogo.pdf", spell: false
}));
caja.appendChild(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".headerTamano", m.headerTamano, {
max: 20, placeholder: "243 KB", spell: false
}));
}
caja.appendChild(edMedia("Media de cabecera", ruta + ".headerMedia", m.headerMedia, {
aceptar: tipo === "imagen" ? "image/*" : (tipo === "video" ? "video/*" : ""),
@@ -1666,12 +1768,34 @@
});
}
var v = (m.autoPulsar === undefined || m.autoPulsar === null) ? "" : String(m.autoPulsar);
return edSelect("En «Reproducir demo», pulsar sola", ruta + ".autoPulsar", v, ops, false);
// Valor guardado que ya no corresponde a ninguna opción (borrado, o con URL):
// se muestra tal cual para que no parezca «No» en silencio (y validar() avisa)
if (v !== "" && !ops.some(function (op) { return op[0] === v; })) {
ops.push([v, "⚠ Opción inexistente (" + (Number(v) + 1) + ")"]);
}
var campo = edSelect("En «Reproducir demo», se pulsa sola", ruta + ".autoPulsar", v, ops, true);
campo.classList.add("ed-campo-auto");
return campo;
}
// Casilla «Se pulsa sola en la demo» dentro de cada botón / fila / tarjeta
// (exclusiva dentro del mensaje: marcar una desmarca la anterior)
function edCheckAuto(rutaMsg, indice, elemento) {
var m = rutaGet(rutaMsg);
if (!m || !elemento || !vacioTxt(elemento.url)) return null; // con URL abre el enlace
var marcado = tieneAuto(m) && m.autoPulsar === indice;
return h("label", { class: "ed-check ed-check-auto", title: "En «Reproducir demo» se pulsa sola 1 s después de aparecer" }, [
h("input", { type: "checkbox", "data-rol": "auto-item", "data-ruta": rutaMsg + ".autoPulsar", "data-idx": indice, checked: marcado }),
h("span", { text: "Se pulsa sola en la demo" })
]);
}
function edFormTipo(m, ruta, raiz) {
var frag = document.createDocumentFragment();
var ap = function (n) { if (n) frag.appendChild(n); };
// Clave para las demos: arriba del todo, justo bajo Dirección / Hora
// (cada botón / fila / tarjeta tiene además su casilla «Se pulsa sola»)
if (["template", "buttons", "list", "carousel"].indexOf(m.tipo) >= 0) ap(edAutoPulsar(ruta, m));
switch (m.tipo) {
case "text":
ap(edTextarea("Texto", ruta + ".texto", m.texto, { limite: 4096, rows: 3, formato: true, placeholder: "Hola 👋" }));
@@ -1691,6 +1815,7 @@
break;
case "document":
ap(edCampo("Nombre de archivo (con extensión; define el icono)", ruta + ".filename", m.filename, { max: 80, contar: true, placeholder: "menu.pdf" }));
ap(edCampo("Tamaño (opcional, p. ej. 243 KB)", ruta + ".tamano", m.tamano, { max: 20, placeholder: "243 KB", spell: false }));
ap(edTextarea("Caption (opcional)", ruta + ".caption", m.caption, { limite: 1024 }));
ap(edMedia("Archivo (opcional, no se muestra)", ruta + ".src", m.src, { tipoMedia: "doc", textoDrop: "Clic o arrastra un documento aquí" }));
break;
@@ -1724,7 +1849,6 @@
max: LIMITES.botonesPlantilla, maxTexto: 25, conUrl: true,
etiqueta: "Botón", ejemplo: "Confirmar", raiz: raiz
}));
ap(edAutoPulsar(ruta, m));
break;
}
case "buttons":
@@ -1735,7 +1859,6 @@
max: LIMITES.botonesRapidos, maxTexto: 20, conUrl: false,
etiqueta: "Botón rápido", ejemplo: "Horarios", raiz: raiz
}));
ap(edAutoPulsar(ruta, m));
break;
case "list":
ap(edCampo("Cabecera", ruta + ".header", m.header, { limite: 60 }));
@@ -1743,7 +1866,6 @@
ap(edCampo("Pie (footer)", ruta + ".footer", m.footer, { limite: 60 }));
ap(edCampo("Etiqueta del botón", ruta + ".boton", m.boton, { limite: 20, placeholder: "Ver opciones" }));
ap(edSeccionesEd(ruta + ".secciones", m.secciones, raiz));
ap(edAutoPulsar(ruta, m));
break;
case "cta_url":
ap(edMedia("Cabecera («» = sin cabecera)", ruta + ".headerMedia", m.headerMedia, { aceptar: "image/*", textoDrop: "Clic o arrastra una imagen aquí" }));
@@ -1757,7 +1879,6 @@
case "carousel":
ap(edTextarea("Cuerpo (opcional)", ruta + ".body", m.body, { limite: 1024, rows: 2 }));
ap(edCardsEd(ruta + ".cards", m.cards, raiz));
ap(edAutoPulsar(ruta, m));
break;
default:
ap(h("p", { class: "ed-nota", text: "Tipo desconocido: " + m.tipo }));
@@ -1775,9 +1896,10 @@
var total = o.anidado ? (o.total || 1) : ((WAM.estado && WAM.estado.mensajes) || []).length;
var idx = o.anidado ? o.indice : o.i;
if (!o.anidado) {
acciones.push(btnMini("+", "Insertar un mensaje debajo", "insertar-debajo", { "data-i": o.i, "data-id": m.id }));
// ↑ ↓ primero: su hueco reservado (ocultas en reposo) queda como margen
acciones.push(btnMini("↑", "Subir", "mover", { "data-i": o.i, "data-delta": -1, disabled: idx <= 0 || null }, false, "ed-mov"));
acciones.push(btnMini("↓", "Bajar", "mover", { "data-i": o.i, "data-delta": 1, disabled: idx >= total - 1 || null }, false, "ed-mov"));
acciones.push(btnMini("+", "Insertar un mensaje debajo", "insertar-debajo", { "data-i": o.i, "data-id": m.id }));
acciones.push(btnMini("⧉", "Duplicar", "dup", { "data-i": o.i }));
acciones.push(btnMini("✕", "Borrar mensaje (se puede deshacer)", "borrar", { "data-i": o.i }, true));
} else {
@@ -1804,12 +1926,19 @@
var cab = h("button", {
type: "button", class: "ed-cab-principal", "data-acc": "toggle",
"aria-expanded": abierta ? "true" : "false",
title: abierta ? "Plegar" : "Desplegar"
title: (abierta ? "Plegar" : "Desplegar") + " · " + resumenDe(m)
}, [
// Número + flecha de dirección (↙ entrada / ↗ salida): no depende solo del color
o.anidado ? h("span", { class: "ed-num ed-num-resp", text: "↳" })
: h("span", { class: "ed-num ed-num-" + (esOut ? "out" : "in"), title: esOut ? "Salida (enviado)" : "Entrada (recibido)", text: String(o.i + 1) }),
o.anidado ? null : h("span", { class: "ed-dir ed-dir-" + (esOut ? "out" : "in"), text: esOut ? "Salida" : "Entrada" }),
h("span", { class: "ed-badge", text: CORTO[m.tipo] || etiquetaTipo(m.tipo) }),
: h("span", { class: "ed-num ed-num-" + (esOut ? "out" : "in"), title: esOut ? "Salida (enviado)" : "Entrada (recibido)" }, [
h("span", { class: "ed-num-dir", "aria-hidden": "true", text: esOut ? "↗" : "↙" }),
document.createTextNode(String(o.i + 1)),
h("span", { class: "wam-sr", text: esOut ? " · salida" : " · entrada" })
]),
h("span", { class: "ed-badge", title: etiquetaTipo(m.tipo) }, [
svgIcono(ICONOS_TIPO[m.tipo] || ""),
h("span", { class: "ed-badge-txt", text: CORTO[m.tipo] || etiquetaTipo(m.tipo) })
]),
h("span", { class: "ed-resumen", text: resumenDe(m) }),
h("span", { class: "ed-chips" }, o.anidado ? null : chipsDe(m))
]);
@@ -1853,6 +1982,11 @@
pintarDestinoAlta();
actualizarAvisosGlobal();
actualizarResumenCuenta();
// Demo vacía: sin herramientas de lista (no harían nada) y pista en el teléfono
var herr = document.getElementById("ed-lista-herr") || document.querySelector(".ed-lista-herr");
if (herr) herr.hidden = !mensajes.length;
if (mensajes.length) document.body.removeAttribute("data-wam-vacia");
else document.body.setAttribute("data-wam-vacia", "");
if (!mensajes.length) {
lista.appendChild(h(etiquetaItem, { class: "ed-vacio" }, [
h("p", { class: "ed-vacio-titulo", text: "Sin mensajes todavía" }),
@@ -1887,8 +2021,13 @@
return f;
}
// Botones que borran: tras usarlos el foco NUNCA vuelve a su gemelo (un
// segundo Intro/Espacio borraría otro elemento); lo recoloca quien borra.
function esAccionBorrar(acc) { return acc === "borrar" || /^del-/.test(String(acc || "")); }
function restaurarFoco(cont, f) {
if (!f || !cont) return;
if (esAccionBorrar(f.acc)) return;
var sel = "";
if (f.ruta) sel += '[data-ruta="' + cssEsc(f.ruta) + '"]';
if (f.acc) sel += '[data-acc="' + cssEsc(f.acc) + '"]';
@@ -2017,17 +2156,18 @@
var pal = document.getElementById("nuevo-tipo");
if (pal && pal.tagName !== "SELECT") {
pal.setAttribute("data-valor", tipo);
// Sin estado «seleccionado» visible: cada clic AÑADE (no es un selector).
// Solo se conserva la parada de Tab (roving tabindex) en el último usado.
var hijos = pal.querySelectorAll("[data-tipo]");
for (var i = 0; i < hijos.length; i++) {
var activo = hijos[i].getAttribute("data-tipo") === tipo;
hijos[i].classList.toggle("ed-seleccionada", activo);
hijos[i].tabIndex = activo ? 0 : -1;
hijos[i].tabIndex = hijos[i].getAttribute("data-tipo") === tipo ? 0 : -1;
}
}
var btn = document.getElementById("btn-anadir");
if (btn) {
btn.textContent = "+ Otro «" + etiquetaTipo(tipo) + "»";
btn.title = "Añade otro mensaje de tipo " + etiquetaTipo(tipo);
btn.title = "Repite el último tipo añadido: " + etiquetaTipo(tipo);
btn.hidden = !yaAnadido;
}
}
@@ -2049,8 +2189,13 @@
"data-acc": "anadir-tipo",
"data-tipo": tipo,
tabindex: "-1",
title: "Añadir un mensaje de tipo " + etiquetaTipo(tipo)
}, [svgIcono(ICONOS_TIPO[tipo] || ""), h("span", { class: "ed-tipo-card-e", text: CORTO[tipo] || etiquetaTipo(tipo) })]));
title: "Añadir un mensaje de tipo " + etiquetaTipo(tipo),
"aria-label": "Añadir " + etiquetaTipo(tipo)
}, [
svgIcono(ICONOS_TIPO[tipo] || ""),
h("span", { class: "ed-tipo-card-e", text: CORTO[tipo] || etiquetaTipo(tipo) }),
h("span", { class: "ed-tipo-mas", "aria-hidden": "true", text: "+" })
]));
});
cont.appendChild(fila);
});
@@ -2102,15 +2247,31 @@
while (el.firstChild) el.removeChild(el.firstChild);
var ms = (WAM.estado && WAM.estado.mensajes) || [];
var pos = posicionAlta();
var previo = el.getAttribute("data-pos");
var clave = ms.length ? String(pos >= ms.length ? "fin" : pos) : "vacio";
el.setAttribute("data-pos", clave);
// Chip visible del destino: «al final» o «debajo del n.º N ×» (× = al final)
el.classList.toggle("ed-destino-chip", !!ms.length);
el.classList.toggle("ed-destino-dentro", !!ms.length && pos < ms.length);
if (pos >= ms.length) {
el.appendChild(document.createTextNode(ms.length ? "Se añade al final" : "Elige un tipo para empezar"));
return;
} else {
el.appendChild(document.createTextNode("Se inserta debajo del n.º " + pos));
el.appendChild(h("button", {
type: "button", class: "ed-destino-quitar", "data-acc": "destino-final",
title: "Añadir al final", "aria-label": "Añadir al final", text: "×"
}));
}
// Aviso visual breve cuando el destino cambia solo (al abrir otra tarjeta)
if (previo !== null && previo !== clave && ms.length && !reducirMovimiento()) {
el.classList.remove("ed-destino-cambio");
void el.offsetWidth;
el.classList.add("ed-destino-cambio");
}
el.appendChild(document.createTextNode("Se inserta debajo del n.º " + pos + " "));
el.appendChild(h("button", { type: "button", class: "ed-enlace", "data-acc": "destino-final", text: "Al final" }));
}
function anadirDesdePaleta(tipo) {
yaAnadido = true; // desde ahora se ofrece «+ Otro «X»» (repetir el último)
seleccionarTipo(tipo);
var pos = posicionAlta();
insertarTras = null;
@@ -2239,7 +2400,7 @@
snap();
WAM.setCampo("mensajes", copia);
WAM.renderTarjetas();
WAM.toast("Horas repartidas desde las " + inicio + " (+1 min por mensaje)",
WAM.toast("Horas repartidas desde " + inicio,
typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer } } : null);
}
@@ -2509,12 +2670,29 @@
var ruta = el.getAttribute("data-ruta");
var idx = Number(el.getAttribute("data-idx"));
var min = Number(el.getAttribute("data-min")) || 0;
mutarArray(ruta, function (arr) {
var res = mutarArray(ruta, function (arr) {
if (arr.length <= min) { aviso("Debe quedar al menos " + min, "error"); return null; }
if (idx < 0 || idx >= arr.length) return null;
arr.splice(idx, 1);
return arr;
});
if (res) enfocarTrasBorrar(ruta, idx, res.length);
}
/* Tras quitar un elemento: foco a la cabecera / primer campo del que
ocupa su lugar (o del anterior); si la lista quedó vacía, a su botón
«+ Añadir». Nunca a otro botón ✕. */
function enfocarTrasBorrar(ruta, idx, n) {
var lista = listaNodo();
if (!lista) return;
var j = idx < n ? idx : n - 1;
var r = cssEsc(ruta);
var it = j >= 0 ? lista.querySelector('[data-drag-ruta="' + r + '"][data-idx="' + j + '"], [data-mov-ruta="' + r + '"][data-idx="' + j + '"]') : null;
var foco = it && (it.querySelector(".ed-cab-principal") || it.querySelector("input.ed-input, textarea"));
if (!foco) foco = lista.querySelector('[data-acc^="add-"][data-ruta="' + r + '"]');
if (!foco) return;
try { foco.focus({ preventScroll: true }); } catch (e) { foco.focus(); }
if (foco.scrollIntoView) foco.scrollIntoView({ block: "nearest" });
}
function alternarModoMedia(el) {
@@ -2536,7 +2714,8 @@
abiertas[id] = !abiertas[id];
t.classList.toggle("ed-abierta", abiertas[id]);
el.setAttribute("aria-expanded", abiertas[id] ? "true" : "false");
el.title = abiertas[id] ? "Plegar" : "Desplegar";
var resT = el.querySelector(".ed-resumen");
el.title = (abiertas[id] ? "Plegar" : "Desplegar") + (resT ? " · " + resT.textContent : "");
var raizT = t.getAttribute("data-raiz") || id;
if (!t.classList.contains("ed-anidada")) {
if (abiertas[id]) ultimaAbierta = id;
@@ -2719,6 +2898,18 @@
}
if (el.type === "checkbox") {
snap();
if (el.getAttribute("data-rol") === "auto-item") {
// Exclusiva dentro del mensaje: marcar fija autoPulsar; desmarcar lo quita
var rutaA = String(el.getAttribute("data-ruta") || "").replace(/\.autoPulsar$/, "");
var msgA = rutaGet(rutaA);
if (msgA && typeof msgA === "object") {
if (el.checked) msgA.autoPulsar = Number(el.getAttribute("data-idx")) || 0;
else delete msgA.autoPulsar;
WAM.setCampo(rutaA, msgA);
}
repintarDesde(el);
return;
}
if (el.getAttribute("data-rol") === "header-toggle") {
var rt = el.getAttribute("data-ruta");
WAM.setCampo(rt + ".headerTipo", el.checked ? "texto" : "");
+79 -12
View File
@@ -27,7 +27,42 @@ window.WAM = window.WAM || {};
/* Mensajes de la sesión en curso (efímeros). */
WAM.sesion = [];
var contSesion = 0;
function idSesion() { contSesion++; return "s" + contSesion; }
/* IDs del guion (mensajes, respuestas, botones, filas, cards) para que un
ID de sesión nunca coincida con uno importado (p. ej. un guion con "s1"). */
function idsDelGuion() {
var usados = new Set();
(function recorrer(o) {
if (!o || typeof o !== "object") return;
if (Array.isArray(o)) { for (var i = 0; i < o.length; i++) recorrer(o[i]); return; }
if (typeof o.id === "string" && o.id) usados.add(o.id);
["mensajes", "respuestas", "botones", "secciones", "filas", "cards"].forEach(function (k) {
if (o[k] && typeof o[k] === "object") recorrer(o[k]);
});
})(WAM.estado);
return usados;
}
function idSesion() {
var usados = WAM.estado ? idsDelGuion() : null;
var id;
do { contSesion++; id = "s" + contSesion; } while (usados && usados.has(id));
return id;
}
/* Hora para los mensajes de un flujo: la del último mensaje visible + 1 min,
para que la línea temporal de la demo siga siendo coherente (si no hay
ninguna hora legible, el reloj real). */
function horaTras() {
var metas = document.querySelectorAll("#chat-cuerpo .wa-row .meta");
var t = null;
for (var i = metas.length - 1; i >= 0 && !t; i--) {
t = String(metas[i].textContent || "").match(/(\d{1,2}):(\d{2})/);
}
if (!t) return WAM.horaAhora();
var min = (Number(t[1]) * 60 + Number(t[2]) + 1) % 1440;
return ("0" + Math.floor(min / 60)).slice(-2) + ":" + ("0" + (min % 60)).slice(-2);
}
/* ————————————————— temporizadores cancelables por grupo ————————————————— */
@@ -215,12 +250,12 @@ window.WAM = window.WAM || {};
return pintar(msg);
}
function mensajeOut(texto, cita) {
function mensajeOut(texto, cita, hora) {
var msg = {
id: idSesion(),
dir: "out",
tipo: "text",
hora: WAM.horaAhora(),
hora: hora || WAM.horaAhora(),
estado: "sent",
texto: String(texto == null ? "" : texto)
};
@@ -243,7 +278,7 @@ window.WAM = window.WAM || {};
if (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text";
copia.id = idSesion();
copia.dir = "in";
copia.hora = WAM.horaAhora();
copia.hora = horaTras();
copia.estado = "sent";
delete copia.cita;
return copia;
@@ -257,12 +292,20 @@ window.WAM = window.WAM || {};
return String(s == null ? "" : s)
.replace(/```([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/\*([^*\n]+)\*/g, "$1")
.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1$2")
.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1$2")
.replace(reMarca("*"), "$1$2")
.replace(reMarca("_"), "$1$2")
.replace(reMarca("~"), "$1$2")
.trim();
}
/* Misma regla que render.js: marca pegada a un carácter no blanco por dentro
y sin letra, número ni _ por fuera («2 * 3 * 4» queda literal). */
function reMarca(m) {
var e = m === "*" ? "\\*" : m; /* con flag u, "\_" y "\~" son escapes inválidos */
return new RegExp("(^|[^\\p{L}\\p{N}_" + e + "])" + e + "(?=\\S)([^" + e + "\\n]*?\\S)" + e +
"(?=[^\\p{L}\\p{N}_" + e + "]|$)", "gu");
}
function miniaturaSegura(u) {
if (vacio(u)) return "";
var s = String(u);
@@ -305,6 +348,9 @@ window.WAM = window.WAM || {};
cita.foto = true;
} else if (msg.headerTipo === "video") {
cita.foto = true;
} else if (msg.headerTipo === "documento") {
cita.titulo = vacio(msg.headerTexto) ? "documento.pdf" : String(msg.headerTexto);
cita.doc = true;
}
}
cita.extracto = partes.filter(function (p) { return !vacio(p); }).map(sinFormato).join("\n");
@@ -315,6 +361,9 @@ window.WAM = window.WAM || {};
var SVG_CAMARA = '<svg class="wa-cita-cam" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
'<path fill="currentColor" d="M5.6 2.5h4.8l1 1.5H13a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 13 12.5H3A1.5 1.5 0 0 1 1.5 11V5.5A1.5 1.5 0 0 1 3 4h1.6l1-1.5Zm2.4 3a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Z"/></svg>';
var SVG_DOC = '<svg class="wa-cita-doc" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
'<path fill="currentColor" d="M4 1.5h5.3L13 5.2V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13V3a1.5 1.5 0 0 1 1-1.5Zm5 .9V5.5h3.1L9 2.4ZM5 8h6v1H5V8Zm0 2.2h6v1H5v-1Z"/></svg>';
/* Bloque de cita dentro de la burbuja OUT; todo por textContent (sin XSS). */
function pintarCita(fila, cita) {
var burbuja = fila.querySelector(".bubble");
@@ -330,7 +379,12 @@ window.WAM = window.WAM || {};
if (cita.titulo) {
var tit = document.createElement("div");
tit.className = "wa-cita-titulo";
tit.textContent = cita.titulo;
if (cita.doc) {
var doc = document.createElement("span");
doc.innerHTML = SVG_DOC; /* constante propia, sin datos del usuario */
if (doc.firstChild) tit.appendChild(doc.firstChild);
}
tit.appendChild(document.createTextNode(cita.titulo));
txt.appendChild(tit);
}
if (cita.extracto) {
@@ -413,7 +467,7 @@ window.WAM = window.WAM || {};
WAM.flujo = function (texto, respuestas, cita) {
var lista = Array.isArray(respuestas) ? respuestas : [];
var ctx = contexto("flujo");
var out = mensajeOut(texto, cita || null);
var out = mensajeOut(texto, cita || null, horaTras());
anadirSesion(out);
var tics = animarTics(out, ctx);
tics.catch(nada);
@@ -460,7 +514,8 @@ window.WAM = window.WAM || {};
if (ds.btn !== undefined) {
var n = parseInt(ds.btn, 10);
var boton = (msg.botones || [])[n];
if (!boton) return vacioR;
/* botón de enlace con URL bloqueada: inerte, nunca un quick reply */
if (!boton || boton.urlBloqueada) return vacioR;
return {
texto: vacio(boton.texto) ? "Botón " + (n + 1) : String(boton.texto),
respuestas: boton.respuestas || [],
@@ -472,7 +527,7 @@ window.WAM = window.WAM || {};
if (ds.card !== undefined) {
var c = parseInt(ds.card, 10);
var card = (msg.cards || [])[c];
if (!card) return vacioR;
if (!card || card.urlBloqueada) return vacioR;
return {
texto: vacio(card.textoBoton) ? "Ver más" : String(card.textoBoton),
respuestas: card.respuestas || [],
@@ -509,11 +564,13 @@ window.WAM = window.WAM || {};
return copia;
});
var n = nuevos.length;
if (typeof WAM.puntoDeshacer === "function") { try { WAM.puntoDeshacer(); } catch (e) { /* opcional */ } }
WAM.estado = WAM.normalizar ? WAM.normalizar({
version: WAM.estado.version, tema: WAM.estado.tema, cuenta: WAM.estado.cuenta,
mensajes: WAM.estado.mensajes.concat(nuevos)
}) : WAM.estado;
if (typeof WAM.guardar === "function") WAM.guardar();
if (typeof WAM.marcarEditado === "function") { try { WAM.marcarEditado(); } catch (e) { /* opcional */ } }
if (typeof WAM.render === "function") WAM.render();
if (typeof WAM.renderEditor === "function") WAM.renderEditor();
return n;
@@ -594,7 +651,7 @@ window.WAM = window.WAM || {};
async function flujoEnDemo(sel, ctx) {
if (!sel || !sel.msg) return;
var out = mensajeOut(sel.texto, sel.cita);
var out = mensajeOut(sel.texto, sel.cita, horaTras());
anadirSesion(out);
await animarTics(out, ctx);
await responder(Array.isArray(sel.respuestas) ? sel.respuestas : [], ctx, true);
@@ -609,6 +666,12 @@ window.WAM = window.WAM || {};
}
}
/* Progreso de la demo: mensajes del guion revelados / total. */
var progreso = { paso: 0, total: 0 };
WAM.progresoDemo = function () {
return demoAndando ? { paso: progreso.paso, total: progreso.total } : { paso: 0, total: 0 };
};
WAM.reproducirDemo = function () {
if (demoAndando) return;
/* aborta flujos, ticks y typing en curso: no se mezclan con la demo */
@@ -621,6 +684,8 @@ window.WAM = window.WAM || {};
vaciarChat();
var ctx = contexto("demo");
var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice();
progreso.paso = 0;
progreso.total = secuencia.length;
(async function () {
try {
for (var i = 0; i < secuencia.length; i++) {
@@ -629,6 +694,8 @@ window.WAM = window.WAM || {};
if (msg.dir === "in") await escribirDurante(ctx, 700);
if (!ctx.vivo()) throw CANCELADO;
pintar(msg);
progreso.paso = i + 1;
avisarDemo();
await ctx.esperar(600);
await autoPulsar(msg, ctx);
}
+37 -2
View File
@@ -74,15 +74,50 @@ window.WAM = window.WAM || {};
/* ————————————————— botón "Reproducir demo" ————————————————— */
var SVG_STOP = '<rect x="6.5" y="6.5" width="11" height="11" rx="2"/>';
/* Contador discreto «Paso 3/12» junto al botón (se crea una vez). */
function contadorDemo(btn) {
var c = porId("demo-paso");
if (c) return c;
c = document.createElement("span");
c.id = "demo-paso";
c.className = "demo-paso";
c.setAttribute("aria-live", "polite");
c.setAttribute("role", "status");
c.hidden = true;
btn.parentNode.insertBefore(c, btn);
return c;
}
function pintarBotonDemo() {
var btn = porId("btn-reproducir");
if (!btn) return;
var activa = WAM.demoActiva();
btn.setAttribute("aria-pressed", activa ? "true" : "false");
var svg = btn.querySelector("svg");
if (svg) {
if (!svg.dataset.play) svg.dataset.play = svg.innerHTML;
var deseado = activa ? SVG_STOP : svg.dataset.play;
if (svg.innerHTML !== deseado) svg.innerHTML = deseado;
}
var span = btn.querySelector("span");
if (!span) return;
if (span) {
if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent;
span.textContent = activa ? "■ Detener" : span.dataset.etiqueta;
span.textContent = activa ? "Detener" : span.dataset.etiqueta;
}
if (btn.parentNode) {
var c = contadorDemo(btn);
var p = typeof WAM.progresoDemo === "function" ? WAM.progresoDemo() : null;
if (activa && p && p.total > 0 && p.paso > 0) {
var txt = "Paso " + p.paso + "/" + p.total;
if (c.textContent !== txt) c.textContent = txt;
c.hidden = false;
} else {
c.hidden = true;
c.textContent = "";
}
}
}
/* Evita que un doble clic arranque y detenga la demo en el mismo gesto. */
+179 -30
View File
@@ -29,11 +29,17 @@
function estado() { return WAM.estado || { cuenta: {}, mensajes: [] }; }
var ESCRIBIENDO = false;
var HOJA_ID = ""; /* id del mensaje cuya hoja está abierta ("" = ninguna) */
var ORIGEN_HOJA = null; /* elemento que tenía el foco al abrir la hoja */
var FOCO_EN_HOJA = false; /* la hoja movió el foco a su interior al abrirse */
/* Solo esquemas seguros en href (defensa en profundidad para JSON importados). */
function urlSegura(u) {
var s = String(u == null ? "" : u).trim();
return /^(https?:|mailto:|tel:)/i.test(s) ? s : "";
if (/^(https?:|mailto:|tel:)/i.test(s)) return s;
/* Sin esquema ("www.cafe.com", "cafe.com/menu"): WhatsApp lo abre como https */
if (/^www\.[^\s]+$/i.test(s) || /^[\w-]+(\.[\w-]+)+(:\d+)?([\/?#][^\s]*)?$/.test(s)) return "https://" + s;
return "";
}
/* src de <img>/<video>: bloquea javascript:/vbscript: y data: que no sea media. */
@@ -202,27 +208,61 @@
/* Formato WhatsApp: *negrita* _cursiva_ ~tachado~ ```bloque``` `en línea`
+ URLs hipervinculadas. Dentro del código no se aplica ningún otro formato. */
function formatear(txt) {
var partes = String(txt == null ? "" : txt).split(/(https?:\/\/[^\s<>"]+)/g);
var html = "";
for (var i = 0; i < partes.length; i++) {
var p = partes[i];
if (p === "") continue;
if (/^https?:\/\//.test(p)) {
html += '<a class="wa-link" href="' + esc(p) + '" target="_blank" rel="noopener noreferrer">' + esc(p) + "</a>";
} else {
/* Marcadores de WhatsApp: el marcador va pegado a texto (sin espacio por
dentro) y por fuera no puede haber letra/número ("2 * 3 * 4" no es negrita). */
function reMarca(m) {
var e = m === "*" ? "\\*" : m; /* con flag u, "\_" y "\~" son escapes inválidos */
return new RegExp("(^|[^\\p{L}\\p{N}_" + e + "])" + e + "(?=\\S)([^" + e + "\\n]*?\\S)" + e +
"(?=[^\\p{L}\\p{N}_" + e + "]|$)", "gu");
}
var RE_NEGRITA = reMarca("*"), RE_CURSIVA = reMarca("_"), RE_TACHADO = reMarca("~");
/* Deja fuera del enlace la puntuación final y los ')' sin su '(' */
function separarUrl(u) {
var url = u, resto = "";
for (;;) {
var m = /[.,;:!?'"\]}]+$/.exec(url);
if (m) { resto = m[0] + resto; url = url.slice(0, -m[0].length); continue; }
if (/\)$/.test(url) && (url.split(")").length > url.split("(").length)) {
resto = ")" + resto; url = url.slice(0, -1); continue;
}
break;
}
return [url, resto];
}
function formatoTexto(p) {
var codigos = [];
var guardar = function (_, c) { codigos.push(c); return "\u0000" + (codigos.length - 1) + "\u0000"; };
var t = esc(p.replace(/\u0000/g, ""));
t = t.replace(/```([\s\S]+?)```/g, guardar);
t = t.replace(/`([^`\n]+)`/g, guardar);
t = t.replace(/\*([^*\n]+)\*/g, "<strong>$1</strong>");
t = t.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1<em>$2</em>");
t = t.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1<del>$2</del>");
t = t.replace(/\u0000(\d+)\u0000/g, function (_, n) { return "<code>" + codigos[+n] + "</code>"; });
html += t;
t = t.replace(RE_NEGRITA, "$1<strong>$2</strong>");
t = t.replace(RE_CURSIVA, "$1<em>$2</em>");
t = t.replace(RE_TACHADO, "$1<del>$2</del>");
return t.replace(/\u0000(\d+)\u0000/g, function (_, n) { return "<code>" + codigos[+n] + "</code>"; });
}
function formatear(txt) {
var partes = String(txt == null ? "" : txt).split(/(https?:\/\/[^\s<>"]+)/g);
var html = "", pendiente = "";
for (var i = 0; i < partes.length; i++) {
var p = partes[i];
if (p === "") continue;
if (/^https?:\/\//.test(p)) {
var par = separarUrl(p);
if (pendiente) { html += formatoTexto(pendiente); pendiente = ""; }
if (par[0].length > p.indexOf("//") + 2) {
html += '<a class="wa-link" href="' + esc(par[0]) + '" target="_blank" rel="noopener noreferrer">' + esc(par[0]) + "</a>";
pendiente = par[1];
} else {
pendiente = p;
}
} else {
pendiente += p;
}
}
if (pendiente) html += formatoTexto(pendiente);
return html;
}
@@ -322,18 +362,20 @@
derecha + "</div></div>";
}
function panelDocumento(nombre) {
/* Subtítulo iOS "tamaño • extensión" (243 KB • pdf); sin tamaño, solo "PDF" */
function panelDocumento(nombre, tamano) {
var ext = extDe(nombre);
var sub = vacio(tamano) ? ext.toUpperCase() : String(tamano) + " • " + ext;
return '<div class="doc-panel">' +
'<span class="doc-icono">' + docIcon(ext) + "</span>" +
'<span class="doc-datos"><span class="doc-nombre">' + esc(nombre) + "</span>" +
'<span class="doc-sub">Documento • ' + esc(ext) + "</span></span>" +
'<span class="doc-sub">' + esc(sub) + "</span></span>" +
'<span class="doc-descarga">' + svgDescarga() + "</span></div>";
}
function burbujaDocumento(m) {
var nombre = vacio(m.filename) ? "documento.pdf" : String(m.filename);
var html = '<div class="bubble wa-doc">' + panelDocumento(nombre);
var html = '<div class="bubble wa-doc">' + panelDocumento(nombre, m.tamano);
if (!vacio(m.caption)) html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
else html += '<div class="wa-pie">' + metaHTML(m) + "</div>";
return html + "</div>";
@@ -387,7 +429,7 @@
return '<a class="qr-btn"' + h + ' href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-btn-url="' + j + '">' +
icono(tipoIconoUrl(url)) + lbl + "</a>";
}
if (!vacio(b.url)) {
if (!vacio(b.url) || b.urlBloqueada) {
return '<span class="qr-btn qr-btn-inactiva"' + h + ' aria-disabled="true">' + icono("url") + lbl + "</span>";
}
return '<button type="button" class="qr-btn"' + h + ' data-flujo="' + i + '" data-btn="' + j + '">' +
@@ -436,7 +478,7 @@
}
if (ht === "documento") {
var nom = vacio(m.headerTexto) ? "documento.pdf" : String(m.headerTexto);
return '<div class="tpl-header-doc">' + panelDocumento(nom) + "</div>";
return '<div class="tpl-header-doc">' + panelDocumento(nom, m.headerTamano) + "</div>";
}
if (ht === "texto") {
return vacio(m.headerTexto) ? "" : '<div class="tpl-header-txt">' + esc(m.headerTexto) + "</div>";
@@ -500,7 +542,7 @@
var url = urlSegura(c.url);
var boton;
if (url) boton = '<a class="qr-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + icono(tipoIconoUrl(url)) + lbl + "</a>";
else if (!vacio(c.url)) boton = '<span class="qr-btn qr-btn-inactiva" aria-disabled="true">' + icono("url") + lbl + "</span>";
else if (!vacio(c.url) || c.urlBloqueada) boton = '<span class="qr-btn qr-btn-inactiva" aria-disabled="true">' + icono("url") + lbl + "</span>";
else boton = '<button type="button" class="qr-btn" data-flujo="' + i + '" data-card="' + j + '">' + icono("responder") + lbl + "</button>";
html += '<div class="card">' +
'<img class="card-media" src="' + esc(media) + '" alt="" draggable="false">' +
@@ -590,6 +632,10 @@
if (!vacio(m.id)) li.setAttribute("data-id", String(m.id));
li.innerHTML = cuerpo;
activarPlegado(li, m);
/* Re-render parcial (editor) del mensaje cuya hoja está abierta: la hoja
quedaría desfasada (títulos viejos, fila destino fuera del DOM). */
if (HOJA_ID && !vacio(m.id) && String(m.id) === HOJA_ID) WAM.cerrarSheet();
observarAltura(li);
return li;
};
@@ -608,8 +654,10 @@
for (var s = 0; s < sis.length; s++) frag.appendChild(sis[s]);
for (var i = 0; i < mensajes.length; i++) frag.appendChild(WAM.renderMensaje(mensajes[i], i));
cuerpo.innerHTML = "";
if (RO) { RO.disconnect(); RO.observe(cuerpo); }
if (indEnCuerpo && ind) frag.appendChild(ind); /* conserva el indicador */
cuerpo.appendChild(frag);
if (RO) for (var k = 0; k < cuerpo.children.length; k++) RO.observe(cuerpo.children[k]);
WAM.scrollAbajo();
};
@@ -685,14 +733,61 @@
if (ESCRIBIENDO) WAM.scrollAbajo();
};
/* Desplazamiento INSTANTÁNEO al final (un scroll suave se quedaría en el
máximo del momento si el contenido crece durante la animación). */
WAM.scrollAbajo = function () {
var cuerpo = byId("chat-cuerpo");
if (cuerpo) cuerpo.scrollTop = cuerpo.scrollHeight;
if (!cuerpo) return;
vigilarCuerpo(cuerpo);
var prev = cuerpo.style.scrollBehavior;
cuerpo.style.scrollBehavior = "auto";
cuerpo.scrollTop = cuerpo.scrollHeight;
cuerpo.style.scrollBehavior = prev;
PEGADO = true;
};
/* "Pegado abajo" (como WhatsApp): mientras el usuario no suba, cualquier
crecimiento del chat (imágenes/SVG que decodifican tarde, vídeo, la
pestaña Vista previa que pasa de oculta a visible, una fila repintada)
vuelve a bajar. Si el usuario sube, se respeta su posición. */
var PEGADO = true;
var RO = null;
function distanciaFondo(c) { return c.scrollHeight - c.scrollTop - c.clientHeight; }
function reajustar() {
var c = byId("chat-cuerpo");
if (c && PEGADO && distanciaFondo(c) > 1) WAM.scrollAbajo();
}
function vigilarCuerpo(cuerpo) {
if (cuerpo.__wamVigilado) return;
cuerpo.__wamVigilado = true;
cuerpo.addEventListener("scroll", function () {
if (cuerpo.clientHeight > 0) PEGADO = distanciaFondo(cuerpo) < 60;
}, { passive: true });
if (typeof ResizeObserver === "function") {
RO = new ResizeObserver(reajustar);
RO.observe(cuerpo);
for (var k = 0; k < cuerpo.children.length; k++) RO.observe(cuerpo.children[k]);
}
}
/* Observa la altura de una fila (aunque aún no esté en el DOM). Sin
ResizeObserver, recurre al load de sus imágenes/vídeos. */
function observarAltura(li) {
if (RO) { RO.observe(li); return; }
if (typeof ResizeObserver === "function") return; /* aún sin vigilar: vigilarCuerpo lo hará */
var medios = li.querySelectorAll("img, video");
for (var k = 0; k < medios.length; k++) {
medios[k].addEventListener("load", reajustar, { once: true });
medios[k].addEventListener("loadedmetadata", reajustar, { once: true });
}
}
/* Hoja inferior iOS genérica (B la crea/destruye) en #pantalla.
alClic: manejador opcional de presentación para las filas. */
function montarHoja(titulo, contenido, pie, alClic) {
function montarHoja(titulo, contenido, pie, alClic, opts) {
var pantalla = byId("pantalla");
if (!pantalla) return null;
WAM.cerrarSheet();
@@ -706,6 +801,28 @@
'<div class="sheet-opciones" id="sheet-opciones">' + contenido + "</div>" +
(pie ? '<div class="sheet-pie">' + esc(pie) + "</div>" : "") + "</div>";
pantalla.appendChild(hoja);
/* Foco (diálogo modal): se guarda el origen, se enfoca la primera fila
activa (o la X) y Tab / Mayús+Tab quedan dentro del panel. Durante
«Reproducir demo» (o con opts.sinFoco) no se roba el foco. */
ORIGEN_HOJA = document.activeElement && document.activeElement !== document.body ? document.activeElement : null;
var enDemo = typeof WAM.demoActiva === "function" && WAM.demoActiva();
if (!enDemo && !(opts && opts.sinFoco)) {
var primera = hoja.querySelector('.sheet-fila:not([aria-disabled="true"]):not(span)') || hoja.querySelector(".sheet-cerrar");
if (primera) { try { primera.focus({ preventScroll: true }); } catch (e) { primera.focus(); } }
FOCO_EN_HOJA = !!primera;
}
hoja.addEventListener("keydown", function (e) {
if (e.key !== "Tab") return;
var panel = hoja.querySelector(".sheet-panel");
var focos = panel ? Array.prototype.filter.call(panel.querySelectorAll("a[href], button:not([disabled])"), function (x) {
return !x.hidden;
}) : [];
if (!focos.length) return;
var ini = focos[0], fin = focos[focos.length - 1], act = document.activeElement;
var dentro = panel.contains(act);
if (e.shiftKey && (act === ini || !dentro)) { e.preventDefault(); fin.focus(); }
else if (!e.shiftKey && (act === fin || !dentro)) { e.preventDefault(); ini.focus(); }
});
hoja.addEventListener("click", function (e) {
var t = e.target;
if (!t || !t.closest) return;
@@ -741,20 +858,26 @@
}
if (!total) html = '<div class="sheet-vacio">Sin opciones</div>';
montarHoja(titulo, html, total ? "Toca un elemento para seleccionarlo" : "");
HOJA_ID = vacio(m.id) ? "" : String(m.id);
};
/* Hoja "Todas las opciones" de un mensaje con >3 botones (m = Mensaje o
índice; fila = su li.wa-row). URL/llamada primero, separador, respuestas.
Pulsar una respuesta reenvía el clic al botón [data-btn] (oculto) de la
burbuja, así main.js/interactivity.js lo atienden igual que en línea. */
/* Última li.wa-row pintada de ese mensaje (o null) */
function filaDe(msg) {
if (!msg || vacio(msg.id)) return null;
var filas = document.querySelectorAll("#chat-cuerpo .wa-row");
for (var k = filas.length - 1; k >= 0; k--) if (filas[k].getAttribute("data-id") === String(msg.id)) return filas[k];
return null;
}
WAM.abrirOpciones = function (m, fila) {
var mensajes = estado().mensajes || [];
var msg = (m && typeof m === "object" ? m : (typeof m === "number" ? mensajes[m] : null)) || {};
var li = fila || null;
if (!li && msg.id) {
var filas = document.querySelectorAll("#chat-cuerpo .wa-row");
for (var k = filas.length - 1; k >= 0 && !li; k--) if (filas[k].getAttribute("data-id") === String(msg.id)) li = filas[k];
}
if (!li) li = filaDe(msg);
var bs = Array.isArray(msg.botones) ? msg.botones.slice(0, 10) : [];
var enlaces = "", respuestas = "";
for (var j = 0; j < bs.length; j++) {
@@ -765,7 +888,11 @@
if (url) {
enlaces += '<a class="sheet-fila sheet-fila-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' +
icono(tipoIconoUrl(url)) + etiqueta + "</a>";
} else if (vacio(b.url)) {
} else if (!vacio(b.url) || b.urlBloqueada) {
/* Igual que botonAccion: URL no permitida o bloqueada → fila inerte */
enlaces += '<span class="sheet-fila sheet-fila-btn qr-btn-inactiva" aria-disabled="true">' +
icono("url") + etiqueta + "</span>";
} else {
var usado = li && li.querySelector('[data-btn="' + j + '"].usado');
respuestas += '<button type="button" class="sheet-fila sheet-fila-btn' + (usado ? " usado" : "") + '" data-opcion="' + j + '"' +
(usado ? ' aria-disabled="true"' : "") + ">" + icono("responder") + etiqueta + "</button>";
@@ -773,20 +900,42 @@
}
var html = (enlaces ? '<div class="sheet-grupo">' + enlaces + "</div>" : "") +
(respuestas ? '<div class="sheet-grupo">' + respuestas + "</div>" : "");
montarHoja("Todas las opciones", html || '<div class="sheet-vacio">Sin opciones</div>', "", function (e, t) {
var hojaOp = montarHoja("Todas las opciones", html || '<div class="sheet-vacio">Sin opciones</div>', "", function (e, t) {
if (t.closest("a.sheet-fila")) { WAM.cerrarSheet(); return; }
var op = t.closest("[data-opcion]");
if (!op) return;
e.preventDefault();
if (op.getAttribute("aria-disabled") === "true") return;
var destino = li ? li.querySelector('[data-btn="' + op.getAttribute("data-opcion") + '"]') : null;
/* la fila viva AHORA (el editor puede haberla repintado con la hoja abierta) */
var viva = li && li.isConnected ? li : filaDe(msg);
var destino = viva ? viva.querySelector('[data-btn="' + op.getAttribute("data-opcion") + '"]') : null;
WAM.cerrarSheet();
if (destino) destino.click(); /* burbujea a #chat-cuerpo (delegación de main.js) */
});
if (hojaOp) HOJA_ID = vacio(msg.id) ? "" : String(msg.id);
};
WAM.cerrarSheet = function () {
var hoja = byId("sheet");
var idHoja = HOJA_ID;
var origen = ORIGEN_HOJA;
var movido = FOCO_EN_HOJA;
ORIGEN_HOJA = null;
FOCO_EN_HOJA = false;
var act = document.activeElement;
/* Safari no enfoca los botones al hacer clic: el foco cae en body */
var focoDentro = !!hoja && (hoja.contains(act) || (movido && (!act || act === document.body)));
if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja);
HOJA_ID = "";
if (!focoDentro) return;
/* Devolver el foco: al origen si sigue en el DOM; si hubo re-render, al
mismo control en la fila nueva del mensaje; si no, a la conversación. */
var destino = origen && origen.isConnected ? origen : null;
if (!destino && idHoja) {
var filaNueva = filaDe({ id: idHoja });
if (filaNueva) destino = filaNueva.querySelector("[data-ver-todas], [data-flujo]:not([data-btn]):not([data-card])");
}
if (!destino) destino = byId("chat-cuerpo");
if (destino) { try { destino.focus({ preventScroll: true }); } catch (e) { destino.focus(); } }
};
})();
+79 -12
View File
@@ -221,10 +221,54 @@ window.WAM = window.WAM || {};
return n;
}
function lista(v, max) {
return (Array.isArray(v) ? v : []).slice(0, max || MAX_ITEMS);
/* Contadores de lo que recorta la normalización en curso (ver
WAM.ultimosRecortes). null fuera de WAM.normalizar. */
var recortes = null;
function lista(v, max, clave) {
var arr = Array.isArray(v) ? v : [];
var tope = max || MAX_ITEMS;
if (recortes && clave && arr.length > tope) recortes[clave] += arr.length - tope;
return arr.slice(0, tope);
}
/* URL de un botón/card de enlace: si venía escrita pero es insegura se
vacía y se marca urlBloqueada, para que siga siendo un botón de enlace
(inerte) y no se convierta en una respuesta rápida con flujo. */
function urlEnlace(destino, origen) {
var bruta = cad(origen.url).trim();
destino.url = urlLimpia(bruta, "enlace");
if (!destino.url && (bruta || origen.urlBloqueada === true || origen.urlBloqueada === "true")) {
destino.urlBloqueada = true;
}
}
/* Texto en español con lo recortado, o "" si no se recortó nada. */
WAM.describirRecortes = function (r) {
r = r || WAM.ultimosRecortes;
if (!r) return "";
var partes = [];
function n(c, uno, varios) { if (r[c] > 0) partes.push(r[c] + " " + (r[c] === 1 ? uno : varios)); }
n("botones", "botón", "botones");
n("secciones", "sección", "secciones");
n("filas", "fila", "filas");
n("cards", "tarjeta", "tarjetas");
n("respuestas", "respuesta", "respuestas");
n("profundidad", "respuesta anidada (más de " + MAX_PROFUNDIDAD + " niveles)",
"respuestas anidadas (más de " + MAX_PROFUNDIDAD + " niveles)");
var txt = "";
if (r.mensajesOriginales > r.mensajes) {
txt = "Se importaron " + r.mensajes + " de " + r.mensajesOriginales + " mensajes";
}
if (partes.length) {
var total = 0;
["botones", "secciones", "filas", "cards", "respuestas", "profundidad"].forEach(function (c) { total += r[c] || 0; });
var verbo = total === 1 ? "se recortó " : "se recortaron ";
txt += (txt ? "; " + verbo : verbo.charAt(0).toUpperCase() + verbo.slice(1)) + partes.join(", ");
}
return txt;
};
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
Usado por cargar(), resetear() y editor.importarJSON (import / upgrade).
- Acepta también un array de mensajes suelto como raíz.
@@ -239,6 +283,11 @@ window.WAM = window.WAM || {};
: ((o && typeof o === "object") ? o : {});
entregados = new Set();
var mensajesSrc = Array.isArray(src.mensajes) ? src.mensajes.length : 0;
recortes = {
mensajes: Math.min(mensajesSrc, 500), mensajesOriginales: mensajesSrc,
botones: 0, secciones: 0, filas: 0, cards: 0, respuestas: 0, profundidad: 0
};
var conjunto = new Set();
recolectarIds(src, conjunto);
var tomar = crearAlocador(conjunto);
@@ -270,16 +319,19 @@ window.WAM = window.WAM || {};
};
function respuestas(valor) {
if (profundidad >= MAX_PROFUNDIDAD) return [];
return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
if (profundidad >= MAX_PROFUNDIDAD) {
if (Array.isArray(valor) && valor.length) recortes.profundidad += valor.length;
return [];
}
return lista(valor, 20, "respuestas").map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
}
function botones(valor) {
return lista(valor).map(function (b) {
return lista(valor, MAX_ITEMS, "botones").map(function (b) {
var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? b : "" };
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */
if (typeof boton.url === "string") out.url = urlLimpia(boton.url, "enlace");
if (typeof boton.url === "string" || boton.urlBloqueada === true) urlEnlace(out, boton);
return out;
});
}
@@ -307,6 +359,8 @@ window.WAM = window.WAM || {};
msg.src = urlLimpia(m.src, "media");
msg.caption = cad(m.caption);
msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf";
/* opcional: «243 KB» para el subtítulo «243 KB • pdf» (render.js) */
if (cad(m.tamano).trim()) msg.tamano = cad(m.tamano).trim().slice(0, 20);
break;
case "sticker":
msg.src = urlLimpia(m.src, "media");
@@ -328,6 +382,9 @@ window.WAM = window.WAM || {};
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
msg.headerTexto = cad(m.headerTexto);
msg.headerMedia = urlLimpia(m.headerMedia, "media");
if (msg.headerTipo === "documento" && cad(m.headerTamano).trim()) {
msg.headerTamano = cad(m.headerTamano).trim().slice(0, 20);
}
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.botones = botones(m.botones);
@@ -338,11 +395,11 @@ window.WAM = window.WAM || {};
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.boton = cad(m.boton);
msg.secciones = lista(m.secciones).map(function (s) {
msg.secciones = lista(m.secciones, MAX_ITEMS, "secciones").map(function (s) {
var seccion = (s && typeof s === "object") ? s : {};
return {
titulo: cad(seccion.titulo),
filas: lista(seccion.filas).map(function (f) {
filas: lista(seccion.filas, MAX_ITEMS, "filas").map(function (f) {
var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" };
return {
titulo: cad(fila.titulo),
@@ -363,15 +420,17 @@ window.WAM = window.WAM || {};
break;
case "carousel":
msg.body = cad(m.body);
msg.cards = lista(m.cards).map(function (c) {
msg.cards = lista(m.cards, MAX_ITEMS, "cards").map(function (c) {
var card = (c && typeof c === "object") ? c : {};
return {
var salida = {
media: urlLimpia(card.media, "media"),
texto: cad(card.texto),
textoBoton: cad(card.textoBoton),
url: urlLimpia(card.url, "enlace"),
url: "",
respuestas: respuestas(card.respuestas)
};
urlEnlace(salida, card);
return salida;
});
autoPulsar();
break;
@@ -389,7 +448,7 @@ window.WAM = window.WAM || {};
}
var cuentaSrc = (src.cuenta && typeof src.cuenta === "object") ? src.cuenta : {};
return {
var resultado = {
version: 1,
tema: src.tema === "oscuro" ? "oscuro" : "claro",
cuenta: {
@@ -404,6 +463,14 @@ window.WAM = window.WAM || {};
return normalizarMensaje(x, false, 0);
})
};
/* Lo recortado queda en WAM.ultimosRecortes y, no enumerable (no se
guarda ni se exporta), en resultado.__recortes. */
WAM.ultimosRecortes = recortes;
try {
Object.defineProperty(resultado, "__recortes", { value: recortes, enumerable: false, configurable: true });
} catch (e) { /* entorno sin defineProperty */ }
recortes = null;
return resultado;
};
/* ————————————————— persistencia ————————————————— */
+291 -39
View File
@@ -5,7 +5,8 @@
· Indicador de guardado en la toolbar (#estado-guardado)
· Confirmación de Restablecer / Importar (WAM.confirmarReemplazo)
· Diálogo «Demos»: escenarios por industria + demos guardadas (wam-demos)
· Atajos de teclado globales y diálogo de ayuda (?)
· Atajos de teclado globales y diálogo de ayuda (?); G / F6 y el enlace
«Saltar a la vista previa» mueven el foco entre editor y teléfono
· Pestañas Editor / Vista previa en ventana estrecha y menú «⋯»
· Enlace vista previa → tarjeta (Alt+clic o doble clic en una burbuja)
· Región sr-only que anuncia los mensajes nuevos del chat
@@ -127,9 +128,32 @@
/* ------------------------------------------------------------------
Historial deshacer / rehacer
------------------------------------------------------------------ */
/* Cada instantánea guarda también qué demo guardada estaba en uso
(«wam-demo-activa»): al deshacer o rehacer se repone, para que
«Actualizar» nunca escriba el contenido de otra demo en su hueco. */
function instantanea() {
return { estado: clonar(WAM.estado), activa: lsGet(CLAVE_ACTIVA) };
}
function reponerActiva(id) {
if (id) lsSet(CLAVE_ACTIVA, id); else lsDel(CLAVE_ACTIVA);
}
/* Un cambio nuevo deja obsoletos los avisos con acción («Deshacer» /
«Rehacer»): su botón ya no desharía lo que anuncian. */
function cerrarAvisosConAccion() {
var ts = document.querySelectorAll("#ed-toasts .ed-toast");
for (var i = 0; i < ts.length; i++) {
if (!ts[i].querySelector(".ed-toast-accion")) continue;
if (typeof ts[i].__cerrar === "function") ts[i].__cerrar();
else if (ts[i].parentNode) ts[i].parentNode.removeChild(ts[i]);
}
}
WAM.puntoDeshacer = function () {
if (!WAM.estado) return;
pilaDeshacer.push(clonar(WAM.estado));
cerrarAvisosConAccion();
pilaDeshacer.push(instantanea());
if (pilaDeshacer.length > LIMITE_HISTORIAL) pilaDeshacer.shift();
pilaRehacer = [];
pintarBotonesHistorial();
@@ -137,10 +161,19 @@
WAM.puedeDeshacer = function () { return pilaDeshacer.length > 0; };
// El tema es una preferencia de visualización, no contenido: deshacer y
// rehacer conservan el tema actual en vez del de la instantánea.
function conTemaActual(x) {
if (x && WAM.estado) x.tema = WAM.estado.tema;
return x;
}
WAM.deshacer = function () {
if (!pilaDeshacer.length) { toast("No hay nada que deshacer"); return false; }
pilaRehacer.push(clonar(WAM.estado));
sustituirEstado(pilaDeshacer.pop());
var previo = pilaDeshacer.pop();
pilaRehacer.push(instantanea());
sustituirEstado(conTemaActual(previo.estado));
reponerActiva(previo.activa);
pintarBotonesHistorial();
toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } });
return true;
@@ -148,8 +181,10 @@
WAM.rehacer = function () {
if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; }
pilaDeshacer.push(clonar(WAM.estado));
sustituirEstado(pilaRehacer.pop());
var siguiente = pilaRehacer.pop();
pilaDeshacer.push(instantanea());
sustituirEstado(conTemaActual(siguiente.estado));
reponerActiva(siguiente.activa);
pintarBotonesHistorial();
toast("Cambio rehecho");
return true;
@@ -253,14 +288,17 @@
if (!dentro) dlg.close();
});
var foco = null;
var restaurar = true;
dlg.addEventListener("close", function () {
setTimeout(function () { dlg.remove(); }, 0);
if (foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
if (restaurar && foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
});
document.body.appendChild(dlg);
return {
dlg: dlg, cuerpo: cuerpo, pie: pie,
abrir: function () { foco = document.activeElement; dlg.showModal(); }
abrir: function () { foco = document.activeElement; dlg.showModal(); },
// La acción elegida mueve el foco a otro sitio (p. ej. el nombre de la cuenta)
sinRestaurar: function () { restaurar = false; }
};
}
@@ -281,7 +319,7 @@
lsDel(CLAVE_ACTIVA);
ejecutar();
marcarEditado();
toast("Demo de ejemplo restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
toast("Demo de ejemplo «Café Aurora» restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
} else {
ejecutar(); // el punto de deshacer lo toma importarJSON al aplicar el archivo
}
@@ -290,11 +328,11 @@
var nombre = (WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "sin nombre";
var resumen = "«" + nombre + "», " + nMensajes() + " mensaje" + (nMensajes() === 1 ? "" : "s");
if (!soportaDialog) {
if (window.confirm(esReset ? "¿Restablecer la demo de ejemplo? Se sustituirá " + resumen + "."
if (window.confirm(esReset ? "¿Volver al ejemplo Café Aurora? Se sustituirá " + resumen + "."
: "¿Importar un JSON? Se sustituirá " + resumen + ".")) hacer();
return;
}
var d = crearDialogo("dlg-confirmar", esReset ? "¿Restablecer la demo de ejemplo?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
var d = crearDialogo("dlg-confirmar", esReset ? "¿Volver al ejemplo Café Aurora?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
d.cuerpo.appendChild(h("p", { text: (esReset
? "La demo actual (" + resumen + ") se sustituirá por la demo de ejemplo «Café Aurora»."
: "El archivo que elijas sustituirá la demo actual (" + resumen + ").") }));
@@ -361,6 +399,18 @@
return entrada;
}
/* ¿El estado actual cambió de cuenta o de número de mensajes respecto a
lo guardado en el hueco «id»? */
function cambioSustancial(id) {
var guardada = null;
try { guardada = JSON.parse(lsGet(PREFIJO_DEMO + id) || "null"); } catch (e) { guardada = null; }
if (!guardada || typeof guardada !== "object") return false;
var nomG = (guardada.cuenta && guardada.cuenta.nombre) || "";
var nomA = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "";
var nG = Array.isArray(guardada.mensajes) ? guardada.mensajes.length : 0;
return nomG !== nomA || nG !== nMensajes();
}
function cargarDemo(id) {
var bruto = lsGet(PREFIJO_DEMO + id);
var datos = null;
@@ -372,14 +422,59 @@
return true;
}
/* Tras cargar un escenario lo primero es poner el nombre del cliente:
se despliega «Cuenta» y se enfoca (y selecciona) su nombre. */
function enfocarNombreCuenta() {
if (esEstrecha() && document.body.classList.contains("vista-movil")) return;
if (document.body.classList.contains("presentacion")) return;
var det = porId("bloque-cuenta");
if (det && !det.open) det.open = true;
var inp = porId("cuenta-nombre");
if (!inp) return;
try { inp.focus({ preventScroll: true }); } catch (e) { inp.focus(); }
if (inp.scrollIntoView) inp.scrollIntoView({ block: "nearest" });
if (typeof inp.select === "function") inp.select();
}
/* Siempre deja un punto de deshacer (también desde una demo vacía) y un
único aviso con «Deshacer»: WAM.toast retira los avisos con acción
anteriores, que ya no deshacen lo que dicen. */
function aplicarDemoCompleta(crudo, texto) {
WAM.puntoDeshacer();
sustituirEstado(normalizado(crudo));
lsDel(CLAVE_ACTIVA);
toast(texto, { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
}
function cargarEscenario(esc) {
var crudo = WAM.datosEscenario ? WAM.datosEscenario(esc.id) : null;
if (!crudo) return;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(crudo));
lsDel(CLAVE_ACTIVA);
toast("Escenario «" + esc.nombre + "» cargado", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
aplicarDemoCompleta(crudo, "Escenario «" + esc.nombre + "» cargado. Cambia el nombre del negocio en «Cuenta».");
enfocarNombreCuenta();
}
// Tipos de mensaje que usa un escenario (chips del diálogo Demos)
var NOMBRE_TIPO = {
text: "Texto", image: "Imagen", video: "Vídeo", audio: "Audio", document: "Documento",
sticker: "Sticker", location: "Ubicación", contact: "Contacto", template: "Plantilla",
buttons: "Botones", list: "Lista", cta_url: "Botón URL", carousel: "Carrusel"
};
// Los más distintivos primero (texto casi siempre está)
var ORDEN_TIPOS = ["template", "list", "carousel", "buttons", "cta_url", "location", "document",
"contact", "image", "video", "audio", "sticker", "text"];
function chipsTipos(mensajes) {
var usados = Object.create(null);
(mensajes || []).forEach(function (m) { if (m && m.tipo) usados[m.tipo] = true; });
var lista = ORDEN_TIPOS.filter(function (t) { return usados[t]; });
if (!lista.length) return null;
var max = 4;
var cont = h("span", { class: "wam-esc-tipos", "aria-label": "Incluye: " + lista.map(function (t) { return NOMBRE_TIPO[t]; }).join(", ") });
lista.slice(0, max).forEach(function (t) {
cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: NOMBRE_TIPO[t] || t }));
});
if (lista.length > max) cont.appendChild(h("span", { class: "wam-esc-tipo", "aria-hidden": "true", text: "+" + (lista.length - max) }));
return cont;
}
function fechaCorta(iso) {
@@ -397,29 +492,28 @@
// --- Escenarios ---
var gridEsc = h("div", { class: "wam-esc-grid" });
(WAM.ESCENARIOS || []).forEach(function (esc) {
var n = 0;
try { n = (esc.datos().mensajes || []).length; } catch (e) { n = 0; }
var ms = [];
try { ms = esc.datos().mensajes || []; } catch (e) { ms = []; }
var b = h("button", { type: "button", class: "wam-esc", "data-esc": esc.id }, [
h("span", { class: "wam-esc-nombre", text: esc.nombre }),
h("span", { class: "wam-esc-desc", text: esc.descripcion }),
h("span", { class: "wam-esc-meta", text: n ? n + " mensajes" : "Vacía" })
chipsTipos(ms),
h("span", { class: "wam-esc-meta", text: ms.length ? ms.length + " mensajes" : "Vacía" })
]);
b.addEventListener("click", function () { d.dlg.close(); cargarEscenario(esc); });
b.addEventListener("click", function () { d.sinRestaurar(); d.dlg.close(); cargarEscenario(esc); });
gridEsc.appendChild(b);
});
var bAurora = h("button", { type: "button", class: "wam-esc" }, [
var msAurora = [];
try { msAurora = (typeof WAM.datosDemo === "function" && WAM.datosDemo().mensajes) || []; } catch (e) { msAurora = []; }
var bAurora = h("button", { type: "button", class: "wam-esc", "data-esc": "aurora" }, [
h("span", { class: "wam-esc-nombre", text: "Catálogo · Café Aurora" }),
h("span", { class: "wam-esc-desc", text: "Los 13 tipos de mensaje de la Cloud API. Útil como referencia." }),
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" })
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" + (msAurora.length ? " · " + msAurora.length + " mensajes" : "") })
]);
bAurora.addEventListener("click", function () {
d.dlg.close();
if (typeof WAM.datosDemo !== "function") return;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(WAM.datosDemo()));
lsDel(CLAVE_ACTIVA);
toast("Demo «Café Aurora» cargada", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
aplicarDemoCompleta(WAM.datosDemo(), "Demo «Café Aurora» cargada");
});
gridEsc.appendChild(bAurora);
@@ -432,19 +526,33 @@
// --- Demos guardadas ---
var lista = h("ul", { class: "wam-demos-lista" });
var activa = demoActiva();
// Sin nombre precargado: el consultor escribe el del cliente (si lo deja
// vacío se usa el nombre de la cuenta).
var nombreCuenta = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "";
var inputNombre = h("input", {
type: "text", class: "wam-dlg-input", id: "dlg-demo-nombre", maxlength: "60",
value: activa ? activa.nombre : ((WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "Mi demo"),
value: activa ? activa.nombre : "",
placeholder: "Nombre de la demo (p. ej. el cliente)" + (nombreCuenta ? " · si lo dejas vacío: «" + nombreCuenta + "»" : ""),
"aria-label": "Nombre de la demo"
});
var bGuardar = boton(activa ? "Actualizar" : "Guardar la actual", "wam-dlg-primario", function () {
var nombre = String(inputNombre.value || "").trim() || "Mi demo";
var nombre = String(inputNombre.value || "").trim() || nombreCuenta || "Mi demo";
var misma = activa && activa.nombre === nombre;
// Defensa: si lo que se va a escribir sobre una demo existente tiene
// otra cuenta u otro número de mensajes, se pide un segundo clic.
if (misma && bGuardar.getAttribute("data-confirmar") !== "1" && cambioSustancial(activa.id)) {
bGuardar.setAttribute("data-confirmar", "1");
bGuardar.textContent = "¿Sobrescribir «" + activa.nombre + "»?";
toast("El contenido actual no se parece al de «" + activa.nombre + "» (otra cuenta u otro número de mensajes). Pulsa otra vez para sobrescribirla o cambia el nombre para guardarla como nueva.", { ms: 7000 });
return;
}
bGuardar.removeAttribute("data-confirmar");
var e = guardarDemoComo(nombre, misma ? activa.id : null);
if (e) { toast("Demo «" + e.nombre + "» guardada"); pintarLista(); activa = demoActiva(); }
});
inputNombre.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); bGuardar.click(); } });
inputNombre.addEventListener("input", function () {
bGuardar.removeAttribute("data-confirmar");
bGuardar.textContent = (activa && activa.nombre === String(inputNombre.value).trim()) ? "Actualizar" : "Guardar como nueva";
});
@@ -539,27 +647,43 @@
/* ------------------------------------------------------------------
Atajos de teclado
------------------------------------------------------------------ */
// «Ctrl» se pinta como ⌘ en Mac (los atajos aceptan ctrlKey o metaKey)
var ATAJOS = [
["N", "Nueva demo: escenarios y demos guardadas"],
["P", "Modo presentación"],
["Espacio", "Reproducir / detener la demo"],
["G", "Saltar entre el editor y el teléfono (también F6)"],
["T", "Tema claro / oscuro"],
["F", "Pantalla completa"],
["N", "Demos y escenarios"],
["Esc", "Cerrar la lista del chat o salir de la presentación"],
["Ctrl + S", "Exportar JSON"],
["Ctrl + Z", "Deshacer (fuera de un campo de texto)"],
["Ctrl + Mayús + Z", "Rehacer"],
["Alt + ↑ / ↓", "Mover el mensaje o elemento con el foco"],
["Alt + clic en una burbuja", "Abrir su tarjeta en el editor (también doble clic)"],
["Alt + clic", "Sobre una burbuja: abre su tarjeta en el editor (también doble clic)"],
["?", "Esta ayuda"]
];
function esMac() {
var nav = window.navigator || {};
var plat = (nav.userAgentData && nav.userAgentData.platform) || nav.platform || nav.userAgent || "";
return /mac|iphone|ipad|ipod/i.test(String(plat));
}
function teclaVisible(k) {
if (!esMac()) return k;
if (k === "Ctrl") return "⌘";
if (k === "Alt") return "⌥";
if (k === "Mayús") return "⇧";
return k;
}
WAM.abrirAtajos = function () {
if (!soportaDialog) return;
var d = crearDialogo("dlg-atajos", "Atajos de teclado", "wam-dlg-chico");
var tabla = h("dl", { class: "wam-atajos" });
ATAJOS.forEach(function (a) {
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(function (k, i, arr) {
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(teclaVisible).map(function (k, i, arr) {
var frag = document.createDocumentFragment();
frag.appendChild(h("kbd", { text: k }));
if (i < arr.length - 1) frag.appendChild(document.createTextNode(" + "));
@@ -591,12 +715,66 @@
return !!(s && !s.hidden && s.offsetParent !== null);
}
/* ------------------------------------------------------------------
Saltar entre paneles: enlace «Saltar a la vista previa» y G / F6
------------------------------------------------------------------ */
var ultimoFocoEditor = null;
function enfocar(el) {
if (!el) return false;
try { el.focus({ preventScroll: true }); } catch (e) { el.focus(); }
return document.activeElement === el;
}
function irAVistaPrevia() {
if (esEstrecha() && !document.body.classList.contains("presentacion")) WAM.setVistaMovil("vista");
var cuerpo = porId("chat-cuerpo");
if (!cuerpo) return;
// tras el cambio de pestaña (display) el nodo ya es enfocable
requestAnimationFrame(function () { enfocar(cuerpo); });
}
function irAlEditor() {
if (document.body.classList.contains("presentacion")) return false;
if (esEstrecha()) WAM.setVistaMovil("editor");
requestAnimationFrame(function () {
var lista = porId("lista-mensajes");
var previo = ultimoFocoEditor && ultimoFocoEditor.isConnected && ultimoFocoEditor.getClientRects().length ? ultimoFocoEditor : null;
if (previo && enfocar(previo)) return;
var cab = lista && lista.querySelector(".ed-cab-principal");
if (cab && enfocar(cab)) { if (cab.scrollIntoView) cab.scrollIntoView({ block: "nearest" }); return; }
var pal = document.querySelector('#nuevo-tipo [data-tipo][tabindex="0"]') || document.querySelector("#nuevo-tipo [data-tipo]");
enfocar(pal || lista);
});
return true;
}
function alternarPanel() {
var movil = porId("panel-movil");
var a = document.activeElement;
if (movil && a && movil.contains(a)) irAlEditor();
else irAVistaPrevia();
}
function enlazarSaltos() {
var enlace = porId("saltar-vista");
if (enlace) enlace.addEventListener("click", function (e) { e.preventDefault(); irAVistaPrevia(); });
var ed = porId("panel-editor");
if (ed) ed.addEventListener("focusin", function (e) { ultimoFocoEditor = e.target; });
}
function alTecla(e) {
if (e.defaultPrevented || e.isComposing) return;
var k = e.key;
var mod = e.ctrlKey || e.metaKey;
var hayDialogo = !!document.querySelector("dialog[open]");
if (k === "F6" && !mod && !e.altKey && !hayDialogo) {
e.preventDefault();
alternarPanel();
return;
}
if (mod && !e.altKey && (k === "s" || k === "S")) {
e.preventDefault();
if (!hayDialogo && typeof WAM.exportarJSON === "function") WAM.exportarJSON();
@@ -625,6 +803,7 @@
case "t": case "T": e.preventDefault(); clicEn("btn-tema"); break;
case "f": case "F": e.preventDefault(); alternarPantallaCompleta(); break;
case "n": case "N": e.preventDefault(); WAM.abrirDemos(); break;
case "g": case "G": e.preventDefault(); alternarPanel(); break;
case "?": e.preventDefault(); WAM.abrirAtajos(); break;
case " ":
if (esInteractivo(e.target)) return; // Espacio activa el botón con foco
@@ -638,15 +817,28 @@
/* ------------------------------------------------------------------
Menú «⋯» de la toolbar (ventana estrecha) y pestañas Editor / Vista
------------------------------------------------------------------ */
function cerrarMenu() {
function cerrarMenu(devolverFoco) {
var tb = porId("toolbar");
var mas = porId("btn-mas");
if (!tb || !tb.classList.contains("tb-menu-abierto")) return false;
var menu = porId("tb-menu");
var dentro = menu && menu.contains(document.activeElement);
tb.classList.remove("tb-menu-abierto");
if (mas) mas.setAttribute("aria-expanded", "false");
if (mas) {
mas.setAttribute("aria-expanded", "false");
if (devolverFoco !== false && dentro) mas.focus();
}
return true;
}
// Opciones visibles del menú «⋯» abierto (en escritorio solo «Volver al ejemplo»)
function opcionesMenu() {
var menu = porId("tb-menu");
if (!menu) return [];
var bs = esEstrecha() ? menu.querySelectorAll("button:not([disabled])") : menu.querySelectorAll("#btn-reset");
return Array.prototype.filter.call(bs, function (b) { return b.getClientRects().length > 0; });
}
function enlazarMenu() {
var tb = porId("toolbar"), mas = porId("btn-mas"), menu = porId("tb-menu");
if (!tb || !mas || !menu) return;
@@ -654,10 +846,27 @@
e.stopPropagation();
var abierto = tb.classList.toggle("tb-menu-abierto");
mas.setAttribute("aria-expanded", abierto ? "true" : "false");
if (abierto) { var b = menu.querySelector("button:not([disabled])"); if (b) b.focus(); }
if (abierto) { var ops = opcionesMenu(); if (ops[0]) ops[0].focus(); }
});
// Flechas dentro del menú abierto
tb.addEventListener("keydown", function (e) {
if (!tb.classList.contains("tb-menu-abierto") || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
var ops = opcionesMenu();
var i = ops.indexOf(document.activeElement);
if (i < 0 && document.activeElement !== mas) return;
e.preventDefault();
var j = i < 0 ? 0 : (i + (e.key === "ArrowDown" ? 1 : -1) + ops.length) % ops.length;
if (ops[j]) ops[j].focus();
});
// Al salir con Tab del menú abierto, se cierra
tb.addEventListener("focusout", function (e) {
if (!tb.classList.contains("tb-menu-abierto")) return;
var sig = e.relatedTarget;
if (sig && (menu.contains(sig) || sig === mas)) return;
if (sig) cerrarMenu(false);
});
menu.addEventListener("click", function (e) {
if (e.target.closest && e.target.closest("button")) cerrarMenu();
if (e.target.closest && e.target.closest("button")) cerrarMenu(false);
});
document.addEventListener("click", function (e) {
if (!tb.classList.contains("tb-menu-abierto")) return;
@@ -708,9 +917,18 @@
var fijar = function () {
document.documentElement.style.setProperty("--wam-toolbar-alto", Math.ceil(tb.getBoundingClientRect().height) + "px");
};
var barra = porId("barra-movil");
var fijarBarra = function () {
if (!barra) return;
var r = barra.getBoundingClientRect();
if (r.height) document.documentElement.style.setProperty("--wam-barra-alto", Math.ceil(r.height + 14) + "px");
};
fijar();
if (typeof ResizeObserver === "function") new ResizeObserver(fijar).observe(tb);
else window.addEventListener("resize", fijar);
fijarBarra();
if (typeof ResizeObserver === "function") {
new ResizeObserver(fijar).observe(tb);
if (barra) new ResizeObserver(fijarBarra).observe(barra);
} else window.addEventListener("resize", function () { fijar(); fijarBarra(); });
}
/* ------------------------------------------------------------------
@@ -782,9 +1000,41 @@
/* ------------------------------------------------------------------
Estado de los botones de la toolbar
------------------------------------------------------------------ */
var temaPintado = null;
function sincronizarTema() {
var tema = document.body.getAttribute("data-tema");
var b = porId("btn-tema");
if (b) b.setAttribute("aria-pressed", document.body.getAttribute("data-tema") === "oscuro" ? "true" : "false");
if (b) {
b.setAttribute("aria-pressed", tema === "oscuro" ? "true" : "false");
b.setAttribute("aria-label", "Tema oscuro");
}
// El cambio de tema (botón o T) también pasa por «Guardando…»
if (temaPintado !== null && tema !== temaPintado) marcarEditado();
temaPintado = tema;
}
/* ------------------------------------------------------------------
Zoom manual del teléfono (Ajustar / 100 % / 125 %). El CSS vive en
iphone.css (body[data-wam-zoom]); sin atributo = ajustar al hueco.
------------------------------------------------------------------ */
var CLAVE_ZOOM = "wam-zoom";
function aplicarZoom(v) {
v = v === "100" || v === "125" ? v : "";
if (v) document.body.setAttribute("data-wam-zoom", v);
else document.body.removeAttribute("data-wam-zoom");
var sel = porId("sel-zoom");
if (sel && sel.value !== v) sel.value = v;
return v;
}
WAM.setZoom = function (v) {
v = aplicarZoom(String(v == null ? "" : v));
if (v) lsSet(CLAVE_ZOOM, v); else lsDel(CLAVE_ZOOM);
return v;
};
function enlazarZoom() {
aplicarZoom(lsGet(CLAVE_ZOOM) || "");
var sel = porId("sel-zoom");
if (sel) sel.addEventListener("change", function () { WAM.setZoom(sel.value); });
}
/* ------------------------------------------------------------------
@@ -809,6 +1059,8 @@
enlazarPestanas();
observarToolbar();
enlazarChat();
enlazarSaltos();
enlazarZoom();
envolverAnuncios();
sincronizarTema();
+311
View File
@@ -0,0 +1,311 @@
// Cobertura de lo integrado tras los parches de render, editor/toolbar y lógica:
// scroll inicial, zoom manual, JSON malicioso, recortes al importar, contador
// de pasos, horas e IDs de sesión, formato WhatsApp, citas y documentos.
import {
test, expect, estado, filasChat, filaChat, cargarEstado, rapido, irA, URL_FILE, tarjetas,
} from "./fixtures.mjs";
async function importar(page, datos, nombre = "demo.json") {
const buffer = Buffer.from(typeof datos === "string" ? datos : JSON.stringify(datos));
await page.locator("#btn-importar").click();
const confirmar = page.locator("#dlg-confirmar");
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
(async () => {
if (await confirmar.isVisible().catch(() => false)) {
await confirmar.getByRole("button", { name: "Elegir archivo…" }).click();
}
})(),
]);
await chooser.setFiles({ name: nombre, mimeType: "application/json", buffer });
}
const alFondo = (page) => page.evaluate(() => {
const c = document.getElementById("chat-cuerpo");
return Math.abs(c.scrollHeight - c.clientHeight - c.scrollTop) <= 2;
});
function estadoCon(mensajes, extra = {}) {
return { version: 1, tema: "claro", cuenta: { nombre: "Prueba", verificado: false }, mensajes, ...extra };
}
test.describe("Scroll inicial del chat", () => {
test("al cargar, el chat queda pegado al último mensaje", async ({ app }) => {
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
// y sigue abajo tras recargar (imágenes data:SVG que decodifican tarde)
await app.reload();
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
});
test("por file:// también arranca abajo", async ({ page }) => {
await irA(page, URL_FILE);
await expect.poll(() => alFondo(page), { timeout: 5000 }).toBe(true);
});
});
test.describe("Zoom manual", () => {
const ancho = (page) => page.locator("#iphone").evaluate((el) => el.getBoundingClientRect().width);
test("Ajustar / 100 % / 125 % cambian la escala y se recuerda", async ({ app }) => {
const sel = app.locator("#sel-zoom");
await expect(sel).toBeVisible();
await expect(sel).toHaveValue("");
const ajustado = await ancho(app);
await sel.selectOption("125");
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "125");
await expect.poll(() => ancho(app)).toBeGreaterThan(ajustado);
const grande = await ancho(app);
await sel.selectOption("100");
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "100");
await expect.poll(() => ancho(app)).toBeLessThan(grande);
await app.reload();
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "100");
await expect(app.locator("#sel-zoom")).toHaveValue("100");
await app.locator("#sel-zoom").selectOption("");
await expect(app.locator("body")).not.toHaveAttribute("data-wam-zoom", /.*/);
await expect.poll(() => ancho(app)).toBeCloseTo(ajustado, 0);
});
test("en Presentación manda el ajuste automático", async ({ app }) => {
await app.locator("#sel-zoom").selectOption("125");
await app.locator("#btn-presentacion").click();
await expect(app.locator("body")).toHaveClass(/presentacion/);
const caja = await app.locator("#iphone").boundingBox();
const vh = app.viewportSize().height;
expect(caja.y + caja.height).toBeLessThanOrEqual(vh + 1);
await app.keyboard.press("Escape");
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
});
});
test.describe("JSON malicioso", () => {
test("importar HTML y URLs peligrosas no ejecuta nada ni inyecta nodos", async ({ app }) => {
const img = '<img src=x onerror="window.__xss=1">';
await importar(app, estadoCon([
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: img + " *hola* <script>window.__xss=2</script>" },
{
id: "m2", dir: "in", tipo: "template", hora: "10:01", headerTipo: "texto", headerTexto: img, body: img,
botones: [
{ texto: img, url: "javascript:window.__xss=3" },
{ texto: "Datos", url: "data:text/html,<script>window.__xss=4</script>" },
],
},
{
id: "m3", dir: "in", tipo: "carousel", hora: "10:02", body: "",
cards: [
{ media: "javascript:alert(1)", texto: img, textoBoton: "Ver", url: "vbscript:msgbox(1)" },
{ media: "", texto: "ok", textoBoton: "Ver" },
],
},
{
id: "m4", dir: "in", tipo: "list", hora: "10:03", body: "Menú", boton: img,
secciones: [{ titulo: img, filas: [{ id: "f1", titulo: img, descripcion: img }] }],
},
{ id: "m5", dir: "in", tipo: "image", hora: "10:04", src: "javascript:window.__xss=5", caption: img },
], { cuenta: { nombre: img, avatar: "javascript:window.__xss=6", verificado: false } }));
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(5);
const e = await estado(app);
// URLs peligrosas bloqueadas y marcadas
expect(e.mensajes[1].botones[0]).toMatchObject({ url: "", urlBloqueada: true });
expect(e.mensajes[1].botones[1]).toMatchObject({ url: "", urlBloqueada: true });
expect(e.mensajes[2].cards[0]).toMatchObject({ url: "", urlBloqueada: true });
expect(e.mensajes[4].src).toBe("");
// nada ejecutable en el DOM del chat ni del editor
const sospechosos = await app.evaluate(() => ({
scripts: document.querySelectorAll("#pantalla script, #panel-editor script").length,
onerror: document.querySelectorAll("[onerror]").length,
jsHref: document.querySelectorAll('a[href^="javascript"], a[href^="data:text"], a[href^="vbscript"]').length,
jsSrc: Array.from(document.querySelectorAll("img, video")).filter((el) => /^javascript:/i.test(el.getAttribute("src") || "")).length,
}));
expect(sospechosos).toEqual({ scripts: 0, onerror: 0, jsHref: 0, jsSrc: 0 });
// el texto se ve literal
await expect(filaChat(app, "m1")).toContainText("<img src=x");
await expect(app.locator("#chat-nombre")).toContainText("<img src=x");
// botones bloqueados: inertes, sin flujo
await expect(filaChat(app, "m2").locator(".qr-btn-inactiva")).toHaveCount(2);
await expect(filaChat(app, "m2").locator("[data-flujo]")).toHaveCount(0);
await expect(filaChat(app, "m3").locator(".card").first().locator(".qr-btn-inactiva")).toHaveCount(1);
const n = await filasChat(app).count();
await filaChat(app, "m2").locator(".qr-btn-inactiva").first().click({ force: true });
await app.waitForTimeout(600);
await expect(filasChat(app)).toHaveCount(n);
// la hoja de lista también pinta texto literal
await filaChat(app, "m4").locator("[data-flujo]").click();
await expect(app.locator(".sheet-panel").first()).toContainText("<img src=x");
expect(await app.evaluate(() => window.__xss)).toBeUndefined();
expect(await app.evaluate(() => document.querySelectorAll("[onerror]").length)).toBe(0);
});
test("editar a mano la URL de un botón bloqueado retira la marca", async ({ app }) => {
await cargarEstado(app, estadoCon([
{ id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "b", botones: [{ texto: "Ir", url: "javascript:1" }] },
]));
expect((await estado(app)).mensajes[0].botones[0].urlBloqueada).toBe(true);
await app.evaluate(() => WAM.setCampo("mensajes.0.botones.0.url", "https://example.com"));
const b = (await estado(app)).mensajes[0].botones[0];
expect(b.urlBloqueada).toBeUndefined();
await expect(filaChat(app, "m1").locator('a.qr-btn[href="https://example.com"]')).toHaveCount(1);
});
test("un JSON enorme se recorta y el aviso lo cuenta", async ({ app }) => {
const mensajes = Array.from({ length: 505 }, (_, i) => ({ id: "m" + (i + 1), dir: "in", tipo: "text", hora: "10:00", texto: "n" + i }));
await importar(app, estadoCon(mensajes));
await expect.poll(async () => (await estado(app)).mensajes.length, { timeout: 15000 }).toBe(500);
await expect(app.getByText("Se importaron 500 de 505 mensajes")).toBeVisible();
// el resumen no se guarda ni se exporta
const guardado = await app.evaluate(() => { WAM.guardarYa(); return localStorage.getItem("wam-mockup-v1"); });
expect(guardado).not.toContain("__recortes");
});
});
test.describe("Demo: contador, horas e IDs de sesión", () => {
const GUION = estadoCon([
{ id: "m1", dir: "in", tipo: "text", hora: "10:30", texto: "Hola" },
{ id: "m2", dir: "out", tipo: "text", hora: "10:31", texto: "Buenas", estado: "read" },
{ id: "m3", dir: "in", tipo: "text", hora: "10:33", texto: "¿Qué tal?" },
]);
test("mientras corre se ve «Paso N/M» y el botón muestra Detener", async ({ app }) => {
await cargarEstado(app, GUION);
await rapido(app);
const paso = app.locator("#demo-paso");
await expect(paso).toBeHidden();
await app.locator("#btn-reproducir").click();
await expect(paso).toBeVisible();
await expect(paso).toHaveText(/Paso \d+\/3/);
await expect(app.locator("#btn-reproducir")).toContainText("Detener");
await expect.poll(() => app.evaluate(() => WAM.demoActiva()), { timeout: 10000 }).toBe(false);
await expect(paso).toBeHidden();
await expect(app.locator("#btn-reproducir")).toContainText("Reproducir demo");
});
test("la saliente de un botón toma la hora del último mensaje + 1 min", async ({ app }) => {
await cargarEstado(app, estadoCon([
{ id: "m1", dir: "in", tipo: "text", hora: "10:30", texto: "Hola" },
{
id: "m2", dir: "in", tipo: "buttons", hora: "10:33", body: "¿Qué quieres?",
botones: [{ texto: "Precios", respuestas: [{ id: "m3", dir: "in", tipo: "text", hora: "", texto: "Barato" }] }],
},
]));
await rapido(app);
await filaChat(app, "m2").locator("[data-flujo]").first().click();
const out = app.locator('#chat-cuerpo li.out[data-id^="s"]').last();
await expect(out).toContainText("Precios");
await expect(out.locator(".meta")).toContainText("10:34");
const resp = app.locator('#chat-cuerpo li.in[data-id^="s"]').last();
await expect(resp).toContainText("Barato", { timeout: 6000 });
await expect(resp.locator(".meta")).toContainText("10:35");
});
test("los IDs de sesión no chocan con los del guion", async ({ app }) => {
await cargarEstado(app, estadoCon([
{ id: "s1", dir: "in", tipo: "text", hora: "10:00", texto: "uno" },
{ id: "s2", dir: "in", tipo: "text", hora: "10:01", texto: "dos" },
]));
await app.locator("#composer-input").fill("tres");
await app.locator("#composer-input").press("Enter");
const ids = await app.locator("#chat-cuerpo > li.wa-row").evaluateAll((els) => els.map((e) => e.getAttribute("data-id")));
expect(new Set(ids).size).toBe(ids.length);
expect(ids[2]).toMatch(/^s/);
expect(["s1", "s2"]).not.toContain(ids[2]);
});
test("«Repetir desde el inicio» vuelve al guion tras pulsar un botón", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
const btn = app.locator("#btn-reiniciar");
await expect(btn).toContainText("Repetir desde el inicio");
await rapido(app);
await app.locator("#chat-cuerpo [data-flujo][data-btn]").first().click();
await expect.poll(() => filasChat(app).count()).toBeGreaterThan(n);
await btn.click();
await expect(filasChat(app)).toHaveCount(n);
await expect(app.locator("#chat-cuerpo .qr-btn.usado")).toHaveCount(0);
});
});
test.describe("Formato, citas y documentos", () => {
test("negrita solo con marcas pegadas al texto; «2 * 3 * 4» queda literal", async ({ app }) => {
await cargarEstado(app, estadoCon([
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: "*negrita* y _cursiva_ y ~tachado~" },
{ id: "m2", dir: "in", tipo: "text", hora: "10:01", texto: "2 * 3 * 4 y *negrita *" },
]));
await expect(filaChat(app, "m1").locator("strong, b")).toHaveText("negrita");
await expect(filaChat(app, "m1").locator("em, i")).toHaveText("cursiva");
await expect(filaChat(app, "m1").locator("s, del, strike")).toHaveText("tachado");
await expect(filaChat(app, "m2").locator("strong, b")).toHaveCount(0);
await expect(filaChat(app, "m2")).toContainText("2 * 3 * 4 y *negrita *");
});
test("la cita de la respuesta quita el formato con la misma regla", async ({ app }) => {
await cargarEstado(app, estadoCon([
{
id: "m1", dir: "in", tipo: "buttons", hora: "10:00", body: "*Hola* 2 * 3",
botones: [{ texto: "Vale", respuestas: [] }],
},
]));
await rapido(app);
await filaChat(app, "m1").locator("[data-flujo]").click();
const cita = app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-extracto').last();
await expect(cita).toHaveText("Hola 2 * 3");
});
test("citar una plantilla con cabecera de documento muestra icono y nombre", async ({ app }) => {
await cargarEstado(app, estadoCon([
{
id: "m1", dir: "in", tipo: "template", hora: "10:00", headerTipo: "documento", headerTexto: "carta.pdf",
body: "Te enviamos la carta", botones: [{ texto: "Gracias", respuestas: [] }],
},
]));
await rapido(app);
await filaChat(app, "m1").locator("[data-flujo]").click();
const tit = app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-titulo').last();
await expect(tit).toHaveText("carta.pdf");
await expect(tit.locator("svg.wa-cita-doc")).toHaveCount(1);
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-mini')).toHaveCount(0);
});
test("documento: «tamaño • ext» con tamano; si no, la extensión en mayúsculas", async ({ app }) => {
await cargarEstado(app, estadoCon([
{ id: "m1", dir: "in", tipo: "document", hora: "10:00", filename: "menu.pdf", tamano: "243 KB" },
{ id: "m2", dir: "in", tipo: "document", hora: "10:01", filename: "hoja.xlsx" },
{
id: "m3", dir: "in", tipo: "template", hora: "10:02", headerTipo: "documento", headerTexto: "cat.pdf",
headerTamano: "1,2 MB", body: "b", botones: [],
},
]));
await expect(filaChat(app, "m1").locator(".doc-sub")).toHaveText("243 KB • pdf");
await expect(filaChat(app, "m2").locator(".doc-sub")).toHaveText("XLSX");
await expect(filaChat(app, "m3").locator(".doc-sub")).toHaveText("1,2 MB • pdf");
const e = await estado(app);
expect(e.mensajes[1].tamano).toBeUndefined();
expect(e.mensajes[2].headerTamano).toBe("1,2 MB");
});
test("botón con URL sin esquema abre con https:// y no da aviso de validación", async ({ app }) => {
await cargarEstado(app, estadoCon([
{ id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "b", botones: [{ texto: "Web", url: "www.cafe-aurora.mx" }] },
]));
await expect(filaChat(app, "m1").locator('a.qr-btn[href="https://www.cafe-aurora.mx"]')).toHaveCount(1);
await expect(tarjetas(app).first().locator(".ed-validacion")).toBeHidden();
await expect(tarjetas(app).first()).not.toContainText("URL no válida");
});
});
test.describe("Móvil sin marco @movil", () => {
test("pantalla a todo el ancho, sin isla ni zoom, y el chat arranca abajo @movil", async ({ app }) => {
await expect(app.locator("#tab-vista")).toHaveAttribute("aria-selected", "true");
await expect(app.locator("#sel-zoom")).toBeHidden();
await expect(app.locator("#isla")).toBeHidden();
const caja = await app.locator("#pantalla").boundingBox();
expect(Math.round(caja.width)).toBeGreaterThanOrEqual(app.viewportSize().width - 1);
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
});
test("un zoom guardado en escritorio no afecta al móvil @movil", async ({ app }) => {
await app.evaluate(() => localStorage.setItem("wam-zoom", "125"));
await app.reload();
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "125");
const caja = await app.locator("#pantalla").boundingBox();
expect(caja.width).toBeLessThanOrEqual(app.viewportSize().width + 1);
});
});
+152
View File
@@ -0,0 +1,152 @@
// Regresiones: deshacer/rehacer con demos guardadas, avisos «Deshacer»
// obsoletos, hoja «Todas las opciones» con URLs bloqueadas y foco en las hojas.
import { test, expect, estado, filaChat, cargarEstado, rapido } from "./fixtures.mjs";
async function nombreCuenta(page, v) {
await page.evaluate(() => { const d = document.getElementById("bloque-cuenta"); if (d) d.open = true; });
await page.locator("#cuenta-nombre").fill(v);
await expect.poll(async () => (await estado(page)).cuenta.nombre).toBe(v);
}
const activaLS = (page) => page.evaluate(() => localStorage.getItem("wam-demo-activa"));
async function guardarComo(page, nombre) {
await page.locator("#btn-demos").click();
const dlg = page.locator("#dlg-demos");
await dlg.locator("#dlg-demo-nombre").fill(nombre);
await dlg.getByRole("button", { name: /Guardar/ }).click();
await expect(dlg.locator(".wam-demo-nombre", { hasText: new RegExp("^" + nombre + "$") })).toBeVisible();
await dlg.locator(".wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click();
}
test.describe("Deshacer y demos guardadas", () => {
test("deshacer / rehacer reponen la demo activa y «Actualizar» no pisa otra demo", async ({ app }) => {
await guardarComo(app, "A");
const idA = await activaLS(app);
// cargar un escenario y guardarlo como B
await app.locator("#btn-demos").click();
await app.locator("#dlg-demos .wam-esc").first().click();
await nombreCuenta(app, "Cuenta B");
await guardarComo(app, "B");
const idB = await activaLS(app);
expect(idB).not.toBe(idA);
// abrir A
await app.locator("#btn-demos").click();
await app.locator("#dlg-demos .wam-demo").filter({ has: app.locator(".wam-demo-nombre", { hasText: /^A$/ }) })
.getByRole("button", { name: "Abrir" }).click();
expect(await activaLS(app)).toBe(idA);
// deshacer: vuelve el contenido de B y también la demo activa B
await app.locator("#btn-deshacer").click();
expect((await estado(app)).cuenta.nombre).toBe("Cuenta B");
expect(await activaLS(app)).toBe(idB);
await app.locator("#btn-demos").click();
await expect(app.locator("#dlg-demo-nombre")).toHaveValue("B");
await app.locator("#dlg-demos .wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click();
// rehacer: vuelve A
await app.locator("#btn-rehacer").click();
expect(await activaLS(app)).toBe(idA);
// deshacer un escenario (que borra la clave activa) la repone
await app.locator("#btn-demos").click();
await app.locator("#dlg-demos .wam-esc").first().click();
expect(await activaLS(app)).toBeNull();
await app.locator("#btn-deshacer").click();
expect(await activaLS(app)).toBe(idA);
// el hueco de A sigue siendo A
const slotA = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id) || "null"), idA);
expect(slotA && slotA.cuenta.nombre).not.toBe("Cuenta B");
});
test("«Actualizar» pide un segundo clic si cambió la cuenta", async ({ app }) => {
await guardarComo(app, "A");
const idA = await activaLS(app);
const nombreOriginal = (await estado(app)).cuenta.nombre;
await nombreCuenta(app, "Otra cuenta");
await app.locator("#btn-demos").click();
const bGuardar = app.locator("#dlg-demos .wam-dlg-primario");
await expect(bGuardar).toHaveText("Actualizar");
await bGuardar.click();
await expect(bGuardar).toHaveText(/Sobrescribir/);
let slot = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id)), idA);
expect(slot.cuenta.nombre).toBe(nombreOriginal);
await bGuardar.click();
slot = await app.evaluate((id) => JSON.parse(localStorage.getItem("wam-demo-" + id)), idA);
expect(slot.cuenta.nombre).toBe("Otra cuenta");
});
test("el «Deshacer» de un aviso desaparece cuando hay otro cambio", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await app.evaluate(() => WAM.borrarMensaje(0));
const accion = app.locator("#ed-toasts .ed-toast-accion");
await expect(accion).toHaveCount(1);
await nombreCuenta(app, "Nombre nuevo");
// sin esperar: el aviso caduca solo a los 6 s y enmascararía el fallo
expect(await accion.count()).toBe(0);
expect((await estado(app)).mensajes.length).toBe(n - 1);
});
});
const PLANTILLA = [
{
id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "Elige",
botones: [
{ texto: "Uno" }, { texto: "Dos" },
{ texto: "Bloqueado", url: "javascript:alert(1)" },
{ texto: "Relativa", url: "/menu" },
{ texto: "Cinco" },
],
},
{
id: "m2", dir: "in", tipo: "list", hora: "10:01", body: "Menú", boton: "Ver menú",
secciones: [{ titulo: "S", filas: [{ id: "f1", titulo: "Fila 1" }, { id: "f2", titulo: "Fila 2" }] }],
},
];
test.describe("Hojas inferiores", () => {
test.beforeEach(async ({ app }) => {
await rapido(app);
await cargarEstado(app, { mensajes: PLANTILLA });
await expect(filaChat(app, "m1")).toHaveCount(1);
});
test("«Todas las opciones» pinta inertes los botones con URL bloqueada o no válida", async ({ app }) => {
const fila = filaChat(app, "m1");
const inertesBurbuja = await fila.locator(".qr-btn-inactiva").allTextContents();
expect(inertesBurbuja.map((t) => t.trim())).toEqual(["Bloqueado", "Relativa"]);
await fila.locator("[data-ver-todas]").click();
const hoja = app.locator("#sheet");
const inertesHoja = await hoja.locator('.sheet-fila.qr-btn-inactiva[aria-disabled="true"]').allTextContents();
expect(inertesHoja.map((t) => t.trim())).toEqual(inertesBurbuja.map((t) => t.trim()));
await expect(hoja.locator("[data-opcion]")).toHaveCount(3);
await expect(hoja.locator('[data-opcion="2"], [data-opcion="3"]')).toHaveCount(0);
await expect(hoja.locator(".sheet-fila")).toHaveCount(5);
});
test("foco: la hoja lo recibe, lo retiene con Tab y lo devuelve al cerrar", async ({ app }) => {
const toggle = filaChat(app, "m1").locator("[data-ver-todas]");
await toggle.focus();
await app.keyboard.press("Enter");
await expect(app.locator("#sheet")).toHaveCount(1);
const enHoja = () => app.evaluate(() => !!document.activeElement.closest("#sheet"));
expect(await enHoja()).toBe(true);
for (let k = 0; k < 6; k++) {
await app.keyboard.press("Tab");
expect(await enHoja()).toBe(true);
}
await app.keyboard.press("Shift+Tab");
expect(await enHoja()).toBe(true);
await app.keyboard.press("Escape");
await expect(app.locator("#sheet")).toHaveCount(0);
expect(await app.evaluate(() => document.activeElement.hasAttribute("data-ver-todas"))).toBe(true);
});
test("foco: la lista «Ver opciones» enfoca su primera fila", async ({ app }) => {
const abridor = filaChat(app, "m2").locator("[data-flujo]:not([data-btn]):not([data-card])").first();
await abridor.focus();
await app.keyboard.press("Enter");
await expect(app.locator("#sheet")).toHaveCount(1);
expect(await app.evaluate(() => document.activeElement.getAttribute("data-fila"))).toBe("0");
await app.locator("#sheet .sheet-cerrar").click();
await expect(app.locator("#sheet")).toHaveCount(0);
expect(await app.evaluate(() => document.activeElement.closest('[data-id="m2"]') !== null)).toBe(true);
});
});
+3
View File
@@ -153,6 +153,7 @@ test.describe("Restablecer, deshacer y rehacer", () => {
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("Mi cambio");
const mio = await estado(app);
await app.locator("#btn-mas").click();
await app.locator("#btn-reset").click();
const dlg = app.locator("#dlg-confirmar");
await expect(dlg).toBeVisible();
@@ -168,6 +169,7 @@ test.describe("Restablecer, deshacer y rehacer", () => {
});
test("«Exportar antes» del diálogo descarga la demo actual", async ({ app }) => {
await app.locator("#btn-mas").click();
await app.locator("#btn-reset").click();
const { nombre } = await exportar(app, () => app.locator("#dlg-confirmar").getByRole("button", { name: "Exportar antes" }).click());
expect(nombre).toBe("cafe-aurora.json");
@@ -177,6 +179,7 @@ test.describe("Restablecer, deshacer y rehacer", () => {
test("sin mensajes restablece sin preguntar", async ({ app }) => {
await vaciar(app);
await app.locator("#btn-mas").click();
await app.locator("#btn-reset").click();
await expect(app.locator("#dlg-confirmar")).toHaveCount(0);
await expect(tarjetas(app)).toHaveCount(14);
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig({
expect: { timeout: 7_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
retries: 1, // tolera algún fallo intermitente por carga con muchos workers
// WebKit en Windows se vuelve inestable con muchos workers en paralelo
workers: process.env.CI ? 2 : Number(process.env.WAM_WORKERS || 6),
reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]],