This commit is contained in:
Matheus
2026-09-23 12:29:08 -03:00
commit f5d231ab8f
59 changed files with 16658 additions and 0 deletions
@@ -0,0 +1,784 @@
---
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) |
@@ -0,0 +1,28 @@
<?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="dashboard-contratos-mobile">
<name>Dashboard de Contratos</name>
<description>Dashboard de Contratos</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="dashContratos" panelKey="dashboard-contratos"
layoutId="rootLayout" external="true" height="100%"
forceLayoutsRender="topBar, filterBar, mainArea, contentPanel, panelRoot"
forceFieldsRender="cmbAno, cmbMes, cmbOrdem" />
</VerticalLayout>
</components>
</form>
</panel-form>
@@ -0,0 +1,28 @@
<?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="dashboard-tecnicos-mobile">
<name>Dashboard Técnicos</name>
<description>Dashboard de produtividade dos técnicos com ranking, SLA e detalhamento por OS.</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="dashTecnicosMobile" panelKey="dashboard_tecnicos"
layoutId="rootLayout" external="true" height="100%"
forceLayoutsRender="topBar, mainArea, filterBar, contentPanel, panelRoot"
forceFieldsRender="dtIni, dtFim, nmfSla, cmbExecucao" />
</VerticalLayout>
</components>
</form>
</panel-form>
@@ -0,0 +1,90 @@
/**
* Nome: Parâmetro de indicadores
* Sigla: dashboard_ia
* Descrição: Biblioteca compartilhada dos dashboards de indicadores da Diretoria: preferências de usuário (tema), CSS do topbar e toolbar, helpers de tema.
*/
(function() {
var PREF_NS = 'preferences';
function loadPreferences(login) {
try {
var raw = vConfigService.getUserConfigAsString(String(login), PREF_NS);
if (raw != null && String(raw) !== '') {
return JSON.parse(String(raw));
}
} catch (e) {}
return { dark_mode: true };
}
function savePreferences(login, prefs) {
try {
vConfigService.saveUserConfig(String(login), PREF_NS, JSON.stringify(prefs));
} catch (e) {}
}
function getTheme(engine) {
return String(engine.getGlobalVariable('theme') || 'dark');
}
function isLight(engine) {
return getTheme(engine) === 'light';
}
function applyTheme(themeName, page, btnTheme) {
page.getJavaScript().execute(
themeName === 'light'
? "document.body.classList.add('theme-light-app');"
: "document.body.classList.remove('theme-light-app');"
);
if (btnTheme != null) {
btnTheme.setCaption(themeName === 'dark' ? '☽' : '☀');
}
}
function addSharedCss(page) {
var style =
".dash-topbar { background:#0f1923 !important; border-bottom:1px solid #243447; } " +
".dash-topbar .v-button { background:transparent !important; border:1px solid transparent !important; color:#556677 !important; border-radius:4px !important; transition:none !important; } " +
".dash-topbar .v-button .v-button-caption { color:#556677 !important; } " +
".dash-topbar .v-button.dash-nav-active { background:#1a2733 !important; border-color:#4a9edd !important; } " +
".dash-topbar .v-button.dash-nav-active .v-button-caption { color:#4a9edd !important; font-weight:bold !important; } " +
".dash-topbar .v-caption { color:#8899aa !important; font-size:10px !important; } " +
".dash-topbar input.v-textfield, .dash-topbar .v-datefield-textfield, .dash-topbar input.v-filterselect-input { background:#1a2733 !important; color:#ccc !important; border-color:#243447 !important; } " +
".dash-topbar .v-datefield-button { background:#1a2733 !important; border-color:#243447 !important; } " +
".dash-topbar .v-select-select { background:#1a2733 !important; color:#ccc !important; border-color:#243447 !important; } " +
"body.theme-light-app .dash-topbar { background:#f8fafc !important; border-bottom:1px solid #e2e8f0; } " +
"body.theme-light-app .dash-topbar .v-button { color:#475569 !important; } " +
"body.theme-light-app .dash-topbar .v-button .v-button-caption { color:#475569 !important; } " +
"body.theme-light-app .dash-topbar .v-button.dash-nav-active { background:#dbeafe !important; border-color:#2563eb !important; color:#2563eb !important; } " +
"body.theme-light-app .dash-topbar .v-button.dash-nav-active .v-button-caption { color:#2563eb !important; } " +
"body.theme-light-app .dash-topbar input.v-textfield, body.theme-light-app .dash-topbar .v-datefield-textfield, body.theme-light-app .dash-topbar input.v-filterselect-input { background:#ffffff !important; color:#334155 !important; border-color:#e2e8f0 !important; } " +
"body.theme-light-app .dash-topbar .v-datefield-button { background:#ffffff !important; border-color:#e2e8f0 !important; } " +
"body.theme-light-app .dash-topbar .v-select-select { background:#ffffff !important; color:#334155 !important; border-color:#e2e8f0 !important; } " +
".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; } " +
".mov-toolbar { display:flex; align-items:center; gap:8px; margin-bottom:8px; } " +
".mov-filter-input { background:#1a2733; border:1px solid #243447; color:#ccc; padding:5px 10px; border-radius:4px; font-size:11px; flex:1; 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; } " +
".theme-light .mov-filter-input { background:#ffffff; border-color:#e2e8f0; color:#334155; } " +
".theme-light .mov-filter-input:focus { border-color:#2563eb; } " +
".theme-light .mov-export-btn { background:#ffffff; border-color:#e2e8f0; color:#64748b; } " +
".theme-light .mov-export-btn:hover { border-color:#2563eb; color:#2563eb; } ";
page.getStyles().add(style);
}
return {
loadPreferences: loadPreferences,
savePreferences: savePreferences,
getTheme: getTheme,
isLight: isLight,
applyTheme: applyTheme,
addSharedCss: addSharedCss
};
})()