Files
2026-09-23 12:29:08 -03:00

39 KiB
Raw Permalink Blame History

name, description
name description
vitruvio-criar-dashboard-mobile Use when creating the MOBILE version of an existing Vitruvio indicator dashboard panel — a DesktopPanel shell (`<key>-mobile.xml`) that delegates to `<key>-desktop.xml`, plus the mobile detection/adaptation added to the desktop form: collapsible filter sidebar, stacked cards, dark/light theme toggle, and developer mobile preview. Triggers: "criar mobile do dashboard", "adicionar versão mobile ao indicador", "mobile preview do painel indicador", "CriarMobileIndicador". Called by vitruvio-criar-indicador-dashboard for the mobile part; can also be invoked directly with the target `<key>-desktop.xml` path. For the desktop part use vitruvio-criar-dashboard-desktop.

Criar Mobile para Painel Indicador

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

Você está criando a versão mobile de um painel Vitruvio existente. O padrão usado neste repositório é o DesktopPanel delegado: o <key>-mobile.xml é um shell mínimo que delega toda a renderização ao <key>-desktop.xml, que detecta o contexto mobile e adapta o layout sozinho.

Receba o caminho do <key>-desktop.xml alvo como argumento (ex: panels/meu-painel/meu-painel-desktop.xml).


LEITURA OBRIGATÓRIA AO INICIAR — CONTEXT.md

Esta é a primeira coisa a fazer, antes de abrir qualquer outro arquivo.

Derive o diretório do painel a partir do argumento e leia:

panels/<panel-key>/CONTEXT.md

Se o CONTEXT.md existir:

  • Leia-o completo primeiro. Ele contém os IDs reais dos campos, layouts, funções JS e o estado atual do painel — tudo que você precisa para criar o mobile corretamente sem reler o XML inteiro.
  • Use a seção "IDs de layout e campo" para preencher forceLayoutsRender e forceFieldsRender no <key>-mobile.xml.
  • Use a seção "Funções JavaScript principais" para entender o que já existe e evitar duplicar.
  • Informe ao usuário: "Li o contexto do painel. Vou criar o mobile com base nele."
  • Ao final, atualize o CONTEXT.md: marque a versão mobile como "concluída", adicione o caminho do <key>-mobile.xml e registre a modificação no histórico.

Se o CONTEXT.md não existir:

  • Informe ao usuário que não há arquivo de contexto para este painel.
  • Leia o <key>-desktop.xml completo para extrair os IDs necessários.
  • Ao final, crie o CONTEXT.md completo (veja o formato na Fase 8 da skill vitruvio-criar-dashboard-desktop).

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

Nunca invente — pergunte quando tiver dúvida

  • Se não conhecer um componente, atributo ou comportamento da plataforma: pergunte ao usuário antes de inventar. Uma pergunta custa menos do que um bug em produção.
  • Baseie-se sempre no que já existe: leia o painel alvo e outros painéis deste repositório para entender o padrão adotado. O que já funciona aqui é a referência.
  • Não suponha assinaturas de serviços, nomes de atributos ou comportamentos de componentes que não estejam evidentes no código existente.

Se o painel tem filtros acima do conteúdo — mova-os para uma sidebar colapsável

Se o painel alvo tem filtros (combos, campos de texto, datas) posicionados acima do conteúdo principal (em linha no topo), não deixe assim no mobile. Filtros acima ocupam espaço valioso e poluem a tela.

O padrão deste repositório é a sidebar de filtros colapsável — os dois painéis de referência canônicos estão empacotados junto com a skill desktop, leia-os antes de aplicar o padrão:

  • .claude/skills/vitruvio-criar-dashboard-desktop/dashboard-contrato-desktop.xml
  • .claude/skills/vitruvio-criar-dashboard-desktop/dashboard-tecnicos-desktop.xml

O painel usa a global dashLib (carregada pelo run() desktop via libService.loadScript('dashboard_ia')) para tema/preferências — veja .claude/skills/vitruvio-criar-dashboard-desktop/dashboard_ia.js para a API disponível. É material de consulta apenas: nunca crie, copie ou registre um scripts/dashboard_ia.js no repositório de destino.

O padrão é:

Layout estrutural (XML):

rootLayout (VerticalLayout, 100% x 100%)
  ├── topBar (HorizontalLayout) — botão "◄ Filtros" + navegação + spacer + tema
  └── mainArea (HorizontalLayout, expandRatio=1)
        ├── filterBar (VerticalLayout, width=220px) — os filtros em coluna
        └── contentPanel (VerticalLayout, expandRatio=1) — o conteúdo principal

