Files
jogos_matheus/.claude/skills/vitruvio-criar-dashboard-desktop/SKILL.md
T
2026-09-23 12:29:08 -03:00

73 KiB
Raw Blame History

name, description
name description
vitruvio-criar-dashboard-desktop Use when creating or adapting the DESKTOP form of a Vitruvio indicator dashboard panel — KPI cards, SVG charts (line/donut/bar), and a drill-down movimentação table, following the dashboard-contratos / dashboard-tecnicos pattern. Triggers: "criar dashboard", "criar indicador", "criar painel indicador", "novo dashboard de KPIs", "dashboard com gráficos", or adapting an existing panel XML to this pattern. Called by vitruvio-criar-indicador-dashboard for the desktop part; can also be invoked directly with an optional existing XML path. For the mobile shell of an existing indicator dashboard use vitruvio-criar-dashboard-mobile.

Criar Painel Indicador Dashboard

Todas as mensagens ao usuário devem ser em Português.

Você está criando ou adaptando um painel indicador no padrão Vitruvio com gráficos, KPIs e movimentação com drill-down. O padrão é baseado nos painéis dashboard-contratos e dashboard-tecnicos deste repositório.

Receba como argumento opcional o caminho de um arquivo XML existente (ex: panels/meu-painel/meu-painel-desktop.xml) para adaptar ao padrão. Sem argumento, cria um novo painel do zero.


LEITURA OBRIGATÓRIA AO INICIAR — CONTEXT.md

Esta é a primeira coisa a fazer, sempre, antes de qualquer outra ação.

Se o argumento fornecido aponta para um painel existente (ex: panels/meu-painel/meu-painel-desktop.xml), derive o diretório do painel e tente ler o arquivo de contexto:

panels/<panel-key>/CONTEXT.md

Se o CONTEXT.md existir:

  • Leia-o completo antes de abrir qualquer outro arquivo.
  • Use-o como fonte primária de verdade sobre o estado atual do painel: queries SQL reais, IDs dos campos, funções JS, pendências, histórico.
  • Só leia o <panel-key>-desktop.xml depois, para verificar se o CONTEXT.md ainda está em sincronia com o código. Se houver divergência, atualize o CONTEXT.md ao final.
  • Informe ao usuário: "Li o contexto do painel. Última modificação: [data]. Pendências encontradas: [lista do TODO]."

Se o CONTEXT.md não existir ainda:

  • Informe ao usuário que não há arquivo de contexto e que ele será criado ao final.
  • Leia o <panel-key>-desktop.xml completo para reconstruir o contexto.

Para novo painel (sem argumento): não há CONTEXT.md ainda — será criado na Fase 8.


Princípios obrigatórios — leia antes de qualquer coisa

Nunca invente — pergunte quando tiver dúvida

  • Não invente nomes de tabelas, colunas, views ou sequências. Se o usuário não fornecer o DDL ou a estrutura da tabela, pergunte. Uma pergunta custa menos do que um bug em produção.
  • Se o usuário mencionar uma tabela mas não os campos, pergunte quais campos existem nela antes de escrever SQL.
  • Se houver dúvida sobre tipo de dado (numérico, texto, data), pergunte.
  • Baseie-se sempre em código real. Os dois painéis de referência canônicos deste padrão estão empacotados junto com esta skill — leia-os por completo antes de escrever qualquer linha:
    • .claude/skills/vitruvio-criar-dashboard-desktop/dashboard-contrato-desktop.xml
    • .claude/skills/vitruvio-criar-dashboard-desktop/dashboard-tecnicos-desktop.xml
    • dashboard-contrato-desktop.xml é a versão mais recente/evoluída do padrão (ex: usa a global _mobPreviewMode que dashboard-tecnicos-desktop.xml ainda não tem) — em caso de divergência entre os dois, prefira o padrão do dashboard-contrato-desktop.xml.
    • .claude/skills/vitruvio-criar-dashboard-desktop/dashboard_ia.js documenta a API do dashLib (addSharedCss, loadPreferences, savePreferences, applyTheme, paleta de cores, etc.). É material de consulta apenas — leia-o para saber que métodos existem e como usá-los, mas nunca crie, copie ou registre um scripts/dashboard_ia.js no repositório de destino. O painel gerado apenas chama libService.loadScript('dashboard_ia'); a lib em si é externa ao escopo desta skill.

JavaScript — ES5 Rhino somente

// PROIBIDO: const, let, =>, template literals, destructuring, class, async/await, import/export
// OBRIGATÓRIO: var, function(){}, concatenação de strings, for(var i...)

Paleta de cores padrão (do script dashboard_ia)

Contexto Cor
Fundo dark (wrapper) #0f1923
Card/box dark #1a2733
Borda/grid dark #243447
Texto primário dark #fff / #ccc
Texto secundário dark #8899aa
Azul destaque #4a9edd
Verde positivo #2ecc71
Vermelho negativo #e74c3c
Laranja atenção #e67e22 / #f39c12
Fundo light (wrapper) #f0f2f5
Card/box light #ffffff
Texto primário light #1e293b / #334155
Texto secundário light #64748b

Nunca use cores fora desta paleta sem justificativa explícita do usuário.


FASE 0 — Verificar repositório e modo de operação

ls vitruvio.json 2>/dev/null && echo "OK" || echo "NOT_A_VITRUVIO_REPO"

Se NOT_A_VITRUVIO_REPO, pare e peça para o usuário entrar no diretório correto.

Se recebeu um arquivo como argumento:

  1. Leia o arquivo XML completo.
  2. Identifique o que já existe: formKey, layouts, campos, queries SQL, funções JS.
  3. Identifique o que está faltando em relação ao padrão (topBar com botões padrão, filterBar colapsável, detecção mobile, funções de sort/filter client-side, tema duplo, etc.).
  4. Documente o delta antes de fazer perguntas ao usuário.
  5. Informe o usuário o que será preservado e o que será alterado — peça confirmação antes de reescrever qualquer lógica de negócio existente.

Se não recebeu arquivo (novo painel):

Prossiga direto para a Fase 1.


FASE 1 — Coleta de informações básicas

Pergunte ao usuário (pode fazer tudo de uma vez em um bloco organizado):

Preciso de algumas informações para criar o painel. Responda o que souber — 
se não souber algo agora, pode deixar em branco e adicionar depois.

1. IDENTIFICAÇÃO
   a) Nome do painel (ex: "Dashboard de Rentabilidade")
   b) Chave do painel em kebab-case (ex: "dashboard-rentabilidade")
   c) Descrição curta (uma linha)
   d) Categoria no menu (ex: "Financeiro/Indicadores")

2. GRUPOS DE ACESSO
   Quais grupos de usuários terão acesso? (ex: "financeiro", "diretoria")
   Se não souber agora, pode deixar vazio.

FASE 2 — Coleta da fonte de dados

Esta é a fase mais crítica. Nunca avance sem ter as tabelas e campos reais.

Preciso entender de onde vêm os dados.

3. FONTE DE DADOS
   a) Quais tabelas ou views serão consultadas?
      (Se não tiver certeza dos nomes exatos, cole o DDL das tabelas ou 
       o resultado de um SELECT * LIMIT 1 para eu ver os campos)
   
   b) Como as tabelas se relacionam? (JOINs principais)
   
   c) Qual datasource usar? (padrão: vitruvio_producao)
   
   d) A query é PostgreSQL ou Oracle?
      (Isso afeta funções de data: EXTRACT vs TRUNC, CURRENT_DATE vs SYSDATE, etc.)

Independente da resposta em (d), o painel sempre detecta o banco em tempo de execução com banco.isOracle() (no run()) para tratar os ícones — ver seção 7.2.2. A pergunta (d) é só para escrever o SQL no dialeto certo.

Se o usuário não fornecer DDL suficiente, pergunte especificamente:

"Você pode colar o resultado de \d nome_da_tabela (PostgreSQL) ou DESCRIBE nome_da_tabela (Oracle) para eu ver os campos exatos?"


FASE 3 — Coleta dos filtros

4. FILTROS (sidebar lateral)
   
   Para cada filtro, me diga:
   - Nome exibido ao usuário
   - Tipo: ComboBox (lista fixa), DBComboBox (lista do banco), 
           DateField (data), NumericField (número), TwinColSelect (multi-seleção)
   - Se for DBComboBox: qual SQL gera as opções?
   - Valor padrão (se houver)
   - O filtro dispara re-render automático ao mudar, 
     ou precisa de um botão "Pesquisar"?
   
   Exemplos comuns:
   - Ano: DBComboBox com generate_series → auto-reload
   - Mês: ComboBox fixo com 12 meses → auto-reload  
   - Período: DateField De/Até + botão Pesquisar
   - Técnico: DBComboBox da tabela de usuários
   - Unidade/Filial: DBComboBox

Se o filtro vem de banco de dados, solicite o SQL de população antes de escrever.

Sempre que houver ao menos um filtro que precisa de botão "Pesquisar", esse botão — ao ser clicado — aplica os filtros e recolhe a filterBar ao estado inicial (fechada). É o comportamento padrão, não pergunte ao usuário; ver seção 7.2.1.


FASE 4 — Coleta dos KPIs e gráficos

