Communitygithub.com

ui-demo

Grave vídeos de demonstração de IU polidos usando o Playwright. Use quando o usuário solicitar a criação de um demo, passo a passo, gravação de tela ou tutorial de uma aplicação web. Produz vídeos WebM com cursor visível, ritmo natural e sensação profissional.

O que é ui-demo?

A habilidade **ui-demo** permite criar vídeos de demonstração de interfaces de usuário (UI) de alta qualidade usando o Playwright, com foco em produção profissional para documentação, integração de novos usuários ou apresentações. O processo é dividido em três fases: **Descobrir** (mapear elementos interativos da página), **Ensaio** (verificar todos os seletores sem gravação para evitar falhas silenciosas) e **Gravar** (criar o vídeo com sobreposição de cursor, legendas, ritmo natural e movimentos suaves). A habilidade suporta injeção de um cursor SVG, legendas estilizadas, digitação lenta e movimentos de mouse realistas, garantindo um resultado profissional. É recomendada para qualquer assistente de código (como Claude Code, Cursor, Codex) que precise gerar vídeos de demonstração de aplicações web, seguindo um fluxo narrativo de história (Entrada → Contexto → Ação → Variação → Resultado).

Funciona com~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/alhurwitz/alberts-agentic-arsenal/tree/HEAD/skills/ui-demo

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Gravador de Vídeos de Demonstração de UI

Grave vídeos de demonstração polidos de aplicações web usando a gravação de vídeo do Playwright com uma sobreposição de cursor injetada, ritmo natural e fluxo narrativo.

Quando Usar

  • O usuário pede um "vídeo de demonstração", "gravação de tela", "passo a passo" ou "tutorial"
  • O usuário quer mostrar visualmente um recurso ou fluxo de trabalho
  • O usuário precisa de um vídeo para documentação, integração de novos usuários ou apresentação para partes interessadas

Processo de Três Fases

Cada demonstração passa por três fases: Descobrir -> Ensaio -> Gravar. Nunca pule direto para a gravação.


Fase 1: Descobrir

Antes de escrever qualquer script, explore as páginas alvo para entender o que realmente existe.

Por quê

Você não pode criar um script para o que não viu. Campos podem ser <input> e não <textarea>, menus suspensos podem ser componentes personalizados e não <select>, e caixas de comentário podem suportar @menções ou #tags. Suposições quebram gravações silenciosamente.

Como

Navegue para cada página no fluxo e descarte seus elementos interativos:

// Execute isso para cada página no fluxo ANTES de escrever o script de demonstração
const fields = await page.evaluate(() => {
  const els = [];
  document.querySelectorAll('input, select, textarea, button, [contenteditable]').forEach(el => {
    if (el.offsetParent !== null) {
      els.push({
        tag: el.tagName,
        type: el.type || '',
        name: el.name || '',
        placeholder: el.placeholder || '',
        text: el.textContent?.trim().substring(0, 40) || '',
        contentEditable: el.contentEditable === 'true',
        role: el.getAttribute('role') || '',
      });
    }
  });
  return els;
});
console.log(JSON.stringify(fields, null, 2));

O que procurar

  • Campos de formulário: São <select>, <input>, menus suspensos personalizados ou comboboxes?
  • Opções de seleção: Descarte valores E textos das opções. Placeholders geralmente têm value="0" ou value="" que parece não vazio. Use Array.from(el.options).map(o => ({ value: o.value, text: o.text })). Pule opções onde o texto inclui "Selecionar" ou o valor é "0".
  • Texto rico: A caixa de comentário suporta @menções, #tags, markdown ou emoji? Verifique o texto do placeholder.
  • Campos obrigatórios: Quais campos bloqueiam o envio do formulário? Verifique required, * nos rótulos e tente enviar vazio para ver erros de validação.
  • Conteúdo dinâmico: Os campos aparecem depois que outros campos são preenchidos?
  • Rótulos de botões: Texto exato como "Enviar", "Enviar Solicitação" ou "Enviar".
  • Cabeçalhos de colunas de tabela: Para modais orientados por tabela, mapeie cada input[type="number"] para seu cabeçalho de coluna em vez de assumir que todas as entradas numéricas significam a mesma coisa.

Saída

Um mapa de campos para cada página, usado para escrever seletores corretos no script. Exemplo:

/purchase-requests/new:
  - Código Orçamentário: <select> (primeiro select na página, 4 opções)
  - Entrega Desejada: <input type="date">
  - Contexto: <textarea> (não input)
  - Tabela BOM: células editáveis inline com span.cursor-pointer -> padrão input
  - Enviar: <button> text="Enviar"

/purchase-requests/N (detalhe):
  - Comentário: <input placeholder="Digite uma mensagem..."> suporta tags @usuário e #PR
  - Enviar: <button> text="Enviar" (desabilitado até que o input tenha conteúdo)

Fase 2: Ensaio

Execute todos os passos sem gravação. Verifique se cada seletor resolve.

Por quê

Falhas silenciosas de seletores são a principal razão pela qual as gravações de demonstração quebram. O ensaio as detecta antes de você desperdiçar uma gravação.