Botão toggle no topBar:

<ButtonWidget id="btnToggleFiltros" caption="◄ Filtros">
   <onClickScript language="JavaScript">
      <![CDATA[
         function run() {
            var fb = engine.getLayout('filterBar');
            if (!fb) return;
            var filterLayout = fb.getRootComposition();
            var estaVisivel = filterLayout.isVisible();
            filterLayout.setVisible(!estaVisivel);
            var btn = engine.getWidgetController('btnToggleFiltros').getButton();
            btn.setCaption(estaVisivel ? '► Filtros' : '◄ Filtros');
         }
      ]]>
   </onClickScript>
</ButtonWidget>

No mobile: o filterBar começa invisível (escondido no bloco _mobileRender do run()). O mobile tem seu próprio botão de filtros dentro da filterBar (btnPreviewFiltros) que só aparece no mobile — ele aciona o mesmo toggle, mantendo consistência sem adicionar nova lógica.

Botão de tema dentro da filterBar (obrigatório no mobile): adicione um ButtonWidget id="btnThemeMob" no fundo do filterBar (após o spacer Label), com visible="false". No bloco _mobileRender do run(), torne-o visível. Ele deve chamar a mesma função doToggleTheme que o btnTheme do topBar — defina essa função no ScriptWidget InitScript, registre-a com engine.setGlobalVariable('doToggleTheme', doToggleTheme) no init(), e tenha ambos os botões chamando-a via engine.getGlobalVariable('doToggleTheme'). A função atualiza a caption de AMBOS os botões de tema (btnTheme e btnThemeMob), o CSS do filterBar e o HTML gerado.

<!-- dentro do filterBar, após o spacer Label -->
<ButtonWidget id="btnThemeMob" caption="&#x263D;" description="Alternar tema" visible="false" width="100%">
   <onClickScript language="JavaScript">
      <![CDATA[
         function run() {
            var fn = engine.getGlobalVariable('doToggleTheme');
            if (fn) fn();
         }
      ]]>
   </onClickScript>
</ButtonWidget>
// No bloco _mobileRender do run():
var _btnThemeMob = engine.getWidgetController('btnThemeMob');
if (_btnThemeMob) { _btnThemeMob.getButton().setVisible(true); }
// Oculta btnTheme do topBar — no mobile apenas btnThemeMob é visível
var _btnThemeTb = engine.getWidgetController('btnTheme');
if (_btnThemeTb) { _btnThemeTb.getButton().setVisible(false); }

// doToggleTheme no ScriptWidget InitScript — atualiza os dois botões:
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 bT = engine.getWidgetController('btnTheme');
   if (bT) { bT.getButton().setCaption(next === 'dark' ? '☽' : '☀'); }
   var bM = engine.getWidgetController('btnThemeMob');
   if (bM) { bM.getButton().setCaption(next === 'dark' ? '☽' : '☀'); }
   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('meu-painel-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();
}

// No init():
engine.setGlobalVariable('doToggleTheme', doToggleTheme);

Os campos de filtro dentro da filterBar são os do painel novo — não copie os campos do dashboard-contratos. Leia o painel alvo, identifique seus filtros originais (cmbAno, cmbMes, txtCliente, etc.) e coloque-os dentro do filterBar como VerticalLayout com spacing="true" margin="true".

forceFieldsRender no <key>-mobile.xml: inclua todos os campos de filtro da sidebar. Sem isso, engine.getField('cmbAno') falha no WebView mobile.

Campo de pesquisa/busca do desktop: se o painel desktop tem um campo de texto para pesquisa (ex: filtrar por cliente, número, palavra-chave), ele obrigatoriamente deve aparecer no mobile também, dentro da filterBar. Nunca omita a pesquisa no mobile — o usuário mobile precisa dela tanto quanto o desktop. Se a pesquisa estiver inline acima da tabela no desktop, mova-a para dentro da filterBar no mobile (ou mantenha nos dois lugares).


Siga o padrão do repositório

Este repositório já tem painéis funcionando. Antes de criar algo novo:

  • Leia pelo menos um <key>-desktop.xml existente deste repo para entender como estão estruturados os componentes, como são chamados os serviços, como é o estilo visual
  • Reutilize classes CSS já definidas (ex: dash-kpi, dash-kpi-sm, mov-card, mobile-topbar) em vez de criar novas
  • Reutilize padrões de run() / init() / renderHtml() que já existem no painel alvo