5. KPIs (cards de valor no topo do dashboard)
   
   Quais valores numéricos importantes devem aparecer como KPI cards?
   Para cada um:
   - Rótulo (ex: "Total de Contratos", "MRR", "Chamados Abertos")
   - Cor do valor: azul (#4a9edd), verde (#2ecc71), vermelho (#e74c3c), 
                   laranja (#e67e22), branco (padrão)
   - Comparar com período anterior? (mostra diferença + / -)

6. GRÁFICOS
   
   Tipos disponíveis (todos gerados como SVG inline, sem bibliotecas externas):
   
   A) Linha temporal — evolução mês a mês ou dia a dia
      Preciso saber: eixo X (datas/meses), eixo Y (qual valor)
   
   B) Donut — proporção entre 2 ou 3 categorias
      Preciso saber: quais categorias e como calcular cada fatia
   
   C) Barras horizontais (ranking) — lista de itens ordenados por valor
      Preciso saber: o que é cada barra (item) e qual valor
   
   D) Barras verticais — comparação por período ou categoria
      Preciso saber: eixo X (categorias), eixo Y (valor), cores
   
   E) Barras empilhadas — composição de totais
      Preciso saber: as fatias e suas cores
   
   Para cada gráfico, informe:
   - Qual tipo (A/B/C/D/E)
   - Título do gráfico
   - Disposição no desktop: linha única, ou ao lado de outro gráfico?
     (ex: "Donut SLA ao lado de Barras por Tipo de Serviço")
   - SQL que gera os dados (ou descreva o que calcular)

FASE 5 — Coleta da Movimentação (tabela de detalhes)

7. MOVIMENTAÇÃO (visão tabular com linha base + drill-down)
   
   A) LINHA BASE (colunas visíveis na tabela principal)
      Para cada coluna:
      - Nome exibido (cabeçalho)
      - Campo no banco / expressão
      - Tipo: texto, número, data, badge colorido
      - Ordenável pelo usuário? (clique no cabeçalho)
      - Incluso no filtro de pesquisa global? (campo de busca)
      - Largura aproximada: pequena / média / grande
   
   B) DRILL-DOWN (linha de detalhe expandida ao clicar)
      Ao clicar em uma linha, o que aparece abaixo dela?
      - Campos adicionais não visíveis na linha principal
      - Sub-tabela com itens relacionados?
      - SQL adicional executado no servidor OU dados já carregados no HTML?
        (Para performance, prefira carregar tudo de uma vez se o volume permitir)
   
   C) FILTROS CLIENT-SIDE da movimentação
      - Campo de pesquisa de texto livre? (em quais colunas busca?)
      - Selects de filtro rápido? (ex: filtrar por Status, por Técnico)
   
   D) EXPORTAÇÃO CSV
      - Quais colunas exportar?
      - Nome do arquivo de download
   
   E) BADGES / CORES de linha
      Alguma linha tem cor de fundo especial por status?
      (ex: linha verde para "novo", vermelha para "cancelado")

FASE 6 — Validação antes de gerar

Após coletar todas as informações, apresente um resumo estruturado ao usuário:

## Resumo do painel "NOME"

**Identificação:** key = painel-key | Categoria: X

**Filtros (sidebar):**
- Ano: DBComboBox → auto-reload
- Mês: ComboBox fixo → auto-reload
- [outros]

**Dashboard — KPIs:**
1. Total de X → R$ 0,00 (azul)
2. [outros]

**Dashboard — Gráficos:**
1. Linha temporal: evolução mensal de X (largura total)
2. Donut: proporção A vs B (lado esquerdo) | Barras: ranking por cliente (lado direito)

**Movimentação:**
Colunas: [lista]
Drill-down: [descrição]
Filtro global: busca em [colunas]
Badges: [descrição]

**Pendências / dúvidas antes de gerar:**
- [listar qualquer ponto incerto]

Posso prosseguir com a geração?

Só gere código após o usuário confirmar o resumo.


FASE 7 — Geração do painel

7.1 — Estrutura obrigatória do XML

Todo painel indicador neste repositório deve ter exatamente esta estrutura:

rootLayout (VerticalLayout, 100% x 100%)
  ├── topBar (HorizontalLayout)
  │     ├── btnToggleFiltros  → colapsa/expande filterBar
  │     ├── btnDash           → muda currentView para 'dashboard'
  │     ├── btnMov            → muda currentView para 'movimentacao'
  │     ├── Label (spacer, expandRatio=1)
  │     ├── btnDevPreview     → preview mobile (vi_developer, visible=false)
  │     ├── btnExitPreview    → sair preview (visible=false)
  │     └── btnTheme          → alterna tema dark/light (chama doToggleTheme)
  │
  └── mainArea (HorizontalLayout, expandRatio=1)
        ├── filterBar (VerticalLayout, 220px)
        │     ├── btnThemeMob (visible=false — PRIMEIRO filho; aparece no mobile)
        │     ├── [campos de filtro do painel]
        │     ├── btnPesquisar (só se houver filtro sem auto-reload; ao clicar aplica
        │     │                 os filtros E recolhe a filterBar — ver seção 7.2.1)
        │     └── Label (spacer, expandRatio=1)
        │
        └── contentPanel (Panel, expandRatio=1)
              └── panelRoot (VerticalLayout)
                    └── scriptDashboard (ScriptWidget)

7.2 — initScript (run())

O run() no <initScript> da raiz do form deve sempre ter esta sequência:

function run() {
   var dashLib = libService.loadScript('dashboard_ia');
   var login = String(engine.getLoggedUser().getLogin());
   engine.setGlobalVariable('dashLib', dashLib);
   engine.setGlobalVariable('userLogin', login);

   var page = Packages.com.vaadin.ui.UI.getCurrent().getPage();
   dashLib.addSharedCss(page);
   addCss(page);  // CSS específico do painel

   var _topBar = engine.getLayout('topBar');
   if (_topBar) { _topBar.getRootComposition().addStyleName('dash-topbar'); }

   // ── Detecção de banco: Oracle x PostgreSQL (ver seção 7.2.2) ──
   // Precisa vir cedo, antes de qualquer setCaption com ícone, porque no Oracle
   // os ícones astrais (emoji) não renderizam e precisam de fallback BMP/ASCII.
   var _dbIco   = libService.loadScript('db');
   var _bancoIco = new _dbIco(_dbIco.VITRUVIO_DATASOURCE);
   var _isOracle = false;
   try { _isOracle = !!_bancoIco.isOracle(); } catch(e) {}
   engine.setGlobalVariable('isOracle', _isOracle);
   engine.setGlobalVariable('ico', ico);   // helper de ícone (seção 7.2.2)
   // aplicarIconesOracleNaTopBar() é chamado MAIS ABAIXO, depois de dashLib.applyTheme,
   // senão o applyTheme reescreve o caption do btnTheme por cima.

   // ── Detecção mobile ──
   if (engine.isGlobalVariableSet('_mobileRender')) {
      engine.setGlobalVariable('isMobile', true);
      var _rootLayout = engine.getLayout('rootLayout');
      if (_rootLayout) {
         var _rootLayoutComp = _rootLayout.getRootComposition();
         // 700px é só o placeholder inicial — o server não sabe a altura da tela do cliente.
         // O script abaixo troca por um valor proporcional a window.screen.height assim que a
         // página carrega (ver detalhes na skill vitruvio-criar-dashboard-mobile, seção 4.1).
         _rootLayoutComp.setHeight("700px");
         _rootLayoutComp.addStyleName('mobile-root-frame');
         page.getJavaScript().execute(
            "(function(){" +
            "var el=document.querySelector('.mobile-root-frame');" +
            "if(!el){return;}" +
            "var sh=window.screen.height;" +
            "var byMargin=sh-220;" +
            "var byRatio=Math.round(sh*0.80);" +
            "var h=Math.max(320,Math.min(byMargin,byRatio));" +
            "el.style.height=h+'px';" +
            "})();"
         );
      }
      if (_topBar) { _topBar.getRootComposition().addStyleName('mobile-topbar'); }
      var _filterBar = engine.getLayout('filterBar');
      if (_filterBar) { _filterBar.getRootComposition().setVisible(false); }
      var _btnThemeMob = engine.getWidgetController('btnThemeMob');
      if (_btnThemeMob) { _btnThemeMob.getButton().setVisible(true); }
      // Oculta btnTheme do topBar — no mobile só btnThemeMob (dentro da filterBar) é visível.
      // Sem isso o usuário vê dois botões de tema ao mesmo tempo.
      var _btnThemeTb = engine.getWidgetController('btnTheme');
      if (_btnThemeTb) { _btnThemeTb.getButton().setVisible(false); }
      page.getStyles().add(
         ".mobile-view .mov-scroll { height:520px !important; overflow-y:auto !important;" +
         " -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } "
      );
   }

   // ── Verificar vi_developer ──
   try {
      var _dbLib = libService.loadScript('db');
      var _bancoChk = new _dbLib(_dbLib.VITRUVIO_DATASOURCE);
      var _devRow = _bancoChk.queryRow(
         "SELECT COUNT(*) AS CNT FROM nauth.usuario u " +
         "INNER JOIN nauth.usuario_grupo ug ON u.usuario_id = ug.usuario_fk " +
         "INNER JOIN nauth.grupo g ON g.grupo_id = ug.grupo_fk " +
         "WHERE u.login = :login AND g.sigla = 'vi_developer'",
         { login: login }
      );
      var _isDev = _devRow && Number(_devRow.CNT) > 0;
      engine.setGlobalVariable('isDeveloper', _isDev);
      var _btnDevCtrl = engine.getWidgetController('btnDevPreview');
      if (_btnDevCtrl) { _btnDevCtrl.getButton().setVisible(_isDev); }
   } catch(e) {}

   // ── filterBar começa fechado ──
   var _filterBarInit = engine.getLayout('filterBar');
   if (_filterBarInit) { _filterBarInit.getRootComposition().setVisible(false); }
   var _btnToggle = engine.getWidgetController('btnToggleFiltros');
   if (_btnToggle) { _btnToggle.getButton().setCaption(ico('filtros_abrir')); }

   // ── Tema salvo nas preferências ──
   var prefs = dashLib.loadPreferences(login);
   var savedTheme = prefs.dark_mode ? 'dark' : 'light';
   engine.setGlobalVariable('theme', savedTheme);
   if (_filterBarInit) {
      _filterBarInit.getRootComposition().addStyleName(
         savedTheme === 'light' ? 'dash-filterbar-light' : 'dash-filterbar-dark'
      );
   }
   dashLib.applyTheme(savedTheme, page, engine.getWidgetController('btnTheme').getButton());

   // ── Ícones Oracle: reescreve os captions estáticos do topBar (seção 7.2.2) ──
   // Depois do applyTheme para o caption do btnTheme não ser sobrescrito.
   if (_isOracle) {
      aplicarIconesOracleNaTopBar();
      var _bTh = engine.getWidgetController('btnTheme');
      if (_bTh) { _bTh.getButton().setCaption(ico(savedTheme === 'dark' ? 'tema_escuro' : 'tema_claro')); }
   }

   // ── Registra doToggleTheme como global (chamado por btnTheme e btnThemeMob) ──
   engine.setGlobalVariable('doToggleTheme', doToggleTheme);
   engine.setGlobalVariable('enterMobilePreview', enterMobilePreview);
   engine.setGlobalVariable('exitMobilePreview', exitMobilePreview);

   // ── Registra função JS client-side de aplicar tema no HTML gerado ──
   page.getJavaScript().execute(
      "window.<panelId>ApplyTheme=function(){" +
      "var w=document.getElementById('<panel-id>-main');" +
      "if(!w)return;" +
      "if(document.body.classList.contains('theme-light-app'))w.classList.add('theme-light');" +
      "else w.classList.remove('theme-light');" +
      "};"
   );

   // ── Registra sort e filter client-side ──
   // (adaptar as colunas conforme a movimentação do painel)
   page.getJavaScript().execute(
      "window.movSort=function(c,t){ /* ... padrão dos outros painéis ... */ };" +
      "window.movFilter=function(){ /* ... adaptar colunas ... */ };" +
      "window.movExportCSV=function(){ /* ... adaptar colunas ... */ };"
   );

   // ── Valores padrão dos filtros (se aplicável) ──
   // ex: engine.getField('dtIni').setValue(...); engine.getField('nmfSla').setValue(3);
}