Como

Use ensureVisible, um wrapper que registra e falha em voz alta:

async function ensureVisible(page, locator, label) {
  const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
  const visible = await el.isVisible().catch(() => false);
  if (!visible) {
    const msg = `FALHA NO ENSAIO: "${label}" não encontrado - seletor: ${typeof locator === 'string' ? locator : '(objeto locator)'}`;
    console.error(msg);
    const found = await page.evaluate(() => {
      return Array.from(document.querySelectorAll('button, input, select, textarea, a'))
        .filter(el => el.offsetParent !== null)
        .map(el => `${el.tagName}[${el.type || ''}] "${el.textContent?.trim().substring(0, 30)}"`)
        .join('\n  ');
    });
    console.error('  Elementos visíveis:\n  ' + found);
    return false;
  }
  console.log(`ENSAIO OK: "${label}"`);
  return true;
}

Estrutura do script de ensaio

const steps = [
  { label: 'Campo de email de login', selector: '#email' },
  { label: 'Botão de enviar login', selector: 'button[type="submit"]' },
  { label: 'Botão Nova Solicitação', selector: 'button:has-text("Nova Solicitação")' },
  { label: 'Selecionar Código Orçamentário', selector: 'select' },
  { label: 'Data de entrega', selector: 'input[type="date"]:visible' },
  { label: 'Campo de descrição', selector: 'textarea:visible' },
  { label: 'Botão Adicionar Item', selector: 'button:has-text("Adicionar Item")' },
  { label: 'Botão Enviar', selector: 'button:has-text("Enviar")' },
];

let allOk = true;
for (const step of steps) {
  if (!await ensureVisible(page, step.selector, step.label)) {
    allOk = false;
  }
}
if (!allOk) {
  console.error('ENSAIO FALHOU - corrija os seletores antes de gravar');
  process.exit(1);
}
console.log('ENSAIO APROVADO - todos os seletores verificados');

Quando o ensaio falha

  1. Leia o dump de elementos visíveis.
  2. Encontre o seletor correto.
  3. Atualize o script.
  4. Reexecute o ensaio.
  5. Prossiga apenas quando todos os seletores passarem.

Fase 3: Gravar

Somente após a descoberta e o ensaio passarem você deve criar a gravação.

Princípios de Gravação

1. Fluxo Narrativo

Planeje o vídeo como uma história. Siga a ordem especificada pelo usuário ou use este padrão:

  • Entrada: Faça login ou navegue até o ponto de partida
  • Contexto: Passeie pelos arredores para que os espectadores se orientem
  • Ação: Execute as principais etapas do fluxo de trabalho
  • Variação: Mostre um recurso secundário, como configurações, tema ou localização
  • Resultado: Mostre o resultado, confirmação ou novo estado

2. Ritmo

  • Após o login: 4s
  • Após a navegação: 3s
  • Após clicar em um botão: 2s
  • Entre etapas principais: 1.5-2s
  • Após a ação final: 3s
  • Atraso de digitação: 25-40ms por caractere

3. Sobreposição de Cursor

Injete um cursor de seta SVG que segue os movimentos do mouse:

async function injectCursor(page) {
  await page.evaluate(() => {
    if (document.getElementById('demo-cursor')) return;
    const cursor = document.createElement('div');
    cursor.id = 'demo-cursor';
    cursor.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
      <path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
    </svg>`;
    cursor.style.cssText = `
      position: fixed; z-index: 999999; pointer-events: none;
      width: 24px; height: 24px;
      transition: left 0.1s, top 0.1s;
      filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
    `;
    cursor.style.left = '0px';
    cursor.style.top = '0px';
    document.body.appendChild(cursor);
    document.addEventListener('mousemove', (e) => {
      cursor.style.left = e.clientX + 'px';
      cursor.style.top = e.clientY + 'px';
    });
  });
}

Chame injectCursor(page) após cada navegação de página porque a sobreposição é destruída na navegação.

4. Movimento do Mouse

Nunca teletransporte o cursor. Mova para o alvo antes de clicar:

async function moveAndClick(page, locator, label, opts = {}) {
  const { postClickDelay = 800, ...clickOpts } = opts;
  const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
  const visible = await el.isVisible().catch(() => false);
  if (!visible) {
    console.error(`AVISO: moveAndClick ignorou - "${label}" não visível`);
    return false;
  }
  try {
    await el.scrollIntoViewIfNeeded();
    await page.waitForTimeout(300);
    const box = await el.boundingBox();
    if (box) {
      await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 10 });
      await page.waitForTimeout(400);
    }
    await el.click(clickOpts);
  } catch (e) {
    console.error(`AVISO: moveAndClick falhou em "${label}": ${e.message}`);
    return false;
  }
  await page.waitForTimeout(postClickDelay);
  return true;
}

Cada chamada deve incluir um label descritivo para depuração.

5. Digitação

Digite visivelmente, não preenchimento instantâneo:

async function typeSlowly(page, locator, text, label, charDelay = 35) {
  const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
  const visible = await el.isVisible().catch(() => false);
  if (!visible) {
    console.error(`AVISO: typeSlowly ignorou - "${label}" não visível`);
    return false;
  }
  await moveAndClick(page, el, label);
  await el.fill('');
  await el.pressSequentially(text, { delay: charDelay });
  await page.waitForTimeout(500);
  return true;
}

6. Rolagem

Use rolagem suave em vez de saltos:

await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);

7. Passeio no Painel

Ao mostrar um painel ou página de visão geral, mova o cursor pelos elementos principais:

async function panElements(page, selector, maxCount = 6) {
  const elements = await page.locator(selector).all();
  for (let i = 0; i < Math.min(elements.length, maxCount); i++) {
    try {
      const box = await elements[i].boundingBox();
      if (box && box.y < 700) {
        await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
        await page.waitForTimeout(600);
      }
    } catch (e) {
      console.warn(`AVISO: panElements ignorou elemento ${i} (seletor: "${selector}"): ${e.message}`);
    }
  }
}

8. Legendas

Injete uma barra de legendas na parte inferior da janela de visualização:

async function injectSubtitleBar(page) {
  await page.evaluate(() => {
    if (document.getElementById('demo-subtitle')) return;
    const bar = document.createElement('div');
    bar.id = 'demo-subtitle';
    bar.style.cssText = `
      position: fixed; bottom: 0; left: 0; right: 0; z-index: 999998;
      text-align: center; padding: 12px 24px;
      background: rgba(0, 0, 0, 0.75);
      color: white; font-family: -apple-system, "Segoe UI", sans-serif;
      font-size: 16px; font-weight: 500; letter-spacing: 0.3px;
      transition: opacity 0.3s;
      pointer-events: none;
    `;
    bar.textContent = '';
    bar.style.opacity = '0';
    document.body.appendChild(bar);
  });
}

async function showSubtitle(page, text) {
  await page.evaluate((t) => {
    const bar = document.getElementById('demo-subtitle');
    if (!bar) return;
    if (t) {
      bar.textContent = t;
      bar.style.opacity = '1';
    } else {
      bar.style.opacity = '0';
    }
  }, text);
  if (text) await page.waitForTimeout(800);
}

Chame injectSubtitleBar(page) junto com injectCursor(page) após cada navegação.

Padrão de uso:

await showSubtitle(page, 'Passo 1 - Fazendo login');
await showSubtitle(page, 'Passo 2 - Visão geral do painel');
await showSubtitle(page, '');

Diretrizes:

  • Mantenha o texto da legenda curto, idealmente com menos de 60 caracteres.
  • Use o formato Passo N - Ação para consistência.
  • Limpe a legenda durante pausas longas onde a IU pode falar por si mesma.

Modelo de Script

'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-FEATURE.webm';
const REHEARSAL = process.argv.includes('--rehearse');

// Cole injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible e panElements aqui.

(async () => {
  const browser = await chromium.launch({ headless: true });

  if (REHEARSAL) {
    const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
    const page = await context.newPage();
    // Navegue pelo fluxo e execute ensureVisible para cada seletor.
    await browser.close();
    return;
  }

  const context = await browser.newContext({
    recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 720 } },
    viewport: { width: 1280, height: 720 }
  });
  const page = await context.newPage();

  try {
    await injectCursor(page);
    await injectSubtitleBar(page);

    await showSubtitle(page, 'Passo 1 - Fazendo login');
    // ações de login

    await page.goto(`${BASE_URL}/dashboard`);
    await injectCursor(page);
    await injectSubtitleBar(page);
    await showSubtitle(page, 'Passo 2 - Visão geral do painel');
    // passeio no painel

    await showSubtitle(page, 'Passo 3 - Fluxo de trabalho principal');
    // sequência de ações

    await showSubtitle(page, 'Passo 4 - Resultado');
    // revelação final
    await showSubtitle(page, '');
  } catch (err) {
    console.error('ERRO DE DEMONSTRAÇÃO:', err.message);
  } finally {
    await context.close();
    const video = page.video();
    if (video) {
      const src = await video.path();
      const dest = path.join(VIDEO_DIR, OUTPUT_NAME);
      try {
        fs.copyFileSync(src, dest);
        console.log('Vídeo salvo:', dest);
      } catch (e) {
        console.error('ERRO: Falha ao copiar o vídeo:', e.message);
        console.error('  Origem:', src);
        console.error('  Destino:', dest);
      }
    }
    await browser.close();
  }
})();

Uso:

# Fase 2: Ensaio
node demo-script.cjs --rehearse

# Fase 3: Gravar
node demo-script.cjs

Lista de Verificação Antes de Gravar

  • Fase de descoberta concluída
  • Ensaio aprovado com todos os seletores OK
  • Modo headless habilitado
  • Resolução definida para 1280x720
  • Sobreposições de cursor e legenda reinjetadas após cada navegação
  • showSubtitle(page, 'Passo N - ...') usado nas principais transições
  • moveAndClick usado para todos os cliques com rótulos descritivos
  • typeSlowly usado para entrada visível
  • Sem capturas silenciosas;

…

[truncado para tradução]

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

Habilidades Relacionadas