Botões e campos padronizados

  • Botões de ação (ButtonWidget): use style="DEFAULT" para ações neutras, style="GREEN" para confirmar/salvar, style="RED" para cancelar/excluir. Nunca crie botões com HTML customizado quando um ButtonWidget padrão serve.
  • Campos de filtro: ComboBox para listas fechadas, TextField para texto livre, DateField para datas. Sempre com id único e caption em português.
  • Ícones: use os já presentes no painel alvo. Se precisar de novo ícone, use os codepoints já usados no repositório (ex: &#x1F4F1; para mobile, &#x2715; para fechar) — não invente.
  • Labels de valor monetário: sempre use a função de formatação já existente no painel (ex: formatBRL(valor)) — não crie nova.

JavaScript — ES5 Rhino somente

Nunca use ES6+. Veja o que é proibido e o que usar no lugar:

// PROIBIDO
const x = 1;          // use: var x = 1;
let y = 2;            // use: var y = 2;
() => {};             // use: function() {}
`texto ${var}`;       // use: 'texto ' + var
const { a } = obj;    // use: var a = obj.a;
class Foo {}          // use: function Foo() {}
async/await           // use: callbacks
import/export         // não disponível
for (const x of arr) // use: for (var i = 0; i < arr.length; i++)


Passo 1 — Confirmar repositório e ler o painel alvo

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.

Leia o <key>-desktop.xml alvo completo antes de escrever qualquer linha. Leia também pelo menos um outro painel do repositório para entender os padrões já adotados. Só depois prossiga.

Leia o <key>-desktop.xml alvo para entender:

  • O formKey do painel
  • Os layouts declarados (id de cada VerticalLayout, HorizontalLayout, Panel, etc.)
  • Os campos de filtro (ComboBox, TextField, etc.)
  • A estrutura do initScript / run() / init() do ScriptWidget
  • O que é renderizado em HTML (ScriptWidget que chama renderHtml)

Leia o vitruvio.json para identificar a entrada do painel (key, forms).


Passo 2 — Criar o <key>-mobile.xml

Crie panels/<key>/<key>-mobile.xml. Ele é um shell que delega ao desktop:

<?xml version="1.0" encoding="UTF-8"?>
<panel-form xmlns="http://www.davinti.com.br/vitruvio/form/mobile/panel"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="http://www.davinti.com.br/vitruvio/form/mobile/panel
              https://bitbucket.org/davinTI/vitruvio-xds/raw/master/vitruvio-mobile-panel-form.xsd">
   <form formKey="<key>-mobile">
      <name><NOME DO PAINEL></name>
      <description><DESCRIÇÃO></description>

      <initScript language="JavaScript">
         <![CDATA[
            function run() {
               // _mobileRender é setado automaticamente pelo DesktopPanel antes
               // do painel desktop inicializar — o desktop detecta e adapta o layout.
            }
         ]]>
      </initScript>

      <components>
         <VerticalLayout width="100%" height="100%">
            <DesktopPanel id="<camelCaseId>" panelKey="<key>"
               layoutId="rootLayout" external="true"
               forceLayoutsRender="<lista de layout ids separados por vírgula>"
               forceFieldsRender="<lista de field ids separados por vírgula>" height="100%" />
         </VerticalLayout>
      </components>
   </form>
</panel-form>

forceLayoutsRender: liste todos os layouts do desktop que precisam existir no DOM móvel (ex: topBar, filterBar, mainArea, contentPanel, panelRoot). Leia o XML do desktop para identificá-los.

forceFieldsRender: liste os campos de filtro que o script usa (ex: cmbAno, cmbMes, cmbOrdem). Sem eles, engine.getField('cmbAno') falha no mobile.

height="100%" no VerticalLayout e no DesktopPanel: os dois níveis do shell usam 100% — a altura real vem do container mobile que envolve este <key>-mobile.xml. Um valor fixo grande (ex: 3000px/700px) já foi usado aqui para forçar os layouts filhos a calcularem porcentagem, mas isso quebrava a tela (espaço em branco/corte); com 100% nos dois níveis do shell a altura se propaga corretamente do container pai, sem precisar desse artifício.


Passo 3 — Atualizar vitruvio.json

No vitruvio.json, na entrada do painel:

  • Adicione "mobile": "panels/<key>/<key>-mobile.xml" dentro de "forms"
  • Defina "showInMobileList": true
{
  "key": "<key>",
  "forms": {
    "desktop": "panels/<key>/<key>-desktop.xml",
    "mobile": "panels/<key>/<key>-mobile.xml"
  },
  "showInMobileList": true
}

Passo 4 — Adaptar o <key>-desktop.xml

Esta é a parte principal. O desktop precisa detectar o contexto mobile e adaptar o HTML gerado.

4.1 — Bloco de inicialização mobile no run()

Dentro do run() (initScript), após o bloco de CSS principal, adicione a 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, ANTES do colapso de renderHtml (ver seção 4.6).
      _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';" +
         "})();"
      );
   }
   // Aplica estilo de topbar mobile ao topBar se existir
   var _topBar = engine.getLayout('topBar');
   if (_topBar) { _topBar.getRootComposition().addStyleName('mobile-topbar'); }
   // Esconde filterBar no mobile (filtros ficam em outro fluxo ou botão)
   var _filterBar = engine.getLayout('filterBar');
   if (_filterBar) { _filterBar.getRootComposition().setVisible(false); }
   // Exibe btnThemeMob (dentro da filterBar) e oculta btnTheme (topBar)
   // Sem isso o usuário vê dois botões de tema simultaneamente no mobile
   var _btnThemeMobR = engine.getWidgetController('btnThemeMob');
   if (_btnThemeMobR) { _btnThemeMobR.getButton().setVisible(true); }
   var _btnThemeTbR = engine.getWidgetController('btnTheme');
   if (_btnThemeTbR) { _btnThemeTbR.getButton().setVisible(false); }
   // CSS específico mobile adicionado via page.getStyles().add()
   page.getStyles().add(
      // Tabelas/grids mobile: altura fixa com scroll interno
      ".mobile-view .mov-scroll { height:520px !important; overflow-y:auto !important;" +
      " -webkit-overflow-scrolling:touch !important; overscroll-behavior:contain; touch-action:pan-y; } "
      // Adicione aqui outros CSS mobile específicos deste painel
   );
}