7.2.1 — Botão Pesquisar (padrão obrigatório quando há filtros sem auto-reload)

Filtros que disparam re-render sozinhos ao mudar (ComboBox de ano/mês) não precisam de botão. Mas sempre que houver filtros que só devem ser aplicados sob demanda (DateField De/Até, campos de texto, multi-seleção), a filterBar ganha um btnPesquisar.

Comportamento obrigatório do btnPesquisar: ao clicar, além de reaplicar os filtros (renderView()), a filterBar volta a ficar oculta, exatamente como estava ao abrir o painel. A sidebar de filtros é um overlay de trabalho — depois que o usuário escolheu o que queria, o valor está na tela do dashboard, não na sidebar. Deixá-la aberta só rouba largura do conteúdo e obriga um segundo clique no toggle. Fechar sozinha devolve o dashboard inteiro e deixa claro que a pesquisa foi aplicada.

<ButtonWidget id="btnPesquisar" caption="Pesquisar" style="BLUE" width="100%">
   <onClickScript language="JavaScript">
      <![CDATA[
         function run() {
            // 1) aplica os filtros
            var fn = engine.getGlobalVariable('renderView');
            if (fn) { fn(); }
            // 2) recolhe a filterBar ao estado inicial (fechada)
            var fb = engine.getLayout('filterBar');
            if (fb) { fb.getRootComposition().setVisible(false); }
            var bt = engine.getWidgetController('btnToggleFiltros');
            if (bt) {
               var icoFn = engine.getGlobalVariable('ico');
               bt.getButton().setCaption(icoFn ? icoFn('filtros_abrir') : '► Filtros');
            }
         }
      ]]>
   </onClickScript>
</ButtonWidget>

O caption de "fechada" tem que ser o mesmo que o run() e o btnToggleFiltros usam para o estado fechado — se você mudar o glifo num lugar, mude nos três, senão o botão de toggle passa a mentir sobre o estado.

7.2.2 — Ícones e Oracle (padrão obrigatório)

O ambiente Oracle corrompe caracteres fora do plano BMP — emojis e símbolos astrais (📱 U+1F4F1, 🤖 U+1F916, 💬 U+1F4AC, ⬇ U+2B07…) chegam à tela como ¿, ? ou quadrados, porque o charset/NLS do driver não os transporta. Símbolos BMP (◄ U+25C4, ► U+25BA, ☽ U+263D, ☀ U+2600, ✕ U+2715, ✓ U+2713) sobrevivem normalmente.

Por isso todo painel deve descobrir o banco uma única vez — banco.isOracle() num new db(...) — e, quando for Oracle, trocar os ícones astrais por um equivalente BMP ou ASCII. Nunca gere um emoji direto no caption ou no HTML sem passar por esse tratamento; o painel roda igual nos dois bancos e o mesmo XML pode ser importado num cliente Oracle e noutro PostgreSQL.

1 — Detecção no run() — já incluída no bloco da seção 7.2 (engine.setGlobalVariable('isOracle', ...)).

2 — Helper ico(chave) — declare no <initScript> da raiz (junto de run, doToggleTheme) e registre como global para o ScriptWidget usar o mesmo mapa:

function ico(chave) {
   var oracle = engine.getGlobalVariable('isOracle') == true;
   // pg  = glifo usado em PostgreSQL (pode ser emoji);  ora = fallback seguro no Oracle
   var MAP = {
      'filtros_abrir':  { pg: '► Filtros', ora: '► Filtros' }, // ► BMP, ok nos dois
      'filtros_fechar': { pg: '◄ Filtros', ora: '◄ Filtros' }, // ◄ BMP, ok nos dois
      'tema_escuro':    { pg: '☽',          ora: '☽' },        // ☽ BMP, ok nos dois
      'tema_claro':     { pg: '☀',          ora: '☀' },        // ☀ BMP, ok nos dois
      'fechar':         { pg: '✕',          ora: 'X' },             // ✕
      'preview_mobile': { pg: '📱',    ora: '[M]' },           // 📱 astral → fallback
      'csv':            { pg: '⬇ CSV',      ora: 'CSV' },           // ⬇ astral → fallback
      'robo':           { pg: '🤖',    ora: '[IA]' },          // 🤖 astral → fallback
      'chat':           { pg: '💬',    ora: 'Chat' }           // 💬 astral → fallback
   };
   var e = MAP[chave];
   if (!e) { return ''; }
   return oracle ? e.ora : e.pg;
}

Amplie o MAP com toda chave de ícone que o painel usar — a regra é: símbolo BMP pode repetir pg/ora; emoji/astral precisa de um ora em ASCII ou BMP.

3 — Reescrever os captions estáticos do XML — os ButtonWidget do topBar têm o caption declarado no XML (não passam pelo ico()). Quando Oracle, corrija-os no run():

function aplicarIconesOracleNaTopBar() {
   var mapa = {
      btnToggleFiltros: 'filtros_abrir',   // filterBar começa fechada
      btnDevPreview:    'preview_mobile',
      btnExitPreview:   'fechar'
      // btnTheme fica de fora: o caption dele depende do tema atual (☽/☀) e é
      // ajustado logo após esta chamada, no próprio run() (ver seção 7.2).
   };
   for (var id in mapa) {
      if (!mapa.hasOwnProperty(id)) { continue; }
      var c = engine.getWidgetController(id);
      if (c) { c.getButton().setCaption(ico(mapa[id])); }
   }
}

4 — No HTML gerado pelo ScriptWidget — sempre que injetar um ícone no HTML (toolbar da movimentação, botão CSV, FAB do chat), busque o helper: var ico = engine.getGlobalVariable('ico'); e use ico('csv') em vez de escrever ⬇ na string.

7.3 — doToggleTheme (padrão obrigatório)

function doToggleTheme() {
   var dashLib = engine.getGlobalVariable('dashLib');
   var login   = engine.getGlobalVariable('userLogin');
   var current = String(engine.getGlobalVariable('theme') || 'dark');
   var next    = current === 'dark' ? 'light' : 'dark';
   engine.setGlobalVariable('theme', next);
   try {
      var ui = Packages.com.vaadin.ui.UI.getCurrent();
      if (dashLib && ui) { dashLib.applyTheme(next, ui.getPage(), null); }
      if (dashLib && login) { dashLib.savePreferences(login, { dark_mode: next === 'dark' }); }
   } catch(e) {}
   var icoFn = engine.getGlobalVariable('ico');
   var capTema = next === 'dark'
      ? (icoFn ? icoFn('tema_escuro') : '☽')
      : (icoFn ? icoFn('tema_claro')  : '☀');
   var bT = engine.getWidgetController('btnTheme');
   if (bT) { bT.getButton().setCaption(capTema); }
   var bM = engine.getWidgetController('btnThemeMob');
   if (bM) { bM.getButton().setCaption(capTema); }
   var _fb = engine.getLayout('filterBar');
   if (_fb) {
      var fbComp = _fb.getRootComposition();
      if (next === 'light') { fbComp.removeStyleName('dash-filterbar-dark'); fbComp.addStyleName('dash-filterbar-light'); }
      else { fbComp.removeStyleName('dash-filterbar-light'); fbComp.addStyleName('dash-filterbar-dark'); }
   }
   try {
      Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute(
         "var el=document.getElementById('<panel-id>-main');" +
         "if(el){if('" + next + "'==='light')el.classList.add('theme-light');else el.classList.remove('theme-light');}"
      );
   } catch(e2) {}
   var fn = engine.getGlobalVariable('renderView');
   if (fn) fn();
}

7.4 — ScriptWidget: estrutura interna

