Estilos & Design Tokens
O SDK expõe um conjunto de CSS Custom Properties (--tempest-*) que controlam toda a aparência dos componentes. Apps consumidores customizam o tema sobrescrevendo esses tokens — não é necessário tocar em CSS Modules.
import "tempest-react-sdk/styles.css";
Pronto. Tudo o que está abaixo já está disponível na sua aplicação.
Sobrescreva tokens no :root
A única forma de tematizar é redefinir os tokens --tempest-* no seu próprio
CSS. Coloque os overrides num :root (ou numa subárvore para escopo parcial)
depois do import — nunca edite os CSS Modules do SDK.
Tokens são API pública
Os nomes --tempest-* fazem parte do contrato semver do SDK. Veja a
política de versionamento no fim da
página antes de depender de um token específico.
Seção utilities-css da gallery — rode localmente para interagir.
Sumário
- Cor
- Brand — primary tints
- Neutros — gray scale
- Status — triplets (fg/bg/border/solid)
- Data viz — cores de série
- Gerando a paleta com
createTheme - Tipografia
- Espaçamento
- Radius
- Elevação (shadow)
- Motion
- A superfície do documento
color-scheme— as superfícies que o browser pinta- Focus ring
- Estado selecionado
- Z-index
- Densidade —
data-tempest-density - Tema dark —
data-tempest-theme - Componentes — variants disponíveis
- Quando o app já tem o próprio layout
- O foco dos seus próprios elementos
- O plugin —
tempestStyles() - Camada utilitária opt-in —
utilities.css
Importar menos CSS
Cada componente carrega o próprio CSS. import { Button } traz o markup do
botão e a folha do botão junto — você não lista folha nenhuma, e não há folha
global de componentes para importar:
// src/main.tsx — as duas linhas de fundação, e mais nada
import "tempest-react-sdk/styles/tokens.css";
import "tempest-react-sdk/styles/scoped.css";
Medido num app Vite montando Button, Card e Badge:
| O que o app importa | raw | brotli |
|---|---|---|
styles.css (a folha inteira) |
241,73 kB | 29,15 kB |
| fundação + os componentes montados | 27,40 kB | 4,49 kB |
idem, com tempestStyles() enxugando os tokens |
21,86 kB | 3,37 kB |
O mesmo app em webpack, sem plugin nenhum: 28,53 kB raw / 4,88 kB brotli. O
corte não depende de plugin nem de bundler — cada *.module.js publicado
importa a folha do seu componente, e sideEffects: ["**/*.css"] no
package.json é o que deixa o bundler jogar fora a folha de um componente que
o app não alcança.
Fora de um bundler, use o loader que o pacote ships
O import de .css que faz isso funcionar é o mesmo que o Node não sabe
carregar: node -e "import('tempest-react-sdk')" morre com
ERR_UNKNOWN_FILE_EXTENSION. Vite, webpack, Rspack, Parcel e esbuild
resolvem esse import; Node cru, um script .mjs solto e o require() do
Jest não. Como o SDK é client-side only e roda sempre atrás de um bundler,
nenhum consumidor real de app fica de fora — mas o seu script, o seu runner
de teste e qualquer leitura da superfície por introspecção ficam.
Para esses, o pacote ships a peça em vez de mandar você escrevê-la:
node --import tempest-react-sdk/node-css-loader seu-script.mjs
Ou de dentro do script, antes do primeiro import do pacote:
import "tempest-react-sdk/node-css-loader";
const sdk = await import("tempest-react-sdk");
console.log(Object.keys(sdk).length);
Ele resolve toda folha para um módulo vazio, em import e em require
— nada de estilo é emulado, que é a resposta certa num contexto que não
pinta nada.
No Vitest, o createViteConfig já resolve: ele inclui o tempestVitest(),
que faz o Vitest transformar o SDK em vez de entregá-lo ao Node. Com um
vitest.config.ts próprio, adicione o plugin — sem ele, a suíte cai com
Unknown file extension ".css" (detalhes em
Testes com Vitest). No Jest, mapeie a
extensão para um stub, que é a mesma configuração que qualquer projeto com
CSS Modules já carrega:
// jest.config.js
export default {
moduleNameMapper: { "\\.css$": "<rootDir>/test/css-stub.js" },
};
Com test/css-stub.js exportando um objeto vazio (module.exports = {}).
O render é o mesmo, byte a byte de CSS a menos
Medido em browser real, 37 seletores comparados com transition desligada,
claro e escuro: os computed styles de styles.css inteiro e do caminho por
componente são idênticos. O que muda é quanto CSS chega, não o que ele
faz.
styles.css continua publicado
Uma linha, todos os 132 componentes, e nada quebra: o bundler deduplica a
folha que o componente importa contra a que você importou. Custa bytes, nunca
correção — é a mesma troca do auto.css.
Ainda precisa de fundação
A fundação é a única coisa que o componente não traz, e não é esquecimento: os
tokens vivem em :root, e uma folha por componente que os repetisse poria N blocos
de mesma especificidade competindo com o :root do seu app — createTheme e
qualquer override seu parariam de mandar. Medido, embutir custa +14 kB raw para
devolver 351 B brotli, então a troca também é ruim em bytes.
Ela vem em quatro peças, e qual combinação usar depende de quem manda no documento — as duas linhas acima são a resposta para um app com layout próprio, e Quando o app já tem o próprio layout tem a tabela das quatro e o porquê de cada uma.
Importando folha à mão
Os caminhos antigos continuam de pé, para o caso em que a varredura do bundler não
alcança o componente — render por dangerouslySetInnerHTML, markup montado por
outro pacote, uma página que só existe em runtime:
import "tempest-react-sdk/styles/core.css";
import "tempest-react-sdk/styles/component/Button.css";
import "tempest-react-sdk/styles/forms.css";
| Entrada | O que traz | Quando |
|---|---|---|
styles.css |
tudo | uma linha só, e o peso não incomoda |
styles/core.css |
fundação, zero componente | sempre, com qualquer das outras |
styles/inverse.css |
a superfície invertida do azul do SDK — opt-in, fora do styles.css |
você pinta uma seção na marca e não gera o tema com inverse |
styles/<Grupo>.css |
uma família inteira | você usa boa parte dela |
styles/component/<Componente>.css |
um componente | você quer o mínimo |
Grupos disponíveis: actions, advanced, br, chat, data, editor,
feedback, forms, geo, icons, identity, layout, media,
navigation, overlay, utility.
Prefira styles/component/<X>.css a styles/<X>.css
styles/<Componente>.css existe e funciona — é um @import de uma linha do
arquivo em component/. Mas dois nomes de componente colidem com nome de grupo
num sistema de arquivos que ignora caixa: Chat.css com chat.css, Layout.css
com layout.css. No macOS e no Windows o grupo ganha, então esses dois importes
entregam a família inteira em vez do componente — mais bytes, nunca menos regra.
O caminho component/ não tem esse problema por construção, e é o que o
componente usa internamente.
A divisão é exata, não uma poda
Cada classe é hasheada por módulo CSS (tempest_[local]_[hash]), e cada
dist/**/*.module.js carrega o caminho de origem junto dos nomes que aquele
módulo declara — atribuir uma regra a um componente é consulta, não palpite. O
build falha se alguma regra nomear classes de dois módulos, que é o que
tornaria a divisão um palpite — e é também o que garante que a ordem em que as
folhas chegam não importa.
Quando o app já tem o próprio layout
core.css é fundação e reset global ao mesmo tempo, e o reset reivindica o
documento inteiro:
*, ::before, ::after { box-sizing: border-box }
:where(html, body) { height: 100% }
:where(#root) { min-height: 100% }
:where(#root:has(> :only-child)) { height: 100% }
body { margin: 0; background: var(--tempest-bg); color: var(--tempest-text) }
button { background: none; border: 0; padding: 0 }
:where(ul, ol)[class] { list-style: none; margin: 0; padding: 0 }
Num app que o SDK monta inteiro isso é o que você quer. Num app que já tem o
próprio layout, é o SDK tomando a superfície do documento — o botão do app perde
borda e fundo, a lista perde marcador, o body perde a cor.
A saída óbvia — remover o import — é pior, e por um motivo que não aparece à
primeira vista: os componentes são escritos contra o reset.
.tempest_button conta com button { background: none; border: 0 }, toda conta
de largura conta com box-sizing: border-box, os campos contam com
font-family: inherit. Sem o reset o problema não é acabamento: é o box model.
Por isso a fundação vem em três peças, e não uma:
| Entrada | O que traz | Toca o markup do app? |
|---|---|---|
styles/tokens.css |
os 220 tokens --tempest-* (373 declarações, contando dark e densidades) + color-scheme |
não — não pinta markup |
styles/scoped.css |
o reset confinado a :where([class*="tempest_"]) |
não — só dentro de componente |
styles/base.css |
o reset global (html, body, #root, button…) |
sim, é o ponto dele |
styles/core.css |
tokens + base, como sempre foi |
sim |
Um app com layout próprio troca core.css por duas linhas:
import "tempest-react-sdk/styles/tokens.css";
import "tempest-react-sdk/styles/scoped.css";
color-scheme viaja com os tokens, não com o reset
Ela não é custom property, mas é o que manda o browser pintar as superfícies
dele — scrollbar, popup de <select>, autofill, date picker — no tema ativo.
Nenhuma regra .tempest_* alcança essas superfícies. Deixá-la no reset global
daria as cores do SDK com o chrome do browser ainda em claro.
:where() é o que mantém isso um republish
Cada seletor de scoped.css é o seletor original embrulhado em
:where([class*="tempest_"]), que contribui zero de especificidade. A
regra pesa exatamente o que pesava global, então override do seu app que
ganhava antes continua ganhando.
O foco dos seus próprios elementos
base.css declarava :focus-visible sem escopo, então todo elemento focável
da página — os seus inclusive — ganhava o anel do SDK. scoped.css não faz isso,
por desenho: ele alcança o nó com classe tempest_ e o que estiver dentro dele, e
nada mais.
O que os seus elementos herdam no lugar não é "nada": é o anel do user agent.
Medido em Chromium, um <button> do app sobre #14213d: outline: rgb(16,16,16)
auto 1px — 1,19:1. Na prática, foco invisível.
Migrar de core.css para o par escopado sem ler isto regride acessibilidade
Não há erro, warning ou diferença visual. A página parece igual até alguém
navegar por teclado. Se o seu app não declara :focus-visible próprio, declare
ao migrar.
Uma linha reativa o anel do SDK no seu markup, com os mesmos tokens:
:focus-visible {
outline: var(--tempest-focus-ring-width) solid var(--tempest-focus-ring-color);
outline-offset: var(--tempest-focus-ring-offset);
}
Escrever isso é opt-in, que é o ponto do escopo — mas o passo é mecânico e sempre tem a mesma resposta certa, então está aqui pronto em vez de deduzido.
color-scheme chega ao documento pelo atributo, não pela folha
O anel do UA acima saiu rgb(16,16,16) — o valor do esquema claro — mesmo
com color-scheme: dark herdado. Se você escopa o tema numa subárvore em vez de
pôr data-tempest-theme no <html>, as superfícies que o browser pinta ficam
no esquema claro.
O plugin — tempestStyles()
A folha de cada componente chega sozinha, então o plugin não existe mais para montar lista de import. Ele existe pela peça que sobra: a fundação.
tokens.css são 220 tokens, e um app que monta três componentes lê 105 deles. Os
outros 115 são bytes que nada na página pode consultar — e, agora que os
componentes não trazem mais a folha inteira consigo, essa sobra é o maior item
único do CSS que o app baixa. Medido no app de Button + Card + Badge:
2516 B brotli de tokens contra 1689 B de todas as folhas de componente juntas.
O plugin lê as folhas que o app vai carregar, fecha o transitivo dos tokens que
elas consultam — alias como --tempest-primary: var(--tempest-primary-500) conta
o alvo junto — e emite só esses:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tempestStyles } from "tempest-react-sdk/vite";
export default defineConfig({
plugins: [react(), tempestStyles()],
});
// src/main.tsx — uma linha, para sempre
import "tempest-react-sdk/styles/auto.css";
| O que o app importa | brotli |
|---|---|
tokens.css + scoped.css + as 3 folhas |
4,49 kB |
auto.css com o plugin |
3,37 kB |
O @import das folhas de componente continua lá, e não custa nada: o bundler
deduplica contra a folha que o próprio componente importou — medido, a regra base
do Button aparece uma vez no CSS servido.
Opções
tempestStyles({
dir: "src", // onde varrer. Default: "src"
reset: "scoped", // "scoped" | "global" | "none". Default: "scoped"
tokens: "used", // "used" | "all". Default: "used"
include: ["Toast"], // nomes que a varredura não vê
});
reset |
Emite | Quando |
|---|---|---|
"scoped" |
tokens + scoped.css |
o app tem layout próprio (default) |
"global" |
tokens + base.css |
o SDK monta a página inteira |
"none" |
tokens | o app já tem um reset equivalente |
tokens |
Emite | Quando |
|---|---|---|
"used" |
só os tokens que as folhas alcançam | default |
"all" |
tokens.css inteiro |
o app lê token de onde a varredura não olha |
O corte varre o seu CSS também
Você pode ler --tempest-* em CSS seu — está documentado, e cortar um token que
o seu .app-card consulta não deixaria a folha menor, deixaria a propriedade sem
valor. Por isso a varredura inclui os .css/.scss/.less sob dir, não só os
.tsx.
Nome de token montado em runtime cai para a folha inteira
style={{ color: `var(--tempest-${tone})` }} nomeia um token que nenhuma
varredura estática conhece. O plugin detecta a construção e emite tokens.css
inteiro — servir mais do que o pedido, nunca menos do que o necessário. É o mesmo
desenho do fallback de namespace import, abaixo. Token lido de fora de dir
(outro pacote, um <style> no index.html) é o caso de tokens: "all".
Sem o plugin, auto.css continua correto
tempest-react-sdk/styles/auto.css é um arquivo real, que sem o plugin
resolve para a folha completa. Tirar o plugin custa bytes, nunca correção —
o mesmo desenho do tempest-react-sdk/icons/virtual.
Namespace import desliga a varredura
import * as sdk from "tempest-react-sdk" resolve sdk.Button em runtime,
então nenhum conjunto estático de nomes é conhecível. O plugin detecta isso e
cai para a folha completa em vez de servir a menos — se você quer o corte,
importe nomeado.
Cor
Brand — primary tints
Scale 50 (mais claro) → 900 (mais escuro). Use --tempest-primary como cor canônica de ação.
--tempest-primary-50: #eef4ff;
--tempest-primary-100: #d9e6ff;
--tempest-primary-500: #0066ff; /* === --tempest-primary */
--tempest-primary-700: #003d99;
--tempest-primary-900: #001f4d;
Aliases:
--tempest-primary= primary-500--tempest-primary-hover= primary-600 no claro, primary-600 no escuro (a rampa do escuro é invertida, então 600 é mais claro que 500)--tempest-primary-active= primary-700--tempest-primary-soft= primary-50 (fundo tinted para soft buttons/badges)--tempest-primary-foreground=#ffffffno claro,#1f0606no escuro (cor do texto sobre a primary)--tempest-primary-on-soft= primary-600 no claro, primary-700 no escuro (cor do texto/ícone sobre--tempest-primary-soft)
Texto sobre preenchimento saturado: *-on-solid
--tempest-danger-on-solid·--tempest-info-on-solid=#ffffffno claro,#1f0606no escuro--tempest-success-on-solid·--tempest-warning-on-solid=#1f0606nos dois temas--tempest-neutral-on-solid=#ffffffnos dois temas (o preenchimento neutro é--tempest-gray-700, e o ramp cinza não inverte)
#ffffff sobre preenchimento de status não é seguro, e nunca foi
Medido contra os preenchimentos do tema claro, o default: branco sobre
--tempest-success-solid dá 3,30:1 e sobre --tempest-warning-solid
3,19:1. Verde médio e âmbar simplesmente não carregam texto branco — todo
design system que os embarca põe texto escuro em cima. No tema escuro, onde os
preenchimentos são mais claros, todos falham: primary 3,68:1, danger
3,76:1, info 3,68:1, success 2,28:1, warning 2,15:1.
Por isso a cor do texto é um token por status em vez de um #ffffff cravado. A
tinta escura é um quase-preto puxado pro próprio matiz (#1f0606), não preto
puro — lê como parte da amostra, não como um buraco nela.
No escuro, hover e active vão pro claro
A escala de primary é invertida no tema escuro (300 é o passo mais escuro, 900 o
mais claro), então pegar 400/300 no hover fazia o botão escurecer sob o
ponteiro — o gesto do tema claro aplicado numa superfície escura. Também fazia o
preenchimento fugir do próprio texto: nenhum foreground único passava 4,5:1
contra #3b82f6, #2563eb e #1a4399 ao mesmo tempo. Subir a rampa resolve os
dois.
Um teste segura isso
src/styles/contrast.test.ts calcula a razão de cada par (texto, fundo) que
o SDK renderiza, direto do colors.css, nos dois temas. Se você redefinir a
paleta e um par cair abaixo de 4,5:1, o teste falha — em vez de o problema
aparecer no produto de alguém. O axe do jsdom não pega isso: ele desliga
color-contrast porque não há paint.
E um sweep no browser segura o resto
O teste acima mede os pares que o SDK declara. O que ele não pode ver é o
que a página compõe: texto sobre superfície tingida, sobre overlay, sobre
imagem. Para isso o e2e/gallery.spec.ts roda o axe em Chromium real numa
matriz de quatro células — claro e escuro, a 1280px e a 390px — e lê as
duas listas que o axe devolve.
Ler incomplete é o ponto. O color-contrast cai lá, e não em violations,
sempre que o axe não consegue resolver o que está atrás do texto — que é
exatamente o caso da superfície tingida. Um sweep que lê só violations está
olhando a gaveta onde o achado não está.
Os números medidos por célula vivem em e2e/axe-baseline.json, e o teste
falha quando algum cresce além da folga — dois nós, ou 2% para as regras
grandes. A folga tem o mesmo desenho dos pisos de cobertura, e a mesma
razão: a gallery renderiza demos que dependem do relógio, então o mesmo
build medido duas vezes não devolve sempre a mesma contagem. Medido: 87 e
depois 88 nós de color-contrast em incomplete, sem nada mudar entre as
duas execuções. É um ratchet, não uma allowlist: a gallery
renderiza 67 seções de demo e carrega dívida real de acessibilidade, e
reprovar tudo deixaria o build vermelho para sempre — que é como um gate é
deletado. Cair não falha; os números estão lá para serem baixados.
Medir contraste sem desligar transition dá falso positivo
Trocar data-tempest-theme inicia a transição de color de todo componente
que declara uma, então ler getComputedStyle um ou dois frames depois
amostra a animação: o foreground do tema que sai contra o fundo do que
entra. Medindo o VideoPlayer isso deu 7,32 e depois 2,33 entre
execuções — e 2,33 parece exatamente um defeito real.
Injete *{transition:none!important} antes de medir, e confira que
claro → escuro → claro devolve o mesmo número. Se não devolver, a medição
está errada, não o CSS.
Dois nomes para um trabalho é o vão por onde o defeito passa
O tema escuro entregou 73 violações de contraste medidas em Chromium, e as três causas eram a mesma coisa vista de ângulos diferentes: um token de texto que ninguém validou contra a superfície onde ele cai.
| Causa | Medido | Correção |
|---|---|---|
--tempest-text-subtle no escuro |
4,04:1 sobre surface, 4,37 sobre bg |
#6f7889 → #7b849a (4,79 / 5,19) |
--tempest-primary-contrast no Scheduler |
token inexistente, caía no #fff do fallback → 3,67:1 |
aponta para --tempest-primary-foreground |
--tempest-text-on-primary |
declarado branco no :root e nunca sobrescrito no escuro → 3,67:1 |
segue var(--tempest-primary-foreground) |
O --tempest-primary-foreground já estava certo — alguém mediu os 3,68:1 e
o corrigiu, escrevendo no colors.css que "todos os 16 usos" apontavam
para ele. A medição estava certa; a contagem não: o AppBar usava o gêmeo
e o Scheduler usava um nome que não existe.
Se você redefinir a paleta, redefina os dois nomes. E prefira o
canônico: --tempest-text-on-primary existe por compatibilidade e segue o
outro.
Um var() com fallback esconde um token que não existe
A análise de CSS do tempest doctor não reporta var() com fallback,
de propósito: sem essa regra, o idioma de knob
(var(--tempest-card-padding, 1rem)) gerava 43 falsos positivos e a
ferramenta seria ignorada. O preço é este ponto cego —
var(--tempest-primary-contrast, #fff) compila, roda, e pinta branco para
sempre.
Ao escrever um var() com fallback, pergunte se o nome é um knob (o
consumidor define) ou um token de propósito (o SDK define). No segundo
caso, o fallback está escondendo um erro de digitação.
Texto sobre primary-soft usa primary-on-soft, não primary
--tempest-primary sobre --tempest-primary-soft dá 4,38:1 no tema claro e
4,28:1 no escuro — o
WCAG AA pede 4.5:1 pra texto. Por isso existe o --tempest-primary-on-soft
(~6:1). Se você redefinir a paleta, redefina os dois: trocar só o
--tempest-primary deixa os estados selecionados (Toggle, ToggleGroup,
ListTile, Stepper, NavigationRail, FileUpload) fora de conformidade.
Para trocar a brand inteira:
:root {
--tempest-primary-500: #7c3aed; /* roxo */
--tempest-primary-600: #6d28d9;
--tempest-primary-700: #5b21b6;
--tempest-primary-soft: #ede9fe;
}
Neutros — gray scale
--tempest-gray-50: #f8f9fb;
--tempest-gray-500: #667085;
--tempest-gray-900: #101828;
Aliases semânticos:
| Token | Uso |
|---|---|
--tempest-bg |
Background canvas |
--tempest-surface |
Cards, headers, footers |
--tempest-surface-2 |
Surface elevada (chip, button hover) |
--tempest-surface-3 |
Surface mais elevada |
--tempest-border |
Borda padrão |
--tempest-border-strong |
Borda com mais contraste |
--tempest-text |
Texto principal |
--tempest-text-muted |
Texto secundário |
--tempest-text-subtle |
Texto terciário (placeholders) |
Status — triplets (fg/bg/border/solid)
Cada status (success, warning, danger, info) expõe 4 cores:
--tempest-success-fg: /* texto sobre bg soft */ --tempest-success-bg: /* fundo soft tinted */
--tempest-success-border: /* borda outline */ --tempest-success-solid: /* fill solid */;
Atalhos:
--tempest-success— cor principal (igual asuccesssolid escuro no light, mais clara no dark).--tempest-danger-hover— variação para hover em danger solid.
Componentes que aceitam appearance="soft|solid|outline" (Badge, Alert, etc.) escolhem automaticamente a combinação certa.
Data viz — cores de série
Oito cores categóricas em ordem cíclica, mais o cromado do gráfico:
| Token | Uso |
|---|---|
--tempest-chart-1 … -8 |
Cores de série, aplicadas por índice (ciclam) |
--tempest-chart-grid |
Linhas de grid |
--tempest-chart-axis |
Linhas e rótulos de eixo |
As oito são espaçadas por matiz — são categóricas, não um ramp. Para escala sequencial ou divergente, use --tempest-primary-*.
O módulo tempest-react-sdk/charts lê esses tokens em runtime e re-resolve quando o tema vira, então sobrescrevê-los muda os gráficos sem tocar em prop nenhuma:
:root {
--tempest-chart-1: #0f766e;
--tempest-chart-2: #f97316;
}
Não passe var() como cor de série
O recharts aplica cor como atributo de apresentação do SVG, e var() não é resolvido ali. É por isso que o SDK lê o token via getComputedStyle e entrega cor literal. Detalhes em Charts › Cores e tema.
Gerando a paleta com createTheme
Escrever os ~30 valores de uma marca na mão (dez degraus × claro/escuro + aliases) é trabalhoso e fácil de errar no dark, onde o ramp inverte. createTheme deriva tudo de uma cor, em OKLCH:
import { applyTheme, createTheme } from "tempest-react-sdk";
applyTheme(createTheme({ primary: "#7c3aed", radius: "lg" }));
Isso emite os mesmos tokens desta página — é açúcar sobre a API de token, não um segundo sistema de tema. Guia completo em Tema › createTheme.
Cores de sintaxe (--tempest-code-*)
CodeBlock pinta com dez tokens próprios: comment · punctuation · string · number · keyword · literal · function · tag · attribute · property.
Não reaproveite a rampa de chart pra texto
É tentador — as oito cores de série já existem e já são validadas. Mas elas são validadas pro piso de marca: 3:1, que é o que a WCAG pede pra um elemento gráfico. Cor de sintaxe é texto, e texto precisa de 4,5:1. Medindo a rampa como texto no browser, uma palavra-chave em --tempest-chart-1 deu 3,47:1 na superfície escura e uma string em --tempest-chart-3 deu 2,03:1 na clara. As duas passam como marca e reprovam como texto.
Cada token de código foi resolvido em OKLCH: fixa a matiz, e busca a claridade mais alta (no dark, a mais baixa) que ainda passa AA contra os dois fundos em que o token pode cair — a superfície do bloco e a linha marcada, depois que o realce de 10% do primary compõe sobre ela. Resolver só contra a superfície não basta: o realce move o chão, e uma palavra-chave chegou a medir 4,17:1 em cima dele.
src/styles/colors.contrast.test.ts lê o colors.css e reafere cada token nos dois modos contra os dois fundos. Se você sobrescrever esses tokens no seu app, refaça essa conta.
Tipografia
Famílias
--tempest-font-sans: /* system stack */ --tempest-font-mono: /* monospace stack */
--tempest-font-display: /* === sans, override pra heading */;
Tamanhos
| Token | Pixels |
|---|---|
--tempest-text-2xs |
10px |
--tempest-text-xs |
12px |
--tempest-text-sm |
13px |
--tempest-text-base |
14px |
--tempest-text-md |
15px |
--tempest-text-lg |
16px |
--tempest-text-xl |
18px |
--tempest-text-2xl |
20px |
--tempest-text-3xl |
24px |
--tempest-text-4xl |
30px |
--tempest-text-5xl |
36px |
--tempest-text-6xl |
48px |
Line heights
--tempest-leading-none|tight|snug|normal|relaxed|loose (1.0 → 1.9).
Pesos
--tempest-weight-regular|medium|semibold|bold|extrabold (400 → 800).
Letter spacing
--tempest-tracking-tight|normal|wide|wider|widest.
Espaçamento
Base 4px. Vai de 0 até 24 (96px).
--tempest-space-0: 0 --tempest-space-1: 4px --tempest-space-2: 8px --tempest-space-3: 12px
--tempest-space-4: 16px --tempest-space-5: 20px --tempest-space-6: 24px --tempest-space-7: 28px
--tempest-space-8: 32px --tempest-space-10: 40px --tempest-space-12: 48px --tempest-space-16: 64px
--tempest-space-20: 80px --tempest-space-24: 96px;
Radius
--tempest-radius-xs: 2px --tempest-radius-sm: 4px --tempest-radius-md: 8px /* controls padrão */
--tempest-radius-lg: 12px /* cards padrão */ --tempest-radius-xl: 16px /* modais */
--tempest-radius-2xl: 24px --tempest-radius-full: 9999px;
Elevação (shadow)
--tempest-shadow-xs: /* hairline, controls em rest */ --tempest-shadow-sm: /* card padrão */
--tempest-shadow-md: /* hover card, dropdown */ --tempest-shadow-lg: /* drawer, popover */
--tempest-shadow-xl: /* modal */ --tempest-shadow-inner: /* tracks, inputs sunken */;
Shadows são automaticamente mais escuros no tema dark.
Motion
Duração
--tempest-duration-instant: 0ms --tempest-duration-fast: 120ms /* hover, focus */
--tempest-duration-base: 180ms /* enter/leave padrão */ --tempest-duration-slow: 280ms
/* drawer, modal */ --tempest-duration-slower: 420ms;
Easing
--tempest-ease-linear
--tempest-ease-in
--tempest-ease-out
--tempest-ease-in-out
--tempest-ease-emphasized /* enter animations */
--tempest-ease-bounce
Composite shortcuts
--tempest-transition-color: /* color + bg + border, fast */ --tempest-transition-shadow:
/* box-shadow, base */
--tempest-transition-transform: /* transform, fast */
--tempest-transition-base: /* tudo acima + opacity */;
Reduced motion
@media (prefers-reduced-motion: reduce) zera todas as durações de tokens automaticamente. Componentes que usam keyframes pesados (modal, drawer, toast, tooltip, skeleton) também detectam e desabilitam animações específicas.
A superfície do documento
Antes de qualquer componente, styles.css reivindica o body:
:where(html, body) {
height: 100%;
}
:where(#root) {
min-height: 100%;
}
:where(#root:has(> :only-child)) {
height: 100%;
}
body {
margin: 0;
background-color: var(--tempest-bg);
color: var(--tempest-text);
}
São três promessas, e vale saber qual é qual.
A margem. O user-agent dá 8px de margin ao body. Isso não é cosmético ao
lado do AppShell, que é min-height: 100dvh: o documento passa a medir
100dvh + 16px, então uma página que cabe na viewport nasce com barra de
rolagem vertical.
A pintura. O ThemeProvider escreve data-tempest-theme no <html> e os
tokens moram no :root, mas isso sozinho não pinta nada — só declara. Sem a
regra acima, a área atrás do shell fica branca no tema escuro, emoldurando o
conteúdo nos quatro lados, e qualquer elemento sem fundo próprio resolve até um
body branco.
A cadeia de altura. height: 100% é uma porcentagem, e porcentagem precisa
de um pai com altura resolvida. Sem os três elos, um shell de altura percentual
colapsa no conteúdo em vez de preencher a viewport.
O #root só recebe a altura fixa quando tem um único filho. Altura fixa no
#root fixa também o containing block de cada filho, e um elemento sticky não
sai do próprio containing block: um Navbar colocado direto no #root, ao lado
de um <main> longo, sumia depois de uma viewport de rolagem (#406). Com
min-height o #root cresce com o conteúdo e a barra fica no topo. Um shell de
tela cheia (Page, Resizable, um <div style="height: 100%"> seu) é o filho
único do #root, enquanto uma barra sticky sempre tem um irmão — o conteúdo
abaixo dela —, e é o :only-child que separa os dois casos.
Medido em Chromium e Firefox a 1440×900, com scrollY 1261 para a barra
(probe com os componentes publicados, getBoundingClientRect().top e
offsetHeight):
Layout dentro do #root |
height: 100% (antes) |
só min-height: 100% |
atual |
|---|---|---|---|
Navbar + <main> longo — topo da barra |
−417px | 0 | 0 |
AppShell com conteúdo longo — topo da barra |
−1261px | −1261px | 0 |
Page curta, filho único |
900px | 271px | 900px |
Resizable, filho único |
900px | 24px | 900px |
<div style="height: 100%">, filho único |
900px | 120px | 900px |
A linha do AppShell é outro defeito, que tinha a mesma cara: o wrapper da
navbar era um bloco da altura da barra, então o Navbar sticky dentro dele não
tinha para onde grudar. O wrapper agora é display: contents, e quem decide é o
sticky do próprio Navbar.
Shell de tela cheia com um irmão no #root
O :only-child conta elementos, não caixas no fluxo. Um toast ou banner
position: fixed renderizado ao lado do shell no #root faz o #root
voltar a crescer com o conteúdo, e um Page curto deixa de preencher a
viewport (900px → 212px, mesma medição). Os overlays do SDK vão por portal
para o body e não contam. Para um overlay de terceiro, envolva o shell e o
overlay num único elemento, ou devolva a altura fixa com uma regra sua:
#root { height: 100%; }
Num app com Navbar direto no #root, essa regra traz o defeito do #406 de
volta.
Trocar o display do #root para grid ou flex, que preencheria a viewport com
qualquer número de filhos, foi medido e descartado: o filho com
max-width: 600px; margin: 0 auto encolhe para a largura do conteúdo (32px), e
cada filho é esticado até a altura da viewport.
Especificidade zero na cadeia de altura
#root é markup que esta folha não desenha — o id é uma convenção sua,
não do SDK. Por isso a cadeia vai dentro de :where(), que não soma
especificidade: qualquer regra sua ganha desta sem !important.
#root { height: auto; }
Trocar o fundo é uma declaração
body é seletor de especificidade 0-0-1. Um app que queira outro fundo —
uma imagem, um gradiente, a cor de uma marca — sobrescreve normalmente:
body { background: linear-gradient(180deg, #0b0d12, #14171f); }
O que o reset não decide
A família tipográfica do documento. O SDK declara --tempest-font-sans, mas
não a aplica no body: qual fonte a página inteira renderiza é escolha do
app, e um SDK que a impusesse capava quem ships uma fonte própria.
body { font-family: var(--tempest-font-sans); }
color-scheme — as superfícies que o browser pinta
Token não alcança tudo. O popup do <select>, a barra de rolagem, o preenchimento
de autofill, o date picker, o spinner do input[type=number] e o canvas default
são desenhados pelo user-agent, a partir de uma propriedade só:
:root {
color-scheme: light;
}
[data-tempest-theme="dark"] {
color-scheme: dark;
}
O SDK ships as duas. Você não precisa fazer nada — mas precisa saber que existem,
porque é o que explica por que um <input> sem estilo nenhum dentro do seu
app já nasce escuro no tema escuro.
Ler prefers-color-scheme não é declarar color-scheme
São coisas diferentes com nomes parecidos, e confundi-las custou 58 releases.
O ThemeProvider sempre leu a media query para resolver o modo
"system". Declarar a propriedade é o que diz ao browser de qual paleta
pintar o que ele mesmo desenha.
Medido em Chromium com data-tempest-theme="dark" ligado e a propriedade
ausente: getComputedStyle(document.documentElement).colorScheme devolvia
"normal", um <select> sem classe pintava #efefef e um <input> sem
classe pintava #ffffff, os dois com o texto #f1f3f8 do tema escuro por
cima — 1,03:1 e 1,11:1.
Escopo parcial funciona
A declaração vai no seletor de atributo, não numa media query, então uma subárvore escura dentro de uma página clara ganha controle nativo escuro junto:
<aside data-tempest-theme="dark">
<select><!-- popup escuro --></select>
</aside>
O reset e o seu markup
styles.css traz um reset moderno, e uma das regras dele alcança elementos que
o SDK não desenha:
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
max-width: 100%;
}
Deixar mídia em bloco evita o espaço fantasma abaixo de uma imagem inline, que é
o motivo de a regra existir em todo reset moderno. Mas um <button> do
user-agent centraliza o conteúdo com text-align: center, e text-align só
alcança caixa inline — então um ícone sozinho num botão seu encostaria na
borda esquerda.
O SDK ships o contrapeso junto:
:where(button, a, label, summary) > svg:only-child {
margin-inline: auto;
}
Especificidade zero, de propósito
:where() não soma especificidade, então qualquer regra sua ganha desta
sem !important. Centralizado é o default; outro alinhamento é uma
declaração normal:
.toolbar button > svg { margin-inline: 0; }
Só vale para ícone sozinho
:only-child mantém a regra restrita ao botão só-de-ícone. Ícone ao lado de
rótulo já vive num flex seu, onde margin: auto empurraria o texto — esse
caso continua com o alinhamento que você deu.
Focus ring
--tempest-focus-ring-color: #0052cc;
--tempest-focus-ring-width: 3px;
--tempest-focus-ring-offset: 2px;
:focus-visible global aplicado em reset.css (e, no caminho escopado, em
scoped.css). Componentes interativos — Button, Card interactive, Tabs,
Pagination — reaplicam o anel com os mesmos tokens.
No tema dark a cor é #60a5fa. Nenhum dos dois é o --tempest-primary do tema,
e não por acaso: no claro eles seriam a mesma cor, e o anel de um Button primary
viraria a tinta do próprio preenchimento separada por 2px — halo, não anel. No dark,
#3b82f6 cai para 3,73:1 contra --tempest-surface-3, e a margem some assim que o
anel encosta numa superfície de app que o SDK nunca aferiu.
A cor do anel precisa ser opaca
Até a 0.60.0 estes tokens eram semitransparentes, e essa é a única razão pela qual reprovavam: um anel tinto não tem contraste próprio — ele tem o contraste do que estiver embaixo. Medido, o valor antigo dava 1,60:1 no tema claro e 2,08:1 no dark, contra os 3:1 que a WCAG 2.2 (SC 1.4.11, Non-text Contrast) exige de indicador não-textual.
Se você sobrescrever o token, sobrescreva com cor opaca e confira o número.
Para customizar por subárvore (tema marca branca, por exemplo):
.my-app {
/* opaco, e aferido contra as superfícies em que o anel pode cair */
--tempest-focus-ring-color: #7c3aed;
}
tokens.css é pré-requisito, não companhia opcional
O var() do reset tem fallback (currentColor), mas ele socorre o markup do
seu app, não os componentes do SDK: eles declaram o anel com
var(--tempest-focus-ring-color) sem fallback — 59 usos —, e com o token
indefinido essas declarações são inválidas em computed-value time e caem para
outline: none. Medido em Chromium: Button e Input perdem o anel inteiro.
Carregue tokens.css sempre que carregar scoped.css ou base.css.
Contra o que medir
O anel aparece onde houver elemento focável, e um componente aninha tão fundo
quanto o app o colocar. Meça a sua cor contra as quatro superfícies —
--tempest-bg, --tempest-surface, --tempest-surface-2,
--tempest-surface-3 — e não contra uma. Fixar um fundo é como um token passa
no próprio teste e falha dentro de um card.
src/styles/focus-ring.contrast.test.ts faz exatamente isso com os tokens do
SDK, nos dois temas, e falha o build abaixo de 3:1 — inclusive se a cor voltar
a ter alfa.
Estado selecionado
--tempest-selected-indicator: #0052cc; /* dark: #60a5fa */
A tinta com que um componente diz "este aqui está selecionado": o segmento ativo do SegmentedControl, a linha destacada do Command, do DropdownMenu e do ContextMenu.
Ele existe porque superfície não faz esse trabalho. As quatro superfícies são, de propósito, um degrau curto uma da outra:
| par | claro | escuro |
|---|---|---|
bg vs surface |
1,053:1 | 1,085:1 |
bg vs surface-2 |
1,112:1 | 1,225:1 |
bg vs surface-3 |
1,240:1 | 1,416:1 |
Nem os extremos da rampa alcançam os 3:1 que a WCAG 2.2 SC 1.4.11 pede de indicador não-textual — e alargar o degrau para servir de indicador estragaria todo cartão empilhado sobre fundo.
Tint da marca também não resolve — e é por isso que este token não é --tempest-primary-soft
A resposta intuitiva é marcar a seleção com --tempest-primary-soft, que é o que ToggleGroup, ListTile, TreeView e NavigationRail fazem. Medido nas doze marcas do guard de tema, --tempest-primary-soft fica entre 1,03:1 e 1,61:1 contra --tempest-bg nos dois esquemas: ele muda de matiz, e o contraste WCAG não conta matiz.
Por isso o indicador é uma tinta saturada desenhada sobre o tint — borda no SegmentedControl, barra à esquerda nos três menus —, e não a troca do fundo. O tint continua lá; ele é reforço, não o sinal.
Um tema gerado por createTheme emite este token pelo mesmo método do anel de foco: sobe a rampa da marca até a tinta alcançar 3:1 contra as quatro superfícies, nos dois esquemas. Diferente do anel, ele nunca aceita alfa — focusRingAlpha não chega aqui, porque indicador translúcido tem o contraste do que estiver embaixo, que é exatamente o defeito que este token encerra.
.my-app {
/* opaco, e aferido contra as quatro superfícies */
--tempest-selected-indicator: #7c3aed;
}
O guard
src/styles/selected-indicator.contrast.test.ts mede o token contra as quatro superfícies nos dois temas e também fixa os números acima — se alguém propuser de novo "usa surface-2, que dá dois degraus", a asserção que reprova a proposta já está escrita. src/styles/selected-indicator.usage.test.ts garante que os quatro componentes desenham o estado com o token, e que nenhum outro o usa por engano.
Z-index
--tempest-z-base: 0 --tempest-z-raised: 10 --tempest-z-dropdown: 1000 --tempest-z-sticky: 1020
--tempest-z-overlay: 1050 --tempest-z-modal: 1100 --tempest-z-popover: 1150
--tempest-z-toast: 1200 --tempest-z-tooltip: 1300;
Densidade — data-tempest-density
Atributo aplicado em qualquer elemento (geralmente <html> ou <body>) ajusta altura, padding, font-size e radius de todos os controles na subárvore.
<html data-tempest-density="compact"></html>
Valores: compact | comfortable (padrão) | spacious.
Tokens controlados:
--tempest-control-height-xs..xl
--tempest-control-padding-xs..xl
--tempest-control-font-xs..xl
--tempest-control-radius
--tempest-control-gap
--tempest-control-caret-offset
--tempest-control-caret-size
Button, Input, Select, Textarea já lêem desses tokens — basta trocar o atributo no root e tudo redimensiona junto.
Tema dark — data-tempest-theme
<html data-tempest-theme="dark"></html>
Atributo aplicado em qualquer elemento ativa o tema escuro só naquela subárvore. Tokens de cor (primary scale, neutrals, status, focus ring, shadow) são todos sobrescritos.
Use junto com <ThemeProvider> (tempest-react-sdk/theme) para persistência + flash prevention.
Use data-tempest-theme=\"dark\", não class=\"dark\"
O dark mode do SDK liga pelo atributo data-tempest-theme, nunca por uma
classe dark. Isso permite escopar o tema escuro a uma subárvore específica
em vez do documento inteiro — algo que a convenção de classe não faz.
Componentes — variants disponíveis
Button
<Button variant="primary | secondary | danger | success | ghost | soft | outline | link" />
<Button size="xs | sm | md | lg | xl" />
<Button iconOnly aria-label="..." />
<Button pill />
<Button loading />
Badge
<Badge
variant="neutral | primary | success | warning | danger | info"
appearance="soft | solid | outline"
size="sm | md | lg"
shape="pill | square"
dot
/>
Alert
<Alert variant="neutral | info | success | warning | danger"
appearance="soft | solid | outline"
title="..."
description="..."
icon={<Icon />}
onClose={() => ...} />
Card
<Card elevation="flat | default | raised | elevated"
interactive
title="..."
actions={...}
footer={...} />
Input
<Input size="sm | md | lg" />
Spinner
<Spinner size="xs | sm | md | lg | xl" />
Divider
<Divider
orientation="horizontal | vertical"
variant="solid | dashed"
label="OR"
align="start | center | end"
/>
Kbd
<Kbd size="sm | md | lg">Ctrl</Kbd>
Importando tokens em CSS-in-JS
O prefixo tempest_ evita colisão
As classes geradas pelos CSS Modules saem prefixadas com tempest_, então
nunca colidem com o CSS do seu app nem com Tailwind/Stitches/Linaria rodando
lado a lado. Você só interage com os tokens --tempest-* — não precisa
conhecer os nomes de classe.
CSS Modules é a única estratégia de estilo do SDK
Os componentes são estilizados por CSS Modules + tokens --tempest-*, e ponto.
Não existe modo "headless" nem hook de classe (data-tempest-classname) para
Tailwind/Stitches/Linaria assumirem a estilização — e isso não está no
backlog. Manter dois caminhos de estilo dobraria a superfície de cada
componente e diluiria os tokens.
O que você pode fazer: rodar seu utilitário favorito lado a lado no resto
do app, ler os tokens do SDK com var(--tempest-*) e customizar o SDK
sobrescrevendo os tokens no :root.
Como os tokens são CSS Custom Properties, qualquer solução (styled-components, emotion, vanilla-extract, Tailwind arbitrary values) lê com var(--tempest-*):
import styled from "styled-components";
const Card = styled.div`
background: var(--tempest-bg);
border: 1px solid var(--tempest-border);
border-radius: var(--tempest-radius-lg);
padding: var(--tempest-space-5);
box-shadow: var(--tempest-shadow-sm);
`;
Tailwind via theme.extend.colors:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
tempest: {
primary: "var(--tempest-primary)",
bg: "var(--tempest-bg)",
border: "var(--tempest-border)",
},
},
},
},
};
Camada utilitária opt-in — utilities.css
Os componentes são estilizados por CSS Modules, o que resolve o dentro deles. O que sobrava pro app era o em volta: casca de página, form de duas colunas, linha de ações, card, região que rola na horizontal. Todo app reescrevia esse CSS.
utilities.css é essa camada, escrita só com tokens --tempest-* — então ela acompanha o tema, incluindo o que sai do createTheme e o modo escuro.
// src/main.tsx
import "tempest-react-sdk/styles.css";
import "tempest-react-sdk/utilities.css"; // opt-in
Por que opt-in e não dentro do styles.css
São ~50 nomes de classe globais. Um app que já tem o próprio sistema de layout não deve pagar por eles, e injetar classe global em página alheia sem pedir é falta de educação. Custo se você optar: 1.13 KB brotli.
Isto não é um Tailwind, e não vira
A camada tem um punhado de primitivas de layout — não existe (nem entra no backlog) p-4 mt-2 text-sm bg-blue-500 para cada valor possível. A decisão consolidada segue valendo: CSS Modules + tokens é a estratégia de estilo dos componentes. Isso aqui é ferramenta pro código do app, não um segundo caminho de estilizar o SDK.
Layout
| Classe | O que faz | Ajuste |
|---|---|---|
.tempest-container |
Centraliza, limita a largura, aplica gutter respeitando a safe area | --tempest-container-width, --tempest-container-gutter |
.tempest-stack |
Fluxo vertical com um gap só | --tempest-stack-gap |
.tempest-cluster |
Grupo horizontal que quebra linha em vez de estourar | --tempest-cluster-gap |
.tempest-row |
Grupo horizontal que não quebra (toolbar, campos inline) | --tempest-row-gap |
.tempest-center |
Centraliza o filho nos dois eixos | — |
.tempest-spread |
Empurra primeiro e último filho pras pontas (título ↔ ações) | --tempest-row-gap |
.tempest-grid-auto |
Grid de cards responsivo sem media query (auto-fill + minmax) |
--tempest-grid-min, --tempest-grid-gap |
.tempest-sidebar-layout |
Sidebar + conteúdo; vira uma coluna abaixo de 768px | --tempest-sidebar-width, --tempest-sidebar-gap |
.tempest-form-grid |
Form de 2 colunas que colapsa abaixo de 640px | --tempest-form-columns, --tempest-form-gap |
.tempest-form-span |
Campo que ocupa a linha inteira do grid de form | — |
.tempest-fill |
Ocupa o espaço restante do flex (com min-width: 0, então truncar funciona) |
— |
.tempest-fixed |
Nunca encolhe abaixo do conteúdo (botão de ícone ao lado de campo) | — |
Espaçamento, texto, superfície, scroll
| Grupo | Classes |
|---|---|
| Gap | .tempest-gap-{0,1,2,3,4,5,6,8,10,12} |
| Padding | .tempest-pad-{0,2,3,4,6,8}, .tempest-pad-block, .tempest-pad-inline |
| Texto | .tempest-truncate, .tempest-clamp-{2,3,4}, .tempest-text-{muted,subtle}, .tempest-text-{xs,sm,base,lg,xl,2xl}, .tempest-weight-{medium,semibold,bold}, .tempest-numeric |
| Superfície | .tempest-card, .tempest-panel, .tempest-inset, .tempest-divider |
| Scroll | .tempest-scroll-x, .tempest-scroll-y |
| Mídia | .tempest-aspect-video, .tempest-aspect-square |
| Diversos | .tempest-visually-hidden, .tempest-no-select, .tempest-busy |
.tempest-numeric existe por um motivo específico
font-variant-numeric: tabular-nums impede que uma coluna de números dance quando o valor muda (dígitos proporcionais têm larguras diferentes). Use em tabela de valores, contador ao vivo e Stat.
.tempest-scroll-x em volta de tabela larga
Sem ele, uma tabela larga faz a página rolar na horizontal — que é o defeito de layout mais comum em mobile. Com ele, a rolagem fica contida na região.
Página inteira, montada
export function UsersPage() {
return (
<div className="tempest-container tempest-page">
<header className="tempest-page-header">
<div>
<h1 className="tempest-page-title">Usuários</h1>
<p className="tempest-page-subtitle">142 ativos · 8 convites pendentes</p>
</div>
<div className="tempest-cluster">
<Button variant="secondary">Exportar</Button>
<Button>Convidar</Button>
</div>
</header>
<div className="tempest-toolbar tempest-toolbar-sticky">
<SearchBar className="tempest-fill" placeholder="Buscar por nome ou e-mail" />
<Select className="tempest-fixed" options={papeis} />
</div>
<div className="tempest-card tempest-scroll-x">
<DataTable data={usuarios} columns={colunas} />
</div>
<div className="tempest-grid-auto" style={{ "--tempest-grid-min": "220px" } as React.CSSProperties}>
<Stat label="Ativos" value={142} />
<Stat label="Convidados" value={8} />
<Stat label="Bloqueados" value={3} />
</div>
</div>
);
}
Note o style={{ "--tempest-grid-min": "220px" }}: os hooks locais são custom properties, então dá pra ajustar por instância sem escrever CSS nem criar variante de classe.
Dashboard de widgets
A camada tem uma fatia própria pra isso, e o que a torna diferente de um grid comum é que as colunas reagem à largura do contêiner, não do viewport.
import "tempest-react-sdk/utilities.css";
export function OperacaoPage() {
return (
<div className="tempest-container tempest-page">
<header className="tempest-page-header">
<div>
<h1 className="tempest-page-title">Operação</h1>
<p className="tempest-page-subtitle">Últimos 30 dias</p>
</div>
<Badge variant="success">no ar</Badge>
</header>
{/* Fileira de tiles: cabe quantos couberem, sem span nenhum */}
<div className="tempest-stat-row">
<div className="tempest-widget-frame">
<span className="tempest-text-muted tempest-text-xs">Pedidos</span>
<strong className="tempest-text-2xl tempest-numeric">1.284</strong>
</div>
{/* … */}
</div>
{/* Grid de 12 colunas com spans por container query */}
<div className="tempest-dashboard">
<section className="tempest-widget tempest-widget-two-thirds">
<div className="tempest-widget-frame">
<div className="tempest-widget-header">
<h2 className="tempest-widget-title">Vendas por dia</h2>
<span className="tempest-text-subtle tempest-text-xs">12 dias</span>
</div>
<div className="tempest-widget-body">
<Sparkline data={vendas} width={320} height={72} label="Vendas por dia" />
</div>
</div>
</section>
<section className="tempest-widget tempest-widget-third">{/* … */}</section>
<section className="tempest-widget tempest-widget-half">{/* … */}</section>
<section className="tempest-widget tempest-widget-half">{/* … */}</section>
</div>
</div>
);
}
| Classe | O que faz |
|---|---|
.tempest-dashboard |
grid de 12 colunas e container de tamanho (container-type: inline-size) |
.tempest-widget |
largura total por default — o estado em que um widget passa a maior parte da vida |
.tempest-widget-half · -third · -quarter · -two-thirds |
spans que abrem em 40rem e 64rem de contêiner |
.tempest-widget-tall |
grid-row: span 2 — gráfico ao lado de uma pilha de tiles |
.tempest-stat-row |
fileira de tiles com auto-fit, sem span. Ajuste com --tempest-stat-min |
.tempest-widget-frame · -header · -title · -body |
a moldura do widget |
Hooks: --tempest-dashboard-columns (12), --tempest-dashboard-gap, --tempest-widget-padding, --tempest-stat-min.
As colunas são do contêiner, não do viewport — e isso é o ponto
Medido no browser, com viewport de 1360px: o mesmo dashboard dentro de um painel de 440px renderiza em coluna única; com 660px, o -third e o -half vão pra mesma linha (span 6); com 1060px, -two-thirds fica em span 8 ao lado do -third em span 4, e as duas metades dividem a linha seguinte.
Media query daria o span de desktop pro painel de 440px, e cada widget viraria uma coluna de texto amassado. É o mesmo motivo pelo qual o Masonry observa o contêiner.
width: 100% no .tempest-page não é decoração
Solto dentro de um flex row — painel de preview, split view — um contêiner de página é um flex item e dimensiona pelo conteúdo: o dashboard colapsou pra ~200px enquanto o pai tinha 500. Só o browser mostra isso; em fluxo normal a declaração não muda nada. Achado exatamente assim, montando esta receita na gallery.
min-height: 0 no -body é o que deixa um gráfico caber
Filho de grid tem min-height: auto por default, então um canvas que reporta altura intrínseca grande empurra a linha em vez de caber nela — e o dashboard ganha uma barra de rolagem que ninguém pediu.
Widget redimensionável pelo usuário é outra coisa
Arrastar a borda de um widget briga com a grade: os tracks vêm do grid, e uma largura em pixel vinda do drag não pode conviver com isso. Se você precisa disso, use o Resizable numa área livre, ou guarde o span escolhido por widget e aplique a classe correspondente — que é a versão que sobrevive a um reload e cabe na URL.
Form de duas colunas
<Form className="tempest-form-grid" onSubmit={form.handleSubmit(onSubmit)}>
<FormField name="nome" label="Nome" required><Input /></FormField>
<FormField name="email" label="E-mail" required><Input type="email" /></FormField>
<FormField name="cpf" label="CPF"><CPFInput /></FormField>
<FormField name="telefone" label="Telefone"><PhoneInput /></FormField>
<FormField name="observacoes" label="Observações" className="tempest-form-span">
<Textarea rows={4} />
</FormField>
<FormActions align="end" className="tempest-form-span">
<Button type="submit">Salvar</Button>
</FormActions>
</Form>
Uma coluna no celular, duas a partir de 640px, e .tempest-form-span para o que ocupa a linha inteira.
Responsive — mobile / tablet / desktop
Breakpoints
| Token | Pixels | Device esperado |
|---|---|---|
--tempest-bp-xs |
480px | Phones pequenos |
--tempest-bp-sm |
640px | Phones large |
--tempest-bp-md |
768px | Tablets |
--tempest-bp-lg |
1024px | Laptops |
--tempest-bp-xl |
1280px | Desktop padrão |
--tempest-bp-2xl |
1536px | Ultrawide |
Convenção useBreakpoint() / <Show> / <Hide>:
- mobile =
< md(< 768px) - tablet =
md..lg-1(768..1023px) - desktop =
>= lg(>= 1024px)
useBreakpoint() hook
import { useBreakpoint } from "tempest-react-sdk";
const bp = useBreakpoint();
bp.current; // "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
bp.width; // pixels (0 no SSR)
bp.above("md"); // boolean
bp.below("lg"); // boolean
bp.isMobile; // < md
bp.isTablet; // md..lg-1
bp.isDesktop; // >= lg
SSR-safe — no servidor retorna xs / width: 0, atualiza no mount.
<Show> / <Hide> components
<Show above="md">Desktop nav</Show>
<Show below="md">Mobile menu</Show>
<Show only="xl">Wide-only banner</Show>
<Show only={["md", "lg"]}>Tablet + laptop</Show>
<Hide above="lg">Hide on desktop</Hide>
Utility classes (CSS-only, sem JS)
<div class="tempest-hide-mobile">desktop apenas</div>
<div class="tempest-show-only-mobile">mobile apenas</div>
<div class="tempest-hide-tablet">esconde em tablets</div>
<div class="tempest-show-only-touch">touch devices apenas</div>
<div class="tempest-hide-print">não imprimir</div>
Componentes responsive — props
<Container> — padding responsivo automático
space-4 mobile / space-6 tablet / space-8 desktop.
<Stack> / <Grid> — props aceitam objeto
<Stack direction={{ mobile: "vertical", desktop: "horizontal" }} gap={{ mobile: 2, desktop: 4 }} />
<Grid columns={{ mobile: 1, tablet: 2, desktop: 3 }} gap={4} />
<Modal> — fullscreen / fullscreenOnMobile / 2xl / 3xl
<Modal size="2xl" /> // 1280px
<Modal size="3xl" /> // 1440px
<Modal fullscreen /> // fill viewport
<Modal fullscreenOnMobile /> // auto-fullscreen < 640px
Padding interno e radius já reduzem abaixo de 640px.
<Drawer> — mobilePlacement + showHandle
// desktop: right drawer; mobile: bottom-sheet
<Drawer placement="right" mobilePlacement="bottom" showHandle />
<Table> — priority + stackOnMobile
<Table
stackOnMobile
columns={[
{ key: "name", header: "Nome" }, // sempre visível
{ key: "email", header: "E-mail", priority: "tablet" }, // some < 768px
{ key: "role", header: "Cargo", priority: "desktop" }, // some < 1024px
]}
data={users}
/>
<ToastProvider> — position
<ToastProvider position="top-right" /> // padrão
<ToastProvider position="bottom-center" /> // mobile-friendly default
Em telas < 480px, container estica left: 0; right: 0 automaticamente.
Touch targets
data-tempest-density="touch"— força altura mínima 44px em todos os controles.@media (pointer: coarse)aplica auto-bump noxs/sm/mdquando o usuário está em dispositivo touch (a menos quedensity="compact"explícito).Button iconOnlysizexs/smganha hit-slop invisível de 8px em todos os lados em pointer coarse.
Safe-area (iOS notch / Android gestures)
Tokens disponíveis:
--tempest-safe-area-top
--tempest-safe-area-right
--tempest-safe-area-bottom
--tempest-safe-area-left
Toast, Modal overlay padding e Drawer já consomem automaticamente. Lembre-se de incluir no HTML:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
Dynamic viewport (iOS Safari address bar bug)
Modal e Drawer usam dvh com fallback vh. Apps que precisam de altura cheia podem fazer o mesmo:
.app {
min-height: 100vh;
min-height: 100dvh;
}
Fluid type
Para headings que escalam com viewport:
.hero-title {
font-size: var(--tempest-text-fluid-5xl); /* clamp(32px, 24px + 4vw, 72px) */
}
Tokens: --tempest-text-fluid-sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl.
Hover-only effects
Efeitos transform / box-shadow em hover (Card interactive lift, Button elevation) ficam atrás de @media (hover: hover) and (pointer: fine) — não disparam em tap mobile.
Tudo embutido em print.css:
- Modal, Drawer, Toast, Tooltip ocultos.
- Background grayscale, cards
page-break-inside: avoid. - Links recebem
(href)ao lado.
Classe tempest-hide-print para esconder elementos próprios.
Política de versionamento de tokens
Tokens são API pública. Mudanças quebram apps consumidores. Política:
- Adições (novos tokens) — bump minor.
- Renames / removals — bump major. Tokens antigos ficam como alias deprecated por pelo menos 1 minor antes de remoção.
- Mudanças de valor que afetam aparência visivelmente (cor primária, radius padrão, font stack) — bump minor + nota no changelog.
Resumo
- Importe
tempest-react-sdk/styles.cssuma vez; tematize sobrescrevendo tokens--tempest-*no:root(ou numa subárvore). - Dark mode liga por
data-tempest-theme="dark"; densidade pordata-tempest-density— ambos escopáveis a qualquer subárvore. - As classes de CSS Module saem prefixadas com
tempest_, sem colisão com o CSS do app nem com Tailwind/Stitches/Linaria. - Tokens são API pública sob semver — adições bumpam minor, renames/removals bumpam major.