Se o painel alvo tem o assistente de IA "Pietro" (sidebar chatBar, veja a seção opcional 7.11 da skill vitruvio-criar-dashboard-desktop): no mesmo bloco _mobileRender, oculte btnToggleChat e mostre btnChatFabMobile — mesmo tratamento dado a btnTheme/btnThemeMob acima. Replique também em enterMobilePreview/exitMobilePreview (Passo 5.3). Se o painel não tiver esses IDs, ignore — é um add-on opcional, não parte obrigatória do padrão.

4.2 — Variável mobileClass no HTML gerado

Em toda função que gera HTML (ex: desenharDashboard, renderView, etc.), adicione:

var mobileClass = (engine.isGlobalVariableSet('isMobile') || engine.isGlobalVariableSet('_mobileRender')) ? ' mobile-view' : '';
var html = '<div class="dash-wrapper' + mobileClass + '" id="meu-painel-main">';

Isso permite que todo o CSS mobile use seletores .mobile-view .classe sem afetar o desktop.

4.3 — Layouts horizontais → cards verticais no mobile

Regra de ouro: no mobile NUNCA coloque duas informações lado a lado. Tudo em coluna única.

Desktop (layout horizontal):

html += '<div style="display:flex; gap:16px;">';
html += '<div>R$ 100.000</div>';
html += '<div>R$ 50.000</div>';
html += '</div>';

Mobile (detectar e converter para vertical):

if (mobileClass) {
   html += '<div style="display:flex; flex-direction:column; gap:8px;">';
   html += '<div class="mob-card"><span class="mob-label">Locação</span><span class="mob-value">R$ 100.000</span></div>';
   html += '<div class="mob-card"><span class="mob-label">Serviço</span><span class="mob-value">R$ 50.000</span></div>';
   html += '</div>';
} else {
   // layout desktop original
}

4.4 — Tabelas (grids) no mobile → cards empilhados

No desktop: <table> com colunas. No mobile: cada linha vira um card com os campos empilhados:

if (mobileClass) {
   html += '<div class="mov-scroll"><div class="mov-cards">';
   for (var i = 0; i < linhas.length; i++) {
      var l = linhas[i];
      html += '<div class="mov-card">';
      html += '<div class="mov-card-top">';
      html += '  <span class="mov-card-cliente">' + l.nome + '</span>';
      html += '</div>';
      html += '<div class="mov-card-meta">' + l.codigo + ' &middot; ' + l.tipo + '</div>';
      html += '<div class="mov-card-values">';
      html += '  <div class="mov-card-val"><span class="mov-card-lbl">Valor</span><span>' + formatBRL(l.valor) + '</span></div>';
      html += '  <div class="mov-card-val"><span class="mov-card-lbl">Status</span><span>' + l.status + '</span></div>';
      html += '</div>';
      html += '</div>';
   }
   html += '</div></div>';
} else {
   // tabela desktop
}

4.5 — CSS mobile obrigatório

No addCss() (ou no CSS principal adicionado em run()), inclua os seletores mobile:

CRÍTICO: Todo CSS de scroll mobile deve estar em addCss() — nunca 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).

// ── Scroll containers no mobile — OBRIGATÓRIO em addCss() ──
".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; } " +

// Tamanhos de fonte mobile — NUNCA menor que o desktop, sempre maior
".mobile-view .dash-kpi-sm .dash-kpi-label { font-size:13px !important; } " +
".mobile-view .dash-kpi-sm .dash-kpi-value { font-size:17px !important; } " +

// Cards de movimentação/dados mobile
".mov-card { background:#1a2733; border-radius:6px; padding:12px 16px; margin-bottom:8px; } " +
".mov-card-cliente { font-weight:bold; color:#fff; font-size:13px; } " +
".mov-card-meta { font-size:11px; color:#8899aa; margin-bottom:8px; } " +
".mov-card-values { display:flex; flex-wrap:wrap; gap:6px; } " +
".mov-card-val { flex:1; min-width:80px; background:#0f1923; border-radius:4px; padding:6px 8px; } " +
".mov-card-lbl { display:block; font-size:10px; color:#8899aa; margin-bottom:2px; } " +

// Mobile: fontes maiores nos cards
".mobile-view .mov-card-cliente { font-size:15px !important; } " +
".mobile-view .mov-card-meta { font-size:13px !important; } " +
".mobile-view .mov-card-lbl { font-size:12px !important; } " +

Classes de scroll padrão:

Classe Altura Uso
.mov-scroll 520px Container da lista de movimentação (cards ou tabela)
.tec-mob-scroll 420px Container de lista de cards de técnicos/ranking
.hbar-scroll max 280px Container de gráfico de barras horizontais (svgHBar)

Padrão de envolvimento obrigatório no mobile:

// ── Movimentação: barra de pesquisa FORA do scroll, cards DENTRO ──
html += '<div class="mob-search-bar">...</div>';   // fica fixo no topo
html += '<div class="mov-scroll">';                // scroll interno
for (var i = 0; i < linhas.length; i++) {
   html += '<div class="mov-card">...</div>';
}
if (linhas.length === 0) html += '<div style="padding:28px;text-align:center;color:#556677;">Sem dados</div>';
html += '</div>';   // fecha mov-scroll

// ── Lista de técnicos/ranking: header fora, cards dentro ──
html += '<div class="dash-chart-box">...header com botões de sort...</div>';
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>';   // fecha tec-mob-list
html += '</div>';   // fecha tec-mob-scroll

// ── Gráficos svgHBar: sempre dentro de hbar-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>';

Tamanhos mínimos de fonte mobile:

Tipo de dado Desktop Mobile mínimo
Label/rótulo 10–12px 12–13px
Valor principal 13–16px 16–18px
Título de seção 14–16px 18–20px
Texto de detalhe 11–12px 13–14px

4.6 — renderHtml: tratar ScriptWidget para mobile e preview

Padrão real (confirmado nos dois painéis de referência): renderHtml distingue preview de dev (isMobile setado, _mobileRender não) do resto — só o preview usa altura natural via base.setHeight(null); mobile real (_mobileRender) e desktop normal sempre usam setSizeFull(). Não existe nenhuma técnica de "colapsar um elemento de 700px" — isso é uma versão obsoleta. Depois do render, o único ajuste feito por JS é liberar overflow:visible nos containers pais intermediários até encontrar o .mobile-root-frame (cuja altura já foi fixada uma única vez no bloco _mobileRender do run(), via window.screen.height — ver seção 4.1), para que o scroll externo não "arraste" a topBar junto:

function renderHtml(html) {
   var comp = components.html(html);
   // isPreviewMode: preview de dev (isMobile=true SEM _mobileRender). Mobile WebView real
   // (_mobileRender=true) e desktop normal usam setSizeFull() — só o preview precisa de altura
   // natural para o Panel (contentPanel) enxergar o overflow real e rolar.
   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); }
   Packages.com.vaadin.ui.UI.getCurrent().getPage().getJavaScript().execute(
      "setTimeout(function(){" +
      "if(typeof meuPainelApplyTheme==='function')meuPainelApplyTheme();" +
      // NÃO redimensionar .mobile-root-frame para caber o conteúdo aqui — sua altura é fixa
      // (definida uma única vez no _mobileRender via window.screen.height). Só libera
      // overflow:visible nos pais intermediários para o scroll externo funcionar sem
      // "arrastar" a topBar junto.
      "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);"
   );
}

Passo 5 — Sistema de Preview Mobile (para grupo vi_developer)

Adicione ao <key>-desktop.xml um botão de preview que simula o mobile diretamente no desktop. Isso permite testar sem abrir o WebView mobile.

5.1 — Botão no topBar (XML)

<ButtonWidget id="btnDevPreview" caption="&#x1F4F1;"
   description="Preview Mobile (apenas desenvolvedores)" visible="false">
   <onClickScript>
      function run() { var fn = engine.getGlobalVariable('enterMobilePreview'); if (fn) fn(); }
   </onClickScript>
</ButtonWidget>
<ButtonWidget id="btnExitPreview" caption="&#x2715; Sair Preview"
   description="Voltar ao modo desktop" visible="false">
   <onClickScript>
      function run() { var fn = engine.getGlobalVariable('exitMobilePreview'); if (fn) fn(); }
   </onClickScript>
</ButtonWidget>

5.2 — CSS do preview

Adicione ao CSS principal:

".preview-mobile-frame { margin:0 auto !important; border:2px solid #4a9edd !important;" +
" border-radius:12px !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; } "

5.3 — Funções enterMobilePreview / exitMobilePreview

Padrão real (confirmado nos dois painéis de referência): o preview usa altura natural, não fixa — base.setHeight(null) faz o ScriptWidget crescer ao tamanho do conteúdo, e o Panel (contentPanel) que o envolve enxerga o overflow real e rola sozinho. Não há valor fixo (nem 700px nem 3000px) nem colapso via JS aqui — essa técnica é só para o _mobileRender real (placeholder 700px recalculado por window.screen.height, seção 4.1), que existe porque ali sim há uma tela de celular real para medir. No preview, que roda no navegador desktop do dev, altura natural é suficiente e mais simples.

Adicione no ScriptWidget, antes do init():

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');
   }
   var _tBar = engine.getLayout('topBar');
   if (_tBar) _tBar.getRootComposition().addStyleName('mobile-topbar');
   // Salva e esconde filterBar
   var _fBar = engine.getLayout('filterBar');
   if (_fBar) {
      var _fBarComp = _fBar.getRootComposition();
      engine.setGlobalVariable('_prevFilterBarVisible', _fBarComp.isVisible());
      _fBarComp.setVisible(false);
   }
   var _btnDev = engine.getWidgetController('btnDevPreview');
   if (_btnDev) _btnDev.getButton().setVisible(false);
   var _btnExit = engine.getWidgetController('btnExitPreview');
   if (_btnExit) {
      _btnExit.getButton().setVisible(true);
      _btnExit.getButton().addStyleName('preview-exit-btn');
   }
   // Exibe btnThemeMob e oculta btnTheme — igual ao _mobileRender real
   var _btnThMob = engine.getWidgetController('btnThemeMob');
   if (_btnThMob) _btnThMob.getButton().setVisible(true);
   var _btnThTb = engine.getWidgetController('btnTheme');
   if (_btnThTb) _btnThTb.getButton().setVisible(false);
   renderView(); // re-renderiza com mobile-view ativo
}

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');
   }
   var _tBar = engine.getLayout('topBar');
   if (_tBar) _tBar.getRootComposition().removeStyleName('mobile-topbar');
   // Restaura filterBar — usa loose equality (== ) para coerção de Java Boolean
   var _fBar = engine.getLayout('filterBar');
   if (_fBar) {
      var _wasVisible = engine.getGlobalVariable('_prevFilterBarVisible');
      _fBar.getRootComposition().setVisible(_wasVisible == true);
      engine.unsetGlobalVariable('_prevFilterBarVisible');
   }
   var _btnExit = engine.getWidgetController('btnExitPreview');
   if (_btnExit) {
      _btnExit.getButton().removeStyleName('preview-exit-btn');
      _btnExit.getButton().setVisible(false);
   }
   // Restaura btnTheme do topBar e oculta btnThemeMob ao sair do preview
   var _btnThMobExit = engine.getWidgetController('btnThemeMob');
   if (_btnThMobExit) _btnThMobExit.getButton().setVisible(false);
   var _btnThTbExit = engine.getWidgetController('btnTheme');
   if (_btnThTbExit) _btnThTbExit.getButton().setVisible(true);
   // Reexibe botão preview se o usuário for developer
   var _isDev = engine.getGlobalVariable('isDeveloper');
   var _btnDev = engine.getWidgetController('btnDevPreview');
   if (_btnDev) _btnDev.getButton().setVisible(!!_isDev);
   renderView();
}