O ScriptWidget scriptDashboard deve sempre ter:

// Carregados uma vez, acessíveis para todas as funções do script
var components = libService.loadScript('vaadinComponents');
var db         = libService.loadScript('db');
var banco      = new db(db.VITRUVIO_DATASOURCE);
var base       = null;

// Helpers de tema
function getTheme()    { return String(engine.getGlobalVariable('theme') || 'dark'); }
function isLight()     { return getTheme() === 'light'; }
function isMobileCtx() { return engine.isGlobalVariableSet('isMobile') || engine.isGlobalVariableSet('_mobileRender'); }

// Helper de ícone — mesmo mapa registrado no run() (seção 7.2.2). Use SEMPRE que
// injetar um ícone no HTML gerado (toolbar da movimentação, botão CSV, FAB do chat),
// para o painel funcionar igual em Oracle e PostgreSQL.
function ico(chave) {
   var fn = engine.getGlobalVariable('ico');
   return fn ? fn(chave) : '';
}
function isOracle() { return engine.getGlobalVariable('isOracle') == true; }

// Função de renderização obrigatória
function renderHtml(html) { /* padrão dos outros painéis */ }

// Funções de gráfico (apenas as que o painel usa)
function svgLineChart(...) { ... }
function svgDonut(...) { ... }
function svgHBar(...) { ... }

// View: dashboard
function renderDash() { ... }

// View: movimentação
function renderMov() { ... }

// Dispatcher principal — chamado por botões e filtros
function renderView() {
   var view = String(engine.getGlobalVariable('currentView') || 'dashboard');
   // Atualiza estilo do botão ativo
   var bD = engine.getWidgetController('btnDash');
   var bM = engine.getWidgetController('btnMov');
   if (bD) { var bd = bD.getButton(); bd.removeStyleName('dash-nav-active'); if (view === 'dashboard') bd.addStyleName('dash-nav-active'); }
   if (bM) { var bm = bM.getButton(); bm.removeStyleName('dash-nav-active'); if (view === 'movimentacao') bm.addStyleName('dash-nav-active'); }
   // Exibe o filtro de ordenação só na movimentação (se aplicável)
   var cmbOrdem = engine.getField('cmbOrdem');
   if (cmbOrdem) { cmbOrdem.setVisible(view === 'movimentacao'); }
   if (view === 'movimentacao') { renderMov(); }
   else { renderDash(); }
}

// Chama renderView no init do ScriptWidget
function init(mapa) {
   base = mapa.get('base');
   engine.setGlobalVariable('renderView', renderView);
   engine.setGlobalVariable('currentView', 'dashboard');
   renderView();
}

7.5 — Gráficos SVG disponíveis

Copie apenas os gráficos que o painel usar, adaptando cores e labels:

  • svgLineChart: linha temporal com área sombreada — do dashboard-contratos
  • svgDonut: donut de proporção 2 fatias — do dashboard-tecnicos
  • svgHBar: barras horizontais de ranking — do dashboard-tecnicos
  • svgBarChart (novo): barras verticais por categoria
  • svgStackedBar (novo): barras verticais empilhadas

Para gráficos novos, use as mesmas convenções de cores e variáveis isLight, colorGrid, colorLbl etc.

7.6 — Movimentação: padrões obrigatórios

Cabeçalho da tabela — colunas sortáveis:

<th data-l="Cliente" onclick="movSort(2,false)" style="cursor:pointer;" data-v="">
   Cliente
</th>

Células com data-v para sort e filter:

<td data-v="VALOR_SORT_NUMERICO">VALOR_EXIBIDO</td>

Linha base + linha detalhe (par de TRs):

<tr class="row-main" onclick="movToggleDetail(this)">
   <td data-v="...">...</td>
</tr>
<tr class="row-detail" style="display:none;">
   <td colspan="N">... conteúdo do drill-down ...</td>
</tr>

Toolbar da movimentação:

html += '<div class="mov-toolbar">';
html += '<input id="mov-fi" class="mov-filter-input" placeholder="Pesquisar..." oninput="movFilter()">';
// Selects de filtro rápido (se houver):
html += '<select id="mov-stf" class="mov-export-btn" onchange="movFilter()"><option value="">Status</option>...</select>';
html += '<span id="mov-rc" style="color:#8899aa;font-size:11px;white-space:nowrap;">' + linhas.length + ' reg.</span>';
html += '<button class="mov-export-btn" onclick="movExportCSV()">' + ico('csv') + '</button>';
html += '</div>';

ico('csv') devolve ⬇ CSV em PostgreSQL e CSV em Oracle (o ⬇ é astral e não renderiza no Oracle — ver 7.2.2). Vale para qualquer ícone que você injete no HTML da movimentação.

7.7 — CSS do painel

Sempre inclua no addCss(page):

var addCss = function(page) {
   var style =
      // Base dark
      ".dash-wrapper { background:#0f1923; padding:16px; font-family:Arial,Helvetica,sans-serif; box-sizing:border-box; width:100%; height:100%; overflow-y:auto; } " +
      ".dash-wrapper.mov-view { overflow:hidden; display:flex; flex-direction:column; } " +
      ".mov-scroll { flex:1; overflow-y:auto; min-height:0; } " +
      ".mov-scroll::-webkit-scrollbar { width:6px; } " +
      ".mov-scroll::-webkit-scrollbar-track { background:#0f1923; } " +
      ".mov-scroll::-webkit-scrollbar-thumb { background:#243447; border-radius:3px; } " +
      // KPIs
      ".dash-kpi-row { display:flex; gap:16px; margin-bottom:16px; } " +
      ".dash-kpi { background:#1a2733; border-radius:6px; padding:20px 24px; flex:1; min-width:180px; } " +
      ".dash-kpi-label { color:#8899aa; font-size:13px; margin-bottom:6px; } " +
      ".dash-kpi-value { font-size:26px; font-weight:bold; color:#fff; } " +
      ".dash-kpi-sm { background:#1a2733; border-radius:6px; padding:10px 14px; flex:1; min-width:0; } " +
      ".dash-kpi-sm .dash-kpi-label { color:#8899aa; font-size:12px; margin-bottom:4px; } " +
      ".dash-kpi-sm .dash-kpi-value { font-size:17px; font-weight:bold; } " +
      // Charts
      ".dash-chart-row { display:flex; gap:16px; margin-bottom:16px; } " +
      ".dash-chart-box { background:#1a2733; border-radius:6px; padding:16px; } " +
      ".dash-chart-title { color:#8899aa; font-size:13px; margin-bottom:10px; } " +
      // Tabela movimentação
      "table.mov-table { border-collapse:collapse; width:100%; font-size:12px; font-family:Arial; } " +
      "table.mov-table th { background:#243447; color:#8899aa; padding:7px 10px; text-align:left; border-bottom:1px solid #2a3a4a; white-space:nowrap; position:sticky; top:0; z-index:5; } " +
      "table.mov-table th.sortable { cursor:pointer; user-select:none; } " +
      "table.mov-table th.sortable:hover { background:#2a3a4a; } " +
      "table.mov-table td { padding:6px 10px; color:#ccc; border-bottom:1px solid #162030; } " +
      "table.mov-table tr.row-main { cursor:pointer; } " +
      "table.mov-table tr.row-main:hover td { background:#1e2d3d; } " +
      "table.mov-table tr.row-detail td { cursor:default; background:#0a1525; white-space:normal; } " +
      "table.mov-table tr.row-detail:hover td { filter:none; } " +
      // Toolbar movimentação
      ".mov-toolbar { display:flex; align-items:center; gap:8px; margin-bottom:8px; flex-wrap:wrap; } " +
      ".mov-filter-input { background:#1a2733; border:1px solid #243447; color:#ccc; padding:5px 10px; border-radius:4px; font-size:11px; flex:1; min-width:120px; outline:none; font-family:Arial; } " +
      ".mov-filter-input:focus { border-color:#4a9edd; } " +
      ".mov-export-btn { background:#1a2733; border:1px solid #243447; color:#8899aa; padding:5px 12px; border-radius:4px; font-size:11px; cursor:pointer; white-space:nowrap; font-family:Arial; } " +
      ".mov-export-btn:hover { border-color:#4a9edd; color:#4a9edd; } " +
      // Sidebar filterBar
      ".dash-filterbar-dark { background:#0d1921 !important; border-right:1px solid #1a2a38; } " +
      ".dash-filterbar-light { background:#f1f5f9 !important; border-right:1px solid #e2e8f0; } " +
      ".dash-filterbar-dark .v-caption { color:#8899aa !important; font-size:12px !important; } " +
      ".dash-filterbar-light .v-caption { color:#64748b !important; font-size:12px !important; } " +
      // Tema light — overrides
      ".theme-light.dash-wrapper { background:#f0f2f5; } " +
      ".theme-light .dash-kpi { background:#ffffff; box-shadow:0 1px 3px rgba(0,0,0,.08); } " +
      ".theme-light .dash-kpi-label { color:#64748b; } " +
      ".theme-light .dash-kpi-value { color:#1e293b; } " +
      ".theme-light .dash-chart-box { background:#ffffff; box-shadow:0 1px 3px rgba(0,0,0,.08); } " +
      ".theme-light .dash-chart-title { color:#64748b; } " +
      ".theme-light table.mov-table th { background:#f8fafc; color:#64748b; border-bottom:1px solid #e2e8f0; } " +
      ".theme-light table.mov-table td { color:#334155; border-bottom:1px solid #e2e8f0; } " +
      ".theme-light table.mov-table tr.row-main:hover td { background:#f1f5f9; } " +
      ".theme-light table.mov-table tr.row-detail td { background:#f8fafc; } " +
      ".theme-light .mov-filter-input { background:#ffffff; border-color:#e2e8f0; color:#334155; } " +
      ".theme-light .mov-export-btn { background:#ffffff; border-color:#e2e8f0; color:#64748b; } " +
      ".theme-light .mov-export-btn:hover { border-color:#2563eb; color:#2563eb; } " +
      // Preview mobile frame
      ".preview-mobile-frame { margin:0 auto !important; border:2px solid #4a9edd !important; border-radius:12px !important; overflow:hidden !important; box-shadow:0 0 40px rgba(74,158,221,0.25) !important; } " +
      ".preview-exit-btn { position:fixed !important; top:10px !important; right:12px !important; z-index:99999 !important; background:#e74c3c !important; border:none !important; border-radius:6px !important; padding:7px 16px !important; font-weight:bold !important; box-shadow:0 2px 12px rgba(0,0,0,0.4) !important; cursor:pointer !important; } " +
      ".preview-exit-btn .v-button-caption { color:#fff !important; font-size:12px !important; } " +
      // Mobile overrides
      ".mobile-view.dash-wrapper { height:auto !important; overflow:visible !important; padding-bottom:24px; } " +
      ".mobile-view .dash-kpi-row { flex-direction:column; gap:8px; } " +
      ".mobile-view .dash-chart-row { flex-direction:column; } " +
      ".mobile-view .dash-kpi { min-width:unset; width:100%; box-sizing:border-box; } " +
      ".mobile-view .dash-kpi-sm { min-width:unset; width:100%; box-sizing:border-box; } " +
      // Mobile — fontes maiores (tela menor exige texto mais legível)
      ".mobile-view .dash-kpi-label { font-size:15px !important; } " +
      ".mobile-view .dash-kpi-sm .dash-kpi-label { font-size:13px !important; } " +
      ".mobile-view .dash-kpi-value { font-size:22px !important; } " +
      ".mobile-view .dash-kpi-sm .dash-kpi-value { font-size:16px !important; } " +
      ".mobile-view .dash-chart-title { font-size:14px !important; } " +
      ".mobile-view .mov-filter-input { font-size:13px !important; } " +
      ".mobile-view .mov-export-btn { font-size:13px !important; } " +
      ".mobile-view table.mov-table td { font-size:13px !important; } " +
      ".mobile-view table.mov-table th { font-size:13px !important; } " +
      // [adicionar aqui CSS mobile específico do painel]
      "";
   page.getStyles().add(style);
};

