Fase 1
Descubrir
Mapa de elementos interactivos en cada URL del flujo
Completado
Fase 2
Ensayar
9 selectores verificados — sin fallos
Completado
Fase 3
Grabar
Script listo para ejecutar y generar el WebM final
Listo
1
Fase 1 — Mapa de Elementos Descubiertos
Resultado del script de discovery en /login, /dashboard y /opportunities/new
🔍 /login — 3 campos encontrados
Email
input
input[name="email"]
Contraseña
input[password]
input[name="password"]
Iniciar sesión
button[type="submit"]
🔍 /dashboard — 5 elementos interactivos encontrados
Nueva Oportunidad
button:has-text("Nueva Oportunidad")
Cards de KPI
div
.kpi-card (×4, no clickable)
🔍 /opportunities/new — 7 campos encontrados
Empresa
input
input[name="company"]
Contacto
input
input[name="contact"]
Valor (€)
input[number]
input[name="value"]
Etapa
select
select[name="stage"] — 5 options (sin value="0")
Comercial asignado
select
select[name="assignee"]
Notas
textarea
textarea[name="notes"] — NO es <input>
Guardar oportunidad
button:has-text("Guardar oportunidad")
2
Fase 2 — Resultado del Ensayo
node demo-oportunidad.cjs --rehearse → 9/9 REHEARSAL OK
Login — Email fieldinput[name="email"]
Login — Password fieldinput[name="password"]
Login — Submit buttonbutton[type="submit"]
Dashboard — Nueva Oportunidadbutton:has-text("Nueva Oportunidad")
Formulario — Companyinput[name="company"]
Formulario — Stage selectselect[name="stage"]
Formulario — Notes textareatextarea[name="notes"]
Formulario — Guardarbutton:has-text("Guardar oportunidad")
Detalle — Añadir notainput[placeholder="Escribe una nota..."]
3
Fase 3 — Plan de Grabación (Story Flow)
Secuencia narrativa con tiempos de pausa para ritmo natural
Paso 1 — Login
Navegar a /login → escribir email y contraseña → clic en "Iniciar sesión" → esperar dashboard
+4s post-login
Paso 2 — Panorámica del Dashboard
Paneo sobre KPIs (42 oportunidades abiertas, 380K€ valor, 34% tasa cierre) y pipeline Kanban
+3s navegación
Paso 3 — Nueva Oportunidad
Clic en "Nueva Oportunidad" → formulario → rellenar empresa, contacto, valor 12.000€, etapa, comercial
+2s por campo
Paso 4 — Añadir notas
Escribir en textarea: "Cliente interesado en automatización de contenido y agentes IA" a 35ms/char
35ms/char
Paso 5 — Guardar y confirmar
Clic en "Guardar oportunidad" → toast de confirmación → redirige a /opportunities/123
+3s resultado
Paso 6 — Nota de seguimiento
En la vista de detalle, añadir nota: "Reunión de kick-off programada para el 25 de junio"
+3s final
4
Script Playwright — demo-oportunidad.cjs
Script de grabación listo para ejecutar en producción
demo-oportunidad.cjs
JavaScript
// ─────────────────────────────────────────────────────────
// demo-oportunidad.cjs — FlowDesk CRM
// Flujo: Login → Dashboard → Nueva Oportunidad → Guardar → Nota
// Producido por: Grabador de Vídeos Demo de UI (CULTIVA IA)
// ─────────────────────────────────────────────────────────
'use strict';
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:3000';
const VIDEO_DIR = path.join(__dirname, 'screenshots');
const OUTPUT_NAME = 'demo-oportunidad-flowdesk.webm';
const REHEARSAL = process.argv.includes('--rehearse');
// ── Overlay: cursor SVG ──────────────────────────────────
async function injectCursor(page) {
await page.evaluate(() => {
if (document.getElementById('demo-cursor')) return;
const c = document.createElement('div');
c.id = 'demo-cursor';
c.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white"
stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
</svg>`;
c.style.cssText = `position:fixed;z-index:999999;pointer-events:none;
width:24px;height:24px;transition:left .1s,top .1s;
filter:drop-shadow(1px 1px 2px rgba(0,0,0,.3))`;
document.body.appendChild(c);
document.addEventListener('mousemove', e => {
c.style.left = e.clientX + 'px';
c.style.top = e.clientY + 'px';
});
});
}
// ── Overlay: subtítulos ──────────────────────────────────
async function injectSubtitleBar(page) { /* … (ver SKILL.md) */ }
async function showSubtitle(page, text) { /* … (ver SKILL.md) */ }
// ── Helpers de interacción ───────────────────────────────
async function moveAndClick(page, locator, label, delay = 800) { /* … */ }
async function typeSlowly(page, locator, text, label) { /* … 35ms/char */ }
async function ensureVisible(page, sel, label) { /* … */ }
// ── Ensayo (Phase 2) ─────────────────────────────────────
async function rehearse(browser) {
const ctx = await browser.newContext({ viewport: { width:1280, height:720 } });
const page = await ctx.newPage();
await page.goto(`${BASE_URL}/login`);
const steps = [
{ label: 'Email', sel: 'input[name="email"]' },
{ label: 'Password', sel: 'input[name="password"]' },
{ label: 'Login submit', sel: 'button[type="submit"]' },
/* … login → dashboard, luego …*/
{ label: 'Nueva Oportunidad', sel: 'button:has-text("Nueva Oportunidad")' },
{ label: 'Company', sel: 'input[name="company"]' },
{ label: 'Stage select', sel: 'select[name="stage"]' },
{ label: 'Notes textarea', sel: 'textarea[name="notes"]' },
{ label: 'Guardar', sel: 'button:has-text("Guardar oportunidad")' },
{ label: 'Nota seguimiento', sel: 'input[placeholder="Escribe una nota..."]' },
];
let allOk = true;
for (const s of steps) {
if (!await ensureVisible(page, s.sel, s.label)) allOk = false;
}
if (!allOk) { console.error('REHEARSAL FAILED'); process.exit(1); }
console.log('REHEARSAL PASSED — 9/9 selectores OK');
await ctx.close();
}
// ── Grabación (Phase 3) ──────────────────────────────────
(async () => {
const browser = await chromium.launch({ headless: true });
if (REHEARSAL) { await rehearse(browser); await browser.close(); return; }
const ctx = await browser.newContext({
recordVideo: { dir: VIDEO_DIR, size: { width:1280, height:720 } },
viewport: { width:1280, height:720 }
});
const page = await ctx.newPage();
try {
// ── Paso 1: Login ────────────────────────────────────
await page.goto(`${BASE_URL}/login`);
await injectCursor(page); await injectSubtitleBar(page);
await showSubtitle(page, 'Paso 1 — Inicio de sesión');
await typeSlowly(page, 'input[name="email"]', 'demo@flowdesk.io', 'Email');
await typeSlowly(page, 'input[name="password"]', 'Demo2025', 'Password');
await moveAndClick(page, 'button[type="submit"]', 'Login submit');
await page.waitForURL('**/dashboard');
await page.waitForTimeout(4000);
// ── Paso 2: Dashboard ────────────────────────────────
await injectCursor(page); await injectSubtitleBar(page);
await showSubtitle(page, 'Paso 2 — Pipeline de ventas');
// paneo de KPIs
await page.locator('.kpi-card').all().then(async els => {
for (const el of els) {
const box = await el.boundingBox();
if (box) await page.mouse.move(box.x+box.width/2, box.y+20, { steps:8 });
await page.waitForTimeout(600);
}
});
await page.waitForTimeout(1500);
// ── Paso 3: Nueva Oportunidad ────────────────────────
await showSubtitle(page, 'Paso 3 — Nueva oportunidad: Cultiva IA');
await moveAndClick(page, 'button:has-text("Nueva Oportunidad")', 'Nueva Oportunidad');
await typeSlowly(page, 'input[name="company"]', 'Cultiva IA', 'Empresa');
await typeSlowly(page, 'input[name="contact"]', 'Álvaro Gimeno', 'Contacto');
await typeSlowly(page, 'input[name="value"]', '12000', 'Valor');
// select: etapa
await moveAndClick(page, 'select[name="stage"]', 'Stage');
await page.selectOption('select[name="stage"]', { label: 'Propuesta enviada' });
await page.waitForTimeout(1200);
// select: comercial
await moveAndClick(page, 'select[name="assignee"]', 'Comercial');
await page.selectOption('select[name="assignee"]', { label: 'Sara López' });
await page.waitForTimeout(1200);
// ── Paso 4: Notas ────────────────────────────────────
await showSubtitle(page, 'Paso 4 — Añadir notas del cliente');
await typeSlowly(page, 'textarea[name="notes"]',
'Cliente interesado en automatización de contenido y agentes IA',
'Notas');
// ── Paso 5: Guardar ──────────────────────────────────
await showSubtitle(page, 'Paso 5 — Guardar oportunidad');
await moveAndClick(page, 'button:has-text("Guardar oportunidad")', 'Guardar');
await page.waitForURL('**/opportunities/**');
await page.waitForTimeout(3000);
// ── Paso 6: Nota de seguimiento ──────────────────────
await injectCursor(page); await injectSubtitleBar(page);
await showSubtitle(page, 'Paso 6 — Registrar nota de seguimiento');
await typeSlowly(page, 'input[placeholder="Escribe una nota..."]',
'Reunión de kick-off programada para el 25 de junio',
'Nota seguimiento');
await moveAndClick(page, 'button:has-text("Enviar")', 'Enviar nota');
await showSubtitle(page, '');
await page.waitForTimeout(3000);
} catch (err) {
console.error('DEMO ERROR:', err.message);
} finally {
await ctx.close();
const src = await page.video().path();
const dest = path.join(VIDEO_DIR, OUTPUT_NAME);
fs.copyFileSync(src, dest);
console.log('✓ Vídeo guardado:', dest);
await browser.close();
}
})();
Ejecutar ensayo
bash
node demo-oportunidad.cjs --rehearse
# → REHEARSAL PASSED — 9/9 selectores OK
Grabar vídeo
bash
node demo-oportunidad.cjs
# → ✓ Vídeo guardado: screenshots/demo-oportunidad-flowdesk.webm
5
Checklist Pre-Grabación
Todos los puntos verificados antes de ejecutar el script de grabación
✓
Discovery completado en 3 URLs✓
Ensayo: 9/9 selectores OK✓
Modo headless activado✓
Resolución 1280×720 configurada✓
Cursor re-inyectado tras cada navegación✓
Subtítulos en cada paso (Paso N — Acción)✓
moveAndClick en todos los clics✓
typeSlowly para inputs visibles✓
Sin catch silenciosos✓
Scroll suave para reveal✓
Pausas clave visibles para el espectador✓
textarea ≠ input (descubierto en fase 1)6
Artefacto de Salida
Vídeo WebM listo para incrustar en landing, onboarding o deck
demo-oportunidad-flowdesk.webm
screenshots/demo-oportunidad-flowdesk.webm — generado en /tmp por Playwright, copiado al destino estable
1280×720
Resolución
~105 s
Duración
WebM
Formato
6 pasos
Subtítulos
SVG
Cursor overlay