5.4 — Checar grupo vi_developer e exibir botão no init()

No init(mapa) ou no run(), após a inicialização principal:

// Verifica acesso de desenvolvedor (vi_developer)
try {
   var _dbLib = libService.loadScript('db');
   var _banco = new _dbLib(_dbLib.VITRUVIO_DATASOURCE);
   var _devRow = _banco.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 ug.grupo_fk = g.grupo_id " +
      "WHERE u.login = '" + engine.getLoggedUser().getLogin() + "' " +
      "AND g.sigla = 'vi_developer'"
   );
   if (_devRow && String(_devRow.CNT) !== '0') {
      engine.setGlobalVariable('isDeveloper', true);
      var _btnDev = engine.getWidgetController('btnDevPreview');
      if (_btnDev) _btnDev.getButton().setVisible(true);
   }
} catch(e) {}
// Registra funções de preview como variáveis globais (acessíveis pelo XML dos botões)
engine.setGlobalVariable('enterMobilePreview', enterMobilePreview);
engine.setGlobalVariable('exitMobilePreview', exitMobilePreview);

Passo 6 — Armadilhas conhecidas (não repita estes erros)

Java Boolean vs JS boolean

engine.getGlobalVariable() retorna Java Boolean, não JS primitive:

// ERRADO — Java Boolean.FALSE !== JS false (tipos diferentes, strict equality falha)
if (_wasVisible !== false) { ... }

// CERTO — loose equality funciona com Java Boolean
if (_wasVisible == true) { ... }
// OU
if (!!_wasVisible) { ... }

setExpandRatio antes de addComponent

// ERRADO — comp não é filho de base ainda, Vaadin lança IllegalArgumentException
comp.setSizeFull();
base.setExpandRatio(comp, 1.0); // ← ERRO: comp não está em base
base.addComponent(comp);

// CERTO — setExpandRatio sempre APÓS addComponent
comp.setSizeFull();
base.removeAllComponents();
base.addComponent(comp);
base.setExpandRatio(comp, 1.0); // ← OK: comp já é filho

base.setHeight(null) no preview — por que funciona

enterMobilePreview põe base (o container do ScriptWidget) em altura natural com base.setHeight(null) — sem isso, o container mantém a altura da viewport inteira e o Panel (contentPanel) não enxerga o conteúdo real para rolar. No _mobileRender real esse mesmo base.setHeight(null) não é usado — lá o renderHtml sempre chama comp.setSizeFull(), porque quem controla a altura do frame é o cálculo de window.screen.height no rootLayout (seção 4.1), não o base.

inline style sobrescreve class CSS

Se o HTML gerado tem style="font-size:12px" no elemento, CSS de classe não sobrescreve (exceto com !important). Mude o valor diretamente na geração do HTML para contexto mobile:

var fontSize = mobileClass ? '14px' : '12px';
html += '<div style="font-size:' + fontSize + ';color:#8899aa;">Texto</div>';

Redimensionar o frame mobile a cada render — não faça isso

Uma versão antiga recalculava a altura do .mobile-root-frame para el.getBoundingClientRect().bottom a cada renderHtml. Isso fazia o frame crescer para caber TODO o conteúdo (não só o viewport), transformando topBar + conteúdo num único bloco comprido que a página externa rolava inteiro — "arrastando" a topBar junto. A altura do .mobile-root-frame é definida uma única vez no bloco _mobileRender do run() (via window.screen.height, seção 4.1) e deve permanecer fixa; renderHtml só libera overflow:visible nos pais intermediários (seção 4.6), nunca redimensiona o frame em si.


