---
# access-control.md
# Access Control (RBAC)
Checagens de **permissão e papel** para esconder ou bloquear ações que o usuário não pode executar. Enquanto o [`AuthGuard`](./auth.md) responde só "está logado?", o Access Control responde "esse usuário pode **excluir um post**?" — granularidade de `:`.
!!! info "Por que isso existe além do `AuthGuard`?"
`AuthGuard` é binário: autenticado ou não. Mas dentro de um app logado, um editor pode criar posts e um leitor não; um admin vê o botão de excluir e o resto não. Esconder esses controles na UI (e idealmente bloquear no backend) é trabalho de RBAC. O Access Control entrega um contrato plugável (`AccessControl`), uma estratégia pronta baseada em papéis, e os utilitários de UI (`useCan`, ``) para amarrar tudo.
!!! warning "RBAC no frontend é UX, não segurança"
Esconder um botão **não** protege o endpoint. A decisão de permissão que importa acontece no backend (no `tempest-fastapi-sdk`). O Access Control aqui melhora a experiência — evita mostrar ações que vão falhar com 403 — mas o servidor continua sendo a fonte da verdade.
## Quando usar
- Esconder/desabilitar botões e links conforme a permissão (``).
- Ramificar lógica por permissão dentro de um componente (`useCan`).
- Derivar o conjunto de permissões do usuário a partir do JWT (`permissionsFromToken`).
## Provider — ``
Envolva o app com ``, passando uma estratégia `AccessControl`. Toda checagem (`useCan`, ``) lê dessa estratégia via contexto:
```tsx
import { AccessControlProvider, createRoleAccessControl } from "tempest-react-sdk";
import { App } from "./App";
const accessControl = createRoleAccessControl({
role: "editor",
roles: {
editor: ["posts:create", "posts:update", "comments:read"],
admin: ["*"],
},
});
export function Root() {
return (
);
}
```
!!! note "Sem provider → libera tudo"
Se **nenhum** `` estiver acima na árvore, `useCan`/`` tratam toda checagem como **permitida**. Isso mantém o SDK opt-in: você liga a aplicação dropando um provider, e desliga removendo-o. Útil em dev ou em apps sem RBAC ainda — os componentes que usam `` continuam funcionando, só não bloqueiam nada.
## `createRoleAccessControl`
Constrói uma estratégia RBAC a partir de um conjunto estático de permissões. A assinatura é `createRoleAccessControl({ permissions?, roles?, role? })`:
- `permissions` — strings concedidas diretamente, independente de papel.
- `roles` — mapa `nome do papel → permissões` que aquele papel concede.
- `role` — o(s) papel(éis) ativo(s) (string ou array). As permissões deles (de `roles`) são mescladas.
O conjunto efetivo é `permissions` **mais**, para cada papel ativo em `role`, as permissões listadas em `roles[role]`.
### Regras de matching
Cada permissão é uma string `":"` (ex.: `"posts:create"`) ou um `""` sozinho. Os curingas:
| Permissão concedida | O que libera |
| ------------------- | -------------------------------------------------------- |
| `"*"` | **tudo** — qualquer action em qualquer resource |
| `"posts:*"` | qualquer action no resource `posts` |
| `"posts:create"` | exatamente a action `create` no resource `posts` |
| `"export"` | a action global `export` (checagem **sem** `resource`) |
```ts
import { createRoleAccessControl } from "tempest-react-sdk";
const ac = createRoleAccessControl({
role: "editor",
roles: { editor: ["posts:*", "comments:read"] },
});
ac.can({ action: "create", resource: "posts" }); // { can: true } — bate em "posts:*"
ac.can({ action: "read", resource: "comments" }); // { can: true } — bate exato
ac.can({ action: "delete", resource: "users" }); // { can: false, reason: "missing permission" }
```
!!! tip "Combine `permissions` e `roles`"
`permissions` é para concessões diretas (um override pontual num usuário específico), `roles` + `role` é para o caso comum baseado em papel. Os dois se somam — uma permissão direta vale mesmo que nenhum papel a conceda.
## `permissionsFromToken` — permissões a partir do JWT
Em vez de manter a lista de permissões à mão, derive-a do JWT do usuário. `permissionsFromToken(token, { claim })`:
- Lê a claim configurada (default `"permissions"`).
- Se ausente, cai para as claims OAuth `"scopes"` e depois `"scope"`.
- Claim em array é usada como está; claim em string é quebrada por espaços (convenção de `scope` OAuth).
- Retorna `[]` em qualquer falha de decode ou quando nenhuma claim reconhecível existe.
```ts
import { createRoleAccessControl, permissionsFromToken } from "tempest-react-sdk";
const token = getAccessTokenFromSomewhere();
// Lê a claim "permissions" (default), caindo para "scopes"/"scope"
const permissions = permissionsFromToken(token);
const accessControl = createRoleAccessControl({ permissions });
```
!!! warning "`permissionsFromToken` não valida assinatura"
Ele só decodifica o payload do JWT (via `decodeJWT`) para ler a claim — **não** verifica a assinatura. É leitura defensiva para UX, igual ao [`decodeJWT`](./auth.md). Confiar nessas permissões para segurança real é trabalho do backend.
Para uma claim custom (ex.: o backend emite `"perms"`):
```ts
const permissions = permissionsFromToken(token, { claim: "perms" });
```
## `useCan` — checagem programática
`useCan({ action, resource })` resolve a checagem contra a estratégia em contexto e devolve `{ allowed, isLoading, reason }`. Funciona com `can` síncrono (boolean ou `CanResult`) e com `Promise` (políticas remotas), re-rodando quando os params mudam:
```tsx
import { useCan } from "tempest-react-sdk";
export function PostActions({ postId }: { postId: string }) {
const { allowed, isLoading } = useCan({ action: "update", resource: "posts" });
if (isLoading) return null; // checagem assíncrona em andamento
return (
editPost(postId)}>
Editar
);
}
```
!!! note "`reason` explica o `false`"
Quando `allowed` é `false`, `reason` costuma trazer o motivo (`"missing permission"` vindo do `createRoleAccessControl`, ou a mensagem de erro de uma política async que rejeitou). Útil para tooltip ("Você não tem permissão para X") em vez de só esconder.
## `` — render condicional
`` renderiza `children` quando a action é permitida, senão o `fallback` (ou nada). Enquanto uma checagem async está pendente, renderiza o `fallback` (ou nada):
```tsx
import { Can } from "tempest-react-sdk";
Sem permissão para criar.
}>
;
```
## `useAccessControl` — a estratégia crua
Quando você precisa da estratégia em si (chamar `can` fora de render, checar
várias permissões num loop, decidir uma rota antes de montar a árvore),
`useAccessControl()` devolve o `AccessControl` do contexto — ou `null` quando
não há provider:
```tsx
import { useAccessControl } from "tempest-react-sdk";
function useBulkPermissions(ids: string[]) {
const control = useAccessControl();
return ids.filter((id) => control?.can({ action: "delete", resource: "posts", params: { id } }) ?? true);
}
```
!!! danger "`null` significa **permitir**, não negar"
Sem provider na árvore, `useAccessControl()` devolve `null` — e a convenção
do SDK é tratar isso como "libera tudo", igual ao `useCan`/``. Escrever
`control?.can(...) ?? false` inverte a regra e trava o app inteiro em
qualquer ambiente que ainda não montou o provider. O `?? true` do exemplo
não é descuido.
## Exemplo completo — botão "Excluir" protegido
Tudo junto: deriva permissões do JWT, configura a estratégia, e protege a ação de excluir tanto por render (``) quanto por estado desabilitado (`useCan`):
```tsx
import {
AccessControlProvider,
Can,
createRoleAccessControl,
permissionsFromToken,
useCan,
} from "tempest-react-sdk";
// 1. Estratégia derivada do JWT do usuário logado.
function buildAccessControl(token: string) {
return createRoleAccessControl({
// ex.: token com { permissions: ["posts:read", "posts:delete"] }
permissions: permissionsFromToken(token),
});
}
// 2. Provider no topo do app.
export function Root({ token }: { token: string }) {
return (
);
}
// 3a. Esconder a ação inteira com .
function PostRow({ id, title }: { id: string; title: string }) {
return (
{title}
);
}
// 3b. Mostrar sempre, mas desabilitar + explicar com useCan.
function DeleteButton({ id }: { id: string }) {
const { allowed, isLoading, reason } = useCan({ action: "delete", resource: "posts" });
return (
deletePost(id)}
>
Excluir
);
}
```
!!! tip "`` esconde; `useCan` desabilita"
Use `` quando a ação simplesmente não deve existir para quem não pode (limpa a UI). Use `useCan` quando você quer manter o controle visível mas inerte, com um `title`/tooltip explicando o porquê — útil para descoberta ("isso existe, mas precisa de outro papel").
## Recap
- `` injeta uma estratégia `AccessControl`; **sem provider, toda checagem libera** (opt-in).
- `createRoleAccessControl({ permissions, roles, role })` monta RBAC estático; matching por `":"`, com curingas `"*"` (tudo) e `":*"` (resource inteiro).
- `permissionsFromToken(token, { claim })` lê permissões do JWT (claim default `"permissions"`, fallback `"scopes"`/`"scope"`); não valida assinatura.
- `useCan({ action, resource })` → `{ allowed, isLoading, reason }`, com suporte a checagens síncronas e assíncronas.
- `` esconde a UI; `useCan` desabilita e explica com `reason`.
- RBAC no frontend é UX — o backend continua sendo a fonte da verdade da autorização.
## Veja também
- [Auth + Guard](./auth.md) — `AuthGuard` (autenticado?) e `decodeJWT`/`isJWTExpired`
- [Data Provider](./data-provider.md) — proteja as ações de CRUD por papel/permissão
---
# app-providers.md
# AppProviders
Todo app React precisa de um punhado de _providers_ no topo da árvore: cache de
dados, tema, internacionalização, captura de erros. `` reúne todos
eles em **um único bloco declarativo** — você diz o que quer ligado e como
configurar, e o SDK monta a pirâmide na ordem certa pra você. 🚀
## O problema: a pirâmide de providers
Sem o ``, a raiz da sua aplicação costuma virar uma pirâmide
aninhada à mão. Você precisa lembrar **quais** providers existem, **a ordem**
certa de aninhamento e repetir isso em todo projeto:
```tsx
// App.tsx — montagem manual (o que queremos evitar)
import {
ErrorBoundary,
QueryProvider,
ThemeProvider,
I18nProvider,
AppRouter,
} from "tempest-react-sdk";
import { routes } from "@/routes";
export function App() {
return (
Something went wrong.}>
Loading…} />
);
}
```
Funciona, mas é frágil: a ordem importa (o `ErrorBoundary` precisa ficar por
fora pra capturar erros dos providers internos), o aninhamento cresce em
diagonal e cada app reescreve a mesma estrutura.
!!! note "Os providers continuam existindo isolados"
`QueryProvider`, `ThemeProvider`, `I18nProvider` e `ErrorBoundary` seguem
exportados individualmente — use-os direto quando precisar de controle fino.
O `` é só a conveniência que conecta os quatro pra você.
## A solução: um bloco só
```tsx
// App.tsx
import { AppProviders, AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";
export function App() {
return (
Something went wrong. }}>
Loading…} />
);
}
```
É isso. O `` aninha tudo de fora pra dentro nesta ordem:
```text
ErrorBoundary → QueryProvider → ThemeProvider → I18nProvider → children
```
Repare que **Query e Theme já vêm ligados** com os defaults do SDK — você não
precisou configurar nada. Só pedimos o `errorBoundary` porque ele é opcional
(veja abaixo).
!!! tip "Onde o `` mora"
Os providers ficam **por fora** do roteamento; o `` (e portanto
todas as suas rotas) fica **por dentro**. Assim cada página tem acesso a
cache, tema e i18n, e qualquer erro de renderização de qualquer rota cai no
`ErrorBoundary`. Veja a página de roteamento pra detalhes do ``.
## O que vem ligado por padrão vs. opt-in
| Prop | Estado padrão | Como ligar / configurar |
| --------------- | ------------------ | ---------------------------------------- |
| `query` | **Ligado** | Já ativo. Passe um objeto pra ajustar. |
| `theme` | **Ligado** | Já ativo. Passe um objeto pra ajustar. |
| `i18n` | Desligado (opt-in) | Passe `{ locale, messages }` pra montar. |
| `errorBoundary` | Desligado (opt-in) | Passe `{ fallback }` pra montar. |
- **`query`** e **`theme`** já estão ativos com os defaults do SDK — o caso
comum (você quer cache de dados e tema) não exige nenhuma configuração.
- **`i18n`** e **`errorBoundary`** só entram na árvore quando você passa a prop
correspondente. Omitiu? O provider simplesmente não é montado.
!!! info "Defaults de query"
Quando ligado por padrão, o `QueryProvider` usa: `staleTime` de 5 minutos,
`gcTime` de 30 minutos, `retry: 1` e `refetchOnWindowFocus: false`.
## Desligando um padrão com `false`
Às vezes o app já monta o seu próprio `QueryClient`, ou você não quer o tema do
SDK. Passe `false` na prop pra **remover aquele provider** da árvore:
```tsx
// query e theme desligados — o app monta os seus por fora
import { AppProviders } from "tempest-react-sdk";
export function App() {
return (
);
}
```
!!! warning "`false` remove o provider — não o desativa silenciosamente"
Com `query={false}`, nenhum `QueryClient` é montado pelo ``.
Se algum componente filho usar `useQuery`, ele precisa de um provider montado
por você mais acima na árvore — senão o React Query lança erro em runtime.
## Ajustando cada provider
Cada prop aceita um objeto com as mesmas opções do provider isolado (menos
`children`, que o `` controla).
### Query
```tsx
import { AppProviders } from "tempest-react-sdk";
;
```
Você também pode passar um `client` (`QueryClient`) já configurado em vez de
`defaultOptions`.
### Theme
```tsx
import { AppProviders } from "tempest-react-sdk";
;
```
### i18n
```tsx
import { AppProviders } from "tempest-react-sdk";
const messages = {
"pt-BR": { hello: "Olá" },
"en-US": { hello: "Hello" },
};
;
```
`i18n` aceita `locale`, `messages`, e opcionalmente `fallbackLocale` e
`storageKey`.
## A fallback do ErrorBoundary
A prop `errorBoundary` aceita `fallback` em duas formas: um `ReactNode` fixo, ou
uma **função de render** que recebe `{ error, reset }` — útil pra mostrar a
mensagem do erro e oferecer um botão de "tentar de novo":
```tsx
import { AppProviders } from "tempest-react-sdk";
{error.message} ,
}}
>
;
```
Além de `fallback`, você pode passar `onError` (callback ao capturar) e
`resetKeys` (valores que, ao mudarem, resetam o boundary automaticamente).
## Exemplo completo: todas as props juntas
Aqui está tudo em ação — query ajustada, tema desligado, i18n montado e
error boundary com função de render:
```tsx
import { AppProviders } from "tempest-react-sdk";
const messages = {
"pt-BR": { hello: "Olá" },
"en-US": { hello: "Hello" },
};
export function Root() {
return (
{error.message} ,
}}
>
);
}
```
## Recap
- `` substitui a pirâmide manual de providers por **um bloco
declarativo**, aninhando de fora pra dentro: `ErrorBoundary → QueryProvider →
ThemeProvider → I18nProvider → children`. ✅
- **`query` e `theme` vêm ligados** com os defaults do SDK; **`i18n` e
`errorBoundary` são opt-in** — só montam quando você passa a prop.
- Passe `false` em `query` ou `theme` pra **remover** aquele provider (quando o
app monta o seu próprio).
- Passe um **objeto** em qualquer prop pra ajustar o provider correspondente.
- A `fallback` do error boundary pode ser um `ReactNode` ou uma **função** que
recebe `{ error, reset }`.
- Coloque o `` **por fora** do ``: providers fora,
rotas dentro. 💡
---
# architecture.md
# Arquitetura
O `tempest-react-sdk` é um pacote único com camadas independentes. O consumidor
importa só o que usa; tudo é externalizado no bundle do SDK, então o bundler do
app faz tree-shake do que não é referenciado.
!!! info "Esta página é a arquitetura **do pacote**"
Aqui você aprende como o SDK é montado — camadas, dependências, subpaths,
bundle. Se o que você quer é como organizar o **seu app** (camadas, pastas,
onde mora cada estado, limites de arquivo), a página é
[Camadas de um app frontend](./design/architecture.md), na aba
[Design de Software](./design/index.md).
!!! tip "Importe só o que usa"
Não existe penalidade por o SDK ser grande. Cada camada (HTTP, auth, query,
forms…) é independente — se você nunca importa `createOfflineStore`, o `dexie`
não entra no seu bundle. Comece com um `Button` e cresça a partir daí.
> Diagrama editável: [architecture.drawio](./diagrams/architecture.drawio) (abra no [draw.io](https://app.diagrams.net)).
## Escopo: só client-side
O SDK é feito para **SPA client-rendered com capacidade offline** — service
worker, outbox no IndexedDB, prompt de instalação, background sync. Ele **não**
suporta SSR nem React Server Components: nenhum módulo declara `"use client"` e
os componentes assumem que montam num browser. O App Router do Next não é alvo.
!!! warning "Isso é escolha de escopo, não lacuna"
Cobrir os dois mundos custaria em cada API (dois caminhos de render,
hidratação, `window` proibido no topo do módulo) e o offline-first — a razão
de existir do pacote — sairia pior. Os guards
`typeof window === "undefined"` que existem nos hooks servem pra não explodir
fora do browser (testes em Node, contexto de service worker, plugin de
build), não pra prometer render no servidor.
## Camadas
### Fundação de aplicação
A base opinativa que monta um app React inteiro. É o que a CLI
[`create-tempest-app`](./scaffold.md) gera.
| Camada | O que faz | Página |
| ---------------------- | ------------------------------------------------------------------------------------- | -------------------------------- |
| **Vite (`vite/`)** | `createViteConfig` — plugin React + alias `@` → `src` + dev server (subpath `/vite`). | [Vite & alias](./vite-config.md) |
| **Router (`router/`)** | `defineRoutes`, ``, `` + re-exports do React Router v8. | [Roteamento](./routing.md) |
| **Store (`store/`)** | `createStore`, `createSelectors` (fábricas Zustand genéricas). | [Estado](./state.md) |
| **App (`app/`)** | `` — compõe ErrorBoundary → Query → Theme → i18n num bloco. | [Providers](./app-providers.md) |
### Blocos de UI e integrações
| Camada | O que faz |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Componentes (`components/`)** | 70+ UI primitives (Button, Input, Modal, Table, DataTable, Command, Calendar…) com CSS Modules prefixados `tempest_`. |
| **Hooks (`hooks/`)** | `useDebounce`, `usePagination`, `useMediaQuery`, `useKeyboardShortcut`, `useFocusTrap`… |
| **HTTP (`http/`)** | `createApiClient`, `parseResponse`, `uploadWithProgress`, `retry`, `usePoll`. |
| **Auth (`auth/`)** | `createAuthStore` (Zustand) + `AuthGuard` + JWT helpers + `lazyWithRetry`. |
| **Query (`query/`)** | `QueryProvider`, `createQueryKeys`, presets de tempo. |
| **SSE / WebSocket / Push / SW** | Transportes em tempo real com reconnect. |
| **Offline (`offline/`)** | `createOfflineStore` (Dexie). |
| **Forms (`forms/`)** | `useZodForm`, `zodResolver`, `FormField`, inputs mascarados BR. |
| **Theme / i18n / Logger / Telemetry / Feature Flags** | Tema (no-flash), i18n in-house, logger leveled, adapters injetáveis. |
| **Utils (`utils/`)** | `cn`, format BR, arrays/objects/guards/functions/promises, strings, numbers, `randomId`. |
## Dependências
**`react`**, **`react-dom`** e **`react-router`** são **peer dependencies** — os
três carregam contexto React, e uma segunda cópia não é peso extra no bundle, é
uma segunda *instância* que quebra em runtime. Todo o resto é **dependência
direta** — instalada automaticamente por `npm install tempest-react-sdk` e
externalizada no bundle (o bundler do app resolve do `node_modules` e faz
tree-shake).
| Pacote | Status | Usado por |
| ------------------------------ | ------------------- | ---------------------------------------------------------------------------------------- |
| `react`, `react-dom` | **Peer (obrigat.)** | Tudo |
| `react-router` (`^7 \|\| ^8`) | **Peer (obrigat.)** | `AppRouter`, `defineRoutes`, `RouteGuard`, re-exports |
| `zustand` | Dep direta | `createStore`, `createSelectors`, `createAuthStore` |
| `@tanstack/react-query` | Dep direta | `QueryProvider`, `createQueryKeys`, `AppProviders` |
| `zod` | Dep direta | `parseResponse`, `validateForm`, `zodResolver`, `useZodForm` |
| `react-hook-form` | Dep direta | `useZodForm`, `FormField`, inputs mascarados |
| `dexie` | Dep direta | `createOfflineStore` |
| `lucide-react` | Dep direta | Ícones (`leftIcon`/`rightIcon`) |
| `vite`, `@vitejs/plugin-react` | **Peer opcional** | `createViteConfig` (subpath `tempest-react-sdk/vite`) — já presente em qualquer app Vite |
!!! warning "Por que `react-router` é peer, e não dep direta"
Ele guarda contexto React. Uma cópia aninhada em
`tempest-react-sdk/node_modules` é um `` **diferente** do que o seu
app renderiza, então qualquer hook do SDK que alcance esse contexto estoura
com `useNavigate() may be used only in the context of a ` — crash de
runtime, não regressão de tamanho. É a mesma razão de `react` ser peer, e é a
única exceção à regra "todo o resto é dep direta". O range `^7 || ^8` deixa o
app em qualquer um dos dois majors instalar uma cópia só: a superfície
re-exportada é idêntica nas duas versões, e ambas entregam os bindings de DOM
dentro do próprio `react-router` (não existe `react-router-dom` separado).
!!! note "O resto continua dep direta"
`zustand`, `zod`, `dexie`, `react-hook-form`, `@tanstack/react-query` e
`lucide-react` são dependências diretas — `npm install tempest-react-sdk` já
traz tudo, sem você listar nada à mão. Duas cópias dessas custam bytes, não
correção.
Adapters de SDKs externos (Sentry, PostHog, GrowthBook, LaunchDarkly) **não**
são declarados — o caller injeta a instância na factory.
## Subpaths
| Import | Conteúdo |
| ------------------------------ | ----------------------------------------------------- |
| `tempest-react-sdk` | Barrel principal (componentes, hooks, foundation…). |
| `tempest-react-sdk/styles.css` | Tokens `--tempest-*` + reset + CSS Modules. |
| `tempest-react-sdk/vite` | `createViteConfig` (Node-only, pro `vite.config.ts`). |
| `tempest-react-sdk/testing` | `createMockHandlers` (helpers MSW pra testes). |
| `tempest-react-sdk/icons` | `Icon` por slug + registro estático ([Ícones](./icons.md)). |
## Bundle
Vite library mode → ESM (`tempest-react-sdk.js`) + CJS (`.cjs`) + `.d.ts`
rollupado + `styles.css` (CSS Modules num arquivo só, `cssCodeSplit: false`).
Orçamento monitorado por `size-limit` no CI.
## Recap
- Um pacote, camadas independentes; você importa só o que usa e o bundler faz
tree-shake do resto.
- Só `react` + `react-dom` são peers; as demais libs são deps diretas instaladas
junto.
- Subpaths: o barrel principal, `…/styles.css`, `…/vite` (Node-only), `…/testing` e
`…/icons` (ícone por slug).
- A fundação de app ([Vite](./vite-config.md) · [Router](./routing.md) ·
[Store](./state.md) · [Providers](./app-providers.md)) é o que o
[`create-tempest-app`](./scaffold.md) monta pra você.
## Veja também
- [Design de Software — camadas do seu app](./design/architecture.md)
- [Scaffold — `create-tempest-app`](./scaffold.md)
- [HTTP — fluxo de request](./http.md)
- Diagrama: [architecture.drawio](./diagrams/architecture.drawio)
---
# audio.md
# Áudio
Áudio no navegador, nas duas direções.
**Reprodução** — notificação sonora (chime de mensagem, confirmação de pagamento) com `playAudio`, `useAudio` e `createAudioPlayer`, e o componente `AudioPlayer` quando você precisa de transporte (play/pause, barra, tempos).
**Captura** — `AudioRecorder` pra uma nota de voz em uma linha, e por baixo `useMediaPermission`, `useMediaDevices`, `useMicrophone`, `useAudioRecorder`, `createLevelMeter` e `blobToWav`. Nenhuma dependência nova: a fatia inteira mede **5,50 KB brotli**.
!!! tip "Se você só quer gravar uma nota de voz, pule para [Gravação](#gravacao-comece-pelo-componente)"
O resto da página é a camada de baixo, pra quando o componente não serve.
!!! info "Por que um wrapper em volta de `new Audio()`?"
Tocar som no navegador esbarra na _autoplay policy_ e em vazamento de elementos `Audio`. O SDK encapsula: rastreia o clipe atual (pra dar `stop`), normaliza volume, trata o bloqueio de autoplay devolvendo `null` em vez de estourar, e limpa no unmount quando você usa o hook.
## `playAudio` — one-off no player compartilhado
Ideal pra um som disparado por um evento, sem estado de UI:
```tsx
import { playAudio, useEventStream } from "tempest-react-sdk";
interface StreamEvent {
type: "NOTIFY" | "PAYMENT-SUCCESS";
}
export function PaymentSounds() {
useEventStream(`${import.meta.env.VITE_API_URL}/notifications`, {
onMessage: ({ data }) => {
if (data.type === "PAYMENT-SUCCESS") {
void playAudio("/audio/dinheiro.mp3", { volume: 0.5 });
}
},
});
return null;
}
```
`playAudio(src, options)` retorna `Promise` — `null` quando o navegador bloqueou o autoplay. Opções: `volume` (0–1, default 1), `loop`, `autoplay`, `stopPrevious`, `onEnded`, `onError`. Pra parar o que o player compartilhado está tocando, use `stopAudio()`.
## `useAudio` — player privado por componente
Cada instância do hook tem seu próprio player, então desmontar para o áudio automaticamente:
```tsx
import { useAudio } from "tempest-react-sdk";
export function NotificationBell() {
const audio = useAudio();
return (
audio.play("/audio/plim.wav", { volume: 0.8 })}>
🔔 {audio.unlocked ? "" : "(toque pra ativar som)"}
);
}
```
- `audio.play(src, options)` — toca no player privado (mesmas options do `playAudio`).
- `audio.stop()` — para o clipe atual.
- `audio.unlocked` — vira `true` após o primeiro `play()` bem-sucedido. Útil pra esconder UI que pede a interação inicial.
- Cleanup automático no unmount.
!!! tip "Use `unlocked` pra guiar o usuário"
Antes do primeiro clique, o navegador bloqueia áudio. Mostre uma dica ("toque pra ativar som") enquanto `unlocked === false` e esconda assim que ele virar `true`.
## `createAudioPlayer` — canais isolados
`createAudioPlayer()` cria um tracker independente do default. Use quando precisar tocar dois sons simultaneamente sem que um corte o outro (ex.: música de fundo + efeito sonoro):
```ts
import { createAudioPlayer } from "tempest-react-sdk";
const music = createAudioPlayer();
const sfx = createAudioPlayer();
await music.play("/audio/loop.mp3", { loop: true, volume: 0.3 });
await sfx.play("/audio/coin.wav", { volume: 1 }); // não corta a música
music.stop(); // para só a música
console.log(sfx.current()); // HTMLAudioElement | null
```
Cada player rastreia **um** clipe atual. `stopPrevious: true` no `play()` para o clipe anterior daquele mesmo player antes de tocar o novo.
## `createSfxPool` / `useSfxPool` — efeitos sonoros curtos
`new Audio(src)` a cada disparo aloca um elemento e re-entra na pilha de rede por um arquivo que o navegador já tem. Para um som que toca dezenas de vezes por minuto — um blip de menu, um hit, um pickup — é a forma errada. O pool aloca uma vez por fonte e reproduz.
```ts
import { useSfxPool } from "tempest-react-sdk";
function Menu({ sfxVolume }: { sfxVolume: number }) {
const sfx = useSfxPool({ volume: sfxVolume / 100, baseUrl: import.meta.env.BASE_URL });
useEffect(() => {
sfx.preload(["sfx/move.mp3", "sfx/select.mp3", "sfx/back.mp3"]);
}, [sfx]);
return sfx.play("sfx/select.mp3")}>Confirmar ;
}
```
- `play(src, { volume })` — o volume por play é multiplicado pelo master do pool.
- `preload(src | src[])` — busca antes do primeiro disparo, pra ele não sair mudo enquanto o arquivo baixa.
- `setVolume(v)` — aplica também no que já está soando, **reescalando pelo ganho de cada um**: um clipe que começou a meio volume não é puxado pra cima.
- `stop(src?)` — para uma fonte, ou todas.
- `dispose()` — libera tudo. O `useSfxPool` já chama no unmount.
!!! note "`voices`: repetir ou sobrepor"
O default (`voices: 1`) **reinicia** o clipe a cada play, que é o que um blip de menu quer. Suba pra deixar o som se sobrepor — um hit tocando enquanto o anterior ainda ressoa:
```ts
const hits = createSfxPool({ voices: 3 });
```
!!! tip "Não confunda com `createAudioPlayer`"
`createAudioPlayer` rastreia **um** clipe atual, com loop, roteamento de saída e callbacks de ciclo de vida — é o que música de fundo precisa. Efeitos são o caso oposto: muitas fontes, todas curtas, dispara-e-esquece, e o que importa é que disparar seja barato.
Mudar `volume` no `useSfxPool` chama `setVolume` no pool existente em vez de recriá-lo — recriar jogaria fora todo elemento que o usuário já baixou, que é exatamente o custo que o pool existe pra evitar. `baseUrl`, `voices` e `maxSources` são lidos uma vez, na criação.
## Autoplay policy
Navegadores bloqueiam playback antes da primeira interação do usuário. `playAudio` / `play()` retornam `null` quando bloqueado (e chamam `onError` se passado) — em vez de lançar.
!!! warning "Destrave o áudio no primeiro clique"
Não dá pra tocar som antes de qualquer interação. Desenhe o app pra disparar um `play()` (mesmo de um clipe silencioso curto) no primeiro clique de qualquer botão; a partir daí o navegador libera os próximos.
## Assets
O SDK **não** embute áudios. Sirva em `/audio/*` (ou CDN) e passe a URL. Inspiração de paleta sonora (alofans):
```ts
export const AUDIOS = {
plim: "/audio/plim.wav",
dinheiro: "/audio/dinheiro.mp3",
notification: "/audio/bell_sound.wav",
};
```
## Gravação: comece pelo componente
Se você só quer uma nota de voz, é uma linha. O `AudioRecorder` cuida da permissão, do medidor de nível, do relógio e da revisão antes de você receber o áudio:
```tsx
import { AudioRecorder } from "tempest-react-sdk";
export function NotaDeVoz({ ticketId }: { ticketId: string }) {
return (
{
const form = new FormData();
form.append("audio", blob, `nota.${mimeType.includes("mp4") ? "m4a" : "webm"}`);
form.append("duracao", String(durationMs));
void fetch(`/api/tickets/${ticketId}/audio`, { method: "POST", body: form });
}}
footer={Máximo 2 minutos. }
/>
);
}
```
O que você ganha sem escrever nada:
| Você fez | O componente faz |
| --- | --- |
| nada | **Não** pede o microfone no mount — só no primeiro toque em Gravar |
| nada | Se a permissão já está `denied`, diz isso e como resolver, em vez de oferecer um botão que não funciona |
| nada | Medidor de nível ao vivo, relógio que **desconta pausa**, pausar/continuar |
| `maxDurationMs` | Para sozinho no limite, e mostra o limite ao lado do relógio |
| nada | Player de revisão com transporte, e "Gravar de novo" reaproveitando o mesmo stream |
| `format="wav"` | Converte antes de te entregar, então o `onRecorded` **sempre** dá o formato que você pediu |
!!! danger "O prompt de permissão não é disparado no mount, e isso é a decisão mais importante da página"
Um prompt que o usuário não provocou é a forma mais confiável de ganhar um **Block permanente** — e depois disso o `getUserMedia` rejeita **sem nunca mais perguntar**. Por isso o microfone abre no primeiro toque em Gravar, e o toque sobrevive ao round-trip: o componente arma a gravação e começa quando o stream chega. Esperar um segundo clique faria o primeiro parecer quebrado.
### Props
| Prop | Tipo | Default | O que faz |
| --- | --- | --- | --- |
| `onRecorded` | `(recording: AudioRecording) => void` | — | Recebe o áudio pronto. Não dispara em cancelamento. |
| `maxDurationMs` | `number` | — | Para sozinho. **Vale sempre setar** em tela pública. |
| `deviceId` | `string` | — | Microfone específico, de `useMediaDevices().audioInputs`. |
| `format` | `"native" \| "wav"` | `"native"` | `"wav"` converte no stop. Veja o custo abaixo. |
| `wavOptions` | `WavOptions` | — | `{ mono: true, sampleRate: 16000 }` serve pra fala. |
| `audioBitsPerSecond` | `number` | — | 32000–64000 basta pra voz em Opus. |
| `review` | `boolean` | `true` | Player de revisão antes de entregar. |
| `locale` | `"pt-BR" \| "en"` | `"pt-BR"` | Rótulos. |
| `footer` | `ReactNode` | — | Dica, contagem, aviso legal. |
| `onError` | `(error: unknown) => void` | — | Falha do gravador, ou conversão WAV que não deu. |
`AudioRecording = { blob, mimeType, durationMs }`.
## `AudioPlayer` — transporte pra um clipe
Aceita `Blob` direto, porque o que um app mais toca é a gravação que ele acabou de fazer:
```tsx
```
!!! warning "Passe `durationMs` sempre que tiver — o `` mente sobre gravação nova"
O `MediaRecorder` escreve WebM **sem duração no header**, então `.duration` de uma gravação fresca é `Infinity`. É por isso que o gravador mantém o próprio relógio e você deve repassá-lo. Sem ele o componente aplica o único contorno que existe — buscar além do fim pra forçar o browser a demuxar até o último frame — e a barra fica travada até isso resolver.
| Prop | Tipo | Default | O que faz |
| --- | --- | --- | --- |
| `src` | `string \| Blob \| null` | — | URL ou blob. Blob vira object URL com revoke automático. |
| `durationMs` | `number` | — | Duração conhecida. Ganha do ``. |
| `sinkId` | `string` | — | Saída escolhida. Só Chromium. |
| `autoPlay` · `loop` | `boolean` | `false` | Como no elemento nativo. |
| `actions` | `ReactNode` | — | À direita dos tempos — baixar, apagar. |
| `onEnded` · `onError` | `() => void` | — | Fim e falha de decode/rede. |
## Permissão sem disparar o prompt
`useMediaPermission` lê o estado **sem** pedir o dispositivo. É o que torna um fluxo decente possível:
```tsx
import { useMediaPermission } from "tempest-react-sdk";
export function BotaoDeGravar({ onStart }: { onStart: () => void }) {
const { state, supported } = useMediaPermission("microphone");
if (state === "denied") {
return Microfone bloqueado. Libere nas configurações do site e recarregue.
;
}
return (
{state === "prompt" || !supported ? "Permitir microfone e gravar" : "Gravar"}
);
}
```
| Estado | Significa | O que a UI deve fazer |
| --- | --- | --- |
| `"prompt"` | nunca pediu | botão; pedir mostra o prompt |
| `"granted"` | liberado | botão normal |
| `"denied"` | **sticky** — pedir de novo rejeita na hora, sem prompt | instrução pras configurações do site, não botão |
| `"unknown"` | Permissions API não respondeu (Safari não expõe `microphone`) | trate como "vai ter que pedir pra descobrir" |
O estado é **ao vivo**: se o usuário mudar a permissão nas configurações do site, o hook atualiza.
## Dispositivos: microfone e saída
```tsx
import { useMediaDevices, isAudioOutputSelectionSupported } from "tempest-react-sdk";
const { audioInputs, audioOutputs, labelsAvailable } = useMediaDevices();
```
!!! warning "Os nomes só aparecem depois da permissão"
Antes de o usuário liberar uma captura, **todo** `label` é `""` — os ids e a contagem são reais, os nomes não. Um seletor renderizado ali é uma coluna de vazios. Use `labelsAvailable` pra decidir quando mostrar. Peça o microfone primeiro, depois ofereça a escolha.
!!! info "A lista muda com a página aberta"
Plugar um fone no meio da gravação é o caso normal, não borda. O hook assina `devicechange` em vez de enumerar uma vez no mount.
`audioOutputs` vem **vazio** onde o browser não tem roteamento de saída — Safari e Firefox não implementam `setSinkId`. Vazio ali significa "você não pode oferecer essa escolha", não "não há alto-falantes". Cheque com `isAudioOutputSelectionSupported()` antes de renderizar o seletor, ou o controle é uma mentira em dois dos três motores.
```tsx
import { setAudioOutput } from "tempest-react-sdk";
const ok = await setAudioOutput(audioRef.current, dispositivoEscolhido);
if (!ok) toast("Este navegador não permite escolher a saída de som.");
```
`playAudio` também aceita `sinkId`, útil pra um chime cair no fone enquanto o áudio da chamada fica no alto-falante.
## Montando você mesmo: os hooks
```tsx
import { useMicrophone, useAudioRecorder } from "tempest-react-sdk";
export function GravadorProprio() {
const mic = useMicrophone({ deviceId: undefined, noiseSuppression: true });
const rec = useAudioRecorder(mic.stream, { maxDurationMs: 60_000 });
return (
<>
Liberar microfone
Gravar
void rec.stop()}>Parar
{rec.durationMs} ms
{mic.error && {mic.error.message}
}
>
);
}
```
!!! danger "`stop()` no microfone não é opcional"
Soltar a última referência de um `MediaStream` **não** desliga o microfone. Cada track tem que ser parada à mão — senão o browser continua mostrando o indicador de gravação, o SO mantém o dispositivo ocupado, e o **próximo** `getUserMedia` (em outra aba do mesmo app, tipicamente) falha com `NotReadableError`. O `useMicrophone` para as tracks no `stop()`, no unmount e antes de reabrir.
!!! info "O gravador **não** é dono do stream"
`rec.stop()` deixa o microfone aberto de propósito, pra uma segunda gravação não precisar de outro round-trip de permissão. Fechar é do `mic.stop()`.
### Erros classificados
`useMicrophone().error` já vem traduzido de `DOMException` para algo em que dá pra ramificar:
| `kind` | Causa | Ação |
| --- | --- | --- |
| `insecure` | página em HTTP puro | a correção é uma **URL**, não uma configuração |
| `unsupported` | motor sem captura | outro navegador |
| `permission-denied` | negado | configurações do site |
| `not-found` | nenhum dispositivo, ou nenhum que casa com as constraints | relaxar `deviceId` |
| `in-use` | hardware ocupado | fechar o outro app/aba |
| `unknown` | o resto | mostra a mensagem original |
A ordem importa: um `mediaDevices` ausente é quase nunca "este navegador não faz áudio" — é uma página em HTTP, onde a API inteira simplesmente não existe. Reportar `unsupported` ali manda o dev procurar polyfill pra um problema que um `https://` resolve.
## Nível de gravação
`useAudioRecorder` já publica `level` (0–1) a 10 Hz. Pra uma barra por frame, use o `createLevelMeter` direto e escreva no DOM:
```tsx
const meter = createLevelMeter(stream);
const tick = () => {
barra.style.transform = `scaleX(${meter.level()})`;
raf = requestAnimationFrame(tick);
};
// ...e sempre meter.stop() no cleanup
```
!!! warning "Medidor não é enfeite"
Uma entrada mutada no SO, ou um headset com o braço do mic dobrado pra cima, produz uma gravação **perfeitamente bem-sucedida de silêncio** — e sem nível visível o usuário só descobre depois de terminar de falar.
O valor é RMS, não pico: pico reage a uma amostra só e pisca, RMS acompanha volume percebido. O ataque é instantâneo e a queda é suavizada, que é como todo medidor de hardware se comporta — um que atrasa na subida parece "não está gravando".
!!! info "Feche o `AudioContext`"
O medidor cria um `AudioContext` e o `stop()` fecha. Navegadores limitam contextos vivos (Chrome permite ~6), então um medidor esquecido em unmount quebra todos os próximos da página. Os hooks e os componentes já fecham; se você usar o `createLevelMeter` cru, o `stop()` é seu.
## Formato: o que dá e o que não dá
!!! danger "`MediaRecorder` não produz MP3 nem WAV — em nenhum navegador"
Chromium e Firefox produzem **Opus** (em WebM ou Ogg); Safari produz **AAC** (em MP4). Nenhum motor implementa encoder MP3 ou WAV pra ele. O padrão do SDK negocia nessa ordem e devolve o que saiu de fato — `AudioRecording.mimeType` é o que o browser reportou, não o que você pediu.
Se o backend só aceita **WAV**, o `blobToWav` converte no cliente, com **zero dependência**: decodifica com o decoder do próprio browser (`decodeAudioData`) e reencoda RIFF/PCM 16-bit.
```tsx
import { blobToWav } from "tempest-react-sdk";
const wav = await blobToWav(recording.blob, { mono: true, sampleRate: 16000 });
```
!!! warning "WAV custa ~10× mais bytes"
A mesma nota de voz que tem 40 KB em Opus fica em torno de 500 KB em WAV a 48 kHz estéreo. `{ mono: true, sampleRate: 16000 }` leva isso pra ~80 KB — e 16 kHz mono é o que um endpoint de speech-to-text quer de todo jeito. O reamostrador é o `OfflineAudioContext`, ou seja o do próprio browser, não um escrito à mão.
Se o backend só aceita **MP3**: transcodifique no servidor. Um encoder MP3 no cliente significa um build WASM da ordem de 150 KB no bundle de **todo** consumidor do SDK pra servir um formato — é a troca que este SDK não faz.
## Fora do React: os primitivos
`useAudioRecorder` é uma casca fina em cima de um gravador que não sabe nada de
React. Quando a gravação acontece longe de um componente — num store, num
worker, numa máquina de estados — use o primitivo direto:
```ts
import { createAudioRecorder, isAudioRecordingSupported } from "tempest-react-sdk";
if (!isAudioRecordingSupported()) throw new Error("Este navegador não grava áudio");
const rec = createAudioRecorder(stream, { audioBitsPerSecond: 48_000 });
rec.start();
// ...
const recording = await rec.stop(); // { blob, mimeType, durationMs }
```
O handle expõe `start`, `pause`, `resume`, `stop`, `cancel`, mais os leitores
`status()` e `durationMs()` (que descontam o tempo pausado) e o `mimeType`
negociado.
| Símbolo | O que é |
| ------------------------------------ | ------------------------------------------------------------------------- |
| `createAudioRecorder(stream, opts)` | O gravador por trás do hook. Não é dono do stream — pare o mic você mesmo. |
| `isAudioRecordingSupported()` | `MediaRecorder` existe **e** algum container é produzível. |
| `isMediaCaptureSupported()` | A mesma pergunta para captura em geral (`getUserMedia` + `MediaRecorder`) — use antes de oferecer qualquer botão de gravar. |
| `pickAudioMimeType(preferred?)` | Primeiro container da lista que o browser realmente produz, ou `null`. |
| `AUDIO_MIME_CANDIDATES` | A ordem que o SDK negocia: Opus (WebM/Ogg) → AAC (MP4). |
| `encodeWav({ channels, sampleRate })`| RIFF/PCM 16-bit a partir de canais Float32 — o motor do `blobToWav`. |
!!! tip "`isAudioRecordingSupported()` responde a pergunta certa"
Checar só `typeof MediaRecorder !== "undefined"` deixa passar o motor que
tem a API e não produz nenhum dos containers — a falha aparece no
`start()`, com o usuário já esperando. Esta função checa as duas coisas.
## Upload longo: chunks
```tsx
const rec = useAudioRecorder(mic.stream, {
timesliceMs: 5_000,
onChunk: (chunk) => void upload(chunk),
});
```
Sem `timesliceMs` a gravação inteira fica em memória até o `stop()` — ok pra nota de voz, não ok pra uma reunião de uma hora. **Os chunks não são tocáveis isoladamente**: só o conjunto forma um arquivo válido.
## Recap
- `playAudio(src, options)` — som one-off no player compartilhado; retorna `null` se o autoplay foi bloqueado. `stopAudio()` para esse player.
- `useAudio()` — player privado por componente com `play`/`stop`/`unlocked` e cleanup no unmount.
- `createAudioPlayer()` — canal isolado pra tocar sons simultâneos sem um cortar o outro.
- A autoplay policy é tratada devolvendo `null`; destrave o áudio na primeira interação.
- O SDK não traz arquivos de áudio — você serve e passa a URL.
- `AudioRecorder` — nota de voz completa: permissão, nível, relógio, revisão, retake.
- `AudioPlayer` — transporte pra um clipe; aceita `Blob` e **precisa** de `durationMs` pra gravação nova.
- `useMediaPermission` — estado da permissão **sem** disparar o prompt; separa "nunca pedi" de "negado" (que é sticky).
- `useMediaDevices` — mics e saídas, reage a `devicechange`; `labelsAvailable` diz quando o seletor vale a pena.
- `useMicrophone` — stream + erro classificado; `stop()` **precisa** ser chamado ou o indicador de gravação não apaga.
- `useAudioRecorder` — status, relógio que desconta pausa, nível, `maxDurationMs`, chunks.
- `blobToWav` — WAV sem dependência, ~10× os bytes; MP3 fica no servidor.
- `setAudioOutput` / `isAudioOutputSelectionSupported` — roteamento de saída, só Chromium.
## Veja também
- [SSE](./sse.md) / [Push](./push.md) — gatilhos típicos de áudio
---
# auth.md
# Auth
Autenticação no front-end é sempre o mesmo punhado de problemas: onde guardar a sessão, como proteger uma rota, como ler o que tem dentro do token e o que fazer quando ele expira no meio de um deploy. O módulo `auth` do `tempest-react-sdk` resolve cada um desses problemas com uma peça pequena e independente — você pega só o que precisa, sem herdar um framework de auth inteiro.
São cinco peças, todas desacopladas entre si:
1. `createAuthStore` — fabrica um Zustand store tipado pelo `TUser` do app.
2. `AuthGuard` — gate de rota router-agnostic.
3. `decodeJWT` / `isJWTExpired` — leitura defensiva de JWTs (sem validação criptográfica).
4. `lazyWithRetry` — `React.lazy` com retry de chunk + reload na falha final.
5. `createRefreshQueue` — coalesce chamadas de refresh concorrentes.
!!! info "Por que peças soltas em vez de um `` monolítico"
Cada app Tempest tem um modelo de usuário, um fluxo de login e um backend
diferentes. Um provider monolítico forçaria todos a um único formato. Cinco
primitivos compõem livremente: você usa o store sem o guard, o guard sem o
JWT decoder, e por aí vai.
## Store — `createAuthStore`
O SDK **não é dono do seu modelo de usuário**. Você passa o `TUser` e ganha um store Zustand tipado, já com `persist` configurado.
```tsx
import { createAuthStore } from "tempest-react-sdk";
type SessionUser = { id: string; name: string; is_admin: boolean };
export const useAuthStore = createAuthStore({
name: "tempest-app-auth",
storage: "local",
});
// dentro de um componente: leia o estado (re-renderiza quando muda)
function Header() {
const { user, isAuthenticated, logout } = useAuthStore();
return isAuthenticated ? Sair de {user?.name} : null;
}
// fora do React: escreva pelo getState()
useAuthStore.getState().setSession({
user: { id: "u1", name: "Ana", is_admin: false },
token: "eyJhbGciOi…",
});
useAuthStore.getState().logout();
```
Persiste em `localStorage` (default) ou `sessionStorage` (`storage: "session"`). Apenas `user` e `token` são persistidos (`partialize`); após o hydrate, `isAuthenticated` é re-derivado de `!!token` no `onRehydrateStorage`.
Opções extras: `initialUser`, `initialToken` (úteis para hydration em SSR).
!!! tip "Selecione o mínimo necessário"
`const isAuth = useAuthStore((s) => s.isAuthenticated)` re-renderiza só
quando `isAuthenticated` muda. Desestruturar o store inteiro
(`const { ... } = useAuthStore()`) assina **todas** as fatias e re-renderiza
em qualquer mudança — prefira o seletor em componentes quentes.
## Guard — `AuthGuard`
Router-agnostic: é um if/else puro. Você decide o que renderizar em cada ramo — tipicamente ` ` quando autenticado e ` ` para o redirect.
```tsx
import { Navigate, Outlet } from "react-router";
import { AuthGuard } from "tempest-react-sdk";
export function ProtectedLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return (
}>
);
}
```
`AuthGuard` recebe `isAuthenticated`, `children` e `fallback` — sem opinião sobre router. Compõe com guards de role customizados: aninhe um segundo guard dentro do `children`.
!!! note "Relação com `` do módulo routing"
Se você usa o módulo de roteamento do SDK, o [``](./routing.md)
declarativo já cobre o caso comum (`when` + `redirectTo`) direto na árvore de
rotas. Use `AuthGuard` quando precisar de controle imperativo sobre o que
renderiza em cada ramo, ou quando não estiver usando o ``.
## JWT — `decodeJWT` / `isJWTExpired`
Decoder de **payload only** — **não** valida a assinatura. Use no client para inspeção/UX (mostrar nome, esconder botão de admin, decidir quando renovar). A autorização real é sempre no backend.
```ts
import { decodeJWT, isJWTExpired } from "tempest-react-sdk";
// decodeJWT LANÇA quando o token é malformado — envolva em try/catch
try {
const { header, payload, signature } = decodeJWT(token);
console.log(payload.sub, payload.exp);
} catch {
// token sem 3 segmentos, ou header/payload não-JSON
}
// isJWTExpired nunca lança — token inválido conta como expirado
const expired = isJWTExpired(token, 30); // 30s de leeway → expirado 30s antes do exp
```
!!! warning "`decodeJWT` lança; não retorna `null`"
Em um token malformado, `decodeJWT` **lança** `Error` — sempre envolva em
`try/catch`. Já `isJWTExpired` é defensivo: qualquer erro de decode é tratado
como "expirado" (`true`), então é seguro chamar direto. A assinatura é
`isJWTExpired(token, leewaySeconds = 0)`.
!!! danger "Nunca autorize no client"
O payload de um JWT é base64 — qualquer um pode lê-lo e forjá-lo. Use
`decodeJWT` só para UX. Toda decisão de permissão de verdade acontece no
servidor, que valida a assinatura.
## Code-splitting — `lazyWithRetry`
`React.lazy` com retry de chunk + fallback `window.location.reload()` na falha final.
**Por que existe**: após um deploy, usuários com a aba aberta tentam carregar chunks que já foram deletados (404). O retry com backoff geralmente pega a nova versão; o reload resolve quando o `index.html` em cache também está stale.
```tsx
import { Suspense } from "react";
import { lazyWithRetry } from "tempest-react-sdk";
import { Spinner } from "tempest-react-sdk";
const Settings = lazyWithRetry(() => import("./Settings"), {
retries: 3, // default
initialDelay: 400, // default — backoff exponencial: 400, 800, 1600ms
reloadOnFinalFailure: true, // default
});
export function SettingsRoute() {
return (
}>
);
}
```
### Aquecendo o chunk — `preload()`
O componente devolvido carrega um `preload()`. Chame no momento em que a rota fica **provável**, não quando ela é necessária — o hover do link, a abertura do menu que contém ela, o fim do passo anterior — e o chunk chega antes do usuário decidir. O `Suspense` fallback simplesmente não aparece.
```tsx
void Settings.preload()}>
Configurações
```
O trabalho é compartilhado com o caminho de render: quem disparar primeiro faz o único fetch, e o outro espera a mesma promise. Chamar várias vezes é seguro.
!!! tip "Aquecer várias rotas de uma vez"
`preload()` devolve a promise, então dá para esperar por um conjunto:
```ts
await Promise.all([Settings.preload(), Profile.preload()]);
```
Ou dispare e esqueça — a rejeição já é tratada internamente, então um preload especulativo que ninguém aguarda nunca vira `unhandledrejection`.
## Refresh queue — `createRefreshQueue`
Coalesce N chamadas de refresh concorrentes em **1** request. Enquanto um refresh está em voo, toda chamada extra recebe a **mesma** promise; quando ela resolve, todas retomam juntas.
```ts
import { createRefreshQueue } from "tempest-react-sdk";
const refresh = createRefreshQueue(
async () => {
const { token } = await api.post<{ token: string }>("/auth/refresh");
useAuthStore.getState().setToken(token);
},
{ getToken: () => useAuthStore.getState().token },
);
// 5 requests com 401 simultâneos → 1 único refresh, todos retomam:
await Promise.all([refresh(), refresh(), refresh(), refresh(), refresh()]);
```
!!! warning "Compartilhar a promise sozinho não colapsa uma rajada — passe o `getToken`"
Uma página que dispara várias requests de uma vez recebe vários 401 de volta, mas eles **não chegam na mesma janela**: os retardatários resolvem depois do primeiro refresh já ter terminado, não encontram promise nenhuma pra entrar, e cada um começa outra — rotacionando um token que já está novo. Medido contra um backend de mentira, cinco requests expiradas concorrentes gastaram **dois** refreshes, não um.
O `getToken` fecha essa brecha: a fila lembra qual token o último refresh instalou, e uma chamada que encontra esse mesmo token ainda no lugar retorna na hora, porque o refresh que ela ia fazer já aconteceu. Aí as mesmas cinco requests gastam exatamente um refresh — e vinte também.
!!! tip "Plugue direto no `createApiClient`"
Passe a fila como `refresh` no cliente HTTP — ele a chama em todo 401 e a coalescência acontece de graça, evitando uma tempestade de refreshes paralelos derrubando seu endpoint de auth.
Use junto com `createApiClient`:
```ts
const refresh = createRefreshQueue(async () => {
await AuthService.refresh();
});
const api = createApiClient({
baseURL: "...",
refresh, // chamado em 401 — coalesce concorrentes
onUnauthorized: () => useAuthStore.getState().logout(),
});
```
## Exemplo completo — login, guard e refresh juntos
Um app real costura as cinco peças. Este é o esqueleto completo e copiável:
```tsx
// auth-store.ts
import { createAuthStore } from "tempest-react-sdk";
export type SessionUser = { id: string; name: string; email: string };
export const useAuthStore = createAuthStore({
name: "tempest-app-auth",
storage: "local",
});
```
```ts
// api.ts
import { createApiClient, createRefreshQueue } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";
const refresh = createRefreshQueue(async () => {
const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
const { token } = (await res.json()) as { token: string };
useAuthStore.getState().setToken(token);
});
export const api = createApiClient({
baseURL: "/api",
getToken: () => useAuthStore.getState().token,
refresh, // chamado em 401 → coalesce → retry da request original
onUnauthorized: () => useAuthStore.getState().logout(),
});
```
```tsx
// LoginPage.tsx
import { useNavigate } from "react-router";
import { useAuthStore, type SessionUser } from "./auth-store";
import { api } from "./api";
export function LoginPage() {
const navigate = useNavigate();
const setSession = useAuthStore((s) => s.setSession);
async function handleSubmit(email: string, password: string) {
const { user, token } = await api.post<{ user: SessionUser; token: string }>("/auth/login", {
body: { email, password },
});
setSession({ user, token });
navigate("/", { replace: true });
}
return ;
}
```
```tsx
// ProtectedLayout.tsx
import { Navigate, Outlet } from "react-router";
import { AuthGuard } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";
export function ProtectedLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return (
}>
);
}
```
O fluxo: o login chama `setSession`, persistindo `user` + `token`. O `ProtectedLayout` lê `isAuthenticated` do store. Cada request injeta o token via `getToken`; ao tomar 401, o `createApiClient` chama a fila de refresh (coalescida) e refaz a request. Se o refresh falhar, `onUnauthorized` dispara `logout()` e o guard redireciona para `/login`.
## Resumo
- **`createAuthStore`** — store Zustand tipado e persistido; você é dono do `TUser`.
- **`AuthGuard`** — if/else router-agnostic; você escolhe `children` e `fallback`.
- **`decodeJWT`** lança em token inválido; **`isJWTExpired`** é defensivo e nunca lança. Só para UX, nunca para autorização.
- **`lazyWithRetry`** — sobrevive a chunks stale pós-deploy com backoff + reload.
- **`createRefreshQueue`** — N refreshes concorrentes viram 1.
### Veja também
- [Routing](./routing.md) — `` declarativo, alternativa ao `AuthGuard` na árvore de rotas
- [HTTP](./http.md) — `getToken: () => useAuthStore.getState().token` + `refresh: queue`
- [Error Boundary](./error-boundary.md) — a falha final do `lazyWithRetry` vira um `ChunkLoadError` que o boundary captura
---
# br-pagamentos.md
# Pagamentos & fiscal BR
Os quatro trilhos que todo produto brasileiro acaba precisando: **Pix**, **boleto**, **chave de acesso da NFe** e **feriados / dias úteis**. Tudo puro TypeScript, **sem nenhuma dependência nova** — a fatia inteira mede **9,22 KB brotli**, e só Pix (payload + CRC + componente de QR) mede **6,1 KB**.
!!! info "Import pelo subpath `tempest-react-sdk/br`"
Igual ao resto do módulo BR, estes helpers vivem em `tempest-react-sdk/br` — não na raiz. Quem não importa, não paga.
```ts
import { pixPayload, parseLinhaDigitavel, parseChaveNFe, isBusinessDay } from "tempest-react-sdk/br";
```
!!! warning "O SDK não fala com banco nenhum"
Nada aqui consulta o Bacen, a SEFAZ ou uma API de PSP. São **codificadores e validadores locais**: eles montam a string certa, conferem os dígitos verificadores e leem os campos. Se um boleto existe, está registrado ou já foi pago — só o banco responde. Se uma NF-e foi autorizada — só a SEFAZ.
---
## Parte 1 — Pix
### O problema
Um QR de Pix não é um QR "de link". É um payload **EMV MPM** (o mesmo padrão da EMVCo que o Bacen adotou no "Manual de Padrões para Iniciação do Pix"): uma lista de triplas `ID + tamanho de 2 dígitos + valor`, terminada por um CRC-16.
Escrever isso na mão dá errado sempre no mesmo lugar — o checksum. Ele é CRC-16/CCITT-FALSE (polinômio `0x1021`, valor inicial `0xFFFF`) calculado sobre **todo o payload anterior mais os literais `6304`**, ou seja, incluindo o cabeçalho da própria tag 63. Errar isso produz um QR que abre no app e falha na leitura.
### O caminho curto: ``
```tsx
import { PixQRCode } from "tempest-react-sdk/br";
export function CheckoutPix() {
return (
);
}
```
Isso renderiza o símbolo **e** a linha copia-e-cola com botão de copiar. As duas coisas juntas não é enfeite:
!!! tip "Nunca mostre só o QR"
Num checkout mobile, o QR aparece **no mesmo aparelho** que iria escaneá-lo. Sem a string copiável, o usuário fica travado. É o erro mais comum de tela de Pix.
O payload é montado no navegador e o QR é desenhado pelo encoder próprio do SDK (o mesmo do [`QRCode`](./components/utility.md#qrcode)). Chave, valor e txid **não saem da página** — um serviço de imagem de QR receberia os três.
Quando o payload vem pronto do PSP (cobrança dinâmica assinada), passe direto:
```tsx
```
!!! warning "`pix` e `payload` são mutuamente exclusivos"
Passar os dois lança `PixError`. Prefira `payload` para cobrança dinâmica: aquela string foi o PSP que emitiu, e remontá-la aqui só cria um jeito de errar.
### `pixPayload` — a string, sem UI
```ts
import { pixPayload } from "tempest-react-sdk/br";
const payload = pixPayload({
key: "12345678909",
merchantName: "Loja Tempest",
merchantCity: "Sao Paulo",
amount: 25.5,
txid: "PEDIDO123",
});
// 00020101021126330014br.gov.bcb.pix0111123456789095204000053039865405
// 25.505802BR5912Loja Tempest6009Sao Paulo62130509PEDIDO1236304D68C
```
Campos aceitos:
| Campo | Tag | Obrigatório | Nota |
| --- | --- | --- | --- |
| `key` | 26 / 01 | ✅ | CPF, CNPJ, e-mail, telefone ou EVP. Validada e normalizada. |
| `merchantName` | 59 | ✅ | Máx. **25** caracteres. Acima disso, erro. |
| `merchantCity` | 60 | ✅ | Máx. **15** caracteres. |
| `amount` | 54 | — | Reais. Omita para o pagador digitar o valor. |
| `txid` | 62 / 05 | — | `[A-Za-z0-9]{1,25}`. Default `***`. |
| `description` | 26 / 02 | — | Texto livre que alguns apps mostram. |
| `postalCode` | 61 | — | CEP, só dígitos. |
| `oneTime` | 01 | — | `true` → tag `12` (uso único) em vez de `11`. |
### Estático × dinâmico
A distinção **muda o que liquida**, então não é cosmética:
=== "Estático"
A tag 26 carrega a **chave**. O QR é autocontido, pode ser impresso e reusado. Se `amount` for omitido, liquida com o valor que o pagador digitou.
```ts
pixPayload({
key: "loja@tempest.dev",
merchantName: "Tempest",
merchantCity: "Belo Horizonte",
});
```
=== "Dinâmico"
A tag 26 carrega uma **URL** (`payloadLocation`), e a carteira busca valor e recebedor no PSP. Liquida com o que o PSP serviu. Default: uso único.
```ts
pixPayload({
kind: "dynamic",
url: "pix.example.com/qr/v2/abc123",
merchantName: "Tempest",
merchantCity: "Recife",
});
```
!!! tip "Cobrança que precisa reconciliar centavo a centavo pede dinâmico"
Um QR estático sem valor liquida o que o pagador digitou — inclusive R$ 1,00 numa fatura de R$ 100,00.
### Chaves: validação e a armadilha dos 11 dígitos
```ts
import { normalizePixKey, pixKeyType } from "tempest-react-sdk/br";
pixKeyType("123.456.789-09"); // "cpf"
pixKeyType("11.222.333/0001-81"); // "cnpj"
pixKeyType("loja@tempest.dev"); // "email"
pixKeyType("+5511987654321"); // "phone"
pixKeyType("123e4567-e89b-12d3-a456-426614174000"); // "evp"
pixKeyType("não é chave"); // null
normalizePixKey("(11) 98765-4321"); // { type: "phone", value: "+5511987654321" }
```
CPF e CNPJ passam por `validateCPF` / `validateCNPJ` (os mesmos de [Forms BR](./forms-br.md)) — chave com dígito verificador errado é rejeitada com `PixError`.
!!! danger "CPF e celular nacional têm os dois 11 dígitos"
`"11987654321"` é um celular válido **e** poderia ser um CPF. O desempate é o dígito verificador: 11 dígitos com DV válido viram `"cpf"`, o resto vira `"phone"`. Passe telefone como `+5511987654321` e a ambiguidade desaparece.
### Acentos
O conjunto de caracteres do BR Code não tem acento. O SDK **remove os diacríticos** e rejeita o que sobrar fora do ASCII imprimível:
```ts
pixPayload({ key: "…", merchantName: "Padaria Açúcar", merchantCity: "São Paulo" });
// → "5914Padaria Acucar" … "6009Sao Paulo"
pixPayload({ key: "…", merchantName: "Loja ✅", merchantCity: "Recife" });
// → PixError: merchantName has characters the BR Code cannot carry
```
!!! note "Por que remover em vez de lançar"
"São Paulo" é o nome real da cidade e o payload não pode carregá-lo. Um QR que não escaneia é pior do que um nome sem cedilha. Já um emoji é erro de quem chamou, e esse aparece.
### Lendo um payload de volta
```ts
import { parsePixPayload } from "tempest-react-sdk/br";
const data = parsePixPayload(colado);
data.key; // "12345678909"
data.amount; // 25.5
data.txid; // "PEDIDO123"
data.crcValid; // true
data.fields; // toda TLV, na ordem, inclusive as desconhecidas
```
É **tolerante com tag desconhecida** — PSPs adicionam templates próprios, e um leitor que rejeita isso não serve em produção. O que **não** é tolerado: frame quebrado (tamanho que passa do fim, `6304` ausente) e **CRC divergente**, que lança. Para inspecionar um payload que você já sabe estar corrompido:
```ts
parsePixPayload(colado, { requireCrc: false }); // crcValid: false, sem lançar
```
!!! danger "CRC divergente não é aviso"
Um BR Code com checksum errado foi corrompido no caminho, e a conta que ele aponta agora **não é** a conta que o recebedor publicou. Por isso o default lança.
### `pixCrc16`, se você precisa do checksum sozinho
```ts
import { pixCrc16 } from "tempest-react-sdk/br";
pixCrc16("123456789"); // "29B1" — o check value publicado do CRC-16/CCITT-FALSE
pixCrc16(payload.slice(0, -4)); // os 4 hex que fecham o payload
```
---
## Parte 2 — Boleto
### Dois layouts, o mesmo tamanho
O código de barras tem 44 dígitos nos dois casos, e é aí que mora o bug:
| | Primeiro dígito | Linha digitável | Uso |
| --- | --- | --- | --- |
| **Cobrança** (`"banco"`) | ≠ 8 | 47 dígitos | Boleto de banco contra uma fatura |
| **Arrecadação** (`"arrecadacao"`) | `8` | 48 dígitos | Concessionária, tributo, multa |
Não são variantes de um formato: **todo campo muda de posição e de significado**. O SDK detecta pelo primeiro dígito e devolve uma união discriminada — estreite por `kind` antes de ler.
### Ler o que o leitor de código de barras entregou
```ts
import { parseCodigoBarras, parseLinhaDigitavel } from "tempest-react-sdk/br";
const boleto = parseCodigoBarras("34191157000001234560000123456789012345678901");
if (boleto.kind === "banco") {
boleto.banco; // "341"
boleto.valor; // 1234.56
boleto.vencimento; // Date — 2026-09-15
boleto.linhaDigitavel; // 47 dígitos, já com os DVs
} else {
boleto.segmentoLabel; // "Órgãos governamentais"
boleto.valor; // reais, ou null quando o campo é referência
boleto.empresa; // código FEBRABAN, ou prefixo do CNPJ no segmento 6
}
```
`parseLinhaDigitavel` faz o caminho inverso e aceita as duas linhas (47 ou 48). Os dois parsers **conferem todos os dígitos verificadores** que o layout permite conferir: os 3 (banco) ou 4 (arrecadação) DVs de bloco, mais o DV geral. Qualquer um errado lança `BoletoError` dizendo qual.
Conversão explícita, quando você só quer a outra representação:
```ts
import { codigoBarrasToLinhaDigitavel, linhaDigitavelToCodigoBarras } from "tempest-react-sdk/br";
linhaDigitavelToCodigoBarras("34190000172345678901723456789017115700000123456");
// "34191157000001234560000123456789012345678901"
```
### Validar entrada digitada
```ts
import { formatLinhaDigitavel, validateBoleto } from "tempest-react-sdk/br";
validateBoleto(input); // true / false, sem lançar
formatLinhaDigitavel(input);
// "34190.00017 23456.789017 23456.789017 1 15700000123456"
```
`formatLinhaDigitavel` é helper de **exibição**: entrada que não tem 47 nem 48 dígitos volta intacta.
### Só o layout, sem parsear: `boletoKind`
Para ramificar a UI antes de validar (mostrar o campo certo, escolher o ícone),
`boletoKind` diz o layout a partir do tamanho e do primeiro dígito, e devolve
`null` para entrada que não é boleto — sem lançar:
```ts
import { boletoKind } from "tempest-react-sdk/br";
boletoKind("34191157000001234560000123456789012345678901"); // "banco"
boletoKind("848900000017..."); // "arrecadacao"
boletoKind("123"); // null
```
E `boletoDueDate(fator, options?)` resolve o fator de vencimento isolado — útil
quando o fator veio de outro sistema e você não tem o código de barras inteiro.
Devolve `{ date, epoch }` (dizendo **qual** base foi usada) ou `null` quando o
fator é `0`, que é o valor de "sem vencimento":
```ts
import { boletoDueDate } from "tempest-react-sdk/br";
boletoDueDate(1000, { epoch: "legacy" }); // { date: 2000-07-03, epoch: "legacy" }
boletoDueDate(1000, { epoch: "current" }); // { date: 2025-02-22, epoch: "current" }
boletoDueDate(0); // null
```
O `epoch` de volta importa: ele diz qual das duas leituras ambíguas (abaixo)
saiu, o que é a diferença entre exibir a data e exibir a data **certa**.
### A virada do fator de vencimento (fev/2025)
O vencimento não está no boleto como data — está como **fator de vencimento**, quatro dígitos contando dias desde uma data-base. E essa data-base **mudou**:
- Base original **07/10/1997**. O campo saturou em `9999` no dia **21/02/2025**.
- A partir de **22/02/2025** o contador reiniciou em `1000` sobre a nova base **29/05/2022** (comunicado FEBRABAN FB-009/2023).
!!! danger "As duas leituras são genuinamente ambíguas"
Todo fator entre 1000 e 9999 tem uma leitura em cada base — a antiga cai em `2000-07-03 … 2025-02-21`, a nova em `2025-02-22 … 2049-10-14`. **Nada no código de barras diz qual.**
O default `"auto"` escolhe a que cai mais perto de `reference` (hoje, por default). Isso acerta o caso que importa — um boleto sendo pago agora — e erra numa varredura de arquivo histórico. Quando você sabe, diga:
```ts
parseCodigoBarras(barcode, { epoch: "current" }); // base 29/05/2022
parseCodigoBarras(barcode, { epoch: "legacy" }); // base 07/10/1997
parseCodigoBarras(barcode, { reference: new Date(2025, 5, 1) });
```
O campo resolvido vem acompanhado de qual base foi usada, então a UI pode avisar:
```ts
const boleto = parseCodigoBarras(barcode);
if (boleto.kind === "banco") {
boleto.fatorVencimento; // 1570
boleto.vencimentoEpoch; // "current"
boleto.vencimento; // Date, ou null quando o fator é 0 (boleto sem vencimento)
}
```
Para emitir, o inverso:
```ts
import { fatorVencimento } from "tempest-react-sdk/br";
fatorVencimento(new Date(2025, 1, 22)); // 1000 — base atual
fatorVencimento(new Date(2025, 1, 21), "legacy"); // 9999
fatorVencimento(new Date(2020, 0, 1)); // BoletoError — nenhum fator representa isso
```
### Arrecadação: o que é lido e o que não é
A posição 3 diz duas coisas ao mesmo tempo — se o valor é dinheiro e qual módulo calcula o DV geral:
| Posição 3 | Valor | DV geral |
| --- | --- | --- |
| `6` | Reais | módulo 10 |
| `7` | Quantidade de moeda / referência | módulo 10 |
| `8` | Reais | módulo 11 |
| `9` | Quantidade de moeda / referência | módulo 11 |
Com `7` ou `9`, `valor` vem `null` e o campo cru fica em `valorRaw` — o SDK **não** finge que uma referência é dinheiro. Posição 3 fora de `6-9` é rejeitada em vez de lida errado.
!!! warning "`vencimentoCampoLivre` é pista, não data de liquidação"
O layout diz que uma data de vencimento, **se existir**, ocupa os 8 primeiros dígitos do campo livre como `AAAAMMDD`. Mas o campo é opcional e nada marca sua presença, então um campo livre que só *parece* data também cai ali. Use na UI; nunca para liquidar.
### Os três dígitos verificadores, exportados
Aparecem em qualquer integração FEBRABAN, então estão no barrel:
```ts
import { mod10Dac, mod11DacArrecadacao, mod11DacCobranca } from "tempest-react-sdk/br";
mod10Dac("01230067896"); // 3 — o exemplo resolvido do layout FEBRABAN v7
```
!!! danger "Módulo 11 de cobrança ≠ módulo 11 de arrecadação"
Mesmos pesos, mesma subtração — e regras **diferentes** para os restos degenerados. Cobrança resolve resto `0`, `1` e `10` para **`1`**; arrecadação resolve resto `0` e `1` para **`0`**. Usar um no layout do outro dá dígito errado exatamente 3 vezes em 11, o que passa em teste feito com uma amostra pequena. São funções separadas por isso.
---
## Parte 3 — Chave de acesso da NFe
Os 44 dígitos que identificam qualquer documento fiscal eletrônico:
```text
35 2601 12345678000195 55 001 000000123 1 12345678 5
cUF AAMM CNPJ mod série nNF tp cNF cDV
```
```ts
import { formatChaveNFe, parseChaveNFe, validateChaveNFe } from "tempest-react-sdk/br";
validateChaveNFe(input); // true / false
const chave = parseChaveNFe("35260112345678000195550010000001231123456785");
chave.uf; // "SP" — o tipo UF do próprio módulo br
chave.ano; // 2026
chave.mes; // 1
chave.cnpj; // "12345678000195"
chave.modeloLabel; // "NF-e"
chave.serie; // "001"
chave.numero; // "000000123"
chave.tipoEmissaoLabel; // "Normal"
chave.dv; // "5"
formatChaveNFe("35260112345678000195550010000001231123456785");
// "3526 0112 3456 7800 0195 5500 1000 0001 2311 2345 6785"
```
`validateChaveNFe` confere três coisas: 44 dígitos, `cUF` que é uma UF de verdade, e DV que recalcula (módulo 11, pesos 2–9 da direita para a esquerda, resto `0` ou `1` → dígito `0`).
!!! note "O layout é compartilhado — `modelo` é o que diz o que você tem na mão"
NF-e (`55`), NFC-e (`65`), CT-e (`57`), MDF-e (`58`), BP-e (`63`), NF3e (`66`)… todos usam a mesma chave de 44 dígitos. Modelo fora da tabela vem com `modeloLabel: null` em vez de um chute.
!!! tip "O `cUF` vira o tipo `UF`"
`chave.uf` é o mesmo union `UF` que `citiesByUf`, `getState` e o `BrazilMap` usam — dá para encadear direto com o resto do módulo BR.
### Emitindo: calcular o DV e tratar o erro
Quem **monta** a chave (em vez de só ler uma pronta) precisa do dígito
verificador dos 43 primeiros dígitos. `chaveNFeCheckDigit` faz esse cálculo
isolado, e lança `ChaveNFeError` quando o corpo não tem exatamente 43 dígitos:
```ts
import { ChaveNFeError, chaveNFeCheckDigit } from "tempest-react-sdk/br";
const corpo = "3526011234567800019555001000000123112345678"; // 43 dígitos
try {
const dv = chaveNFeCheckDigit(corpo); // 5
const chave = `${corpo}${dv}`;
} catch (err) {
if (err instanceof ChaveNFeError) console.error(err.message);
}
```
`ChaveNFeError` é a única exceção do grupo NFe — `validateChaveNFe` continua
devolvendo `false` em vez de lançar, porque validar entrada de usuário não é
caso excepcional.
---
## Parte 4 — Feriados e dias úteis
### O que está na tabela
```ts
import { holidaysFor } from "tempest-react-sdk/br";
holidaysFor(2026);
// 13 entradas: 9 feriados nacionais + 4 dias móveis do sistema financeiro
```
Cada entrada traz `date` (`YYYY-MM-DD`), `name`, `movable` e um `kind`:
- **`"national"`** — feriado nacional em lei federal. 9 datas fixas (Lei 662/1949 com a redação da Lei 10.607/2002, Lei 6.802/1980 para 12 de outubro, Lei 14.759/2023 para 20 de novembro).
- **`"banking"`** — não é feriado em lei, mas o sistema financeiro não opera: **Carnaval (segunda e terça)**, **Sexta-feira da Paixão** e **Corpus Christi**. Agência fechada, compensação parada — um boleto ou uma TED datada aí liquida depois.
!!! info "Por que os dois tipos existem"
Carnaval e Corpus Christi **não são feriados nacionais por lei** — mas a Resolução CMN 4.880/2020 fecha os bancos nos quatro dias. Se você calcula prazo de pagamento, os dois contam; se você calcula obrigação trabalhista, só `"national"` conta. O default é o calendário bancário, porque é o que quebra dinheiro:
```ts
isBusinessDay("2026-04-03"); // false — Sexta-feira da Paixão
isBusinessDay("2026-04-03", { kinds: ["national"] }); // true
```
### O que **não** está, e não vai estar
!!! danger "Feriado estadual e municipal não estão cobertos"
A data magna varia por estado e cada um dos 5 570 municípios pode declarar os seus, incluindo até quatro dias religiosos. Nenhuma tabela fica completa e atualizada. Passe pelo `extra`:
```ts
const SAO_PAULO = ["2026-01-25", "2026-07-09", "2026-11-20"];
isBusinessDay("2026-07-09", { extra: SAO_PAULO }); // false
```
Também de fora, de propósito:
- **Ponto facultativo.** Decreto que libera servidor público não é feriado e não muda prazo de ninguém.
- **História pré-2002.** A tabela codifica a lei como está hoje. Pedir 1998 devolve o conjunto de hoje deslocado para 1998, não o que valia então. A única exceção modelada é 20 de novembro, que só aparece **a partir de 2024**.
### Aritmética de dia útil
```ts
import { addBusinessDays, isBusinessDay, isHoliday, nextBusinessDay } from "tempest-react-sdk/br";
isHoliday("2026-11-20"); // true
isBusinessDay("2026-08-03"); // true — segunda-feira comum
nextBusinessDay("2026-12-24"); // 2026-12-28 — pula o Natal e o fim de semana
addBusinessDays("2026-04-01", 2); // 2026-04-06 — pula a Paixão e o fim de semana
addBusinessDays("2026-04-06", -2); // 2026-04-01 — negativo anda para trás
```
Aceitam `Date` ou `"YYYY-MM-DD"` e devolvem `Date` na **meia-noite local**.
!!! warning "Tudo é calendário local, nunca UTC"
"Hoje é feriado?" é pergunta sobre o calendário de quem está olhando. Os helpers usam `getFullYear/getMonth/getDate` e constroem com `new Date(y, m, d)`; passar por `toISOString()` deslocaria o dia para todo viewer a leste de Greenwich.
!!! note "`addBusinessDays(date, 0)` devolve o dia intacto"
Mesmo quando não é dia útil. Ajustar em silêncio esconderia justo o caso que você precisa ver.
### Onde a Páscoa entra
Os quatro dias móveis são derivados do domingo de Páscoa, não listados:
```ts
import { easterSunday } from "tempest-react-sdk/br";
easterSunday(2026); // 2026-04-05
```
É o *computus* gregoriano anônimo (Meeus/Jones/Butcher): aritmética inteira sobre o ano, sem tabela e sem dependência — exato para qualquer ano gregoriano.
---
## Recapitulando
- **Pix** — `pixPayload` monta o BR Code EMV com o CRC-16/CCITT-FALSE certo (incluindo os literais `6304`); `parsePixPayload` lê de volta, tolerante com tag desconhecida e intolerante com checksum errado; `` desenha o símbolo **e** a copia-e-cola, porque num celular só o QR não serve.
- **Boleto** — `parseLinhaDigitavel` / `parseCodigoBarras` convertem 47↔44 e 48↔44 conferindo todo DV; cobrança e arrecadação são layouts diferentes e o SDK nunca confunde os dois; o fator de vencimento tem **duas** bases desde fev/2025 e a escolha é explícita.
- **NFe** — `parseChaveNFe` abre os 44 dígitos e resolve o `cUF` no tipo `UF` do módulo; `validateChaveNFe` confere tamanho, UF e DV.
- **Feriados** — `holidaysFor` devolve os 9 feriados nacionais + os 4 dias bancários móveis, marcados; estado e município entram por `extra`; `isBusinessDay` / `nextBusinessDay` / `addBusinessDays` fazem a conta no calendário local.
Nada disso fala com banco. Para o lado servidor (registrar boleto, criar cobrança Pix, autorizar NF-e), veja [Integração FastAPI](./integration-fastapi.md).
---
# br.md
# Mapa do Brasil & localidades
Mapa nacional **clicável** das 27 unidades federativas + dataset de estados e cidades — **sem nenhuma API paga ou externa**. A geometria é um GeoJSON do IBGE **simplificado e empacotado** no SDK (renderizado como SVG), e a lista de localidades espelha o `utils/locations` do [`tempest-fastapi-sdk`](https://pypi.org/project/tempest-fastapi-sdk/).
!!! info "Import pelo subpath `tempest-react-sdk/br`"
Este módulo empacota dados (nomes de ~5600 cidades + geometria das UFs). Pra não pesar no bundle de quem não usa, ele vive num **subpath separado** — importe de `tempest-react-sdk/br`, não da raiz. A geometria do mapa ainda carrega **lazy** (só quando o `BrazilMap` monta).
```ts
import { BrazilMap, citiesByUf } from "tempest-react-sdk/br";
```
## Quando usar
- Um **mapa do Brasil clicável** pra selecionar um estado (dashboards, filtros regionais).
- **Choropleth**: pintar estados por uma métrica (vendas, usuários, cobertura).
- Um **seletor Estado → Cidade** encadeado em formulários.
- Consultar estados/cidades/regiões offline (`citiesByUf`, `ufChoices`, ...).
---
## Parte 1 — Dados de localidade
Comece pelos dados: funções puras, sem rede, disponíveis imediatamente.
```ts
import {
listStates,
getState,
citiesByUf,
statesByRegion,
ufChoices,
isValidUf,
normalizeUf,
} from "tempest-react-sdk/br";
listStates().length; // 27 (ordenados por nome)
getState("sp");
// { uf: "SP", name: "São Paulo", region: "Sudeste", cities: [...] }
citiesByUf("RJ"); // ["Angra dos Reis", "Aperibé", ..., "Rio de Janeiro", ...]
citiesByUf("XX"); // [] — UF inválida devolve lista vazia (não lança)
statesByRegion("Sul").map((s) => s.uf); // ["PR", "RS", "SC"]
normalizeUf(" rj "); // "RJ"
normalizeUf("zz"); // null
isValidUf("mg"); // true
```
!!! tip "Coleções vazias não são erro"
`citiesByUf` de uma UF inexistente devolve `[]`, não lança. Segue a convenção do backend: "sem correspondência" é um resultado válido.
### Alimentar um `` / ``
`ufChoices()` e `cityChoices(uf)` já devolvem `{ value, label }`:
```tsx
import { Select } from "tempest-react-sdk";
import { ufChoices } from "tempest-react-sdk/br";
;
```
---
## Parte 2 — Seletor Estado → Cidade
O `BrazilStateCitySelect` encadeia dois selects: escolher o estado filtra as cidades daquele UF. A cidade reseta quando o estado muda.
```tsx
import { BrazilStateCitySelect } from "tempest-react-sdk/br";
export function EnderecoForm() {
return (
console.log(uf, city)}
stateLabel="UF"
cityLabel="Município"
/>
);
}
```
- `defaultUf` / `defaultCity` — valores iniciais (não-controlado).
- `onChange({ uf, city })` — dispara a cada mudança; `uf`/`city` são `null` quando vazios.
- `layout="column"` empilha os selects (default é lado a lado).
- `disabled` trava ambos.
!!! note "Cidade só habilita depois do estado"
O select de cidade fica desabilitado até um estado ser escolhido — não há o que listar antes disso.
---
## Parte 3 — O mapa `BrazilMap`
Mapa SVG das 27 UFs, com auto-fit, rótulos de sigla e clique por estado. Nenhum tile externo.
### Mapa clicável
```tsx
import { useState } from "react";
import { BrazilMap, type UF } from "tempest-react-sdk/br";
export function SeletorNoMapa() {
const [uf, setUf] = useState(null);
return (
<>
{uf && Selecionado: {uf}
}
>
);
}
```
- `onSelect(uf)` dispara no clique (e no Enter/Espaço — os estados são focáveis quando há `onSelect`).
- `selected` aceita uma UF **ou uma lista** — útil pra seleção múltipla.
- Cada estado tem `aria-label` com o nome — acessível por padrão.
!!! tip "Tooltip ao passar o mouse"
Por padrão (`showTooltip`, default `true`) aparece uma **dica flutuante** ao passar o mouse: **nome, sigla, região e nº de cidades** — e o valor do choropleth quando `values` está setado (ex.: `São Paulo (SP) · Sudeste · 645 cidades`). Passe `showTooltip={false}` pra desligar, ou `renderTooltip={(data) => ...}` pra customizar o conteúdo (`data` = `{ uf, name, value? }`).
```tsx
(
<>{name} — {value ?? "sem dado"}>
)}
/>
```
### Choropleth (tinta por métrica)
Passe `values` (um número por UF) e cada estado é tingido linearmente entre `minColor` e `maxColor`:
```tsx
import { BrazilMap } from "tempest-react-sdk/br";
const vendas = { SP: 1200, MG: 640, RJ: 580, BA: 410, RS: 390 };
;
```
Estados sem valor ficam com a cor base de superfície.
### Mapa + cidades (receita completa)
O caso que motivou o módulo: clicar no mapa e listar as cidades do estado.
```tsx
import { useState } from "react";
import {
BrazilMap,
BrazilStateCitySelect,
getState,
type UF,
} from "tempest-react-sdk/br";
export function MapaNacional() {
const [uf, setUf] = useState(null);
const estado = uf ? getState(uf) : null;
return (
{estado && (
{estado.name} — {estado.cities.length} cidades
console.log("cidade:", city)}
/>
)}
);
}
```
!!! tip "Reset ao trocar de estado"
O `key={uf}` remonta o seletor quando a UF muda pelo mapa, garantindo que a cidade zere.
### Props do `BrazilMap`
_(veja também o [`BrazilStateMap`](#parte-4-submapa-de-estado-brazilstatemap) para o nível de município.)_
| Prop | Tipo | Default | Descrição |
| --- | --- | --- | --- |
| `selected` | `UF \| UF[] \| null` | — | UF(s) destacada(s). |
| `onSelect` | `(uf: UF) => void` | — | Clique/teclado num estado. |
| `values` | `Partial>` | — | Métrica por UF → choropleth. |
| `minColor` / `maxColor` | `string` | tons de primary | Extremos da escala do choropleth. |
| `height` | `number` | `440` | Altura do viewport em px. |
| `padding` | `number` | `12` | Margem interna em px. |
| `showLabels` | `boolean` | `true` | Sigla no centroide de cada UF. |
| `showTooltip` | `boolean` | `true` | Dica flutuante (nome + região + nº cidades + valor) no hover. |
| `renderTooltip` | `(data) => ReactNode` | — | Conteúdo custom do tooltip (`{ uf, name, value? }`). |
| `label` | `string` | `"Mapa do Brasil por estado"` | Rótulo acessível da região. |
---
## Parte 4 — Submapa de estado (`BrazilStateMap`)
Um submapa de **um estado** com **todos os seus municípios** clicáveis. A geometria municipal é dividida por UF e carregada **lazy** — abrir o mapa de SP baixa só o chunk de SP (~40-70 KB gzip), nunca os ~2 MB do país inteiro.
```tsx
import { useState } from "react";
import { BrazilStateMap, type Municipality } from "tempest-react-sdk/br";
export function MunicipiosDeSP() {
const [city, setCity] = useState(null);
return (
<>
{city && {city.name} — IBGE {city.id}
}
>
);
}
```
- `uf` (obrigatório) — o estado a desenhar.
- `onSelect({ id, name })` — dispara ao clicar num município (`id` = código IBGE de 7 dígitos).
- `selected` — casa por `id` **ou** por `name`; aceita lista.
- `values` — choropleth por município (chave = `id` ou `name`).
- `showLabels` — **`false` por padrão**: um estado tem centenas de municípios e os rótulos se sobrepõem.
- `showTooltip` (default `true`) — dica flutuante no hover com **nome + código IBGE** (+ valor do choropleth quando houver). `renderTooltip={(data) => ...}` customiza (`data` = `{ id, name, value? }`).
### Drill-down nacional → estado (receita)
Combine os dois mapas: clicar no mapa nacional troca o estado do submapa.
```tsx
import { useState } from "react";
import { BrazilMap, BrazilStateMap, type Municipality, type UF } from "tempest-react-sdk/br";
export function DrillDown() {
const [uf, setUf] = useState("SP");
const [city, setCity] = useState(null);
return (
{
setUf(u);
setCity(null);
}}
height={320}
/>
);
}
```
### Choropleth municipal
```tsx
import { BrazilStateMap } from "tempest-react-sdk/br";
;
```
!!! note "Nomes do mapa × dataset de nomes"
Os nomes dos municípios no mapa vêm do GeoJSON do IBGE; o `citiesByUf` vem do dataset de nomes. São quase idênticos, mas grafias/acentos podem divergir em casos raros. Para casar valores, prefira o **código IBGE** (`id`) quando tiver.
### Acesso direto à geometria municipal
```ts
import { loadStateMunicipalities } from "tempest-react-sdk/br";
const sp = await loadStateMunicipalities("SP");
sp?.features.length; // 644
```
---
## Parte 5 — Geocoding offline
Converter entre nome/coordenada e município, **sem rede**. Usa um índice compacto de centroides (~97 KB gzip) carregado **lazy** — nenhuma chamada de rede, nenhuma API key.
```ts
import {
reverseGeocode,
nearestMunicipality,
geocodeMunicipality,
municipalityCentroid,
stateCentroid,
} from "tempest-react-sdk/br";
// Coordenada → município que a CONTÉM (point-in-polygon, exato):
await reverseGeocode({ latitude: -23.5505, longitude: -46.6333 });
// { id: "3550308", name: "São Paulo", uf: "SP" }
// Coordenada → município de centroide mais próximo (rápido, aproximado, sem geometria):
await nearestMunicipality({ latitude: -23.55, longitude: -46.63 });
// { id, name, uf: "SP", latitude, longitude, distanceKm }
// Nome → coordenada (pode haver homônimos em UFs diferentes):
await geocodeMunicipality("Bonito"); // vários
await geocodeMunicipality("São Paulo", "SP"); // filtra por UF
// Centroides:
await municipalityCentroid("3550308"); // { id, name, uf, latitude, longitude }
await stateCentroid("SP"); // { latitude, longitude }
```
!!! tip "`reverseGeocode` vs `nearestMunicipality`"
- **`reverseGeocode`** faz **point-in-polygon** → devolve o município que realmente contém o ponto. Carrega a geometria de **um** estado (chunk lazy por UF). Passe `{ uf }` se já souber, pra pular a detecção do estado candidato.
- **`nearestMunicipality`** compara só **centroides** → rápido e sem carregar geometria, mas perto de bordas/em municípios grandes pode cair num vizinho.
### Receita: "onde estou?" (GPS → município)
Combine com o `usePositionTracker` do módulo [Geolocalização](./geo.md):
```tsx
import { useEffect, useState } from "react";
import { usePositionTracker } from "tempest-react-sdk";
import { reverseGeocode, type ReverseGeocodeResult } from "tempest-react-sdk/br";
export function OndeEstou() {
const { lastPoint, start } = usePositionTracker({ autoStart: true });
const [local, setLocal] = useState(null);
useEffect(() => {
if (lastPoint) reverseGeocode(lastPoint).then(setLocal);
}, [lastPoint]);
return {local ? `Você está em ${local.name} — ${local.uf}` : "Localizando…"}
;
}
```
!!! warning "Precisão"
A geometria é simplificada (~2 km). Pontos a ~1-2 km de uma divisa podem resolver pro município vizinho; pontos no mar/fora do território devolvem o vizinho mais próximo por centroide.
---
## Parte 6 — Marcadores, escalas de cor e legenda
### Marcadores (pins)
`BrazilMap`, `BrazilStateMap` e o `TrajectoryMap` (módulo [Geolocalização](./geo.md)) aceitam `markers` — pontos `{ latitude, longitude }` plotados sobre o mapa, com `label` (tooltip), `color`, `radius` e `id`. `onMarkerClick(marker, index)` no clique.
```tsx
import { BrazilMap, type GeoMarker } from "tempest-react-sdk/br";
const capitais: GeoMarker[] = [
{ id: "sp", latitude: -23.55, longitude: -46.63, label: "São Paulo", color: "#e11d48" },
{ id: "rj", latitude: -22.91, longitude: -43.17, label: "Rio de Janeiro" },
];
console.log(m.label)} />;
```
!!! tip "Pins a partir do geocode"
Combine com o geocode (Parte 5): `municipalityCentroid(id)` ou `geocodeMunicipality(name)` dão as coordenadas pra virar `markers`.
### Escalas de cor + legenda
Pra choropleth além do gradiente de 2 cores, passe `colorScale` (de `sequentialScale`/`quantizeScale`/`thresholdScale`) e emparelhe com ``. Paletas prontas (colorblind-safe): `SEQUENTIAL_BLUES`, `SEQUENTIAL_GREENS`, `SEQUENTIAL_VIRIDIS`, `DIVERGING_RDBU`.
```tsx
import {
BrazilMap,
MapLegend,
sequentialScale,
SEQUENTIAL_VIRIDIS,
} from "tempest-react-sdk/br";
const vendas = { SP: 1200, MG: 640, RJ: 580, BA: 410, RS: 390 };
const scale = sequentialScale(0, 1200, SEQUENTIAL_VIRIDIS);
;
```
- **`sequentialScale(min, max, palette)`** — gradiente contínuo.
- **`quantizeScale(min, max, palette)`** — `palette.length` faixas iguais.
- **`thresholdScale(thresholds, palette)`** — faixas por corte (`palette` tem `thresholds.length + 1` cores).
- **``** — gradiente contínuo (`min`/`max`/`palette` + `format`) **ou** faixas discretas (`items={[{ color, label }]}`).
!!! note "Paleta da marca"
As paletas são padrões públicos (ColorBrewer/Viridis). Troque por qualquer lista ordenada de hex da sua marca — os builders de escala aceitam qualquer `string[]`.
---
## Parte 7 — Zoom, cor por região e busca de município
### Pan & zoom
`zoomable` liga roda-do-mouse (zoom no cursor) + arrastar (pan) em `BrazilMap`/`BrazilStateMap`. Duplo-clique reseta; um botão **Reset** aparece quando há zoom/pan.
```tsx
```
### Cor por região
`colorByRegion` tinge cada estado pela macro-região (categórico), sobrepondo `values`/`colorScale`. Emparelhe com uma legenda de faixas via `regionLegendItems()`.
```tsx
import { BrazilMap, MapLegend, regionLegendItems } from "tempest-react-sdk/br";
;
```
`REGION_COLORS` expõe o mapa `região → cor` (troque pela paleta da marca se quiser).
### Busca de município (autocomplete)
`MunicipalitySearch` é um autocomplete offline (usa `searchMunicipalities`, debounced). Ligue o `onSelect` ao `selected` de um `BrazilStateMap` pra destacar no mapa.
```tsx
import { useState } from "react";
import { MunicipalitySearch, BrazilStateMap, type UF } from "tempest-react-sdk/br";
export function BuscaNoMapa() {
const [uf] = useState("SP");
const [city, setCity] = useState(null);
return (
<>
setCity(m.name)} />
>
);
}
```
- `uf` restringe a busca a um estado; sem ele, busca no país todo (resultado mostra a UF).
- `onSelect(m)` recebe `{ id, name, uf, latitude, longitude }` — dá pra centralizar/marcar também.
---
## Sobre a geometria
- Fonte: fronteiras das UFs do **IBGE** (domínio público), simplificadas com Douglas-Peucker (~2 km de tolerância) e arredondadas a 3 casas decimais.
- Tamanho: **~119 KB cru / ~36 KB gzip**, num chunk à parte carregado **lazy** pelo `BrazilMap`.
- Precisão: adequada pra um **mapa de visão geral clicável**, **não** pra análise geográfica precisa nem cálculo de área.
!!! info "Município: use o `BrazilStateMap`"
O `BrazilMap` desenha **estados**. Para o nível de **município**, o [`BrazilStateMap`](#parte-4-submapa-de-estado-brazilstatemap) desenha todos os municípios de um estado — a geometria municipal (~2 MB no total) é dividida por UF e carregada **lazy**, um chunk por estado, então nunca cai tudo num bundle só.
## Acesso avançado à geometria
Precisa do GeoJSON pra um render próprio? Carregue-o lazy:
```ts
import { loadBrUfGeoJson } from "tempest-react-sdk/br";
const collection = await loadBrUfGeoJson();
collection.features.length; // 27
```
---
## Recap
- **Dados**: `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity` — offline, espelhando o `utils/locations` do FastAPI SDK.
- **Seletor**: `BrazilStateCitySelect` encadeia Estado → Cidade.
- **Mapa nacional**: `BrazilMap` renderiza as 27 UFs em SVG — clicável (`onSelect`), destacável (`selected`) e choropleth (`values`). Zero tile externo; geometria empacotada e lazy.
- **Submapa de estado**: `BrazilStateMap` desenha todos os municípios de uma UF — clicável, choropleth, geometria por estado carregada lazy. `loadStateMunicipalities(uf)` expõe a geometria crua.
- **Import** sempre pelo subpath `tempest-react-sdk/br`.
## Veja também
- [Geolocalização](./geo.md) — coleta de lat/lon, trajetória e `TrajectoryMap` (tile-free)
- [Forms BR](./forms-br.md) — CPF/CNPJ/CEP e `useViaCEP`
- [Componentes: Entrada de dados](./components/inputs.md) — `Select`, `Combobox`
---
# charts.md
# Charts (recharts)
Gráficos transformam números em forma: uma tendência que sobe, uma fatia que
domina, um eixo onde uma série cruza a outra. O SDK embrulha o
[recharts](https://recharts.org) em cinco componentes temados — `AreaChart`,
`BarChart`, `LineChart`, `PieChart` e `RadarChart` — que recebem **dados
tabulares simples** (um array de objetos) e cuidam de eixos, grid, legenda,
tooltip e cores pra você.
Você não monta ``/``/`` na mão: passa `data`, diz qual
chave é o eixo (`index`) e quais chaves virar séries (`categories`), e o
componente faz o resto.
## Por que um subpath separado
Os gráficos não vêm do barrel principal. Você os importa de
`tempest-react-sdk/charts`:
```tsx
import { BarChart, LineChart, AreaChart } from "tempest-react-sdk/charts";
```
!!! info "Por que isolar os charts num subpath?"
O `recharts` é uma dependência **pesada** (D3 por baixo). A maioria dos apps
Tempest não desenha gráfico nenhum — e seria injusto cobrar esse peso de
todos. Por isso os charts moram num subpath dedicado e o `recharts` fica
**externalizado** no bundle do SDK. Apps que nunca importam de
`tempest-react-sdk/charts` **não pagam nada**: o tree-shaking do bundler do
app remove tudo.
!!! tip "Só quer a forma da série? Não precisa de chart"
Um mini-gráfico inline — tendência numa célula de tabela, ao lado de um KPI —
é o [`Sparkline`](./components/data.md#sparkline), que mora na **entrada
raiz** e é SVG puro. Nenhum `recharts` envolvido. Use os charts desta página
quando o leitor precisa **ler valores no eixo**.
Isso é o mesmo padrão do **caller injeta a dependência pesada** que o SDK já usa
nos adapters de telemetria (Sentry/PostHog) e feature flags
(GrowthBook/LaunchDarkly): o SDK descreve a integração, mas a biblioteca de
verdade fica por conta do app. A diferença é que aqui o `recharts` é uma **peer
dependency opcional** — você o instala uma vez e os cinco componentes o
reutilizam.
### Instalação
```bash
npm i recharts
```
!!! warning "Sem o `recharts`, os charts não renderizam"
Como o `recharts` é peer dep **opcional**, o `npm install tempest-react-sdk`
não o traz junto. Se você importar de `tempest-react-sdk/charts` sem ter
rodado `npm i recharts`, o build quebra com `Cannot find module 'recharts'`.
Instale-o no app que de fato usa gráficos.
## A família cartesiana: Area, Bar, Line
`AreaChart`, `BarChart` e `LineChart` compartilham a **mesma** interface de
props, `CartesianChartProps`. Aprenda uma e você sabe as três — troca só o nome
do componente.
O modelo mental é sempre o mesmo:
- `data` — suas linhas (array de objetos).
- `index` — a chave que vira o **eixo X** (rótulos: meses, dias, nomes…).
- `categories` — as chaves que viram **séries** (uma área/barra/linha cada).
### BarChart
```tsx
import { BarChart } from "tempest-react-sdk/charts";
const faturamento = [
{ mes: "Jan", receita: 12000, custo: 8000 },
{ mes: "Fev", receita: 15000, custo: 9000 },
{ mes: "Mar", receita: 18000, custo: 9500 },
{ mes: "Abr", receita: 21000, custo: 11000 },
];
export function FaturamentoMensal() {
return (
`R$ ${v.toLocaleString("pt-BR")}`}
height={320}
/>
);
}
```
Duas séries (`receita`, `custo`), agrupadas lado a lado por mês. O
`valueFormatter` formata os números no tooltip **e** no eixo Y.
### LineChart
Mesma forma de dados, mesmo `index` e `categories` — só muda o componente:
```tsx
import { LineChart } from "tempest-react-sdk/charts";
const visitas = [
{ dia: "Seg", organico: 320, pago: 120 },
{ dia: "Ter", organico: 410, pago: 150 },
{ dia: "Qua", organico: 380, pago: 90 },
{ dia: "Qui", organico: 520, pago: 200 },
{ dia: "Sex", organico: 610, pago: 240 },
];
export function VisitasSemanais() {
return (
v.toLocaleString("pt-BR")}
/>
);
}
```
!!! note "`stack` não empilha linhas"
`CartesianChartProps` tem a prop `stack` por uniformidade, mas o `LineChart`
a **ignora** — linhas empilhadas raramente fazem sentido. Use `stack` no
`AreaChart` ou no `BarChart`, onde ele de fato empilha as séries num
`stackId` comum.
### AreaChart (com `stack`)
```tsx
import { AreaChart } from "tempest-react-sdk/charts";
const trafego = [
{ hora: "08h", desktop: 120, mobile: 80, tablet: 20 },
{ hora: "12h", desktop: 200, mobile: 160, tablet: 30 },
{ hora: "18h", desktop: 90, mobile: 240, tablet: 25 },
{ hora: "22h", desktop: 60, mobile: 300, tablet: 40 },
];
export function TrafegoPorDispositivo() {
return (
`${v} sessões`}
/>
);
}
```
Com `stack`, as três áreas se empilham e o topo mostra o total por hora.
### `CartesianChartProps` — referência
| Prop | Tipo | Default | O que faz |
| ---------------- | --------------------------- | ---------------------- | ------------------------------------------------------------------------------- |
| `data` | `ChartData` | — | Linhas a plotar (array de objetos `chave → string \| number`). |
| `index` | `string` | — | Chave da linha usada no eixo X (cartesiano) ou eixo angular (radar). |
| `categories` | `string[]` | — | Chaves a plotar, uma série cada. |
| `colors` | `string[]` | tokens `--tempest-chart-*` | Cores das séries, cicladas por categoria. |
| `height` | `number` | `300` | Altura do gráfico em pixels. |
| `width` | `number` | — | Largura fixa em px. Quando definida, dispensa o `ResponsiveContainer`. |
| `stack` | `boolean` | `false` | Empilha as séries num `stackId` comum (ignorado pelo `LineChart`). |
| `showLegend` | `boolean` | `true` | Renderiza a legenda. |
| `showGrid` | `boolean` | `true` | Renderiza o grid cartesiano. |
| `showTooltip` | `boolean` | `true` | Renderiza o tooltip. |
| `valueFormatter` | `(value: number) => string` | — | Formata valores numéricos no tooltip e no eixo Y. |
| `className` | `string` | — | Classe extra aplicada ao wrapper do gráfico. |
`ChartData = Array>` — cada linha mapeia uma
chave de coluna a um rótulo (string) ou valor (number).
!!! tip "Uma série, ou várias"
`categories` é um array, então você decide quantas séries quer. Uma só
(`categories={["receita"]}`) desenha um gráfico simples; várias desenham
séries comparativas, cada uma com a próxima cor da paleta.
## PieChart
A `PieChart` tem uma forma de dados diferente: **uma linha por fatia**. Em vez de
`categories`, você diz qual chave segura o **valor** (`category`) e qual segura o
**rótulo** (`index`).
```tsx
import { PieChart } from "tempest-react-sdk/charts";
const planos = [
{ plano: "Free", usuarios: 4200 },
{ plano: "Pro", usuarios: 1800 },
{ plano: "Business", usuarios: 600 },
{ plano: "Enterprise", usuarios: 120 },
];
export function DistribuicaoDePlanos() {
return (
`${v.toLocaleString("pt-BR")} usuários`}
/>
);
}
```
Cada linha vira uma fatia colorida pela próxima cor da paleta. Com `donut`, o
centro fica vazio (raio interno de 60%) — ótimo pra colocar um total no meio.
### `PieChartProps` — referência
| Prop | Tipo | Default | O que faz |
| ---------------- | --------------------------- | ---------------------- | --------------------------------------------------------------------- |
| `data` | `ChartData` | — | Linhas a plotar, uma fatia cada. |
| `category` | `string` | — | Chave da linha com o **valor** numérico da fatia. |
| `index` | `string` | — | Chave da linha com o **nome/rótulo** da fatia. |
| `colors` | `string[]` | tokens `--tempest-chart-*` | Cores das fatias, cicladas por fatia. |
| `height` | `number` | `300` | Altura do gráfico em pixels. |
| `width` | `number` | — | Largura fixa em px. Quando definida, dispensa o `ResponsiveContainer`.|
| `donut` | `boolean` | `false` | Renderiza como rosca (raio interno não-zero) em vez de pizza cheia. |
| `showLegend` | `boolean` | `true` | Renderiza a legenda. |
| `showTooltip` | `boolean` | `true` | Renderiza o tooltip. |
| `valueFormatter` | `(value: number) => string` | — | Formata valores numéricos no tooltip. |
| `className` | `string` | — | Classe extra aplicada ao wrapper. |
!!! note "A `PieChart` não tem `showGrid` nem `stack`"
Pizza não tem grid cartesiano nem empilhamento — essas props da família
cartesiana simplesmente não existem aqui.
## RadarChart
A `RadarChart` reusa `CartesianChartProps` (mesma assinatura de Area/Bar/Line),
mas plota polígonos num eixo radial: `index` vira o **eixo angular** (os vértices)
e cada entrada de `categories` vira um polígono.
```tsx
import { RadarChart } from "tempest-react-sdk/charts";
const skills = [
{ atributo: "Velocidade", time_a: 80, time_b: 65 },
{ atributo: "Defesa", time_a: 70, time_b: 90 },
{ atributo: "Ataque", time_a: 95, time_b: 75 },
{ atributo: "Resistência", time_a: 60, time_b: 85 },
{ atributo: "Técnica", time_a: 88, time_b: 80 },
];
export function ComparativoDeTimes() {
return (
`${v} pts`}
/>
);
}
```
Dois polígonos sobrepostos comparam `time_a` e `time_b` em cada atributo —
perfeito pra comparar perfis multidimensionais.
!!! note "A `RadarChart` ignora `showGrid` e `stack`"
O radar sempre desenha seu próprio `PolarGrid` (não há `showGrid`), e não
empilha séries (`stack` é ignorado). `showLegend`/`showTooltip`/`colors`/
`valueFormatter` funcionam normalmente.
## Cores e tema
**Você não precisa fazer nada:** por padrão as séries saem dos tokens
`--tempest-chart-1` … `--tempest-chart-8` do tema. Trocar a marca com
`createTheme({ chart: [...] })` move os gráficos junto, e virar o tema escuro
troca a paleta pela versão clareada — sem prop nenhuma no gráfico.
```css
/* o que o SDK já define (colors.css) */
:root {
--tempest-chart-1: #2563eb; /* azul */
--tempest-chart-2: #16a34a; /* verde */
--tempest-chart-3: #f59e0b; /* âmbar */
--tempest-chart-4: #7c3aed; /* violeta */
--tempest-chart-5: #ec4899; /* rosa */
--tempest-chart-6: #06b6d4; /* ciano */
--tempest-chart-7: #ea580c;
--tempest-chart-8: #0f766e;
}
```
!!! warning "Uma paleta de 6 cores não deve virar 8"
Se você define só `chart-1..6`, o leitor continuaria andando nos
`--tempest-chart-7`/`-8` embutidos do SDK e o gráfico com 7 séries sairia com
paleta misturada — 6 cores da sua marca + 2 sobras. Por isso existe
`--tempest-chart-count`: o `createTheme` escreve quantas cores o tema tem, e o
`resolveChartColors` para ali. Definindo tokens à mão, declare junto:
```css
:root {
--tempest-chart-1: #0f766e;
--tempest-chart-2: #f97316;
--tempest-chart-count: 2;
}
```
Sobrescreva no seu CSS, ou gere com o factory de tema:
```tsx
import { applyTheme, createTheme } from "tempest-react-sdk";
applyTheme(createTheme({
primary: "#0f766e",
chart: ["#0f766e", "#f97316", "#9333ea"],
}));
```
!!! danger "`colors={["var(--meu-token)"]}` **não** funciona"
O recharts aplica cor como **atributo de apresentação** do SVG
(`fill="…"`), e navegador nenhum substitui `var()` ali — custom property só
é resolvida em **declaração** CSS. Um `var()` passado em `colors` renderiza
como cor inválida (série invisível).
É por isso que o SDK **lê os tokens** via `getComputedStyle` e entrega cor
literal pro recharts. Se você precisa do valor de um token seu em JS, use o
mesmo caminho:
```tsx
import { readThemeToken } from "tempest-react-sdk";
const marca = readThemeToken("--minha-marca"); // "#0f766e"
```
Para um gráfico específico, `colors` continua ganhando de tudo — é a via de
escape, ciclada por índice da série (ou fatia):
```tsx
import { BarChart, DEFAULT_CHART_COLORS } from "tempest-react-sdk/charts";
export function VendasComCoresDaMarca() {
return (
);
}
// Ajustar só a primeira cor e manter o resto do fallback:
const minhaPaleta = ["#e11d48", ...DEFAULT_CHART_COLORS.slice(1)];
```
`DEFAULT_CHART_COLORS` é o **fallback**, usado quando os tokens não são
legíveis: sem `styles.css` importado, fora do browser (testes, script de build)
ou numa página que removeu os tokens.
### Resolvendo tokens você mesmo
```tsx
import { resolveChartColors, useChartColors } from "tempest-react-sdk/charts";
// dentro de um componente — re-resolve quando o tema virar
const colors = useChartColors();
// fora do React (canvas, export de imagem, tooltip customizado)
const palette = resolveChartColors();
```
`useChartColors` observa o atributo `data-tempest-theme` e re-resolve na troca de
tema; passar um array explícito curto-circuita o hook (nenhum observer é criado).
Precisa da cor do grid/eixo? `resolveChartChrome("grid" | "axis")`.
!!! tip "Tema escopado numa seção"
Os dois aceitam um elemento: `useChartColors(undefined, sectionRef.current)`
resolve os tokens **daquela** subárvore, então uma seção com tema próprio
pinta seus gráficos com a paleta dela.
## Escala contínua: magnitude e polaridade
As 8 cores de série codificam **identidade** — qual série é qual. Um heatmap ou um
choropleth codifica **quanto**, e isso é outro trabalho: precisa de *um* hue
escalonado por claridade, não de oito hues.
```tsx
import { sequentialScale, divergingScale, scaleSteps } from "tempest-react-sdk";
const cor = sequentialScale({ min: 0, max: 250 });
;
// Polaridade: variação contra a meta
const desvio = divergingScale({ min: 80, max: 130, center: 100 });
;
```
| Export | O que faz |
| ----------------------- | ------------------------------------------------------------- |
| `sequentialScale` | `{ min, max, ordinal? }` → `(valor) => cor` |
| `divergingScale` | `{ min, max, center? }` → `(valor) => cor` |
| `scaleSteps` | Todos os passos em ordem, pra montar a legenda |
| `SEQUENTIAL_STEP_COUNT` | `7` |
| `DIVERGING_STEP_COUNT` | `9` (1–4 frio · 5 neutro · 6–9 quente) |
| `ORDINAL_START_STEP` | `3` — primeiro passo que passa 2:1 na superfície |
!!! info "Sai da raiz, não do `/charts`"
São matemática de token pura, **sem recharts**. Quem mais precisa delas — um
choropleth do `/br`, um heatmap feito à mão — não tem motivo pra instalar
recharts. Custo medido: **365 B brotli** importando da raiz. O `/charts`
re-exporta só por descoberta.
!!! tip "Devolvem token, não hex"
O retorno é `var(--tempest-chart-sequential-4)`. Um heatmap pintado uma vez
segue o tema — inclusive o escuro, cujos passos são **escolhidos** pra superfície
escura, não invertidos do claro.
!!! warning "Sequencial deixa o zero recuar; ordinal não pode"
Numa **sequencial** o passo mais claro some na superfície de propósito: é o que
"quase nada" deve parecer num heatmap. Numa **ordinal** — degrau de funil, faixa,
tier — cada passo é uma marca que alguém precisa ver, e um passo invisível é um
dado perdido. Passe `ordinal: true` e a escala começa no passo 3.
```tsx
sequentialScale({ min: 0, max: 4, ordinal: true }); // usa 3..7
```
!!! check "Cada braço da divergente escala pelo próprio alcance"
Num domínio assimétrico (−5 a +80) os negativos ainda usam o braço frio inteiro.
Escalar os dois braços pelo mais largo — o erro fácil — colapsaria todo negativo
no passo ao lado do meio e esconderia o sinal.
!!! danger "O meio da divergente é cinza, nunca um hue"
Um meio colorido lê como uma **terceira categoria** em vez de "sem desvio", que é
a única coisa que uma divergente existe pra mostrar. Por isso o token 5 é neutro
nos dois modos.
!!! note "Escala contínua precisa de legenda"
Sem uma faixa com rótulo nas pontas, ninguém converte cor de volta em número. O
`scaleSteps` existe pra isso:
```tsx
{scaleSteps("sequential").map((cor) => (
))}
0 … 250
```
### Como as rampas foram feitas
Não foram escolhidas a olho. Os passos são **calculados** em OKLCH com claridade
espaçada por igual, então passo igual de dado parece passo igual de cor — o que não
acontece espaçando em RGB. A croma segue um domo: as pontas ficam críveis e o meio
carrega o hue.
Cada rampa foi validada por script nos dois modos: claridade monótona, gap ≥ 0,06
entre passos adjacentes, hue único, e a ponta perto da superfície passando 2:1 no
recorte ordinal. O `createTheme` refaz as duas escalas a partir do hue da marca
(usando o `danger` do tema como polo quente, pra "quente" e "ruim" não discordarem na
tela), então rebrandar move o heatmap junto em vez de deixá-lo azul do SDK.
## Responsivo por padrão, fixo quando preciso
Por padrão, cada gráfico se **estica pra largura do pai** via um
`ResponsiveContainer` do recharts — você controla só a `height`. É o que você
quer em quase todo dashboard: a largura acompanha a coluna.
```tsx
// Largura fluida (preenche o container), altura fixa de 300px (default).
```
Mas há casos em que você precisa de uma largura **fixa e determinística**: testes
de snapshot, renderização no servidor (SSR), exportar um PNG de tamanho exato. Aí
você passa `width`:
```tsx
// Largura fixa de 600px — sem ResponsiveContainer.
```
!!! warning "`width` desliga o `ResponsiveContainer`"
Quando você define `width`, o gráfico renderiza **naquela largura exata** e
**não** é embrulhado num `ResponsiveContainer`. Isso é intencional: o
`ResponsiveContainer` mede o pai no cliente e não funciona bem em SSR/jsdom,
onde não há layout calculado. Para uma página normal no navegador, **omita**
`width` e deixe ele preencher o pai.
## Recap
- Importe os charts de **`tempest-react-sdk/charts`** — subpath dedicado. O
`recharts` é peer dep **opcional**: rode `npm i recharts` no app que usa
gráficos. Quem não importa de lá não paga o peso (mesmo padrão do "caller
injeta a dep pesada" dos adapters de telemetria/flags).
- `AreaChart`, `BarChart` e `LineChart` compartilham `CartesianChartProps`:
`data` + `index` (eixo X) + `categories` (séries). `stack` empilha em
Area/Bar; o `LineChart` o ignora.
- `PieChart` usa `category` (valor) + `index` (rótulo), uma linha por fatia, com
`donut` opcional.
- `RadarChart` reusa `CartesianChartProps` (`index` = eixo angular); ignora
`showGrid`/`stack`.
- `DEFAULT_CHART_COLORS` é a paleta padrão (6 cores); sobrescreva via a prop
`colors`, cicladas por série/fatia.
- Sem `width`, o gráfico é **responsivo** (estica no pai via
`ResponsiveContainer`, você controla a `height`). Com `width`, ele renderiza
num tamanho **fixo** sem `ResponsiveContainer` — útil pra testes/SSR.
---
# cli.md
# CLI `tempest`
Além da CLI de scaffolding [`create-tempest-app`](./scaffold.md), o pacote
`tempest-react-sdk` instala um segundo `bin` — **`tempest`** — pra cuidar da
saúde e da higiene do seu projeto no dia a dia: um **doctor** (no estilo do
`flutter doctor`) e um **fix/lint/format** que organiza imports, remove imports
mortos e arruma espaçamento.
Como ele vem dentro do SDK, está disponível assim que você instala a lib — rode
com `npx tempest ` ou pelos scripts `npm run doctor` / `npm run fix`
que o scaffold já cria.
## `tempest doctor`
Faz um diagnóstico do projeto atual e imprime um relatório `[✓] / [!] / [✗]`
**agrupado por seção** (estilo `flutter doctor`) — inclusive **problemas
silenciosos** que não quebram o build mas explodem em runtime ou drenam horas de
depuração:
```bash
npx tempest doctor
```
```text
tempest doctor (/caminho/do/seu/app)
Environment
[✓] Node 22.13.0
[i] tempest CLI v0.18.0
Project
[✓] package.json found
[✓] tempest-react-sdk in dependencies — ^0.18.0
[✓] tempest-react-sdk installed — v0.18.0
[✓] react + react-dom present — v19.2.0
Dependency health
[!] duplicate instance: react — nested copy under tempest-react-sdk;
rode `npm dedupe`; duas instâncias quebram hooks/context
[✓] @types/react matches react — v19
[!] recharts missing (used by charts) — você importa charts mas recharts não
está instalado — npm i recharts
[✓] tempest-react-sdk up to date — v0.18.0
TypeScript
[✓] tsconfig "@/*" alias
[!] moduleResolution: node — use "bundler" (senão os subpaths
tempest-react-sdk/br, /charts… não resolvem tipos)
[✓] jsx: "react-jsx"
[!] strict mode off — enable "strict": true
Integration
[✓] vite.config.ts uses createViteConfig
[✓] src/main.tsx imports styles.css
Stylesheets
[i] 34 stylesheet(s) · 210 rules · 806 declarations
[✗] src/pages/Dashboard.module.css:12 — a `;` está faltando: o valor de
`padding` engole as declarações abaixo, e o browser derruba todas
[!] src/components/Card.module.css:8 — `bacground-color` não é propriedade
CSS — você quis dizer `background-color`?
[i] src/components/Row.module.css:4 — 7 rules em 6 arquivo(s) declaram as
mesmas 3 propriedades — uma classe global bate 7 cópias locais
[i] 2 finding(s) are auto-fixable — rode `tempest fix`
Design
[i] 42 source file(s) · 1830 lines of code · median 38 — largest:
src/pages/Orders.tsx (204)
[!] src/pages/Orders.tsx:1 — 204 lines of code (limit 150) — extract a
sub-component, a hook or a pure function
[!] src/pages/Orders.tsx:31 — a component must not call the network — move it
to a service and read it with useQuery
[i] 2 limit(s) waived with a written reason — @tempest-limits markers
Tooling
[✓] ESLint config present
[✓] eslint installed
[✓] prettier installed
! 3 warning(s) — usable, but worth fixing.
```
!!! info "O que conta como uso, e o que não conta"
As checagens que perguntam "esse projeto usa X?" leem o **código**, não a prosa:
comentários saem antes da busca, então um `@example` mostrando
`import "tempest-react-sdk/styles.css"` não vira um segundo import, e um docstring
com `` não passa a exigir o `leaflet`. Arquivos de teste
também ficam de fora — um teste que renderiza um componente justamente para provar
como ele degrada **sem** o peer opcional não é o projeto pedindo aquele peer.
Peer marcada como `optional` em `peerDependenciesMeta` nunca é reportada como não
satisfeita: é exatamente o que "opcional" quer dizer.
!!! success "Funciona em projeto que ainda não usa o SDK"
Se o `tempest-react-sdk` não está nas suas dependências, o `doctor` roda em
**modo genérico**: audita a saúde de um app React + Vite qualquer e **não** reprova
você por não ter adotado nada.
As checagens que são convenção do SDK saem do relatório — alias `@/*`,
`createViteConfig`, o import do `styles.css`, o `src/main.tsx` esperado, os peers
opcionais dos subpaths. O que continua é o que vale pra qualquer app: versão do
Node, instância duplicada de React, dependência declarada e não instalada, peer não
satisfeita, `@types/react` desalinhado, `strict`/`jsx`/`moduleResolution`, lockfile
(presente, único, não desatualizado), ESLint e Prettier, `.env` no `.gitignore` e
variável de cliente sem prefixo `VITE_`.
```console
$ npx tempest doctor
…
[i] tempest-react-sdk not installed — checking generic React/Vite health only
…
Adopting the SDK (optional)
[i] install — npm i tempest-react-sdk
[i] import the stylesheet once, in your entry — import "tempest-react-sdk/styles.css"
[i] not all-or-nothing — one component at a time works
```
Antes disso o comando dava **duas falhas e exit 1** por um único fato — "você não
instalou isto ainda" — e enterrava os achados acionáveis no meio de avisos que eram
só a opinião do SDK. Ou seja: era inútil exatamente no projeto onde deveria ajudar
mais.
!!! tip "Use no onboarding e na CI"
Rode `tempest doctor` ao clonar um projeto (confirma que tudo está no lugar)
e como passo rápido na CI. Ele sai com código **1** se houver qualquer `✗`
(problema bloqueante); avisos `!` não falham o comando.
### O que ele verifica
**Environment** — Node ≥ 22.12 (e aviso se for uma linha **não-LTS**, major ímpar); versão da CLI; versões do **TypeScript** (≥5) e **Vite** (≥5); `engines.node` do `package.json` satisfeito.
**Project** — `tempest-react-sdk` declarado e instalado (com versão); `react`/`react-dom` presentes; **React major** ≥ 18.
**Dependency health** (os silenciosos):
- **Instância duplicada** de React ou de libs com estado/contexto (`@tanstack/react-query`, `zustand`, `react-hook-form`, `react-router`): uma cópia **aninhada** dentro do `tempest-react-sdk` significa **duas instâncias** no runtime — hooks inválidos, `QueryClient`/contexto de RHF que "somem". Sugere `npm dedupe`. _(Pulado quando o SDK é `file:`/`link:` local.)_
- **Deps declaradas mas não instaladas** (drift entre `package.json` e `node_modules`) → `npm install`.
- **`peerDependencies` do próprio app** não satisfeitas.
- **`@types/react` × `react`** com majors diferentes → erros de tipo fantasma.
- **Peers opcionais de subpaths usados**: se você importa `tempest-react-sdk/charts` sem `recharts`, `/editor` sem `@tiptap/react`, `/vision` sem `onnxruntime-web`, ou passa `tileUrl` no `TrajectoryMap` sem `leaflet` — tudo compila, mas quebra no import lazy em runtime.
- **SDK desatualizado** vs o `latest` no npm (best-effort, com timeout curto; pulado offline).
- **`lucide-react` em cópia dupla** — checagem separada da de cima, porque duas cópias de lucide não quebram hooks como um segundo React: elas duplicam bytes e, o que é pior, deixam as **tabelas de slug geradas** do `/icons` apontando pra exports que a cópia mais antiga não tem. Avisa quando o seu `package.json` declara lucide numa faixa diferente da do SDK (é a causa), quando existe cópia aninhada sob `tempest-react-sdk` (é a prova), e **reprova** quando a versão instalada é mais antiga do que as tabelas exigem — esse caso quebra o build com `… is not exported by lucide-react` apontando pra dentro do SDK. Ver [Ícones por slug](./icons.md).
**TypeScript** — alias `@/*`; **`moduleResolution`** ∈ `bundler`/`node16`/`nodenext` (senão os _subpath exports_ como `tempest-react-sdk/br` não resolvem tipos — silencioso!); **`jsx: "react-jsx"`**; **`strict: true`**; **`skipLibCheck`** ligado; com testes + `vitest`, avisa se o `types` do tsconfig omite `vitest/globals`.
**Integration** — `vite.config.*` usando `createViteConfig`; **`@vitejs/plugin-react`** instalado (JSX/Fast Refresh); import do `styles.css` no entry (e aviso se importado **mais de uma vez**).
**Stylesheets** — análise de **sintaxe e semântica** de todo `.css` do projeto (CSS Modules incluídos): CSS que o browser derruba, declaração morta, nome que não existe, e bloco repetido que pede uma classe global. É a seção detalhada na próxima seção deste guia — [Análise de CSS](#analise-de-css). Aqui o `doctor` mostra no máximo **6 achados por severidade** e diz quantos ficaram de fora; a lista completa sai no `tempest fix --dry-run`.
**Design** — os limites e anti-padrões de [Design de Software](./design/limits.md) medidos no seu código: arquivo/função/hook acima do limite, `Props` com props demais, `any` e `@ts-ignore`, `fetch` dentro de um `.tsx`, `catch` vazio e cor literal em `style={{ … }}`. Mostra **6 achados por severidade** e a mediana de linhas do projeto. Todo achado é `warn` — limite é heurística com saída de emergência escrita (`@tempest-limits — `), então a seção **nunca** derruba o exit code. Pule com `--no-design`.
**Tooling** — config + binários de ESLint e Prettier; **lockfile** presente, único (npm/yarn/pnpm misturados dessincronizam) e **não desatualizado** (`package.json` mais novo que o lock → `npm install`).
**Env & secrets** — **`.env` no `.gitignore`** (senão segredos vazam no commit); variáveis usadas via `import.meta.env.*` **sem prefixo `VITE_`** (o Vite não expõe pro browser → `undefined` em runtime); `.env` vs `.env.example`.
## Análise de CSS
O ESLint não lê `.css` e o Prettier só reformata: entre os dois, **CSS quebrado
passa batido**. O `tempest` analisa cada folha do projeto — inclusive CSS Modules
— em duas frentes, e o resultado alimenta os dois comandos: o `doctor` mostra o
resumo, o `fix` remove a parte que é comprovadamente morta.
```bash
npx tempest doctor # resumo, na seção Stylesheets
npx tempest fix --dry-run # lista completa, sem escrever nada
npx tempest fix # remove o que é morto de verdade
npx tempest fix src/components # só um caminho
npx tempest fix --no-css # pula a passada de CSS
```
### Sintaxe — o que o browser derruba
Erros (`✗`) são coisas que o browser **descarta em silêncio**. Nenhuma delas
quebra o build do Vite, e é justamente isso que as torna caras:
| Achado | Exemplo |
| -- | -- |
| `;` faltando entre declarações | `padding: 8px⏎margin: 0;` → **as duas** morrem |
| declaração sem `:` | `color red;` |
| valor vazio | `color: ;` |
| bloco nunca fechado / `}` sobrando | `.a { color: red;` |
| comentário, string ou `(` sem fechar | `/* pra sempre`, `content: "ops` |
| declaração fora de qualquer regra | `color: red;` no topo do arquivo |
| `{` sem seletor antes | `{ color: red; }` |
!!! danger "O `;` que falta é o pior deles"
`padding: 8px` seguido de `margin: 0;` sem ponto e vírgula no meio é **uma**
declaração sintaticamente válida com valor `8px margin: 0` — o browser
derruba as duas, sem aviso no console, e o layout fica errado num lugar que
você não escreveu. É o achado que paga a análise inteira.
### Semântica — CSS válido que está errado
Avisos (`!`) são folhas que o browser aceita e que ainda assim não fazem o que o
autor quis:
- **Declaração duplicada** — `color` duas vezes com o **mesmo** valor: a primeira
é morta. Auto-fixável.
- **Declaração sobrescrita** — `color` duas vezes com valores **diferentes** na
mesma regra: uma das duas é um engano. Não é fixável — escolher qual valor você
quis é palpite dentro do design de alguém.
- **Seletor declarado duas vezes** — no mesmo contexto de `@media`; diz quais
propriedades a segunda regra mata e pede o merge. Quando as duas regras são
idênticas declaração por declaração, é auto-fixável.
- **Propriedade que não existe** — `bacground-color`, `dispaly`, `paddign`. Só
reporta quando existe uma propriedade real a **até 2 edições** de distância,
então uma propriedade nova que a tabela não conhece nunca é acusada.
- **`@at-rule` inexistente** — `@medai`, `@suports`: o browser pula o bloco todo.
- **Token `--tempest-*` que não existe** — comparado com a tabela lida do
`styles.css` **instalado**, nunca com uma cópia chumbada na CLI.
- **`var(--x)` que ninguém define** e não tem fallback — resolve pra nada.
- **Regra vazia** — código morto. Em `.module.css` é reportada e **nunca**
removida: pode ser a classe-marcador que o seu JS referencia via `styles.x`.
!!! info "Um `var()` com fallback nunca é reportado"
`var(--tempest-card-padding, var(--tempest-space-5))` é o **idioma de knob**
do SDK: o nome não é um token, é um gancho que o app pode sobrescrever. Como
o fallback garante que renderiza, a checagem fica calada. Sem fallback o
mesmo `var()` resolve pra nada — aí é defeito e é reportado.
Foi essa regra que derrubou 43 falsos positivos quando a análise rodou no CSS
do próprio SDK — e deixou de pé **4 bugs reais** (`--tempest-duration-normal`,
`--tempest-primary-solid`, `--tempest-primary-on`, `--tempest-danger-on` sem
fallback), corrigidos no mesmo commit que trouxe a análise.
### Sugestão (`i`) — quando o global bate o local repetido
É a checagem que o CSS Modules **não pode** fazer por você: o escopo garante que
`.card` de um módulo nunca colide com `.card` de outro, e o preço é que nada te
conta que os dois são idênticos. A duplicação é invisível por design.
```text
[i] src/br/MapLegend.module.css:32 — 39 rules em 31 arquivo(s) re-implementam
`.tempest-stack` do utilities.css — importe "tempest-react-sdk/utilities.css"
uma vez e use a classe
[i] src/components/Row.module.css:4 — 7 rules em 6 arquivo(s) declaram as mesmas
4 propriedades (display: flex; align-items: center; gap: 8px; …+1) — uma
classe global bate 7 cópias locais
```
Duas formas do mesmo achado:
- **`global-candidate`** — bloco com **≥ 3 declarações** repetido em **≥ 3
regras** e **≥ 2 arquivos** (dentro de um único arquivo, exige a 4ª cópia).
Agrupa por declaração, não por nome de classe: `.row`, `.line` e `.bar` com o
mesmo corpo contam como três cópias.
- **`utility-candidate`** — quando o bloco repetido é um idioma que o
[`utilities.css`](./styles.md) já entrega: `.tempest-row`, `.tempest-stack`,
`.tempest-center`, `.tempest-cluster`, `.tempest-spread`, `.tempest-truncate`,
`.tempest-grid-auto`, `.tempest-card`. O casamento ignora o valor do `gap`, e
distingue vizinhos (uma coluna é `stack`, não `row`).
Além disso, `hardcoded-token-value` aponta valor literal que é **exatamente** o
de um token (`gap: 8px` → `var(--tempest-space-2)`) — e só quando **um único**
token tem aquele valor, porque `4px` é o valor de vários e mandar você usar
`--tempest-space-1` onde você quis uma borda é um palpite dito com confiança.
!!! tip "Sugestão nunca reprova o comando"
`i` é conselho: pode não valer a pena, e transformar cinco blocos iguais em
uma classe global é uma decisão de **acoplamento entre telas** que a CLI não
tem competência pra tomar. Ela mostra o número e sai da frente.
### O que o `fix` remove — e o que ele nunca toca
A passada de CSS remove **três** coisas, todas comprovadamente mortas:
1. declaração repetida com valor idêntico na mesma regra;
2. regra que repete uma anterior declaração por declaração;
3. regra vazia em folha comum (**não** em `.module.css`).
```console
$ npx tempest fix
→ css (dedupe declarations · drop dead rules)
src/components/Card.module.css 2
12: removed duplicate `color` — line 14 declares the same value
31: removed `.title` — line 40 repeats it exactly
✓ removed 2 dead declaration(s)/rule(s) in 1 file(s)
```
!!! warning "Sempre a cópia **anterior**, nunca a de baixo"
CSS é last-wins: remover a declaração de baixo mudaria o resultado sempre que
algo entre as duas mexer na mesma propriedade. Remover a de cima não muda
nada do que o browser computa — é o que faz a operação segura.
!!! note "Folha com erro de sintaxe não é escrita"
Offset tirado de uma folha pela qual o parser teve que adivinhar caminho não
é offset pra fazer splice. O `fix` reporta o erro, deixa o arquivo intacto e
sai com código **1**: conserte a sintaxe e rode de novo.
O `fix` também **não** reescreve valor pra token, não faz merge de seletor
duplicado e não converte bloco repetido em classe global. Tudo isso é edição
de design, não limpeza.
### `--extract-css`: mover o bloco repetido pra classe global
O `fix` normal **reporta** bloco repetido e não mexe. Com a flag, ele executa: o
bloco vai pra folha global do projeto, as regras locais somem e **os `styles.x`
no TSX passam a apontar pra classe nova**.
```bash
npx tempest fix --extract-css --dry-run # revisa o plano, não escreve
npx tempest fix --extract-css # aplica
npx tempest fix --extract-css --css-target src/styles/globals.css
npx tempest fix --extract-css --css-prefix shared-
```
```console
$ npx tempest fix --extract-css
→ css extract (bloco repetido → classe global)
src/components/Card.module.css 1
removida `.row` (linha 1) → `.u-row` em src/index.css
src/components/Card.tsx 1
`styles.row` → `"u-row"` (linha 5)
src/components/List.module.css 1
removida `.line` (linha 1) → `.u-row` em src/index.css
src/components/List.tsx 1
`styles.line` → `"u-row"` (linha 5)
✓ movidas 2 regra(s) local(is) para 1 classe(s) em src/index.css
```
O que ele reescreve no TSX:
```tsx
// antes // depois
```
!!! danger "É opt-in porque é decisão de design, não limpeza"
As outras passadas removem o que está **comprovadamente morto**. Esta decide
que N telas passam a compartilhar uma classe — e portanto **mudam juntas**. É
uma decisão de acoplamento entre telas; a CLI executa, não escolhe. Por isso
nunca roda sem a flag, e por isso o `--dry-run` existe.
!!! check "Ela recusa tudo que não consegue provar seguro — e diz o motivo"
Nenhuma recusa é silenciosa. Uma ocorrência só é movida quando **todas** valem:
| Condição | Por que |
| -- | -- |
| seletor é uma classe sozinha (`.row`), fora de `@media` | mover pra fora de um `@media` mudaria **quando** a regra vale |
| nenhuma outra regra da folha menciona a classe | um `.row:hover` ou `.row .child` ficaria sem sujeito |
| o módulo continua com pelo menos outra regra | senão o import vira código morto, o ESLint remove, e as regras que sobraram **param de carregar** |
| a classe é lida só como `styles.row` / `styles["row"]` | `styles[key]` ou `Object.keys(styles)` tornam o módulo **opaco**: nada nele é extraído |
| a folha global existe **e** alguém a importa | escrever numa folha que ninguém carrega é no-op silencioso |
| o nome novo não colide na folha global | use `--css-prefix` |
```console
[!] src/components/Card.module.css:1 não extraído — outra regra na mesma folha
usa `.row` (linha 12) e ficaria sem sujeito
```
!!! info "As chamadas são achadas pelo compilador do **seu** projeto"
A varredura usa o `typescript` instalado no projeto, não regex: `styles.row`
dentro de comentário, template literal ou string não é uso, e regex não sabe
diferenciar. Sem `typescript` instalado, a passada avisa e não escreve nada.
O alias do `tsconfig` é respeitado, então `@/components/Card.module.css`
resolve igual.
!!! tip "O nome da classe nova"
É o nome local que o seu código mais usa (por módulo, e depois por número de
chamadas), com o prefixo `u-`. Empate é o caso normal — as cópias moram em
módulos diferentes justamente porque ninguém combinou um nome —, e aí a ordem
das ocorrências decide, o que mantém o resultado igual entre execuções. O nome
escolhido aparece **antes** de qualquer escrita, no `--dry-run`.
!!! info "O que a análise deixa de fora, de propósito"
Folha **minificada** (`*.min.css` ou densidade alta de bytes por linha),
arquivo acima de **512 KB**, e as pastas `node_modules/`, `dist/`, `build/`,
`coverage/`, `public/`, `vendor/`. Acima de **600 folhas** o `doctor` avisa
que bateu o teto em vez de truncar em silêncio. A varredura roda em ~0,3 s
nas 200+ folhas do próprio SDK.
## `tempest fix`
Arruma o código de uma vez: **converte import relativo pra `@/`**, **remove CSS
morto**, **organiza imports**, **remove imports não usados**, **limpa linhas em
branco extras e espaços no fim**, e roda o **Prettier**.
```bash
npx tempest fix # o projeto inteiro
npx tempest fix src/app # só um caminho
npx tempest fix --dry-run # mostra o que mudaria, não escreve
npx tempest fix --no-alias # pula a conversão de import
npx tempest fix --no-css # pula a passada de CSS
npx tempest fix --extract-css # opt-in: bloco repetido → uma classe global
```
São quatro passadas, nessa ordem: a conversão de alias, a
[análise de CSS](#analise-de-css), `eslint --fix` (com as regras
`simple-import-sort`, `unused-imports/no-unused-imports`,
`no-multiple-empty-lines`, `no-trailing-spaces`, `eol-last`) e `prettier --write`.
A conversão vem **primeiro** de propósito: trocar `../../services/api` por
`@/services/api` muda o grupo de ordenação do `simple-import-sort`, então rodar o
ESLint depois deixa tudo ordenado num único `fix`. O CSS vem antes do Prettier
pelo mesmo motivo: o que a remoção deixa torto, o Prettier arruma na sequência.
!!! tip "`--dry-run` é a superfície de revisão do CSS"
O `doctor` mostra 6 achados por severidade; o `--dry-run` lista **todos** os
erros e avisos (só a cauda de sugestões é limitada a 10) e não escreve nada.
É o que você lê antes de deixar a ferramenta mexer.
### A conversão de import
A regra é uma só: **nenhum import sobe de diretório**.
```ts
// antes // depois
import { api } from "../../services/api"; import { api } from "@/services/api";
import { Button } from "../Button"; import { Button } from "@/components/Button";
import { Row } from "./Row"; // inalterado — irmão continua relativo
import cfg from "../../../vite.config"; // inalterado — resolve fora de src/
```
Um import de irmão (`./x`) fica como está: ele já diz "isso mora aqui do lado",
que é informação que o `@/` joga fora. Um caminho que resolve **fora** da base do
alias também fica — é isso que protege `../../../vite.config` e
`../../../scripts/x`.
O que a conversão alcança, além de `import` e `export … from`:
```ts
import type { User } from "../../types/user"; // import type
const m = await import("../../pages/Dashboard"); // import() dinâmico
vi.mock("../../lib/api"); // vi.mock / vi.doMock
```
E em arquivo `.css` (o Vite resolve alias em CSS também):
```css
@import "../../styles/tokens.css"; /* → @/styles/tokens.css */
.hero {
background: url(../../assets/bg.png); /* → @/assets/bg.png */
}
```
!!! tip "Rode com `--dry-run` primeiro num projeto grande"
O `--dry-run` lista arquivo, linha e o antes/depois de cada import sem
escrever nada — e sem rodar ESLint ou Prettier. É a forma de revisar o
diff antes de deixar a ferramenta mexer.
```console
$ npx tempest fix --dry-run
→ alias imports (../ → @/) [dry-run]
src/pages/admin/Users.tsx 2
1: "../../lib/api" → "@/lib/api"
2: "../../styles/tokens.css" → "@/styles/tokens.css"
✓ would convert 2 import(s) in 1 file(s)
```
!!! info "O alias vem do seu `tsconfig.json`, não é chumbado"
A base é lida de `compilerOptions.paths` — seguindo `extends` e aceitando
comentário no JSON. Se o seu projeto usa `~/*` ou `#/*` em vez de `@/*`, é
esse prefixo que sai na conversão; se usa `app/` em vez de `src/`, é essa a
base.
!!! warning "Sem `paths` no tsconfig, a conversão não roda"
Isso é de propósito. O `paths` é o que o **type-checker** honra: um alias
achado ali é um alias que o `tsc --noEmit` aceita depois da conversão.
Adivinhar `@` → `src` só porque existe um `src/` produziria import que não
resolve em projeto nenhum que não tenha o alias configurado. Quando não acha,
o comando avisa e segue pro ESLint sem tocar em nada:
```console
! no path alias found — skipping alias pass add "paths": { "@/*": ["./src/*"] } to tsconfig.json
```
A conversão também precisa do `typescript` instalado no projeto: ela usa o
compilador **do seu projeto** pra achar as posições de import, então uma
string parecida com caminho dentro de comentário, template literal ou
variável nunca é reescrita por engano.
!!! warning "Código morto = imports/vars, não funções inteiras"
O `fix` **remove imports não usados** e **avisa** sobre variáveis não usadas
(não apaga, pra não arriscar). Ele **não** faz eliminação de dead code mais
profunda (funções/exports órfãos) — isso exige análise dedicada e é
arriscado automatizar. Para isso, use uma ferramenta como `knip` à parte.
!!! warning "TypeScript 7 não tem a API que os codemods usam"
O 7 é o **port nativo**: instala com o mesmo nome de pacote, mas publica a API JS
só em `typescript/unstable/*`, com outra forma — o `ts.readConfigFile`/
`ts.createSourceFile` clássicos não existem lá. Como as duas passadas de codemod
(a conversão de alias e o `--extract-css`) precisam do AST, elas **saem do
caminho** e dizem por quê:
```console
! alias pass skipped — typescript 7.0.2 não expõe a API clássica do compilador…
```
Todo o resto continua: a análise de CSS, o dedupe, o ESLint, o Prettier, e as
checagens de tsconfig do `doctor` (que caem num parser JSONC próprio). Pra usar os
codemods, tenha o TypeScript 6 instalado no projeto.
Antes da 0.29.1 isso não era um aviso: a CLI resolvia o pacote, concluía que tinha
TypeScript e chamava a API — `tempest doctor` morria com
`ts.readConfigFile is not a function`.
!!! note "Precisa de ESLint + Prettier no projeto"
Apps gerados pelo `create-tempest-app` já vêm com tudo configurado. Em um
projeto pelado, instale: `npm i -D eslint prettier eslint-plugin-simple-import-sort eslint-plugin-unused-imports`.
## `tempest lint` e `tempest format`
```bash
npx tempest lint # eslint . (só reporta, não altera)
npx tempest format # prettier --write . (só formatação)
```
`lint` é o relatório read-only; `fix` é o `lint` que corrige + formata. Flags que
você passar são repassadas pro binário (`npx tempest lint --max-warnings 0`), e o
caminho continua sendo posicional.
## Ajuda
```bash
npx tempest --help
npx tempest --version
```
## Recap
- O `bin` **`tempest`** vem dentro do SDK — `npx tempest `.
- **`doctor`** diagnostica o projeto (estilo `flutter doctor`), sai com código 1 em problemas bloqueantes.
- **`fix`** converte import relativo pra `@/` + remove CSS morto + organiza imports + remove imports mortos + limpa espaçamento + Prettier. `--dry-run` pra revisar, `--no-alias`/`--no-css` pra pular uma passada.
- A **[análise de CSS](#analise-de-css)** acha sintaxe que o browser derruba, declaração/regra duplicada, nome que não existe e bloco repetido que pede uma classe global. O `doctor` resume, o `--dry-run` lista tudo, o `fix` remove só o que é morto.
- A **[análise de design](./design/limits.md)** mede limite de arquivo/função/hook, contagem de props, `any`, `fetch` em componente, `catch` vazio e cor literal inline — sempre como aviso, com `@tempest-limits — ` como saída de emergência escrita.
- **`lint`** reporta; **`format`** só formata.
- Veja também: [Scaffold](./scaffold.md) · [Arquitetura](./architecture.md).
---
# components.md
# Componentes UI
O catálogo foi dividido por categoria para facilitar navegação. Cada arquivo cobre props, exemplos e notas de acessibilidade.
➡️ **[Começar pela Entrada de dados](./components/inputs.md)**
!!! tip "Ícone por nome"
Precisa renderizar um ícone cujo nome só existe em runtime (menu vindo da API,
campo de CMS)? Veja [Ícones por slug](./icons.md) — ` ` cobre
os 2024 slugs do lucide sem o custo de chunk do `DynamicIcon`.
## Categorias
- **[Entrada de dados](./components/inputs.md)** — Input, Textarea, Select, Combobox, MultiSelect, Checkbox, Radio/RadioGroup, Switch, ChipInput, SearchBar, DatePicker, DateRangePicker, TimePicker, FileUpload, Dropzone, Slider, RangeSlider, RatingStars, PinInput, PasswordInput, StepperInput, Form\*, ImageCropper, SignaturePad
- **[Ação](./components/actions.md)** — Button, FloatingActionButton, Tooltip, DropdownMenu, Popover, ConfirmDialog, InstallButton, InstallBanner
- **[Navegação](./components/navigation.md)** — Navbar, AppBar, Sidebar, NavigationRail, BottomNavigation, Tabs, Stepper, Breadcrumbs, Pagination, SegmentedControl
- **[Overlay](./components/overlay.md)** — Modal, Drawer, BottomSheet, ModalsManager, Lightbox
- **[Layout](./components/layout.md)** — AppShell, Page, Container, Stack, Grid, Divider, Spacer, Center, AspectRatio, SafeArea, Show, Hide
- **[Dados](./components/data.md)** — Table, VirtualList, VirtualTable, DataTable, ListTile, Accordion, Timeline, TreeView, Sparkline
- **[Status & feedback](./components/feedback.md)** — Alert, Banner, Badge, Tag, Stat, Progress, NProgress, Spinner, Skeleton, RefreshIndicator, Toast, EmptyState, ErrorState, OfflineIndicator, SyncStatusBadge, UpdatePrompt
- **[Identidade & micro](./components/identity.md)** — Avatar, AvatarGroup, Card, Kbd
- **[Utilitários & headless](./components/utility.md)** — CopyButton, RelativeTime, Money, TruncateText, VisuallyHidden, Portal, ClickOutside, ConditionalWrapper, For, ErrorText, Image, DataList, DescriptionList, CodeBlock, QRCode
- **Overlays & avançados** — os componentes em paridade com a shadcn/ui, em cinco páginas ([visão geral](./components/advanced.md)):
- **[Essenciais](./components/advanced-essentials.md)** — Toggle, ToggleGroup, Label, Collapsible, ContextMenu, HoverCard, Command
- **[Layout & UX](./components/advanced-layout.md)** — ScrollArea, Resizable, Calendar, Scheduler
- **[Navegação & conteúdo](./components/advanced-navigation.md)** — NavigationMenu, Menubar, Carousel
- **[Dados](./components/advanced-data.md)** — DataTable, Wizard, Markdown, Masonry, Tour, Transfer, FilterBar, Kanban
- **[Conversa](./components/advanced-chat.md)** — Chat, AIChat
## Convenções globais
- **CSS Modules** com prefix `tempest_` — não colide com estilos do app.
- **`className` prop** sempre disponível para customização local.
- **Tokens CSS** (`--tempest-*`) — customize via root, não via copy-paste de CSS.
- **Forward ref** em inputs / textarea / select / botões — funciona com `react-hook-form`.
- **A11y baseline** — `aria-invalid` em erro, `aria-label` em close buttons, `aria-current="page"` em nav, focus trap em Modal/Drawer/BottomSheet.
- **Mobile-aware** — Navbar/BottomNavigation/BottomSheet/Toast/Modal.fullscreen aplicam `env(safe-area-inset-*)`.
- **Responsive props** — `Stack.direction`, `Grid.columns`, `Form.layout` aceitam `ResponsiveValue` (`{ base, sm, md, lg, xl }`).
## Veja também
- [Tema + tokens CSS](./styles.md)
- [Forms (zod + Form layout + masked inputs BR)](./forms.md)
- [Hooks](./hooks.md)
- [Testing helpers (subpath)](./testing.md)
- [README raiz](https://github.com/mauriciobenjamin700/tempest-react-sdk#readme)
- [Gallery (demo)](./gallery.md)
---
# components/actions.md
# Ação
Componentes de **ação** são o ponto onde o usuário dispara algo: clicar, escolher numa lista, confirmar. Eles carregam intenção — um clique muda dados, navega, ou inicia um fluxo. Por isso a categoria reúne tanto o gatilho direto (`Button`) quanto os elementos que cercam uma ação: dica contextual (`Tooltip`), conjunto de ações secundárias (`DropdownMenu`), painel ancorado (`Popover`) e a salvaguarda antes de algo destrutivo (`ConfirmDialog`).
Use esta página quando precisar que o usuário **faça** algo. Para entrada de dados (texto, seleção, datas) veja [inputs](./inputs.md); para apresentar coleções, veja [data](./data.md).
## `Button`
> **Quando usar**: a ação primária ou secundária de qualquer tela — submeter um form, abrir um modal, navegar. É o gatilho de ação por padrão.
Botão primário com variants, sizes, estado de loading.
```tsx
import { Button } from "tempest-react-sdk";
import { Plus, Trash } from "lucide-react";
Salvar ;
}>
Excluir
;
Carregando…
;
}>
Ver mais
;
;
CTA
;
```
| Prop | Tipo | Default |
| ----------- | ----------------------------------------------------------------------------------------------- | ----------- |
| `variant` | `"primary" \| "secondary" \| "success" \| "danger" \| "soft" \| "outline" \| "ghost" \| "link"` | `"primary"` |
| `size` | `"xs" \| "sm" \| "md" \| "lg" \| "xl"` | `"md"` |
| `loading` | `boolean` | `false` |
| `fullWidth` | `boolean` | `false` |
| `iconOnly` | `boolean` (square, requer `aria-label`) | `false` |
| `pill` | `boolean` (border-radius pílula) | `false` |
| `leftIcon` | `ReactNode` | — |
| `rightIcon` | `ReactNode` | — |
!!! warning "iconOnly precisa de rótulo acessível"
`iconOnly` remove o texto visível, então leitores de tela não têm o que anunciar. Sempre passe `aria-label` descrevendo a ação (`aria-label="Excluir"`). Sem isso o botão é um ícone mudo para tecnologia assistiva.
!!! tip "loading bloqueia duplo clique"
`loading` desabilita o botão e seta `aria-busy="true"` — é o padrão para submits assíncronos. Ative-o assim que disparar a request para evitar requisições duplicadas por cliques repetidos.
## `FloatingActionButton`
> **Quando usar**: a ação primária e persistente de uma tela (criar, compor, adicionar) que deve ficar sempre acessível, flutuando sobre o conteúdo. Redondo quando só tem ícone, ou estendido (pílula) quando tem `label`.
Por padrão fica fixo no canto inferior direito; passe `position="none"` para posicioná-lo inline (ex.: dentro de um `NavigationRail`). Espalha todos os props nativos de `` (`onClick`, `disabled`, etc.).
```tsx
import { FloatingActionButton } from "tempest-react-sdk";
import { Plus } from "lucide-react";
} aria-label="Novo" position="none" onClick={create} />;
} label="Novo pedido" onClick={create} />;
```
| Prop | Tipo | Default |
| ---------- | ------------------------------------------ | ---------------- |
| `icon` | `ReactNode` | — |
| `label` | `ReactNode` (presente → FAB estendido) | — |
| `position` | `"bottom-right" \| "bottom-left" \| "none"` | `"bottom-right"` |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` |
| `variant` | `"primary" \| "surface"` | `"primary"` |
| ... | Todos os atributos de `HTMLButtonElement` | — |
!!! warning "FAB só de ícone precisa de `aria-label`"
Sem `label` visível, o FAB redondo não tem nome acessível. Sempre passe `aria-label` descrevendo a ação (`aria-label="Novo"`); quando há `label`, ele já serve de nome.
## `Tooltip`
> **Quando usar**: dar contexto extra a um controle cujo significado não é óbvio — tipicamente botões `iconOnly`. Nunca para informação crítica.
Hover tooltip portalado. Aparece no hover **e** no foco por teclado.
```tsx
```
| Prop | Tipo | Default |
| ----------- | ---------------------------------------- | ------- |
| `content` | `ReactNode` | — |
| `placement` | `"top" \| "right" \| "bottom" \| "left"` | `"top"` |
| `openDelay` | `number` (ms antes de aparecer) | `150` |
| `disabled` | `boolean` (desliga sem mexer no trigger) | `false` |
!!! warning "Não esconda informação essencial num tooltip"
Usuários de touch não têm hover — eles nunca verão o conteúdo. Tooltip é reforço, não a única fonte de uma informação necessária para concluir a tarefa.
## `DropdownMenu`
> **Quando usar**: agrupar ações secundárias atrás de um único gatilho ("Mais ações", menu de perfil) quando elas não cabem na barra principal.
Menu suspenso de ações. Navegação por teclado (↑↓ Home End Esc). Cada entrada precisa de um `id` estável (usado como key do React).
```tsx
Mais ações }
items={[
{ type: "label", id: "h", label: "Conta" },
{ type: "item", id: "edit", label: "Editar perfil", onSelect: () => navigate("/profile") },
{ type: "separator", id: "s1" },
{ type: "item", id: "logout", label: "Sair", onSelect: logout, danger: true },
]}
/>
```
| Entry type | Campos |
| ------------- | ---------------------------------------------------------- |
| `"item"` | `id`, `label`, `icon?`, `onSelect`, `disabled?`, `danger?` |
| `"label"` | `id`, `label` |
| `"separator"` | `id` |
Props do componente: `trigger` (`ReactElement`), `items` (`DropdownMenuEntry[]`), `placement` (`"bottom-start" \| "bottom-end" \| "top-start" \| "top-end"`, default `"bottom-start"`).
!!! note "Fecha após selecionar"
Selecionar um item dispara `onSelect` e fecha o menu. Para um painel que permanece aberto com múltiplas escolhas (checkboxes, filtros), use `Popover` em vez de `DropdownMenu`.
## `Popover`
> **Quando usar**: um painel flutuante com conteúdo arbitrário (filtros, mini-form, preview) ancorado a um gatilho — quando você precisa de mais que uma lista de ações.
Painel flutuante genérico (anchor + outside-click + Esc dismiss). Funciona controlado (`open` + `onOpenChange`) ou não-controlado (`defaultOpen`).
```tsx
Filtros}
>
setOpen(false)}>Aplicar
```
| Prop | Tipo | Default |
| --------------------- | ---------------------------------------- | -------------- |
| `trigger` | `ReactElement` (clonado com handlers) | — |
| `open` | `boolean` | — (controlled) |
| `onOpenChange` | `(open: boolean) => void` | — |
| `defaultOpen` | `boolean` (uso não-controlado) | `false` |
| `placement` | `"top" \| "bottom" \| "left" \| "right"` | `"bottom"` |
| `closeOnEsc` | `boolean` | `true` |
| `closeOnOutsideClick` | `boolean` | `true` |
!!! note "Sem collision detection"
O `Popover` não reposiciona automaticamente quando esbarra na borda da viewport. Se você precisa de flip/shift automático, prefira o `DropdownMenu` (lista simples) ou integre Floating UI no app.
## `ConfirmDialog`
> **Quando usar**: a última barreira antes de uma ação irreversível ou cara (excluir, sobrescrever, cancelar). Sempre com `variant="danger"` quando destrutiva.
Prompt destrutivo pré-montado em cima do [`Modal`](./overlay.md) (texto + 2 botões).
```tsx
{
await deleteUser(user.id);
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
```
| Prop | Tipo | Default |
| -------------- | ------------------------------------------------------- | ------------- |
| `open` | `boolean` | — |
| `title` | `ReactNode` | — |
| `description` | `ReactNode` | — |
| `confirmLabel` | `string` | `"Confirmar"` |
| `cancelLabel` | `string` | `"Cancelar"` |
| `variant` | `"primary" \| "danger"` | `"primary"` |
| `loading` | `boolean` (mostra spinner + desabilita ambos os botões) | `false` |
| `onConfirm` | `() => void \| Promise` | — |
| `onCancel` | `() => void` | — |
!!! tip "Controle o loading durante a request"
`onConfirm` aceita uma promise, mas o `ConfirmDialog` não gerencia o estado de loading sozinho — passe `loading={deleting}` controlado pelo seu estado para travar ambos os botões enquanto a ação assíncrona corre.
## `InstallButton`
Botão de instalação do PWA, ligado ao prompt `beforeinstallprompt` ([`useBeforeInstallPrompt`](../hooks.md)). **Renderiza `null`** quando o app não pode ser instalado — prompt ainda não capturado, já instalado, ou rodando standalone — então você o solta na UI sem guardar visibilidade. Herda todas as props do [`Button`](#button).
```tsx
import { InstallButton } from "tempest-react-sdk";
import { Download } from "lucide-react";
} />;
```
| Prop | Tipo | Default |
| ---------- | ---------------------------------------------------------- | ---------------- |
| `label` | `ReactNode` | `"Instalar app"` |
| `onResult` | `(o: "accepted" \| "dismissed" \| "unsupported") => void` | — |
| … | todas as props de `Button` (`variant`, `size`, `leftIcon`) | — |
## `InstallBanner`
Banner inferior dispensável que convida a instalar o PWA. Aparece só quando há prompt capturado e o app **não** está standalone; em plataformas que nunca disparam `beforeinstallprompt` (iOS Safari) fica oculto — surfa instruções manuais em outro lugar. `storageKey` lembra a dispensa entre recarregamentos.
```tsx
;
```
| Prop | Tipo | Default |
| -------------- | --------------------- | -------------- |
| `title` | `ReactNode` | `"Instale o app"` |
| `description` | `ReactNode` | — |
| `installLabel` | `string` | `"Instalar"` |
| `dismissLabel` | `string` | `"Dispensar"` |
| `icon` | `ReactNode` | — |
| `storageKey` | `string` | — (sessão) |
| `onResult` | `(o) => void` | — |
## Resumo
| Componente | Use para | Gatilho |
| --------------- | ---------------------------------------------- | ---------- |
| `Button` | Disparar a ação primária/secundária | clique |
| `FloatingActionButton` | Ação primária flutuante e persistente | clique |
| `InstallButton` | Instalar o PWA (some quando não aplicável) | clique |
| `InstallBanner` | Convite dispensável pra instalar o PWA | clique |
| `Tooltip` | Contexto não-crítico num controle | hover/foco |
| `DropdownMenu` | Lista de ações secundárias (fecha ao escolher) | clique |
| `Popover` | Painel flutuante com conteúdo arbitrário | clique |
| `ConfirmDialog` | Confirmar ação destrutiva antes de executar | — |
Pontos-chave de acessibilidade:
- Ações destrutivas devem usar `variant="danger"`.
- `Button.loading` é o padrão para submits async — bloqueia duplos cliques.
- Tooltips não devem conter informação crítica (usuários de touch não veem hover).
- `iconOnly` **exige** `aria-label`.
Relacionados: [overlay](./overlay.md) (`ConfirmDialog` é construído sobre `Modal`) · [inputs](./inputs.md) (entrada de dados) · [feedback](./feedback.md) (toasts/alerts após a ação).
---
# components/advanced-chat.md
# Avançados: conversa
`Chat` para thread entre pessoas e `AIChat` para conversa com um modelo. São componentes diferentes, não variantes — e é por isso que têm página própria.
## `Chat`
> **Quando usar**: uma thread de mensagens — suporte, chat interno, comentário de documento, histórico de atendimento.
Agrupa por autor e por dia, marca o lado do usuário atual, mostra estado de entrega, quem está digitando, e traz o composer quando você passa `onSend`.
```tsx
import { Chat, Avatar, type ChatMessage } from "tempest-react-sdk";
import { useState } from "react";
export function Suporte({ me }: { me: { id: string } }) {
const [mensagens, setMensagens] = useState([]);
/** Insert otimista: a mensagem aparece antes do servidor confirmar. */
const enviar = async (texto: string) => {
const id = crypto.randomUUID();
setMensagens((atual) => [
...atual,
{ id, body: texto, authorId: me.id, sentAt: Date.now(), status: "sending" },
]);
await api.post("/mensagens", { body: { id, texto } });
setMensagens((atual) =>
atual.map((m) => (m.id === id ? { ...m, status: "sent" } : m)),
);
};
return (
reenviar(m.id)}
renderAvatar={(m) => }
/>
);
}
```
| Prop | Tipo | Default | O que faz |
| --- | --- | --- | --- |
| `messages` | `ChatMessage[]` | — | A thread, **mais antiga primeiro**. Nunca reordenada. |
| `currentUserId` | `string` | — | Autor tratado como "seu": lado, cor e ticks de entrega. |
| `onSend` | `(text: string) => void \| Promise` | — | Renderiza o composer. Recebe o texto já trimado. |
| `onRetry` | `(message: ChatMessage) => void` | — | Liga o botão de retry numa mensagem `"failed"`. |
| `onSendError` | `(error: unknown) => void` | — | Chamado quando `onSend` rejeita. O rascunho fica no campo. |
| `typing` | `string[]` | `[]` | Quem está digitando. Um, dois ou a contagem é fraseado pra você. |
| `renderAvatar` | `(message) => ReactNode` | — | Avatar da **primeira** mensagem de cada bloco. |
| `header` | `ReactNode` | — | Barra acima da thread, dentro do painel. |
| `groupWindowMs` | `number` | `300000` | Intervalo que ainda mantém mensagens no mesmo bloco. |
| `locale` | `"pt-BR" \| "en"` | `"pt-BR"` | Rótulos ("Hoje", "Você", "Enviando"…). |
| `emptyState` | `ReactNode` | ` ` | Thread vazia. |
| `composerDisabled` | `boolean` | `false` | Sem permissão, thread arquivada, offline. |
`ChatMessage = { id, body, authorId, authorName?, sentAt, status?, data? }` · `status` ∈ `"sending" | "sent" | "read" | "failed"`.
O componente é **apresentacional e controlado**, como o resto do SDK: recebe a lista e emite intenção. De onde vêm as mensagens (REST, o `createWebSocket` do SDK, um stream SSE) e como o insert otimista é feito ficam com o app, porque isso muda por backend.
!!! tip "A rolagem só pula pro fim se você já estava no fim"
Uma thread que sempre rola pra mensagem nova arranca quem está lendo o histórico, toda vez que qualquer pessoa digita. Então o pulo acontece só quando o leitor já estava embaixo (com 48px de folga pra última linha parcialmente visível) — a regra pra qual todo app de chat converge. Verificado no browser: lendo o histórico no topo, três mensagens chegaram e a posição não se moveu.
!!! info "Bloco quebra por autor, por dia **e** por intervalo"
Repetir avatar e nome em cada linha de uma rajada de cinco transforma conversa em lista de recibos. Mas uma resposta uma hora depois é um novo momento da conversa mesmo que ninguém tenha falado no meio — juntar ao bloco anterior colocaria um timestamp só em mensagens separadas por uma hora. O `groupWindowMs` é esse limite.
!!! warning "Estado de falha não é enfeite"
Sem `"failed"` + `onRetry`, o usuário redigita o que já está na tela. A bolha que falhou mantém o **texto legível** (borda e meta em vermelho, não o fundo inteiro) justamente porque reler a mensagem é o que a pessoa faz antes de decidir reenviar.
!!! info "A thread é `role=\"log\"` com `aria-live=\"polite\"` e alcançável por teclado"
Mensagem nova é anunciada sem roubar o foco. O contêiner tem `tabIndex={0}` porque uma área que rola e não tem nada focável dentro é inacessível pelo teclado — o mesmo problema que a [correção de rolagem](./data.md) resolveu no `Table`. Estado de entrega vai em texto (`VisuallyHidden`), não só no glifo: "✓✓" não é lido.
!!! tip "Serve como thread de comentários"
É o mesmo componente **sem** `currentUserId` e sem `typing`: todos do mesmo lado, nome por bloco. Foi por isso que "quem sou eu" virou uma prop em vez de um campo `own` em cada mensagem — num comentário de documento ninguém quer marcar 200 mensagens.
#### `ChatComposer`
Exportado à parte pra quem monta o próprio layout (composer fixo no rodapé de uma rota, por exemplo). Textarea que cresce com o conteúdo, `Enter` envia, `Shift+Enter` quebra linha.
| Prop | Tipo | Default | O que faz |
| --- | --- | --- | --- |
| `onSend` | `(text: string) => void \| Promise` | — | Recebe o texto trimado. Limpa o campo só se não rejeitar. |
| `onError` | `(error: unknown) => void` | — | Erro do `onSend`. Rascunho preservado de qualquer forma. |
| `actions` | `ReactNode` | — | Antes do botão de enviar — anexo, emoji. |
| `maxRows` | `number` | `6` | Altura máxima, em linhas. |
| `sendLabel` | `string` | locale | Rótulo do botão. |
!!! warning "Ele é **não controlado**, de propósito"
Rascunho de chat muda a cada tecla, e subir isso pro estado do app re-renderiza a thread inteira por caractere — o único lugar onde "controlado por default" custa algo visível. Quem precisa do rascunho (composer persistido, menu de slash-command) lê pelo `onChange` ou usa o ref (`focus()`, `setValue()`).
!!! danger "IME: `Enter` durante composição não envia"
Compondo japonês ou coreano, `Enter` confirma a palavra candidata. Enviar ali publica meia palavra e come a confirmação — daí a checagem de `isComposing`.
## `AIChat`
> **Quando usar**: conversa com um **modelo** — copiloto do seu app, assistente de suporte, busca conversacional. É a forma que o ChatGPT, o Claude e o DeepSeek convergiram.
Turnos por papel (`user` / `assistant` / `system`), resposta em Markdown com bloco de código, raciocínio em bloco separado, cursor de streaming, ações por turno (copiar, gerar de novo, editar, 👍/👎) e um composer que **vira botão de parar** enquanto a resposta chega.
!!! info "`AIChat` e [`Chat`](#chat) são componentes diferentes, não variantes"
Uma thread humana é endereçada por **autor** e se preocupa com estado de entrega. Um transcript de modelo é endereçado por **papel**, não tem estado de entrega nenhum, e precisa de três coisas que uma thread humana nunca precisa: saída parcial, raciocínio separado da resposta e re-perguntar. Encaixar os dois num `variant` misturaria dois modelos de dados no mesmo `props` e deixaria `authorId`/ticks mortos no caminho LLM.
Comece com o mínimo — uma lista e um `onSend`:
```tsx
import { AIChat, type AIChatMessage } from "tempest-react-sdk";
import { useState } from "react";
export function Copiloto() {
const [turnos, setTurnos] = useState([]);
const perguntar = async (texto: string) => {
setTurnos((atual) => [
...atual,
{ id: crypto.randomUUID(), role: "user", content: texto },
]);
const resposta = await fetch("/api/ask", {
method: "POST",
body: JSON.stringify({ prompt: texto }),
}).then((r) => r.json());
setTurnos((atual) => [
...atual,
{ id: crypto.randomUUID(), role: "assistant", content: resposta.text },
]);
};
return ;
}
```
Isso já te dá o transcript, o Markdown, o composer, o `Enter`/`Shift+Enter`, a rolagem que segue a resposta e a ação de copiar. O que falta é o **streaming** — e é aí que o componente ganha a cara de produto.
#### Streaming, do zero
O SDK **não** faz a chamada por você: "como eu faço streaming do meu backend" tem resposta diferente por provider. O que ele faz é renderizar o estado. O contrato é simples — vá reescrevendo o `content` do **último** turno e mantenha `streaming: true` nele até acabar:
```tsx
import { AIChat, type AIChatMessage } from "tempest-react-sdk";
import { useRef, useState } from "react";
export function CopilotoStreaming() {
const [turnos, setTurnos] = useState([]);
const [pendente, setPendente] = useState(false);
const abortar = useRef(null);
/** Reescreve o último turno a cada chunk — o componente segue o texto sozinho. */
const escrever = (id: string, texto: string) =>
setTurnos((atual) =>
atual.map((t) => (t.id === id ? { ...t, content: texto } : t)),
);
const perguntar = async (prompt: string) => {
const idResposta = crypto.randomUUID();
setTurnos((atual) => [
...atual,
{ id: crypto.randomUUID(), role: "user", content: prompt },
]);
setPendente(true);
abortar.current = new AbortController();
const resposta = await fetch("/api/stream", {
method: "POST",
body: JSON.stringify({ prompt }),
signal: abortar.current.signal,
});
setPendente(false);
setTurnos((atual) => [
...atual,
{ id: idResposta, role: "assistant", content: "", streaming: true },
]);
const leitor = resposta.body!.pipeThrough(new TextDecoderStream()).getReader();
let acumulado = "";
try {
while (true) {
const { value, done } = await leitor.read();
if (done) break;
acumulado += value;
escrever(idResposta, acumulado);
}
} catch (erro) {
if ((erro as Error).name !== "AbortError") throw erro;
} finally {
setTurnos((atual) =>
atual.map((t) =>
t.id === idResposta ? { ...t, streaming: false } : t,
),
);
}
};
return (
abortar.current?.abort()}
composerFooter={Pode errar — confira números antes de decidir. }
/>
);
}
```
O que você ganha de graça nesse trecho:
| Você fez | O componente faz |
| --- | --- |
| `pending` enquanto a request está no ar | Mostra os três pontinhos e já troca **Enviar** por **Parar** |
| `streaming: true` no último turno | Desenha o cursor `▍` no fim do texto e esconde as ações daquele turno |
| Reescreve `content` a cada chunk | Rola pra acompanhar — **só se** o leitor já estava embaixo |
| `onStop` | Botão de parar no lugar do enviar, e `Escape` no campo também aborta |
| `streaming: false` no fim | Cursor sai, ações voltam, e o leitor de tela anuncia "Resposta concluída" |
!!! tip "Se seu backend fala SSE, use o `createEventStream` do SDK"
O laço acima é `fetch` + `ReadableStream` porque é o caminho comum de APIs de LLM. Pra um endpoint `text/event-stream` de verdade, o [`sse`](../sse.md) do SDK já cuida de reconexão e `Last-Event-ID` — o loop de `escrever()` é o mesmo.
#### Raciocínio (extended thinking / R1)
Um turno com `reasoning` ganha um bloco colapsável **acima** da resposta:
```tsx
{
id: "a1",
role: "assistant",
content: "São 12 pedidos.",
reasoning: "Filtrei por data de entrega vencida e status != entregue…",
}
```
!!! info "Enquanto só o raciocínio chegou, o bloco abre sozinho"
Se o turno está com `streaming: true` e o `content` ainda está vazio, o bloco de raciocínio monta **aberto** — é o único conteúdo que existe, e escondê-lo deixaria a tela parada com um cursor piscando no vácuo. Terminou, ele continua aberto (quem quiser fecha); usar `defaultReasoningOpen` abre **todos**, o que serve pra uma tela de auditoria.
#### Ações por turno
| Ação | Aparece em | Prop que liga |
| --- | --- | --- |
| Copiar | todo turno | sempre (copia o Markdown **cru**, não o HTML) |
| Gerar de novo | **só** o turno de assistente mais novo | `onRegenerate` |
| 👍 / 👎 | turno de assistente | `onFeedback` |
| Editar | turno de usuário | `onEditSubmit` |
| Tentar de novo | turno com `error` | `onRetry` |
```tsx
reperguntar(turno)}
onFeedback={(turno, voto) => track("answer_rated", { id: turno.id, voto })}
onEditSubmit={(turno, texto) => {
truncarAPartirDe(turno.id); // seu app decide o que cai
return perguntar(texto);
}}
votes={votosSalvos} // opcional: votos que vieram do banco
/>
```
!!! warning "Gerar de novo aparece só no último turno de assistente — de propósito"
Re-perguntar um turno do meio joga fora **todo** turno depois dele. Isso é uma operação diferente ("ramificar aqui") e precisa da própria confirmação; oferecer o mesmo botão nos dois casos convida a perder metade da conversa num clique.
!!! info "Editar não decide o que apagar"
O `onEditSubmit` te entrega o turno e o texto novo. Quem trunca o transcript é o app, porque "apagar tudo depois" e "criar uma ramificação" são produtos diferentes e o SDK não deve escolher por você.
#### Prompts sugeridos e estado vazio
```tsx
```
Numa conversa vazia as sugestões aparecem no rodapé da área de transcript; clicar em uma envia direto. Somem no primeiro turno. Sem `onSend` elas não são renderizadas (não haveria pra onde mandar) e cai no `EmptyState` — ou no seu `emptyState`.
#### Props
| Prop | Tipo | Default | O que faz |
| --- | --- | --- | --- |
| `messages` | `AIChatMessage[]` | — | O transcript, **mais antigo primeiro**. Nunca reordenado. |
| `onSend` | `(text: string) => void \| Promise` | — | Renderiza o composer. Recebe o prompt já trimado. |
| `onStop` | `() => void` | — | Aborta o turno no ar. Troca enviar por parar; `Escape` também aborta. |
| `pending` | `boolean` | `false` | Request no ar, nada de volta ainda. |
| `onRegenerate` | `(message) => void` | — | Liga o "gerar de novo" no último turno de assistente. |
| `onEditSubmit` | `(message, text) => void \| Promise` | — | Liga o "editar" nos turnos de usuário. |
| `onFeedback` | `(message, vote) => void` | — | Liga 👍/👎. `vote` ∈ `"up" \| "down"`. |
| `onRetry` | `(message) => void` | — | Liga o retry num turno com `error`. |
| `onSendError` | `(error: unknown) => void` | — | Erro do `onSend` **ou** do `onEditSubmit`. Rascunho preservado. |
| `votes` | `Record` | — | Votos que o app guarda. Sem isso o estado pressionado é local. |
| `suggestions` | `string[]` | `[]` | Prompts oferecidos numa conversa vazia. |
| `renderAvatar` | `(message) => ReactNode` | — | Avatar por turno. |
| `renderContent` | `(message) => ReactNode` | — | Substitui o corpo — card de tool-call, gráfico, lista de citações. |
| `showSystem` | `boolean` | `false` | Mostra turnos `"system"`. |
| `defaultReasoningOpen` | `boolean` | `false` | Abre todos os blocos de raciocínio. |
| `showLineNumbers` | `boolean` | `false` | Numera linha em bloco de código. |
| `header` | `ReactNode` | — | Barra acima do transcript, dentro do painel. |
| `composerActions` | `ReactNode` | — | Antes do botão de enviar — anexo, seletor de modelo. |
| `composerFooter` | `ReactNode` | — | Abaixo do campo — contagem de token, disclaimer. |
| `composerDisabled` | `boolean` | `false` | Sem crédito, conversa arquivada, offline. |
| `maxRows` | `number` | `8` | Altura máxima do composer, em linhas. |
| `locale` | `"pt-BR" \| "en"` | `"pt-BR"` | Rótulos ("Parar", "Raciocínio", "Você"…). |
| `emptyState` | `ReactNode` | ` ` | Conversa vazia. |
`AIChatMessage = { id, role, content, reasoning?, streaming?, error?, createdAt?, model?, attachments?, data? }` · `role` ∈ `"user" \| "assistant" \| "system"`.
`AIChatAttachment = { id, name, size?, url?, mimeType? }` — com `url` vira miniatura, sem `url` vira chip com nome e tamanho.
#### Decisões que valem saber
!!! info "Resposta é Markdown, prompt é texto puro"
Um modelo emite Markdown por contrato. Uma pessoa que digitou `calcule 2 * 3 * 4` não quis abrir um span de ênfase — e ver o próprio prompt reescrito é desconcertante. Por isso o turno de usuário é `white-space: pre-wrap` e o de assistente passa pelo [`Markdown`](advanced-data.md#markdown) (que já usa o [`CodeBlock`](utility.md#codeblock) nos blocos cercados). Quer Markdown no prompt também? `renderContent`.
!!! tip "A resposta é o documento, não uma bolha"
Turno de assistente ocupa a largura toda, sem bolha; turno de usuário é uma bolha estreita encostada no fim da linha. Envolver a resposta numa bolha limitaria a largura dela, brigaria com as tabelas e blocos de código dentro, e faria resposta longa parecer mensagem gritada. O prompt é curto e precisa ser distinguido num relance, o que a bolha faz melhor que qualquer outra coisa.
!!! danger "O transcript **não** é `aria-live` — e isso é acessibilidade, não descuido"
Uma região viva sobre texto em streaming faz o leitor de tela reler a resposta a cada token: inutilizável. Então o `role="log"` fica sem `aria-live`, e os dois momentos que importam ("Gerando resposta", "Resposta concluída") são anunciados por um `role="status"` separado. O turno em andamento leva `aria-busy`, e a resposta pronta é lida do log no ritmo de quem lê. O `axe` do jsdom não pega esse tipo de erro — foi decisão de projeto, verificada no browser.
!!! tip "A rolagem só segue a resposta se você já estava no fim"
Mesma regra do [`Chat`](#chat), e aqui ela pesa mais: um transcript que sempre pula pro texto novo arrancaria o leitor **dezenas de vezes por segundo** durante o streaming. Quando você não está no fim, aparece um botão redondo pra voltar — o pulo nunca acontece sem você pedir.
!!! warning "A dependência do efeito de rolagem não é a lista"
Streaming acrescenta ao **último** turno. Um app que mutasse esse objeto no lugar — ou que re-renderizasse de uma store guardando o mesmo array — manteria a mesma dependência enquanto o texto cresce, e a visão pararia de seguir a resposta. É por isso que existe `tailSignature()` (exportado): tamanho da lista + identidade do último turno + tamanho do texto dele cobrem as duas formas.
!!! info "Só o turno que cresce re-parseia"
O `Markdown` parseia no próprio render, e o React não re-renderiza um filho cujo elemento é referencialmente o mesmo. Segurar esse elemento entre renders é o que impede um transcript de cinquenta turnos de re-parsear toda resposta já pronta a cada token da mais nova.
!!! tip "Parar ocupa o lugar do enviar, não um botão ao lado"
O único botão embaixo do dedo é sempre o que você quer a seguir: enviar quando está parado, abortar quando a resposta está vindo. Dois botões lado a lado significariam acertar o certo no meio do stream.
!!! warning "Ação escondida em `:hover` é ação inexistente no touch"
A linha de ações aparece no hover e no foco de teclado, e fica **sempre** visível onde não existe hover (`@media (hover: none)`). Sem isso, num celular o primeiro toque cairia no que estiver embaixo. Verificado com device de toque emulado: `hover: none` e `pointer: coarse` verdadeiros, linha com `opacity: 1`.
Os botões medem 28×28 — acima do piso de 24×24 da WCAG 2.5.8, abaixo dos 44×44 da 2.5.5, e são **quatro** lado a lado. Em `pointer: coarse` um hit-slop de `::after` leva o alvo real a **44×44** sem mover um pixel do que se vê, o mesmo truque que o [`Button`](actions.md#button) usa nos tamanhos icon-only. Aumentar o `padding` em vez disso espalharia a linha no desktop, onde o ponteiro é preciso e a linha deve ficar quieta.
#### Responsivo: de celular a TV
Medido no browser em 360×640, 390×844, 740×360 (celular em paisagem), 768×1024, 1440×900, 1920×1080 e 3840×2160. Em **toda** largura: zero overflow horizontal na página e no transcript, composer sempre visível, tabela e bloco de código rolando **na própria caixa**.
O que muda com a largura:
| Faixa | O que acontece |
| --- | --- |
| até 480px | `gap` e `padding` do transcript encurtam, bolha do usuário e editor vão a `max-width: 100%` |
| 480px – 768px | a coluna de leitura acompanha a largura disponível |
| 768px e acima | a coluna trava em `48rem` e **centraliza**; a sobra fica de margem |
!!! tip "A largura da coluna é um knob: `--tempest-ai-chat-width`"
Coluna limitada é a resposta certa do celular até um desktop 1920 — texto passando de ~90 caracteres por linha é mensuravelmente mais difícil de rastrear de volta ao começo da linha seguinte, e deixar a resposta correr a tela toda de um monitor largo piora, não ajuda.
De 2560 pra cima a troca se inverte: 768px no meio de uma tela de sala é quase só espaço vazio, e **só o app sabe** a que distância a pessoa está sentada. Por isso é knob e não constante:
```css
:root {
--tempest-ai-chat-width: 72rem; /* default 48rem */
}
```
Um valor só move os turnos, o indicador de "pensando", as sugestões **e** o composer juntos.
!!! warning "Tamanho de tipo não é resolvido aqui"
A fonte é a mesma em 360px e em 4K. Escalar tipo pra TV é decisão de `typography.css` e `density.css` — uma rampa de fonte local ao componente brigaria com os tokens que todo app tematiza. Se você mira TV, suba `--tempest-text-*` no `:root` (ou use `[data-tempest-density="spacious"]`) junto com `--tempest-ai-chat-width`.
#### `AIChatComposer` e `AIChatTurn`
Exportados à parte pra quem monta o próprio layout — um composer fixo no rodapé de uma rota, um diff lado a lado de duas respostas. Mesmas props relevantes do painel, e o `AIChatComposer` é **não controlado** pelo mesmo motivo do [`ChatComposer`](#chatcomposer): rascunho muda a cada tecla, e subir isso pro estado do app re-renderiza o transcript inteiro por caractere — com uma resposta em streaming em cima, isso é visível.
| Helper exportado | Pra que serve |
| --- | --- |
| `visibleTurns({ messages, showSystem })` | A lista que o painel realmente renderiza. |
| `isGenerating(messages)` | Algum turno está em streaming. |
| `lastAssistantId(messages)` | Qual turno recebe o "gerar de novo". |
| `tailSignature(messages)` | Dependência de efeito que muda quando a cauda cresce. |
| `aiChatStrings(locale)` · `roleLabel(role, strings)` · `turnTime(ts, locale)` | Rótulos, pra reusar num layout próprio. |
## Recap
- **Conversa**: `Chat` para thread entre pessoas (autor, entrega, digitando) e `AIChat` para conversa com um modelo (papel, streaming, raciocínio, re-perguntar). São componentes diferentes, não variantes.
- Todos seguem os mesmos padrões controlado/não-controlado, expõem A11y por teclado e importam de `tempest-react-sdk`.
---
# components/advanced-data.md
# Avançados: dados
Tabela stateful, assistente em passos, markdown, mural, tour guiado, transferência entre listas, barra de filtros e kanban. Cada um resolve uma tela inteira.
## `DataTable`
Tabela de dados stateful construída sobre o `Table` headless. Adiciona busca client-side, ordenação por clique no cabeçalho e paginação, delegando toda a marcação à `Table` subjacente.
```tsx
import { DataTable, type DataTableColumn } from "tempest-react-sdk";
interface User {
id: number;
name: string;
email: string;
role: string;
}
const columns: DataTableColumn[] = [
{ key: "name", header: "Nome", sortable: true },
{ key: "email", header: "E-mail" },
{ key: "role", header: "Papel", sortable: true, align: "right" },
];
row.id}
emptyMessage="Nenhum usuário encontrado"
/>;
```
| Prop | Tipo | Default | Descrição |
| -------------- | ------------------------------------- | ------- | ------------------------------------------------------- |
| `data` | `T[]` | — | Dataset completo; sort/filtro/paginação são client-side |
| `columns` | `DataTableColumn[]` | — | Definições de coluna |
| `pageSize` | `number` | `10` | Linhas por página |
| `searchable` | `boolean` | `false` | Renderiza um input de busca acima da tabela |
| `searchKeys` | `(keyof T)[]` | — | Chaves buscadas; default = colunas string/number |
| `initialSort` | `DataTableSort` | — | Ordenação inicial antes de interagir com o cabeçalho |
| `rowKey` | `(row: T, index) => string \| number` | índice | Extrator de chave estável por linha |
| `emptyMessage` | `ReactNode` | — | Conteúdo exibido quando nenhuma linha combina |
`DataTableColumn` = `{ key: keyof T; header: ReactNode; render?: (row: T) => ReactNode; sortable?: boolean; align?: TableAlign; priority?: TablePriority; width?: string | number }`. `DataTableSort` = `{ key: keyof T; direction: "asc" | "desc" }`.
!!! info "Comportamento"
Clicar um cabeçalho ordenável cicla asc → desc → sem ordenação. A busca combina substring case-insensitive nas `searchKeys` (ou em toda coluna string/number quando omitidas). A paginação some quando o resultado cabe em uma única página.
## `Wizard`
Fluxo multi-passo: indicador, um corpo por vez e navegação que respeita **validação por passo**. O `Stepper` desenha o indicador; o `Wizard` é dono do que todo app reescrevia — índice ativo, gate assíncrono antes de avançar, botões desabilitados/pendentes e a chamada de conclusão.
```tsx
import { Button, FormActions, FormField, Input, Wizard, useZodForm } from "tempest-react-sdk";
import { FormProvider } from "react-hook-form";
import { z } from "zod";
const schema = z.object({
nome: z.string().min(2, "Informe o nome"),
email: z.string().email("E-mail inválido"),
cep: z.string().min(9, "CEP incompleto"),
});
export function CadastroEmEtapas() {
const form = useZodForm(schema, { defaultValues: { nome: "", email: "", cep: "" } });
return (
console.log(values))}
steps={[
{
id: "dados",
label: "Dados",
description: "Quem é o cliente",
validate: () => form.trigger(["nome", "email"]),
content: (
<>
>
),
},
{
id: "endereco",
label: "Endereço",
validate: () => form.trigger(["cep"]),
content: ,
},
{
id: "revisao",
label: "Revisão",
content: ({ back }) => (
<>
{JSON.stringify(form.getValues(), null, 2)}
Corrigir
>
),
},
]}
/>
);
}
```
| Prop | Tipo | Default | Descrição |
| -------------------- | --------------------------------------------- | ---------- | ------------------------------------------------------------ |
| `steps` | `WizardStep[]` | — | Passos do fluxo. |
| `activeIndex` | `number` | — | Índice controlado. |
| `defaultActiveIndex` | `number` | `0` | Índice inicial (não controlado). |
| `onStepChange` | `(index, step) => void` | — | Chamado a cada troca de passo. |
| `onComplete` | `() => void \| Promise` | — | Chamado quando o último passo passa na validação. |
| `nextLabel` | `string` | `"Next"` | Rótulo do botão de avanço. |
| `backLabel` | `string` | `"Back"` | Rótulo do botão de voltar. |
| `finishLabel` | `string` | `"Finish"` | Rótulo no último passo. |
| `optionalLabel` | `string` | `"(optional)"` | Sufixo do passo opcional no indicador — troque para localizar. |
| `clickableSteps` | `boolean` | `false` | Permite pular clicando no indicador. |
| `renderActions` | `(controls: WizardControls) => ReactNode` | — | Substitui a linha de botões padrão. |
`WizardStep = { id, label, description?, content, validate?, optional? }` — `content` aceita `ReactNode` **ou** função que recebe os controles.
`WizardControls = { activeIndex, step, validating, isFirst, isLast, next, back, goTo }`.
!!! warning "Só o passo ativo está montado"
Input não commitado em um passo que você abandona **se perde**, a menos que o estado viva fora (o `FormProvider` do react-hook-form, uma store, um `useState` do pai) — que é onde ele deveria estar de todo jeito, já que o último passo normalmente submete tudo de uma vez.
!!! tip "`validate` assíncrono já vem com estado de pendência"
Enquanto a promise corre, o botão de avanço fica em `loading` e o de voltar desabilitado. Um `validate` que **lança** conta como "não permitido": um gate ligado a checagem de rede não deve deixar o usuário num fluxo meio-avançado quando a requisição falha.
!!! note "`clickableSteps` é `false` de propósito"
Um wizard existe porque a **ordem importa**. Com `clickableSteps`, pular pra trás é livre (voltar nunca bloqueia), mas pular pra frente valida **cada passo atravessado** — o primeiro gate que reprovar interrompe o salto ali.
## `Markdown`
> **Quando usar**: renderizar texto que veio de gente — comentário, descrição de ticket, release notes, corpo de mensagem.
Subconjunto de Markdown: headings, parágrafos, listas (aninhadas e numeradas), citação, código cercado (via [`CodeBlock`](utility.md#codeblock)), regra, tabela GFM com alinhamento, e o inline usual (`**forte**`, `*itálico*`, `` `código` ``, `~~riscado~~`, link, imagem, autolink, quebra forçada).
```tsx
import { Markdown } from "tempest-react-sdk";
;
```
| Prop | Tipo | Default | O que faz |
| --- | --- | --- | --- |
| `source` | `string` | — | O Markdown. |
| `headingOffset` | `number` | `2` | Nível que o `#` do documento vira. |
| `highlightCode` | `boolean` | `true` | Código cercado via `CodeBlock` (copiar, número de linha). |
| `showLineNumbers` | `boolean` | `false` | Número de linha no código cercado. |
| `linkProps` | `AnchorHTMLAttributes` | — | Props extras em **todo** link. |
!!! danger "A segurança é estrutural, não uma promessa de escape"
`dangerouslySetInnerHTML` **não existe** neste componente. O parser produz uma árvore de nós e o render vira elementos React — e um filho de React só pode ser texto. Então `` num comentário renderiza como os caracteres que a pessoa digitou, e ` ` também.
Isso não é "HTML sanitizado": **é texto**. É por isso que não há sanitizador aqui, nem lista de tags permitidas — não há caminho por onde markup entre.
!!! danger "URL passa por allowlist de esquema, não blocklist"
Link aceita `http`, `https`, `mailto`, `tel`, `sms` e relativo. Imagem aceita os mesmos, mais `data:image/` **raster** (png/jpeg/gif/webp/avif) — `data:image/svg+xml` fica fora de propósito: um SVG é um documento, carrega `