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

785 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: vitruvio-criar-dashboard-mobile
description: >
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**:
```xml
<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.
```xml
<!-- 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>
```
```javascript
// 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:
```javascript
// 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
```bash
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
<?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`
```json
{
"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:
```javascript
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:
```javascript
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):
```javascript
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):
```javascript
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:
```javascript
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).
```javascript
// ── 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:**
```javascript
// ── 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:
```javascript
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)
```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:
```javascript
".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()`:
```javascript
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:
```javascript
// 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:
```javascript
// 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
```javascript
// 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:
```javascript
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) |