Buscar K
Aparência
Aparência
Um Widget Svelte roda em mais lugares do que o Dashboard onde foi escrito: em outro Tenant depois de instalado por template, no tema escuro, dentro do aplicativo móvel com a cor da marca, no PDF de um alerta. As regras abaixo são o que separa um Widget que funciona nesses lugares de um que funciona só na tela de quem o escreveu. São as mesmas que o assistente de IA do editor segue.
Várias das regras saem de um fato: o Widget é montado de novo a cada recarga do Dashboard, com o onMount rodando outra vez e toda variável no valor inicial (veja Como funciona).
O código vale em qualquer Tenant só se apontar as colunas pelo nome; o motivo está no aviso de Referência de coluna.
// certo
const VALOR = '[Vendas]."VALOR":SUM';
const RECEITA = '["Receita YTD"]';
app.getDateFilterReference(app.columnFromString('[Vendas]."DATA":DATE').column_id);
// errado
const VALOR = '7905:SUM';
app.getDateFilterReference('1185933');Guarde os nomes em constantes no topo do script. Quem for adaptar o Widget a outra Aplicação troca só ali. As três formas de nome estão em Referência de coluna.
O banco devolve 1540.5 e "2026-03-15". A coluna sabe que aquilo é "R$ 1.540,50" e "15/03/2026", no idioma de quem vê. app.formatValue aplica a máscara configurada na coluna, e quem muda a máscara na Aplicação muda o Widget junto.
// certo
const texto = app.formatValue(linha[1], res.Columns[1]);
// errado: ignora a máscara, o idioma e a moeda
const texto = 'R$ ' + linha[1].toFixed(2);Vale para rótulo de eixo, tooltip e célula de tabela. No Highcharts, use formatter chamando app.formatValue.
A consulta leva tempo, pode falhar (coluna sem acesso, tempo esgotado) e pode voltar sem linha (filtro que não casa nada). Sem os três estados, o Widget fica em branco e ninguém sabe se é bug ou ausência de dado.
<script>
import { onMount } from 'svelte';
export let app;
let carregando = true, erro = null, linhas = [];
onMount(async () => {
try {
const res = await app.fetchMatrix({ columns: ['[Vendas]."LOJA":AGP', '[Vendas]."VALOR":SUM'] });
linhas = res.Result;
} catch (e) {
erro = e?.message ?? String(e);
} finally {
carregando = false;
}
});
</script>
{#if carregando}
<div class="flex h-full items-center justify-center text-slate-500 dark:text-navy-300">Carregando…</div>
{:else if erro}
<div class="flex h-full items-center justify-center text-sm text-error">{erro}</div>
{:else if !linhas.length}
<div class="flex h-full items-center justify-center text-slate-500 dark:text-navy-300">Sem dados no período</div>
{:else}
<!-- conteúdo -->
{/if}fetchMatrix e fetchData já levam os filtros da barra, da aba ativa, os cruzados e os Filtros Pré Aplicados do Widget. Repetir um filtro do Dashboard em filters não muda nada no caso comum e, na coluna de data, troca o período do Dashboard pelo seu, sem que o leitor perceba.
// certo: o período do Dashboard já está na consulta
const res = await app.fetchMatrix({ columns: [DATA, VALOR] });
// errado: congela o período que estava aplicado quando o código foi escrito
const res = await app.fetchMatrix({ columns: [DATA, VALOR],
filters: [{ column: DATA, operator: 'between', value: '2026-01-01,2026-01-31' }] });Passe em filters só o que é do Widget ("só pedidos faturados"). Para uma consulta que deve ignorar um filtro do Dashboard, como o total do ano ao lado do mês, use ignoreCrossfilters.
Cada fetchMatrix é uma consulta ao banco, e ela roda de novo a cada recarga do Dashboard, para cada pessoa que o abre. Um Widget com dez consultas num Dashboard com vinte filtros trocados por dia dá centenas de consultas por pessoa.
// certo: uma consulta com as três medidas
const res = await app.fetchMatrix({ columns: [LOJA, VALOR, QTD, META] });
// errado: uma consulta por medida
const v = await app.fetchMatrix({ columns: [LOJA, VALOR] });
const q = await app.fetchMatrix({ columns: [LOJA, QTD] });
const m = await app.fetchMatrix({ columns: [LOJA, META] });Ponha limit no que vai para a tela: um ranking de 10 não precisa das 500 linhas do padrão. Consultas que não dependem uma da outra rodam juntas com Promise.all.
O compilador usa Svelte 4. A sintaxe do Svelte 5 não funciona: as runes ($state, $props) dão erro ou não reagem, e onclick={fn} compila, mas vira um atributo morto e o clique não faz nada.
| Svelte 4 (use) | Svelte 5 (não funciona) |
|---|---|
export let app; | let { app } = $props(); |
let total = 0; | let total = $state(0); |
$: dobro = total * 2; | let dobro = $derived(total * 2); |
on:click={filtrar} | onclick={filtrar} |
<slot /> | {@render children()} |
O Widget já tem largura, altura e rolagem definidas pelo cartão onde está. Na raiz do componente, nunca junte h-full (altura de 100%) com overflow-auto: isso cria uma segunda barra de rolagem dentro do cartão, e na exportação para PDF e imagem o que está abaixo da dobra sai cortado.
<!-- certo: a raiz cresce com o conteúdo; dentro dela, o gráfico pode ocupar 100% -->
<div class="w-full p-4">
<div class="h-full"><Chart {Highcharts} {options} /></div>
</div>
<!-- errado -->
<div class="h-full overflow-auto p-4">…</div>O h-full sozinho serve para centralizar os estados de carregando e de erro. Com a opção Crescer com o conteúdo ligada, o cartão não tem altura fixa e um gráfico que depende de height: 100% some: dê altura explícita ao gráfico (chart: { height: 320 }). Para cortar o que passa da borda em vez de rolar, há a opção Cortar conteúdo que passa da borda no configurador.
O mesmo Widget aparece no tema claro, no escuro e dentro do aplicativo móvel do Lumo, que repinta a página com as cores da marca do cliente. O aplicativo repinta só as classes que a web usa. Fundo, borda e texto escritos com outra classe ou em hex ficam com a cor errada lá.
Marcação. Use as classes desta tabela. No escuro, sempre a paleta navy, nunca gray, slate ou zinc em dark:.
| Papel | Claro | Escuro |
|---|---|---|
| Fundo da página | bg-slate-50 | dark:bg-navy-900 |
| Cartão | bg-white | dark:bg-navy-800 |
| Painel interno | bg-slate-100 | dark:bg-navy-700 |
| Borda | border-slate-200 | dark:border-navy-600 |
| Texto | text-slate-800, text-slate-700 | dark:text-navy-100, dark:text-white |
| Texto secundário | text-slate-500 | dark:text-navy-300 |
A cor do dado (série, vermelho e verde de status, destaque) é livre.
CSS próprio. No <style>, não use hex fixo para fundo, borda ou texto. Use a variável do aplicativo com a cor da web como reserva; fora do aplicativo a variável não existe e vale a reserva.
<div class="widget" class:dark={$app.theme === 'dark'}>…</div>
<style>
.widget { --superficie: var(--mt-cartao, #FFFFFF); --linha: var(--mt-linha, #E2E8F0);
--texto: var(--mt-texto, #1E293B); --secundario: var(--mt-texto-secundario, #64748B); }
.widget.dark { --superficie: var(--mt-cartao, #202B40); --linha: var(--mt-linha, #313E59);
--texto: var(--mt-texto, #C2C9D6); --secundario: var(--mt-texto-secundario, #697A9B); }
</style>As variáveis são --mt-fundo, --mt-cartao, --mt-rebaixado, --mt-linha, --mt-texto, --mt-texto-secundario e --mt-acao.
Gráfico. O Highcharts não lê classe do Tailwind nem variável CSS: var(--mt-linha) passado a ele vira cor nenhuma. Monte as cores em JavaScript a partir de $app.moldura, com reserva, e monte as opções do gráfico com $:, para que ele repinte quando o tema muda.
$: escuro = $app.theme === 'dark';
$: marca = $app.moldura?.ativa ? $app.moldura.cores : null;
$: cores = {
texto: marca?.texto ?? (escuro ? '#C2C9D6' : '#1E293B'),
secundario: marca?.textoSecundario ?? (escuro ? '#697A9B' : '#64748B'),
linha: marca?.linha ?? (escuro ? '#313E59' : '#E2E8F0'),
superficie: marca?.cartao ?? (escuro ? '#202B40' : '#FFFFFF'),
};
$: options = dados && montarOpcoes(dados, cores);new Date('2026-02-23') é meia-noite em UTC, que no Brasil (UTC-3) ainda é dia 22. Um calendário ou um rótulo de dia montado assim mostra o dia anterior.
// certo
const [a, m, d] = '2026-02-23'.split('-');
const dia = new Date(a, m - 1, d); // meia-noite local
// ou
import { parseISO } from 'date-fns';
const dia2 = parseISO('2026-02-23'); // também local
// errado
new Date('2026-02-23').getDate(); // 22Para mostrar a data, app.formatValue resolve sem esse problema.
O Widget também roda sem ninguém olhando: na exportação de PDF e imagem e no envio de alerta, uma vez para cada renderização. Nesse contexto, app.user é quem disparou a exportação, não quem recebe. A exportação espera as consultas feitas pelo app antes de capturar a tela.
Daí duas regras. Não personalize o conteúdo por app.user num Widget que vai em alerta: os trinta destinatários recebem a saudação com o nome do dono do alerta. E não faça efeito colateral ao montar: chamar a API do seu ERP no onMount dispara a chamada em cada abertura, cada recarga de filtro e cada PDF. Ação que muda algo fora do Lumo vai num clique.
Como o componente é recriado a cada recarga, a seleção que o leitor fez num clique se perde quando esse mesmo clique filtra o Dashboard. Derive o que está selecionado do filtro, que sobrevive à recarga.
// certo: ao montar, lê o filtro que está na barra
const COL = app.columnFromString('[Vendas]."VENDEDOR":AGP');
const selecionado = app.getFilter(COL)?.value ?? null;
// errado: some na recarga que o próprio addFilter dispara
let selecionado = null;
function clicar(nome) { selecionado = nome; app.addFilter(COL, '=', nome); }O código vai inteiro para o navegador de quem abre o Dashboard (veja o que o código não pode fazer). Chave de API, senha e token fixo vazam no primeiro acesso; para falar com uma API sua, use app.generateJWTToken() (JWT). Esconder uma coluna no código também não protege nada: o que o leitor não pode ver se tira do acesso dele, no Datamart; veja Acesso a dados.
Num componente paramétrico, o mesmo código serve a vários Dashboards. Coluna, título, cor e meta escritos no código obrigam a editar o componente para cada uso, e a edição publicada chega a todos. Leia de config tudo que muda de um uso para outro, e declare no contrato.
// certo
const coluna = app.columnFromString(config.valor);
const titulo = config.titulo;
// errado
const coluna = app.columnFromString('[Vendas]."VALOR":SUM');
const titulo = 'Vendas do mês';:AGP, :SUM); nenhum ID numérico no códigoapp.formatValuetry/catchfilterslimit no que vai para a telaexport let, $:, on:clickh-full junto com overflow-autonavy no dark:; CSS próprio com var(--mt-…, <cor da web>); cores do gráfico de $app.moldura, com $:app.user em Widget de alertaapp.getFilter, não de variávelconfig