7.8 — enterMobilePreview / exitMobilePreview

Padrão real (confirmado em dashboard-contrato-desktop.xml): o preview de desenvolvedor não usa uma altura fixa tipo 700px/3000px como o _mobileRender real (seção 4.1 da skill vitruvio-criar-dashboard-mobile) — usa altura natural. base.setHeight(null) faz o ScriptWidget crescer para o tamanho do conteúdo, o Panel (contentPanel) enxerga o overflow real e rola sozinho. Isso é mais simples que qualquer técnica de "colapso via JS" e funciona porque o preview roda inteiramente dentro do navegador desktop — não há altura de tela de celular real para calcular.

Em enterMobilePreview:

function enterMobilePreview() {
   engine.setGlobalVariable('isMobile', true);
   // base com height null → cresce ao tamanho do conteúdo → Panel enxerga overflow e rola
   try { base.setHeight(null); } catch(e) {}
   var _rl = engine.getLayout('rootLayout');
   if (_rl) {
      var _rlComp = _rl.getRootComposition();
      _rlComp.setWidth("375px");
      _rlComp.addStyleName('preview-mobile-frame');
   }
   // ... (filterBar, botões de tema, btnDevPreview, btnExitPreview)
   renderView();
}

Em exitMobilePreview:

function exitMobilePreview() {
   engine.unsetGlobalVariable('isMobile');
   // Restaura base para height 100% (modo desktop normal)
   try { base.setHeight("100%"); } catch(e) {}
   var _rl = engine.getLayout('rootLayout');
   if (_rl) {
      var _rlComp = _rl.getRootComposition();
      _rlComp.setWidth("100%");
      _rlComp.removeStyleName('preview-mobile-frame');
   }
   // ... (filterBar, botões de tema, btnDevPreview)
   renderView();
}

Controle de botões de tema — obrigatório em ambas as funções:

Em enterMobilePreview, antes de renderView():

var _btnThMob = engine.getWidgetController('btnThemeMob');
if (_btnThMob) _btnThMob.getButton().setVisible(true);
var _btnThTb = engine.getWidgetController('btnTheme');
if (_btnThTb) _btnThTb.getButton().setVisible(false);

Em exitMobilePreview, antes de renderView():

var _btnThMob = engine.getWidgetController('btnThemeMob');
if (_btnThMob) _btnThMob.getButton().setVisible(false);
var _btnThTbExit = engine.getWidgetController('btnTheme');
if (_btnThTbExit) _btnThTbExit.getButton().setVisible(true);

renderHtml distingue preview de mobile real / desktop — só o preview usa altura natural; mobile real (_mobileRender) e desktop continuam setSizeFull():

function renderHtml(html) {
   var comp = components.html(html);
   // isPreviewMode: preview de dev (isMobile=true SEM _mobileRender). Mobile WebView real
   // (_mobileRender=true) e desktop normal continuam usando setSizeFull() — só o preview
   // precisa de altura natural para o Panel rolar corretamente dentro do navegador do dev.
   var isPreviewMode = engine.isGlobalVariableSet('isMobile') && !engine.isGlobalVariableSet('_mobileRender');
   if (isPreviewMode) {
      try { base.setHeight(null); } catch(e) {}
      comp.setWidth("100%");
   } else {
      comp.setSizeFull();
   }
   base.removeAllComponents();
   base.addComponent(comp);
   if (!isPreviewMode) { base.setExpandRatio(comp, 1.0); }
   // JS após o render: aplica tema e, apenas dentro do frame de preview
   // (.mobile-root-frame — NUNCA redimensionado, sua altura já foi fixada uma única vez pelo
   // bloco _mobileRender do run() via window.screen.height), libera overflow:visible nos pais
   // intermediários para o scroll externo funcionar sem "arrastar" a topBar junto.
   Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute(
      "setTimeout(function(){" +
      "if(typeof meuPainelApplyTheme==='function')meuPainelApplyTheme();" +
      "var el=document.getElementById('meu-painel-main');" +
      "if(el){" +
      "var p=el,n=0;" +
      "while(p&&n<30){p=p.parentElement;n++;" +
      "if(!p){break;}" +
      "if(p.classList&&p.classList.contains('mobile-root-frame')){break;}" +
      "var cs=window.getComputedStyle(p);" +
      "if(cs&&(cs.overflowY==='auto'||cs.overflowY==='scroll'||cs.overflowY==='hidden')){p.style.overflowY='visible';}" +
      "if(cs&&(cs.overflow==='auto'||cs.overflow==='scroll'||cs.overflow==='hidden')){p.style.overflow='visible';}" +
      "}" +
      "}" +
      "},400);"
   );
}

Nota: as funções que geram HTML (desenharDashboard/renderDash etc.) já checam isMobileCtx()/mobileClass com isGlobalVariableSet('isMobile') || isGlobalVariableSet('_mobileRender') — o dashboard-contrato-desktop.xml real adiciona uma terceira condição, _mobPreviewMode, que nenhuma das duas funções acima define (provavelmente um hook para um mecanismo externo). Inclua-a por segurança/compatibilidade, mas não é necessário defini-la para o preview de dev funcionar: engine.isGlobalVariableSet('isMobile') || engine.isGlobalVariableSet('_mobileRender') || engine.isGlobalVariableSet('_mobPreviewMode').

7.10 — Padrões obrigatórios para Mobile

Botão de tema (claro/escuro) — posição no mobile

No mobile, o btnThemeMob deve sempre aparecer junto à barra de filtros, no topo da filterBar — antes dos campos de filtro. Nunca posicionar o botão de tema isolado no rodapé da sidebar no mobile.

Estrutura correta da filterBar no mobile:

filterBar (VerticalLayout)
  ├── btnThemeMob  ← PRIMEIRO, antes dos filtros
  ├── [campos de filtro]
  ├── [botão Pesquisar, se houver]
  └── Label spacer (expandRatio=1)

No form XML, posicione btnThemeMob como primeiro filho da filterBar. No run(), ao detectar mobile, torne-o visível:

var _btnThemeMob = engine.getWidgetController('btnThemeMob');
if (_btnThemeMob) { _btnThemeMob.getButton().setVisible(true); }

Cards (KPIs e blocos) — layout vertical obrigatório no mobile

No mobile, todos os cards devem ser empilhados verticalmente, um embaixo do outro, ocupando 100% da largura:

  • Nunca use flex-direction: row para cards no mobile
  • Cada card ocupa width: 100%; box-sizing: border-box
  • Se houver 3 cards de mesmo contexto (ex: 3 KPIs do mesmo grupo temático), renderize-os juntos em sequência vertical — eles preenchem o espaço de forma coesa
  • Use isMobileCtx() para condicionar o HTML gerado:
if (isMobileCtx()) {
   // Cards empilhados, 100% largura
   html += '<div style="display:flex;flex-direction:column;gap:8px;width:100%;">';
   html += '<div class="dash-kpi" style="width:100%;">...</div>';
   html += '<div class="dash-kpi" style="width:100%;">...</div>';
   html += '<div class="dash-kpi" style="width:100%;">...</div>';
   html += '</div>';
} else {
   // Desktop: linha horizontal
   html += '<div class="dash-kpi-row">';
   html += '<div class="dash-kpi">...</div>';
   html += '</div>';
}

Fontes maiores no mobile

Gere os tamanhos de fonte condicionalmente com isMobileCtx() sempre que o tamanho estiver em style="" inline — o CSS da classe .mobile-view já cobre os casos de classe:

var fsLabel = isMobileCtx() ? '15px' : '13px';
var fsValue = isMobileCtx() ? '22px' : '26px';
html += '<div class="dash-kpi-label" style="font-size:' + fsLabel + ';">' + label + '</div>';
html += '<div class="dash-kpi-value" style="font-size:' + fsValue + ';">' + value + '</div>';
Elemento Desktop Mobile
Label de KPI 13px 15px
Valor de KPI 26px 22px
KPI sm label 12px 13px
KPI sm valor 17px 16px
Título de gráfico 13px 14px
Texto de tabela 12px 13px
Toolbar (input/btn) 11px 13px

Listas e gráficos — alturas fixas e scroll obrigatórios no mobile

Regra crítica: no mobile, toda lista de cards e todo gráfico de barras com N variável de itens deve ter altura fixa e overflow-y:auto. Sem isso, o conteúdo cresce infinitamente e o frame não rola corretamente.

O CSS DEVE estar no addCss() — não apenas no bloco _mobileRender. Se ficar só em _mobileRender, o preview mobile do desenvolvedor não funciona (porque isMobile=true mas _mobileRender não está setado).

// ✅ CERTO — em addCss(), funciona em preview E mobile real
var addCss = function(page) {
   var style =
      // ...outros estilos...
      ".mobile-view.dash-wrapper { height:auto !important; overflow:visible !important; padding-bottom:24px; } " +
      ".mobile-view .mov-scroll { max-height:520px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } " +
      ".mobile-view .tec-mob-scroll { max-height:420px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } " +
      ".mobile-view .hbar-scroll { max-height:280px !important; overflow-y:auto !important; -webkit-overflow-scrolling:touch !important; } " +
      // ...

Classes de scroll padrão:

Classe Altura Uso
.mov-scroll max 520px Container da lista de movimentação (cresce com o conteúdo, scroll acima de 520px)
.tec-mob-scroll max 420px Container de lista de técnicos/ranking (cresce com o conteúdo, scroll acima de 420px)
.hbar-scroll max 280px Container de gráfico de barras horizontais (svgHBar)

Padrão de uso nas funções de render mobile:

// ❌ ERRADO — sem scroll container, conteúdo cresce infinito
if (mobileClass) {
   html += '<div id="lista-cards">';
   for (var i = 0; i < itens.length; i++) {
      html += '<div class="mov-card">...</div>';
   }
   html += '</div>';
}

// ✅ CERTO — sempre com scroll container de altura fixa
if (mobileClass) {
   // Barra de pesquisa FORA do scroll (fica fixo no topo)
   html += '<div class="mob-search-bar">...</div>';
   // Lista DENTRO do scroll
   html += '<div class="mov-scroll">';
   for (var i = 0; i < itens.length; i++) {
      html += '<div class="mov-card">...</div>';
   }
   if (itens.length === 0) html += '<div style="padding:28px;text-align:center;color:#556677;">Sem dados</div>';
   html += '</div>';
}

Para lista de técnicos/ranking com botões de ordenação:

// O header com botões de sort fica FORA do scroll (não some ao rolar)
html += '<div class="dash-chart-box" style="margin-bottom:4px;">';
html += '<div style="display:flex;align-items:center;justify-content:space-between;">';
html += '<span>Técnicos</span>';
html += '<div><button onclick="tecSortCards(\'total\')">Total</button></div>';
html += '</div></div>';
// A lista fica DENTRO do scroll
html += '<div class="tec-mob-scroll">';
html += '<div id="tec-mob-list" data-sc="total" data-sd="-1">';
for (var i = 0; i < tecArr.length; i++) {
   html += '<div class="tec-mob-card" ...>...</div>';
}
html += '</div>';
html += '</div>';

Para gráficos de barras horizontais (svgHBar):

// ❌ ERRADO — gráfico sem limitação de altura no mobile
html += '<div class="dash-chart-box">';
html += svgHBar(dados, '#4a9edd');
html += '</div>';

// ✅ CERTO — gráfico dentro de container com max-height + scroll
html += '<div class="dash-chart-box">';
html += '<div class="dash-chart-title" style="font-size:13px;">Título</div>';
html += '<div class="hbar-scroll">' + svgHBar(dados, '#4a9edd') + '</div>';
html += '</div>';

Lib e tema — sempre carregados ao entrar na tela

Obrigatório em todo painel:

  1. Sempre carregar dashLib = libService.loadScript('dashboard_ia') no run()
  2. Sempre aplicar o tema salvo nas preferências antes de qualquer render: dashLib.loadPreferences(login) determina o tema inicial — nunca assume dark por padrão sem verificar
  3. O tema deve ser aplicado em dois lugares:
    • Vaadin (server-side): dashLib.applyTheme(savedTheme, page, btnTheme)
    • HTML gerado (client-side): via window.<panelId>ApplyTheme() registrado na página
// CORRETO — sempre verificar preferência salva
var prefs = dashLib.loadPreferences(login);
var savedTheme = prefs.dark_mode ? 'dark' : 'light';  // nunca assuma 'dark' sem verificar
engine.setGlobalVariable('theme', savedTheme);
dashLib.applyTheme(savedTheme, page, engine.getWidgetController('btnTheme').getButton());

7.11 — (Opcional) Assistente de IA "Pietro" (chatBar)

Só inclua este bloco se o usuário pedir explicitamente um assistente de IA/chat no dashboard. Não é parte obrigatória do padrão de indicador — é um add-on visto em dashboard-contrato-desktop.xml e dashboard-tecnicos-desktop.xml (ambos os painéis de referência o incluem, mas o padrão "indicador dashboard" em si — KPIs, gráficos, movimentação — funciona plenamente sem ele).

Dependência externa: o chat roda sobre um script IA_chat_agente_ia que não é criado por esta skill — precisa já existir no repositório (é uma lib de chat genérica, reutilizável por qualquer painel). Se o usuário pedir o Pietro mas esse script não existir no repo, avise antes de prosseguir.

Peças do padrão (nomes fixos — mantenha-os, o script IA_chat_agente_ia espera exatamente isso):

  1. Botão no topBar — btnToggleChat, abre/fecha a sidebar chatBar:
<ButtonWidget id="btnToggleChat" caption="Pietro &#x25C4;" description="Abrir/fechar assistente Pietro">
   <onClickScript language="JavaScript">
      <![CDATA[
         function run() {
            var cb = engine.getLayout('chatBar');
            if (!cb) return;
            var comp = cb.getRootComposition();
            var visible = comp.isVisible();
            comp.setVisible(!visible);
            var btn = engine.getWidgetController('btnToggleChat').getButton();
            btn.setCaption(visible ? 'Pietro ◄' : '► Pietro');
         }
      ]]>
   </onClickScript>
</ButtonWidget>
  1. Sidebar chatBar (irmã de mainArea, dentro de rootLayout, 320px, oculta por padrão) — header com título + botão fechar, área de mensagens rolável, barra de input:
<VerticalLayout id="chatBar" width="320px" height="100%" visible="false" spacing="false" margin="false" styleName="dash-chatbar-dark">
   <HorizontalLayout id="chatBarHeader" width="100%" spacing="false" margin="true">
      <Label contentMode="HTML" width="100%" expandRatio="1">
         <value><![CDATA[<span style="font-weight:bold;font-size:13px;color:#f78259;">&#129302; Pietro</span>]]></value>
      </Label>
      <ButtonWidget id="btnFecharChat" caption="&#x2715;" description="Fechar Pietro" visible="false">
         <onClickScript language="JavaScript">
            <![CDATA[
               function run() {
                  engine.getLayout('chatBar').getRootComposition().setVisible(false);
                  var btn = engine.getWidgetController('btnToggleChat');
                  if (btn) btn.getButton().setCaption('Pietro ◄');
               }
            ]]>
         </onClickScript>
      </ButtonWidget>
   </HorizontalLayout>
   <VerticalLayout id="caScope" width="100%" height="100%" expandRatio="1" spacing="false" margin="false">
      <VerticalLayout id="caSurface" width="100%" height="100%" expandRatio="1" spacing="false" margin="false">
         <ScrollPanel id="caPanel" width="100%" height="100%" expandRatio="1">
            <VerticalLayout id="caMsgs" width="100%" margin="false" spacing="false" />
         </ScrollPanel>
      </VerticalLayout>
   </VerticalLayout>
   <HorizontalLayout id="caInputBar" width="100%" spacing="true" margin="true">
      <TextArea id="caTexto" width="100%" expandRatio="1" type="string" rows="2" />
      <ButtonWidget id="btnChatEnviar" caption="Enviar" defaultIcon="SEND" modifierKey="CTRL" keyCode="ENTER" align="MIDDLE_CENTER">
         <onClickScript language="JavaScript">
            <![CDATA[
               importClass(Packages.com.vaadin.ui.UI);
               function run() {
                  var ca = engine.getGlobalVariable('chatAgente');
                  if (ca) ca.enviar();
                  UI.getCurrent().getPage().getJavaScript().execute(
                     "setTimeout(function(){var ta=document.querySelector('textarea');" +
                     "if(ta){ta.value='';ta.dispatchEvent(new Event('input',{bubbles:true}));ta.focus();}},0);"
                  );
               }
            ]]>
         </onClickScript>
      </ButtonWidget>
   </HorizontalLayout>
</VerticalLayout>

<!-- Exigida pelo script IA_chat_agente_ia; o conteúdo real fica na sidebar chatBar -->
<WindowLayout id="wndChatAgente" windowHeight="200px" windowWidth="300px" windowResizable="false" windowClosable="false" windowModal="false">
   <VerticalLayout width="100%" height="100%" />
</WindowLayout>
  1. FAB mobile — no _mobileRender/preview, a sidebar web some e um botão flutuante abre o chat nativo do app (React Native) reaproveitando o mesmo contexto:
<!-- some no desktop; fica visible=true dentro do bloco _mobileRender/enterMobilePreview -->
<ButtonWidget id="btnChatFabMobile" caption="&#128172;" description="Abrir assistente Pietro" visible="false">
   <onClickScript language="JavaScript">
      <![CDATA[
         function run() {
            var chatConfig = engine.getGlobalVariable('nativeChatConfig') || {};
            var st = engine.getGlobalVariable('chatAgenteState');
            var dadosContexto = (st && st.config && st.config.dados_contexto) ? st.config.dados_contexto : null;
            // ... dispara o chat nativo (postMessage) com chatConfig + dadosContexto
         }
      ]]>
   </onClickScript>
</ButtonWidget>

No bloco _mobileRender do run() e em enterMobilePreview/exitMobilePreview: oculte btnToggleChat e mostre btnChatFabMobile (e vice-versa ao sair do preview) — mesmo tratamento dado a btnTheme/btnThemeMob.

Os ícones do Pietro são todos astrais (🤖 U+1F916, 💬 U+128172, Pietro ◄…). Adicione as chaves ao MAP do ico() (seção 7.2.2) — ex: 'chat_fab': { pg: '💬', ora: 'IA' }, 'pietro_abrir'/'pietro_fechar' — e, quando isOracle, reescreva os captions de btnToggleChat, btnFecharChat e btnChatFabMobile no run() (junte-os ao aplicarIconesOracleNaTopBar()).

  1. Inicialização do agente — no <afterFormRenderScript> da raiz do form (roda depois do run(), garantindo que chatBar já tem o tema certo aplicado):
function run() {
   try {
      var dashLib = libService.loadScript('dashboard_ia');
      var login = String(engine.getLoggedUser().getLogin());
      var prefs = dashLib.loadPreferences(login);
      var savedTheme = prefs.dark_mode ? 'dark' : 'light';

      var chatAgente = libService.loadScript('IA_chat_agente_ia');
      engine.setGlobalVariable('chatAgente', chatAgente);

      // Guardado à parte (não só passado pro abrir()) pro FAB mobile reaproveitar
      // exatamente o mesmo contexto/prompt do chat web, sem duplicar o texto.
      var chatConfig = {
         contexto: '<panel-key>',
         titulo: 'Pietro',
         nomeAgente: 'Pietro',
         tema: savedTheme,
         saudacao: 'Olá! Posso ajudar com análise dos dados deste dashboard.',
         prompt: {
            persona: 'Você é Pietro, assistente de análise de dados do <Nome do Painel>.',
            tarefa: 'Ajude o usuário a interpretar os KPIs, gráficos e a movimentação exibidos no painel agora. Use SEMPRE os dados de "secoes" em dados_contexto como sua única fonte de verdade.',
            regras: 'Responda apenas com os dados de "secoes" da view ATUAL (dados_contexto.view) — nunca misture com outra view, outro período/filtro, ou mensagens anteriores. Se faltar dado, diga que não está disponível no contexto atual. Nunca estime ou invente valor.',
            saida: 'Seja direto e objetivo. Ao citar um dado, use o mesmo título de seção exibido na tela.'
         }
      };
      engine.setGlobalVariable('nativeChatConfig', chatConfig);
      chatAgente.abrir(chatConfig);
      chatAgente.fechar();
   } catch(e) {}
}
  1. Convenção de contexto ao vivo — no ScriptWidget, um helper atualizarContextoIA(view, filtros, secoes) substitui dados_contexto por inteiro a cada render (nunca mescla — reflete só a view atual):
function atualizarContextoIA(view, filtros, secoes) {
   var st = engine.getGlobalVariable('chatAgenteState');
   if (!st || !st.config) return;
   st.config.dados_contexto = { view: view, filtros: filtros, secoes: secoes };
}

Chame no fim de cada função de view (desenharDashboard/desenharMovimentacao etc.), logo antes do renderHtml(html). secoes é um array de { titulo, tipo, dados } — o titulo deve ser exatamente o mesmo texto exibido na tela (mesmo título do KPI/gráfico/card), para o agente nunca divergir do que o usuário vê:

atualizarContextoIA('dashboard', { ano: ano, mes: mes }, [
   { titulo: 'Total de Contratos', tipo: 'kpi', dados: { valor: total, valor_formatado: formatBRL(total) } },
   { titulo: 'Evolução Mês a Mês — ' + ano, tipo: 'grafico_linha', dados: dadosMeses },
   { titulo: 'Movimentação', tipo: 'tabela', dados: { total: linhas.length, itens: linhasCtxIA } }
]);
renderHtml(html);
  1. CSS — adicione ao addCss():
".dash-chatbar-dark { background:#0d1921 !important; border-left:1px solid #1a2a38 !important; } " +
".dash-chatbar-light { background:#f1f5f9 !important; border-left:1px solid #e2e8f0 !important; } " +
".dash-chat-fab { position:fixed !important; right:16px !important; bottom:20px !important; width:56px !important; height:56px !important; min-width:0 !important; border-radius:50% !important; overflow:hidden !important; border:none !important; background:#4a9edd !important; box-shadow:0 4px 16px rgba(0,0,0,.4) !important; z-index:9000 !important; padding:0 !important; } " +
".dash-chat-fab .v-button-wrap { border-radius:50% !important; background:transparent !important; } " +
".dash-chat-fab .v-button-caption { font-size:24px !important; line-height:56px !important; color:#fff !important; } "
  1. Tema — sincronize chatBar/caScope com dash-chatbar-dark/dash-chatbar-light nos mesmos pontos onde filterBar já é sincronizado: doToggleTheme, renderView, e no enterMobilePreview/exitMobilePreview.

7.9 — vitruvio.json

Adicione a entrada do painel:

{
  "key": "<panel-key>",
  "name": "<Nome do Painel>",
  "description": "<descrição>",
  "category": "<Categoria>",
  "displayOrder": <próximo disponível>,
  "showInPresentation": false,
  "openInNewWindow": false,
  "showInMobileList": false,
  "displayTimeInSeconds": 0,
  "allowedGroups": ["<grupo>"],
  "allowedUsers": [],
  "forms": {
    "desktop": "panels/<panel-key>/<panel-key>-desktop.xml"
  }
}

FASE 8 — Arquivo de contexto do painel (OBRIGATÓRIO)

Após criar ou modificar o painel, sempre gere o arquivo panels/<panel-key>/CONTEXT.md.

Este arquivo permite retomar o trabalho em uma nova janela de contexto sem precisar reler todo o XML.

Formato obrigatório do CONTEXT.md:

# Contexto: <Nome do Painel>

> Última atualização: <data>

## Identificação

- **Key:** `<panel-key>`
- **formKey:** `<form-key>`
- **Arquivo desktop:** `panels/<panel-key>/<panel-key>-desktop.xml`
- **Arquivo mobile:** `panels/<panel-key>/<panel-key>-mobile.xml` _(se existir)_
- **Lib de base:** `dashboard_ia` (tema, preferências, CSS compartilhado)

## Propósito

<Descrição do que este painel mostra e para quem>

## Datasource

- **Datasource:** `vitruvio_producao`
- **Banco:** PostgreSQL / Oracle
- **Tabelas principais:**
  - `nome_tabela` — <o que representa>
  - `nome_tabela2` — <o que representa>

## Filtros (sidebar)

| ID do Campo | Tipo | Descrição | Comportamento |
|---|---|---|---|
| `cmbAno` | DBComboBox | Ano | Auto-reload ao mudar |
| `cmbMes` | ComboBox | Mês (1–12) | Auto-reload ao mudar |
| `dtIni` | DateField | Data inicial | Requer botão Pesquisar |
| ... | ... | ... | ... |

## KPIs

| Rótulo | SQL / Cálculo | Cor |
|---|---|---|
| Total | `SUM(valor)` da tabela X | `#4a9edd` |
| ... | ... | ... |

## Gráficos no Dashboard

| # | Tipo | Título | SQL / Fonte | Disposição desktop |
|---|---|---|---|---|
| 1 | svgLineChart | Evolução Mensal | `SELECT mes, SUM(valor)...` | Linha completa |
| 2 | svgDonut | Proporção A vs B | Calculado dos dados já carregados | Esquerda |
| 3 | svgHBar | Ranking por Cliente | Agrupamento da query principal | Direita |

## Movimentação

### Query principal

```sql
-- Cole aqui o SQL principal da movimentação
SELECT ...
FROM ...
WHERE ...

Colunas da tabela

# Cabeçalho Campo (data-v) Tipo Sortável No filtro global
0 Código CODIGO texto sim sim
... ... ... ... ... ...

Drill-down

  • Campos adicionais:
  • Sub-tabela: <descrição ou "não tem">
  • Dados: carregados junto com a query principal / query separada no servidor

Filtros client-side da movimentação

  • Campo de busca global: mov-fi → busca em colunas [0, 1, 2]
  • Select de status: mov-stf → filtra coluna [3]
  • Select de tipo: mov-sef → filtra coluna [4]

Exportação CSV

  • Arquivo: <nome>.csv
  • Colunas:

Funções JavaScript principais

Função Onde O que faz
run() initScript raiz Inicializa CSS, tema, detecta mobile, detecta Oracle, registra globals
ico(chave) initScript raiz Devolve o glifo do ícone conforme isOracle (fallback BMP/ASCII)
aplicarIconesOracleNaTopBar() initScript raiz Reescreve os captions estáticos do topBar quando Oracle
btnPesquisar onClick filterBar Aplica filtros (renderView) e recolhe a filterBar (estado inicial)
doToggleTheme() initScript raiz Alterna dark/light, salva preferência, re-renderiza
renderView() ScriptWidget Dispatcher: chama renderDash() ou renderMov()
renderDash() ScriptWidget Gera HTML do dashboard (KPIs + gráficos)
renderMov() ScriptWidget Gera HTML da tabela de movimentação
renderHtml(html) ScriptWidget Injeta HTML no Vaadin, aplica tema, colapsa no mobile
svgLineChart(...) ScriptWidget Gera SVG do gráfico de linha
svgDonut(...) ScriptWidget Gera SVG do donut
svgHBar(...) ScriptWidget Gera SVG de barras horizontais
enterMobilePreview() initScript raiz Ativa preview mobile 375px no desktop
exitMobilePreview() initScript raiz Sai do preview mobile
window.movSort(c,t) JS client-side Ordena tabela movimentação por coluna c
window.movFilter() JS client-side Filtra linhas da movimentação
window.movExportCSV() JS client-side Exporta CSV da movimentação

IDs de layout e campo (para mobile / forceRender)

Layouts: rootLayout, topBar, mainArea, filterBar, contentPanel, panelRoot

Campos: <lista dos IDs de todos os campos de filtro>

Variáveis globais do engine

Variável Tipo O que armazena
dashLib objeto Biblioteca dashboard_ia
userLogin string Login do usuário logado
theme 'dark' ou 'light' Tema atual
currentView 'dashboard' ou 'movimentacao' View ativa
isMobile boolean true quando em modo mobile
isDeveloper boolean true se o usuário é vi_developer
isOracle boolean true quando o datasource é Oracle (define o fallback de ícones)
ico function Helper ico(chave) — glifo do ícone conforme o banco (seção 7.2.2)
renderView function Dispatcher principal
doToggleTheme function Toggle de tema
enterMobilePreview function Entra no preview mobile
exitMobilePreview function Sai do preview mobile

Paleta de cores aplicada

Uso Cor dark Cor light
Fundo wrapper #0f1923 #f0f2f5
Card / box #1a2733 #ffffff
Borda #243447 #e2e8f0
[Cor de destaque 1 do painel] #4a9edd (azul) #2563eb
[Cor de destaque 2 do painel] #e67e22 (laranja) #d97706

Histórico de modificações

Data O que foi feito
Criação inicial do painel

Pendências / TODO

  • <item pendente 1>
  • <item pendente 2>

Versão mobile

  • Status: (não criada / em andamento / concluída)
  • Arquivo: panels/<panel-key>/<panel-key>-mobile.xml
  • Para criar: invoque a skill vitruvio-criar-dashboard-mobile com panels/<panel-key>/<panel-key>-desktop.xml

---

## FASE 9 — Atualizar o CONTEXT.md em toda modificação

**Sempre que fizer qualquer alteração no painel (bugfix, novo gráfico, novo filtro, novo campo na movimentação, adaptação para mobile), atualize o CONTEXT.md:**

1. Adicione uma linha no **Histórico de modificações** com a data e o que foi feito
2. Atualize as seções afetadas (filtros, funções, colunas, etc.)
3. Marque pendências como concluídas ou adicione novas

O objetivo é que qualquer pessoa (ou você mesmo em outra sessão) possa ler o CONTEXT.md e entender completamente o painel sem precisar abrir o XML.

---

## FASE 10 — Oferecer criação do mobile

Após gerar o painel e o CONTEXT.md, sempre pergunte:

O painel desktop foi criado com sucesso!

Deseja criar a versão mobile agora? Vou invocar a skill vitruvio-criar-dashboard-mobile com panels//-desktop.xml.

O processo de criação do mobile vai:

  • Criar panels//-mobile.xml
  • Adaptar o <panel-key>-desktop.xml para detectar o contexto mobile
  • Adicionar sidebar de filtros colapsável no mobile
  • Converter gráficos e tabelas para layout mobile (cards empilhados)
  • Adicionar suporte a preview mobile para desenvolvedores

Responda SIM para iniciar agora, ou NÃO para fazer depois.


Se o usuário confirmar, invoque a skill **vitruvio-criar-dashboard-mobile** passando `panels/<panel-key>/<panel-key>-desktop.xml`.

---

## Armadilhas conhecidas — não repita estes erros

### SQL sem validação de tipo
```javascript
// ERRADO — Number(null) = 0, pode gerar query errada
var ano = Number(engine.getField('cmbAno').getValue());

// CERTO — verificar antes
var anoStr = engine.getField('cmbAno').getValue();
if (!anoStr) { renderHtml('<div class="dash-aviso">Selecione o ano.</div>'); return; }
var ano = Number(anoStr);

Java Boolean com strict equality

// ERRADO
if (_wasVisible !== false) { ... }  // Java Boolean.FALSE !== JS false

// CERTO
if (_wasVisible == true) { ... }    // loose equality funciona
if (!!_wasVisible) { ... }          // coerção funciona

setExpandRatio antes de addComponent

// ERRADO
base.setExpandRatio(comp, 1.0);  // comp não é filho ainda
base.addComponent(comp);

// CERTO
base.addComponent(comp);
base.setExpandRatio(comp, 1.0);  // sempre APÓS addComponent

Iteração de resultado null do banco

// ERRADO
var rows = banco.query(sql, {}); rows.each(...); // NullPointerException se sem resultado

// CERTO
var rows = banco.query(sql, {});
if (rows) { rows.each(function(row) { ... }); }

CSS inline sobrescreve class

// Se o elemento tem style="font-size:12px", !important não sobrescreve
// Solução: gerar o tamanho diretamente no HTML conforme o contexto
var fs = isMobileCtx() ? '14px' : '12px';
html += '<div style="font-size:' + fs + ';">...</div>';

movFilter com colunas erradas

// Sempre validar que o índice de coluna existe antes de acessar
var t0 = (m.cells[0] ? m.cells[0].getAttribute('data-v') || '' : '').toLowerCase();

Checklist de entrega

Antes de declarar pronto:

  • Leu os painéis de referência (dashboard-contratos e dashboard-tecnicos) antes de escrever
  • Não inventou nomes de tabelas ou campos — tudo validado com o usuário
  • <panel-key>-desktop.xml criado com estrutura correta: rootLayout → topBar + mainArea (filterBar + contentPanel)
  • topBar tem: btnToggleFiltros, btnDash, btnMov, spacer, btnDevPreview, btnExitPreview, btnTheme
  • filterBar tem: btnThemeMob como primeiro filho (antes dos filtros), campos de filtro, spacer (expandRatio=1)
  • btnThemeMob aparece no topo da filterBar no mobile — não no rodapé
  • enterMobilePreview usa base.setHeight(null) (altura natural, cresce ao conteúdo) — não copia a altura fixa 700px do _mobileRender real, que só faz sentido para a tela do celular
  • exitMobilePreview restaura base.setHeight("100%")
  • No mobile (_mobileRender e enterMobilePreview): btnTheme do topBar oculto via setVisible(false) — evita dois botões de tema simultâneos
  • No exitMobilePreview: btnThemeMob oculto e btnTheme restaurado via setVisible(true)
  • btnThemeMob e btnTheme chamam doToggleTheme (via global var)
  • doToggleTheme atualiza caption de ambos os botões
  • run() carrega dashLib, aplica CSS, detecta mobile, verifica vi_developer, aplica tema salvo das preferências (dashLib.loadPreferences) — nunca assume dark sem verificar
  • renderView() registrado como global, chamado por todos os filtros e botões de nav
  • renderView() marca botão ativo com dash-nav-active
  • renderHtml() distingue preview (isMobile sem _mobileRender) de mobile real/desktop: só o preview usa base.setHeight(null) + comp.setWidth("100%"); os outros dois usam comp.setSizeFull() + base.setExpandRatio(comp, 1.0)
  • JS client-side registrado: movSort, movFilter, movExportCSV
  • Movimentação tem toolbar com campo de busca, contador de registros e botão CSV
  • filterBar começa fechado (setVisible(false))
  • Se há filtro sem auto-reload: btnPesquisar existe e, ao clicar, aplica os filtros e recolhe a filterBar (mesmo caption de "fechado" que o run() e o btnToggleFiltros usam) — seção 7.2.1
  • run() detecta o banco com banco.isOracle() e grava isOracle + ico como globals
  • Quando Oracle: aplicarIconesOracleNaTopBar() reescreve os captions estáticos do topBar
  • Nenhum emoji/ícone astral escrito direto em caption ou HTML — tudo passa por ico(chave)
  • Tema inicial lido das preferências do usuário (dashLib.loadPreferences)
  • enterMobilePreview / exitMobilePreview registrados como globals
  • Não usou ES6+ (sem const, let, =>, template literals, class)
  • vitruvio.json atualizado com a entrada do painel
  • CONTEXT.md criado em panels/<panel-key>/CONTEXT.md com todas as seções preenchidas
  • No mobile: cards sempre empilhados verticalmente (flex-direction:column), cada um com width:100%
  • No mobile: fontes maiores via isMobileCtx() no HTML inline ou via CSS .mobile-view
  • No mobile: btnThemeMob posicionado antes dos campos de filtro na filterBar
  • Tema sempre verificado via dashLib.loadPreferences ao entrar — nunca assumir dark por padrão
  • CSS de scroll mobile (.mov-scroll, .tec-mob-scroll, .hbar-scroll) está em addCss() — não apenas no bloco _mobileRender
  • No mobile: listas de cards (movimentação) envolvidas em <div class="mov-scroll">
  • No mobile: listas de técnicos/ranking envolvidas em <div class="tec-mob-scroll">
  • No mobile: gráficos svgHBar envolvidos em <div class="hbar-scroll">
  • Perguntou ao usuário sobre criação da versão mobile