Cartão KPI
Cartão de indicador com número grande, explicação opcional, ícone, variação contra o período anterior (com cor pelo sentido bom ou ruim) e mini gráfico de tendência em área, linha ou barras. Exporta também o Sparkline e o IndicadorDeVariacao avulsos.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Sparkline>
Mini gráfico de tendência em SVG puro (área, linha ou barras), sem eixos. Valores `null` interrompem a linha (ou viram uma barra vazia). Com `rotulo`, o gráfico é uma imagem com descrição para leitores de tela; sem ele, é tratado como decoração (use assim quando o número já está no texto ao lado).
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| serieobrigatória | (number | null)[] | — | |
| tipo | TipoDeSerie | "area" | |
| rotulo | string | — | Descrição para leitores de tela ("Vendas em 12 meses: de 40 para 62"). Sem ela, o SVG fica oculto (decorativo). |
| cor | string | "var(--accent)" | Cor do traço e do preenchimento. |
| largura | number | 96 | Largura em px. |
| altura | number | 32 | Altura em px. |
<IndicadorDeVariacao>
Seta e valor da variação, com cor pelo sentido (verde quando a mudança é boa, vermelho quando é ruim) e a direção escrita para leitores de tela.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| valorobrigatória | number | null | — | Variação já em unidade de exibição. `null` mostra o texto de "sem comparativo". |
| formato | (n: number) => string | formatarPercentual | Formata o valor absoluto (a direção já aparece na seta). |
| positivoEhBom | boolean | true | Subir é bom? Desligue para custos, prazos, erros... |
| textoSemComparativo | string | "sem comparativo" | |
| textos | { alta: string; queda: string; estavel: string; } | { alta: "Alta de", queda: "Queda de", estavel: "Estável:" } | Prefixos lidos só por leitores de tela, no lugar da seta. |
<CartaoKpi>
Cartão de indicador: rótulo com explicação opcional, número grande, ícone, variação contra o período anterior, mini gráfico de tendência e um rodapé livre.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotuloobrigatória | string | — | |
| dica | string | — | Explicação curta do indicador, num ícone "?" ao lado do rótulo. |
| valorobrigatória | string | — | Já formatado: "R$ 48,2 mil", "1.284", "92%". |
| sufixo | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Texto menor na mesma linha do número: unidade ou o mesmo valor dito de outro jeito ("/ 1.500", "≈ US$ 9 mil"). |
| icone | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Ícone num quadrado com a cor de destaque, no canto superior direito. |
| variacao | VariacaoDoKpi | — | |
| serie | (number | null)[] | — | Valores do mini gráfico, do mais antigo ao mais recente. `null` é um buraco. Precisa de 2 ou mais valores. |
| tipoDeSerie | TipoDeSerie | "area" | |
| descricaoDaSerie | string | — | Descrição do mini gráfico para leitores de tela. Sem ela, o gráfico é decorativo (o número já está no texto). |
| rodape | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Linha final em texto pequeno ("Meta: 1.500", "média do time: 12"). |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, type ReactNode } from "react";
import { Dica } from "@/library/dica/Dica";
/** Forma do mini gráfico: área com degradê, só a linha, ou barras. */
export type TipoDeSerie = "area" | "linha" | "barras";
/** Variação do número em relação a um período de comparação. */
export type VariacaoDoKpi = {
/** Variação já em unidade de exibição (ex.: 12.5 para +12,5%). `null` mostra "sem comparativo". */
valor: number | null;
/** Formata o valor **absoluto** da variação (a direção já aparece na seta). Padrão: "12,5%". */
formato?: (n: number) => string;
/** Subir é bom (verde) ou ruim (vermelho)? Desligue para custos, prazos, erros... */
positivoEhBom?: boolean;
/** Texto depois do indicador. Padrão: "vs. período anterior". */
rotulo?: string;
};
const formatarPercentual = (n: number) => `${n.toLocaleString("pt-BR", { maximumFractionDigits: 1 })}%`;
// ─── Sparkline ───────────────────────────────────────────────────────────────
type Ponto = { x: number; y: number };
/** Curva suave (Catmull-Rom convertida em Bézier) passando por todos os pontos. */
function caminhoSuave(pts: Ponto[]): string {
if (pts.length === 0) return "";
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
return d;
}
const valido = (v: number | null): v is number => v !== null && Number.isFinite(v);
/**
* Mini gráfico de tendência em SVG puro (área, linha ou barras), sem eixos.
* Valores `null` interrompem a linha (ou viram uma barra vazia).
*
* Com `rotulo`, o gráfico é uma imagem com descrição para leitores de tela; sem
* ele, é tratado como decoração (use assim quando o número já está no texto ao lado).
*/
export function Sparkline({
serie,
tipo = "area",
rotulo,
cor = "var(--accent)",
largura = 96,
altura = 32,
}: {
serie: (number | null)[];
tipo?: TipoDeSerie;
/** Descrição para leitores de tela ("Vendas em 12 meses: de 40 para 62"). Sem ela, o SVG fica oculto (decorativo). */
rotulo?: string;
/** Cor do traço e do preenchimento. */
cor?: string;
/** Largura em px. */
largura?: number;
/** Altura em px. */
altura?: number;
}) {
const idDegrade = `sparkline-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const valores = serie.filter(valido);
const acessibilidade = rotulo ? { role: "img", "aria-label": rotulo } : { "aria-hidden": true };
if (valores.length < 2) {
return <svg width={largura} height={altura} className="block shrink-0" {...acessibilidade} />;
}
const max = Math.max(...valores);
const min = Math.min(...valores, 0);
const faixa = max - min || 1;
if (tipo === "barras") {
const passo = largura / serie.length;
const vao = Math.min(2, passo * 0.25);
const larguraBarra = Math.max(0.5, passo - vao);
return (
<svg width={largura} height={altura} viewBox={`0 0 ${largura} ${altura}`} className="block shrink-0" {...acessibilidade}>
{serie.map((v, i) => {
const h = valido(v) ? Math.max(2, ((v - min) / faixa) * (altura - 2)) : 2;
return (
<rect
key={i}
x={i * passo + vao / 2}
y={altura - h}
width={larguraBarra}
height={h}
rx={Math.min(larguraBarra / 2, 2)}
fill={valido(v) ? cor : "var(--track)"}
opacity={valido(v) ? 0.8 : 1}
/>
);
})}
</svg>
);
}
// Linha e área: um trecho por sequência sem buracos, com 3px de folga em cima e embaixo.
const passoX = largura / (serie.length - 1);
const trechos: Ponto[][] = [];
let atual: Ponto[] = [];
serie.forEach((v, i) => {
if (!valido(v)) {
if (atual.length) trechos.push(atual);
atual = [];
return;
}
atual.push({ x: i * passoX, y: altura - 3 - ((v - min) / faixa) * (altura - 6) });
});
if (atual.length) trechos.push(atual);
return (
<svg
width={largura}
height={altura}
viewBox={`0 0 ${largura} ${altura}`}
className="block shrink-0 overflow-visible"
{...acessibilidade}
>
<defs>
<linearGradient id={idDegrade} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={cor} stopOpacity={tipo === "area" ? 0.28 : 0} />
<stop offset="100%" stopColor={cor} stopOpacity={0} />
</linearGradient>
</defs>
{trechos.map((pts, i) => {
const linha = caminhoSuave(pts);
const ultimo = pts[pts.length - 1];
return (
<g key={i}>
{tipo === "area" && pts.length > 1 && (
<path d={`${linha} L ${ultimo.x} ${altura} L ${pts[0].x} ${altura} Z`} fill={`url(#${idDegrade})`} />
)}
{pts.length === 1 ? (
<circle cx={pts[0].x} cy={pts[0].y} r={1.5} fill={cor} />
) : (
<path d={linha} fill="none" stroke={cor} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
)}
</g>
);
})}
</svg>
);
}
// ─── Indicador de variação ───────────────────────────────────────────────────
/**
* Seta e valor da variação, com cor pelo sentido (verde quando a mudança é boa,
* vermelho quando é ruim) e a direção escrita para leitores de tela.
*/
export function IndicadorDeVariacao({
valor,
formato = formatarPercentual,
positivoEhBom = true,
textoSemComparativo = "sem comparativo",
textos = { alta: "Alta de", queda: "Queda de", estavel: "Estável:" },
}: {
/** Variação já em unidade de exibição. `null` mostra o texto de "sem comparativo". */
valor: number | null;
/** Formata o valor absoluto (a direção já aparece na seta). */
formato?: (n: number) => string;
/** Subir é bom? Desligue para custos, prazos, erros... */
positivoEhBom?: boolean;
textoSemComparativo?: string;
/** Prefixos lidos só por leitores de tela, no lugar da seta. */
textos?: { alta: string; queda: string; estavel: string };
}) {
if (valor === null || !Number.isFinite(valor)) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted">
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<path d="M5 12h14" />
</svg>
{textoSemComparativo}
</span>
);
}
const sentido = valor > 0 ? "alta" : valor < 0 ? "queda" : "estavel";
const bom = sentido === "estavel" ? null : (sentido === "alta") === positivoEhBom;
const cor = bom === null ? "text-muted" : bom ? "text-success" : "text-critical";
return (
<span className={`inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums ${cor}`}>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2.25}
strokeLinecap="round"
strokeLinejoin="round"
>
{sentido === "alta" && <path d="M7 17 17 7M8 7h9v9" />}
{sentido === "queda" && <path d="M7 7l10 10M17 8v9H8" />}
{sentido === "estavel" && <path d="M5 12h14m-4-4 4 4-4 4" />}
</svg>
<span className="sr-only">{textos[sentido]} </span>
{formato(Math.abs(valor))}
</span>
);
}
// ─── Cartão ──────────────────────────────────────────────────────────────────
/**
* Cartão de indicador: rótulo com explicação opcional, número grande, ícone,
* variação contra o período anterior, mini gráfico de tendência e um rodapé livre.
*/
export function CartaoKpi({
rotulo,
dica,
valor,
sufixo,
icone,
variacao,
serie,
tipoDeSerie = "area",
descricaoDaSerie,
rodape,
}: {
rotulo: string;
/** Explicação curta do indicador, num ícone "?" ao lado do rótulo. */
dica?: string;
/** Já formatado: "R$ 48,2 mil", "1.284", "92%". */
valor: string;
/** Texto menor na mesma linha do número: unidade ou o mesmo valor dito de outro jeito ("/ 1.500", "≈ US$ 9 mil"). */
sufixo?: ReactNode;
/** Ícone num quadrado com a cor de destaque, no canto superior direito. */
icone?: ReactNode;
variacao?: VariacaoDoKpi;
/** Valores do mini gráfico, do mais antigo ao mais recente. `null` é um buraco. Precisa de 2 ou mais valores. */
serie?: (number | null)[];
tipoDeSerie?: TipoDeSerie;
/** Descrição do mini gráfico para leitores de tela. Sem ela, o gráfico é decorativo (o número já está no texto). */
descricaoDaSerie?: string;
/** Linha final em texto pequeno ("Meta: 1.500", "média do time: 12"). */
rodape?: ReactNode;
}) {
const temSerie = serie !== undefined && serie.filter(valido).length >= 2;
return (
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-rule bg-page p-5">
<div className="flex items-start justify-between gap-3">
<p className="flex min-w-0 items-center gap-1 text-sm text-ink-2">
<span className="truncate">{rotulo}</span>
{dica && <Dica texto={dica} termo={rotulo} />}
</p>
{icone && (
<span
aria-hidden="true"
className="-my-1 flex size-9 shrink-0 items-center justify-center rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*4))] bg-accent/10 text-accent [&_svg]:size-5"
>
{icone}
</span>
)}
</div>
<div className="flex min-h-8 items-end justify-between gap-3">
{/* min-w-0: o número trunca em vez de empurrar o gráfico para fora do cartão. */}
<p className="flex min-w-0 items-baseline gap-1.5">
<span className="truncate font-display text-3xl leading-none font-semibold tracking-tight tabular-nums">{valor}</span>
{sufixo && <span className="shrink-0 text-sm leading-none text-muted tabular-nums">{sufixo}</span>}
</p>
{temSerie && <Sparkline serie={serie} tipo={tipoDeSerie} rotulo={descricaoDaSerie} />}
</div>
{variacao && (
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<IndicadorDeVariacao valor={variacao.valor} formato={variacao.formato} positivoEhBom={variacao.positivoEhBom} />
<span className="text-xs text-muted">{variacao.rotulo ?? "vs. período anterior"}</span>
</p>
)}
{rodape && (
<div className="flex items-center justify-between gap-2 border-t border-rule pt-3 text-xs text-muted tabular-nums">
{rodape}
</div>
)}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { CartaoKpi, IndicadorDeVariacao, Sparkline, type TipoDeSerie } from "./CartaoKpi";
const VENDAS = [38, 42, 40, 47, 45, 52, 49, 55, 58, 54, 61, 64];
const TICKET = [212, 205, 219, 214, 208, 201, 198, 204, 196, 191, 188, 186];
const CHAMADOS = [14, 9, 12, 7, 11, 6, 8, 5, 9, 4, 6, 3];
const ACESSOS = [820, 910, null, 1040, 990, 1120, 1180, 1090, 1240, 1310, 1280, 1360];
const icone = (d: string) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
const ICONES = {
vendas: icone("M3 3v18h18M7 15l4-4 3 3 6-6"),
ticket: icone("M4 7h16v10H4zM8 12h.01M16 12h.01M12 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5"),
chamados: icone("M21 12a8 8 0 0 1-11.6 7.1L4 21l1.9-5.4A8 8 0 1 1 21 12z"),
acessos: icone("M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"),
};
export default defineStories({
args: {
rotulo: "Vendas no mês",
valor: "R$ 64,2 mil",
sufixo: "",
dica: "Soma dos pedidos pagos no mês, sem frete.",
comIcone: true,
variacao: 12.4,
semComparativo: false,
positivoEhBom: true,
tipoDeSerie: "area",
comSerie: true,
rodape: "Meta do mês: R$ 70 mil",
},
controls: {
rotulo: { type: "text" },
valor: { type: "text" },
sufixo: { type: "text", label: "sufixo (vazio = sem)" },
dica: { type: "text", label: "dica (vazio = sem)" },
comIcone: { type: "boolean", label: "ícone (exemplo)" },
variacao: { type: "number", label: "variacao.valor (%)", min: -50, max: 50, step: 0.1 },
semComparativo: { type: "boolean", label: "sem comparativo (variacao.valor = null)" },
positivoEhBom: { type: "boolean", label: "variacao.positivoEhBom" },
tipoDeSerie: { type: "select", options: ["area", "linha", "barras"] },
comSerie: { type: "boolean", label: "série (exemplo)" },
rodape: { type: "text", label: "rodape (vazio = sem)" },
},
render: (a) => (
<div className="w-full max-w-sm">
<CartaoKpi
rotulo={a.rotulo}
valor={a.valor}
sufixo={a.sufixo || undefined}
dica={a.dica || undefined}
icone={a.comIcone ? ICONES.vendas : undefined}
variacao={{ valor: a.semComparativo ? null : a.variacao, positivoEhBom: a.positivoEhBom }}
serie={a.comSerie ? VENDAS : undefined}
tipoDeSerie={a.tipoDeSerie as TipoDeSerie}
descricaoDaSerie="Vendas nos últimos 12 meses, de R$ 38 mil para R$ 64 mil."
rodape={a.rodape || undefined}
/>
</div>
),
stories: [
{ name: "Completo" },
{
name: "Painel de indicadores",
render: () => (
<div className="grid w-full max-w-5xl gap-4 sm:grid-cols-2 lg:grid-cols-4">
<CartaoKpi
rotulo="Vendas no mês"
valor="R$ 64,2 mil"
icone={ICONES.vendas}
variacao={{ valor: 12.4 }}
serie={VENDAS}
descricaoDaSerie="Vendas nos últimos 12 meses, de R$ 38 mil para R$ 64 mil."
/>
<CartaoKpi
rotulo="Ticket médio"
dica="Valor médio por pedido pago."
valor="R$ 186"
icone={ICONES.ticket}
variacao={{ valor: -3.2 }}
serie={TICKET}
tipoDeSerie="linha"
/>
<CartaoKpi
rotulo="Chamados abertos"
valor="3"
sufixo="/ 14 no início"
icone={ICONES.chamados}
variacao={{ valor: -50, positivoEhBom: false, rotulo: "vs. semana passada" }}
serie={CHAMADOS}
tipoDeSerie="barras"
/>
<CartaoKpi
rotulo="Acessos por dia"
valor="1.360"
icone={ICONES.acessos}
variacao={{ valor: null }}
serie={ACESSOS}
rodape={
<>
<span>média: 1.125</span>
<span>pico: 1.360</span>
</>
}
/>
</div>
),
},
{ name: "Custo (subir é ruim)", args: { rotulo: "Custo por pedido", valor: "R$ 8,40", variacao: 6.1, positivoEhBom: false, tipoDeSerie: "linha", dica: "", rodape: "" } },
{ name: "Mínimo", args: { comIcone: false, comSerie: false, dica: "", rodape: "", semComparativo: true } },
{
name: "Peças avulsas",
render: () => (
<div className="flex flex-col gap-4 text-sm">
<div className="flex flex-wrap items-center gap-5">
<Sparkline serie={VENDAS} tipo="area" rotulo="Tendência de vendas: alta" />
<Sparkline serie={TICKET} tipo="linha" cor="var(--series-2)" />
<Sparkline serie={CHAMADOS} tipo="barras" cor="var(--series-3)" />
<Sparkline serie={ACESSOS} tipo="linha" largura={160} altura={40} />
</div>
<div className="flex flex-wrap items-center gap-5">
<IndicadorDeVariacao valor={8.5} />
<IndicadorDeVariacao valor={-4} />
<IndicadorDeVariacao valor={0} />
<IndicadorDeVariacao valor={12} positivoEhBom={false} />
<IndicadorDeVariacao valor={-1500} formato={(n) => `R$ ${n.toLocaleString("pt-BR")}`} />
<IndicadorDeVariacao valor={null} />
</div>
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Componentes da biblioteca (diretos e indiretos)
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}