Passo 7 — Checklist de entrega

Antes de declarar pronto, confirme:

  • Leu o <key>-desktop.xml alvo e ao menos um outro painel do repositório antes de escrever qualquer coisa
  • Tudo que foi feito tem referência no código existente do repositório — nada inventado
  • Não usou nenhuma feature ES6+ (sem const, let, arrow functions, template literals, destructuring, class, async/await)
  • Botões usam style padrão (DEFAULT, GREEN, RED) — sem HTML customizado para botões
  • Reutilizou funções de formatação já existentes no painel (ex: formatBRL) — sem duplicar
  • Reutilizou classes CSS já existentes — sem criar novas desnecessariamente
  • <key>-mobile.xml criado com DesktopPanel apontando para o panelKey correto
  • forceLayoutsRender lista todos os layouts usados pelo desktop
  • forceFieldsRender lista todos os campos de filtro acessados via engine.getField()
  • vitruvio.json: "mobile" adicionado em "forms" e "showInMobileList": true
  • run() do desktop detecta _mobileRender e adapta rootLayout + filterBar + CSS
  • No _mobileRender real: rootLayout recebe setHeight("700px") + addStyleName('mobile-root-frame'), seguido de page.getJavaScript().execute(...) calculando a altura final a partir de window.screen.height (clamp entre 320px e min(screen.height-220, screen.height*0.80)) — o 700px é só o placeholder inicial, não a altura final do mobile real
  • HTML gerado usa mobileClass e layouts verticais no mobile
  • Tabelas/grids do desktop viram cards empilhados no mobile
  • Fontes mobile respeitam os tamanhos mínimos (labels ≥ 12px, valores ≥ 16px)
  • 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)
  • enterMobilePreview usa base.setHeight(null) (altura natural) — não copia o 700px fixo do _mobileRender real, que só faz sentido para tela de celular
  • exitMobilePreview restaura base.setHeight("100%")
  • JS pós-render (setTimeout 400ms mínimo) só libera overflow:visible nos pais até .mobile-root-frame — nunca redimensiona o frame em si
  • setExpandRatio é chamado APÓS addComponent
  • Funções enterMobilePreview / exitMobilePreview registradas como global vars
  • Botão btnDevPreview visível somente para vi_developer
  • btnThemeMob adicionado como primeiro filho do filterBar, visible="false" no XML, visível no bloco _mobileRender
  • No bloco _mobileRender e em enterMobilePreview: btnTheme (topBar) oculto via setVisible(false) — evita dois botões de tema simultâneos
  • No exitMobilePreview: btnThemeMob oculto e btnTheme restaurado via setVisible(true)
  • doToggleTheme definido no ScriptWidget, registrado no init(), chamado por btnTheme e btnThemeMob
  • doToggleTheme atualiza caption de ambos os botões de tema
  • Comparação de Java Boolean usa == (loose equality), nunca ===
  • CSS de scroll mobile (.mov-scroll, .tec-mob-scroll, .hbar-scroll) está em addCss() — não apenas no bloco _mobileRender
  • Lista de cards de movimentação mobile envolvida em <div class="mov-scroll">
  • Lista de técnicos/ranking mobile envolvida em <div class="tec-mob-scroll">
  • Gráficos svgHBar no mobile envolvidos em <div class="hbar-scroll">

Referência rápida de classes CSS mobile

Classe Uso
.mobile-view Aplicada ao id raiz do HTML gerado quando em modo mobile
.mobile-topbar Estilo do topBar no mobile (botões menores, compactos)
.preview-mobile-frame Borda azul que indica o frame de preview 375px
.preview-exit-btn Botão "✕ Sair Preview" flutuante fixed no canto superior direito
.mov-scroll Container da lista de movimentação (cresce com o conteúdo, scroll acima de 520px)
.tec-mob-scroll Container de lista de técnicos/ranking (cresce com o conteúdo, scroll acima de 420px)
.hbar-scroll Container de gráfico svgHBar (max 280px + scroll)
.mov-cards Container de cards empilhados (substitui a table no mobile)
.mov-card Card individual de cada linha da tabela
.mov-card-cliente Nome/título principal do card (font-size ≥ 15px mobile)
.mov-card-meta Informações secundárias (contrato, tipo, período)
.mov-card-values Flex wrap com os valores numéricos do card
.mov-card-val Célula individual de valor dentro do card
.mov-card-lbl Rótulo do valor (font-size ≥ 12px mobile)