tempest-react-sdk
v0.5.1
Published
SDK público da Tempest com componentes, hooks e integrações para projetos React.
Maintainers
Readme
tempest-react-sdk
Shared React/TypeScript building blocks used across Tempest frontends: UI components, hooks, HTTP client, auth store, query keys, forms (zod), real-time transports (SSE / WebSocket / Web Push / Service Worker), theme, i18n, telemetry, feature flags, offline storage, error boundary, and a curated set of utilities (cn, formatCurrency, formatCPF, etc.).
The goal is to start every new React frontend with the same opinionated foundation already in place — no copy-pasting Button/Input styles, no rewriting the same auth Zustand store, no re-inventing the SSE reconnect loop. The patterns here are a distillation of what was consolidated in alofans-frontend and transport-admin-system — apps that consume the SDK gain consistency without paying for boilerplate.
Table of contents
- Recommended stack
- Install
- What's inside
- Architecture overview
- Quickstart — wiring the app providers
- Recipes
- HTTP client
- Response parsing with zod
- Upload with progress
- Polling
- Retry & idempotency
- Auth store (Zustand)
- Route guard
- JWT helpers & refresh queue
- Code-splitting with retry
- React Query
- Form layout (
Form,FormSection,FormRow,FormActions) - Forms (zod)
- BR validators & masked inputs
- ViaCEP lookup
- WebSocket
- Server-Sent Events (SSE)
- Web Push
- Service Worker helpers
- Audio playback
- Offline storage (IndexedDB / Dexie)
- Error boundary
- Toast notifications
- Modal & ConfirmDialog
- Tables & pagination
- Layout primitives
- Virtual list
- Theming (light / dark)
- i18n
- Feature flags
- Telemetry
- Logger
- Web Share API
- Hooks catalogue
- Utility helpers (
cn,format*,storage)
- Theming reference
- Conventions
- Development
- Release
- License
Recommended stack
Vite + React + TypeScript is the supported consumer stack. The SDK is built and tested against Vite 7 in library mode and assumes a Vite-style host app:
- ESM-first module resolution (the package's
exportsfield declaresimport/requireconditions). import.meta.envfor env vars (the recipes useimport.meta.env.VITE_API_URL,import.meta.env.VITE_VAPID_PUBLIC_KEY, etc.).- Native CSS Modules (the package's hashed
tempest_*class names are emitted as CSS Modules under the hood and consumed via the globaltempest-react-sdk/styles.cssimport). - Fast HMR — provider files (
ThemeProvider,I18nProvider, etc.) opt into React Refresh. - First-class compatibility with the Vite plugin ecosystem (
vite-plugin-pwafor service workers,vite-plugin-dts,vite-plugin-svgr, etc.).
To bootstrap a new app:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install tempest-react-sdkThe demo gallery in examples/gallery is itself a Vite app — use it as the reference project layout.
Other bundlers (Next.js app router, Webpack, Rspack, Parcel) may work — the package ships standard ESM + CJS + rolled-up .d.ts — but they are not exercised in CI, and Vite-specific features used in the recipes (import.meta.env, vite-plugin-pwa) will need their own equivalents. When in doubt, start with Vite.
Vite reference: https://vite.dev/guide/. React + TypeScript template: https://vite.dev/guide/#scaffolding-your-first-vite-project.
Install
npm install tempest-react-sdkVia package.json:
{
"dependencies": {
"tempest-react-sdk": "^0.1.0"
}
}Requires React >=18 and Node >=20.19 to build.
Peer & bundled dependencies
Only react and react-dom are peer dependencies — those must come from the host app so a single React copy lives in the tree.
Everything else (zod, zustand, dexie, react-hook-form, @tanstack/react-query, lucide-react) is a direct dependency of the SDK, installed automatically by npm install tempest-react-sdk. You never need to install them manually.
| Package | Status | Used by |
| ------------------------------------- | ------------------- | ------------------------------------------------------------------- |
| react, react-dom (^18 \|\| ^19) | Peer (required) | Everything |
| @tanstack/react-query (^5) | Direct dep (auto) | QueryProvider, createQueryKeys |
| zod (^3.23 \|\| ^4) | Direct dep (auto) | parseResponse, validateForm, zodResolver, useZodForm |
| zustand (^4 \|\| ^5) | Direct dep (auto) | createAuthStore |
| dexie (^4.4) | Direct dep (auto) | createOfflineStore |
| react-hook-form (^7.76) | Direct dep (auto) | zodResolver, useZodForm, masked inputs |
| lucide-react (>=0.400) | Direct dep (auto) | Component icons (leftIcon/rightIcon on Input, Button, etc.) |
The minimum install is just:
npm install tempest-react-sdk react react-domBundle impact: every bundled dep is externalised in the SDK's Rollup config, so the SDK's published bundle stays at ~104 KB ESM. Your app's bundler (Vite / webpack / Rspack) resolves these from node_modules and tree-shakes — if you never call createOfflineStore, Dexie never enters your final bundle.
Version conflicts: if your app already pins (say) [email protected], npm dedupes when the range is compatible. If ranges diverge you get two copies — pin a single version in your own package.json to force one, or open an issue if the SDK's range is too tight.
Adapters for external SDKs (@sentry/browser, posthog-js, @growthbook/growthbook, launchdarkly-js-client-sdk) are not bundled — install those only when you opt into the adapter. The caller passes the SDK instance to the factory.
CSS import
Import the base stylesheet once at the entry of your app (e.g. main.tsx / src/index.tsx):
import "tempest-react-sdk/styles.css";This injects the design tokens (--tempest-primary, --tempest-radius-md, ...), a minimal CSS reset, and the per-component CSS Modules. Tokens live on :root and on [data-tempest-theme="dark"], so the app can override them globally or per subtree (see Theming).
The styles ship hashed under the tempest_ namespace — they do not collide with Tailwind, Stitches, Linaria, or app-level CSS Modules.
What's inside
Every module is re-exported from the package root — import { Button, useDebounce, createApiClient } from "tempest-react-sdk" always works.
| Module | Exports |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| components | Avatar, Badge, Breadcrumbs, Button, Card, Checkbox, ChipInput, ConfirmDialog, Container, DatePicker, Drawer, EmptyState, ErrorState, FileUpload, Form (FormSection, FormRow, FormActions), Grid, Input, Modal, Pagination, Progress, Radio, RadioGroup, SearchBar, Select, Skeleton, Spinner, Stack, Stepper, Switch, Table, Tabs, Textarea, Toast (ToastProvider, useToast), Tooltip, VirtualList |
| hooks | useDebounce, usePagination, useClientFilter, useMediaQuery, useOnline, useDocumentVisibility, useIntersectionObserver, useResizeObserver, useClipboard, useKeyboardShortcut, useBeforeInstallPrompt, useIdle, useGeolocation, useScrollLock, useFocusTrap, useStableCallback, useDeepMemo |
| http | createApiClient, parseResponse, uploadWithProgress, retry, generateIdempotencyKey, usePoll, types: ApiClient, ApiClientConfig, ApiError, RequestOptions, RetryOptions, UploadProgressEvent, UploadWithProgressOptions, UsePollOptions, UsePollResult |
| auth (peer: zustand) | createAuthStore, AuthGuard, decodeJWT, isJWTExpired, lazyWithRetry, createRefreshQueue, types: AuthState, CreateAuthStoreOptions, AuthGuardProps, DecodedJWT, LazyWithRetryOptions |
| query (peer: @tanstack/react-query) | QueryProvider, createQueryKeys, STALE_TIME, CACHE_TIME, REFETCH_TIME |
| forms (peer: zod, react-hook-form) | validateForm, zodResolver, useZodForm, validateCPF, validateCNPJ, formatCEP, formatCNPJ, unmask, CPFInput, CNPJInput, PhoneInput, CEPInput, MoneyInput, useViaCEP |
| sse | createEventStream, useEventStream |
| ws | createWebSocket, useWebSocket |
| push | WebPushClient, WebPushUnsupportedError, WebPushPermissionDeniedError, usePushSubscription, urlBase64ToUint8Array, isPushSupported |
| sw | registerServiceWorker, skipWaiting, unregisterAllServiceWorkers, installPushHandler, installNotificationClickHandler, installSkipWaitingListener |
| audio | createAudioPlayer, playAudio, stopAudio, useAudio |
| offline (peer: dexie) | createOfflineStore, types: OfflineStore, OfflineStoreConfig, ListOptions |
| error-boundary | ErrorBoundary, useErrorHandler, types: ErrorBoundaryProps, ErrorBoundaryRenderProps |
| theme | ThemeProvider, useTheme, getInitialTheme, themeInitScript, types: ThemeMode, ResolvedTheme |
| i18n | createI18n, I18nProvider, useI18n, useTranslate, types: Catalog, Messages, I18n, InterpolationValues |
| logger | createLogger, consoleSink, types: Logger, LogEntry, LogLevel, LoggerSink |
| telemetry | TelemetryProvider, useTelemetry, consoleTelemetryAdapter, createSentryTelemetryAdapter, createPostHogTelemetryAdapter, types: TelemetryAdapter, TelemetryEvent, TelemetryUser, CreateSentryTelemetryAdapterOptions, SentryLike, CreatePostHogTelemetryAdapterOptions, PostHogLike |
| feature-flags | FeatureFlagsProvider, useFeatureFlag, useFlagValue, createInMemoryFlags, createGrowthBookFeatureFlagsAdapter, createLaunchDarklyFeatureFlagsAdapter, types: FeatureFlagsAdapter, FlagValue, GrowthBookLike, LDClientLike |
| share | share, isShareSupported, types: SharePayload, ShareResult |
| utils | cn, formatCurrency, formatDate, formatDateTime, formatPhone, formatCPF, formatPercent, storage |
Full per-module docs in docs/ (one markdown per module + draw.io diagrams in docs/diagrams/).
A demo app exercising every module lives in examples/gallery — cd examples/gallery && npm install && npm run dev.
Architecture overview
The SDK is a layered set of building blocks. Apps wire the layers together; the SDK never owns the app shell.
┌──────────────────────────────────────────────────────────────┐
│ App entry (main.tsx) │
│ ├── import "tempest-react-sdk/styles.css" │
│ └── <ThemeProvider> │
│ <I18nProvider> │
│ <FeatureFlagsProvider> │
│ <TelemetryProvider> │
│ <QueryProvider> │
│ <ToastProvider> │
│ <ErrorBoundary> │
│ <RouterProvider … /> │
└─────────────────────────────┬────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Pages │ │ Features │ │ Layouts │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
└───────┬───────┴───────┬───────┘
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Components + │ │ Hooks + │
│ Forms │ │ Stores │
└───────┬────────┘ └───────┬────────┘
│ │
└────────┬─────────┘
▼
┌────────────────────────┐
│ HTTP client + zod + │
│ SSE / WS / WebPush / │
│ Offline / Audio │
└────────────────────────┘- Presentation layer (
components): purely visual; uncontrolled-or-controlled, accessible by default, themable via CSS tokens. - Behaviour layer (
hooks,forms,auth,error-boundary): React-aware helpers that orchestrate state. - Transport layer (
http,sse,ws,push,sw): everything that touches the network or the service worker. - Persistence layer (
offline,authpersist,utils/storage,i18npersist): client-side storage abstractions. - Observability layer (
telemetry,logger,error-boundaryonError): everywhere the app reports on itself.
The SDK ships no App.tsx, no router, no global store. Each layer is opt-in.
Quickstart — wiring the app providers
A minimal main.tsx for an app using HTTP + Query + Auth + Toast + Theme + i18n:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import {
ThemeProvider,
I18nProvider,
QueryProvider,
ToastProvider,
ErrorBoundary,
ErrorState,
createI18n,
themeInitScript,
} from "tempest-react-sdk";
import "tempest-react-sdk/styles.css";
import { App } from "./App";
const i18n = createI18n({
locale: "pt-BR",
fallback: "en",
messages: {
"pt-BR": { hello: "Olá, {name}" },
en: { hello: "Hello, {name}" },
},
});
// No-flash dark mode script. Inject in <head> via index.html, OR call here:
document.head.insertAdjacentHTML("afterbegin", `<script>${themeInitScript()}</script>`);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<I18nProvider i18n={i18n}>
<QueryProvider>
<ToastProvider>
<ErrorBoundary
fallback={({ error, reset }) => (
<ErrorState description={error.message} onRetry={reset} />
)}
>
<BrowserRouter>
<App />
</BrowserRouter>
</ErrorBoundary>
</ToastProvider>
</QueryProvider>
</I18nProvider>
</ThemeProvider>
</StrictMode>,
);You only wrap with what you use — every provider is independent. The example above is the maximal case.
Recipes
Each recipe is self-contained. Pick the ones you need.
HTTP client recipe
createApiClient returns a typed ApiClient instance with .get / .post / .put / .patch / .delete methods, a typed ApiError, automatic JSON serialization, AbortSignal support, and pluggable getToken / onUnauthorized hooks.
import { createApiClient } from "tempest-react-sdk";
import { useAuthStore } from "@/store/auth";
export const api = createApiClient({
baseURL: import.meta.env.VITE_API_URL,
getToken: () => useAuthStore.getState().token,
onUnauthorized: () => useAuthStore.getState().logout(),
withCredentials: true,
defaultHeaders: { "X-App-Version": __APP_VERSION__ },
});
const user = await api.get<UserResponse>("/users/me");
await api.post("/orders", { body: { total: 100, items: [...] } });Every method throws ApiError (status, body, url, code) on non-2xx responses. onUnauthorized is called automatically on 401, before the error is thrown — so you can refresh-and-retry or sign out as you choose.
Response parsing with zod recipe
The HTTP client returns untyped JSON (unknown). parseResponse validates against a zod schema and gives you a ZodError-aware failure message tied to the request.
import { createApiClient, parseResponse } from "tempest-react-sdk";
import { z } from "zod";
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
export async function getUser(id: string) {
const raw = await api.get<unknown>(`/users/${id}`);
return parseResponse(userSchema, raw, `GET /users/${id}`);
}On validation failure parseResponse throws an Error whose message includes the request label and the zod issues — exactly the diagnostic you want during a wire-protocol drift.
Upload with progress recipe
fetch cannot report upload progress in browsers. uploadWithProgress falls back to XMLHttpRequest internally while keeping the same ApiError contract:
import { uploadWithProgress } from "tempest-react-sdk";
const formData = new FormData();
formData.append("file", file);
formData.append("alo_id", aloId);
const controller = new AbortController();
await uploadWithProgress<{ url: string }>({
url: `${API}/uploads`,
method: "POST",
body: formData,
withCredentials: true,
getToken: () => useAuthStore.getState().token,
signal: controller.signal,
onProgress: ({ fraction, loaded, total }) => {
if (fraction !== null) setProgress(Math.round(fraction * 100));
},
});
// Cancel:
controller.abort();fraction is null when total is unknown (chunked / unsized uploads).
Polling recipe
usePoll runs a callback on an interval, pauses while the tab is hidden, and exposes start/stop controls. Use it for "kind-of-realtime" data when you don't need a socket.
import { usePoll } from "tempest-react-sdk";
function ServerStatus() {
const poll = usePoll({
interval: 5_000,
callback: async () => {
const data = await api.get<Status>("/status");
setStatus(data);
},
immediate: true,
pauseWhenHidden: true,
});
return (
<Button onClick={poll.running ? poll.stop : poll.start}>
{poll.running ? "Pause" : "Resume"}
</Button>
);
}Retry & idempotency recipe
import { retry, generateIdempotencyKey } from "tempest-react-sdk";
const idempotencyKey = generateIdempotencyKey();
const result = await retry(
() =>
api.post("/payments", {
body: payload,
headers: { "Idempotency-Key": idempotencyKey },
}),
{
retries: 3,
baseDelay: 400,
shouldRetry: (error) => error instanceof Error && /status (5\d\d|429)/.test(error.message),
},
);generateIdempotencyKey returns a v4 UUID using crypto.randomUUID() when available, with a Math.random fallback for older runtimes.
Auth store recipe
createAuthStore<TUser>() returns a typed Zustand store with the persist middleware already wired. The app owns the user shape — the SDK only owns the state shape.
import { createAuthStore } from "tempest-react-sdk";
type SessionUser = { id: string; name: string; is_admin: boolean };
export const useAuthStore = createAuthStore<SessionUser>({
name: "tempest-app-auth",
storage: "local",
});
// Anywhere:
useAuthStore.getState().setSession({ user, token });
const isAuthed = useAuthStore((s) => s.isAuthenticated);
useAuthStore.getState().logout();The store exposes user, token, isAuthenticated, setSession, setUser, setToken, and logout. isAuthenticated is derived from token and rehydrates correctly after page reload.
Route guard recipe
import { Navigate, Outlet } from "react-router-dom";
import { AuthGuard } from "tempest-react-sdk";
import { useAuthStore } from "@/store/auth";
export function ProtectedLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return (
<AuthGuard isAuthenticated={isAuthenticated} fallback={<Navigate to="/login" replace />}>
<Outlet />
</AuthGuard>
);
}AuthGuard is a pure render gate — no router coupling. Use any redirect mechanism (react-router, next/navigation, wouter, ...).
JWT helpers & refresh queue recipe
import { decodeJWT, isJWTExpired, createRefreshQueue } from "tempest-react-sdk";
const decoded = decodeJWT<{ sub: string; exp: number; role: string }>(token);
const expired = isJWTExpired(token, 30); // 30-second skew
// Coalesce concurrent refreshes so only one network call runs at a time:
const refresh = createRefreshQueue(async () => {
const newToken = await api.post<{ token: string }>("/auth/refresh");
useAuthStore.getState().setToken(newToken.token);
return newToken.token;
});
// Two concurrent calls → one request, both get the same resolved value:
await Promise.all([refresh(), refresh()]);Code-splitting with retry recipe
lazyWithRetry wraps React.lazy so that a stale chunk error retries with exponential backoff instead of crashing the page. A common cause is users on a tab with an old index.html after a deploy — the retry usually picks up the new bundle; a final location.reload() recovers from a stale index.html.
import { lazyWithRetry } from "tempest-react-sdk";
const Settings = lazyWithRetry(() => import("./Settings"), {
retries: 3,
initialDelay: 400,
reloadOnFinalFailure: true,
});
<Route
path="/settings"
element={
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
}
/>;React Query recipe
import { QueryProvider, createQueryKeys, STALE_TIME } from "tempest-react-sdk";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryProvider defaultOptions={{ queries: { staleTime: STALE_TIME.MEDIUM } }}>
{children}
</QueryProvider>
);
}STALE_TIME, CACHE_TIME, and REFETCH_TIME ship as named constants (SHORT, MEDIUM, LONG) so cache windows stay consistent across features.
Typed query-key factory:
import { createQueryKeys } from "tempest-react-sdk";
export const eventKeys = createQueryKeys("event", {
all: ["all"] as const,
list: (filters: { page: number; size: number }) => ["list", filters] as const,
byId: (id: string) => [id] as const,
});
// eventKeys.list({ page: 1, size: 20 }) === ["event", "list", { page: 1, size: 20 }]
// eventKeys.byId("42") === ["event", "42"]Form layout recipe
Form is a <form> wrapper with a built-in layout variant — pick stack for stacked fields (one per row), inline for a wrapping horizontal row, or grid for an N-column layout. Pair with FormSection (titled subgroup), FormRow (forces side-by-side inside a stacked form), and FormActions (footer button row).
Stacked (default) — one field per row:
import { Form, FormActions, Input, Button } from "tempest-react-sdk";
<Form layout="stack" gap={4} onSubmit={onSubmit}>
<Input label="Nome" {...form.register("name")} />
<Input label="Email" type="email" {...form.register("email")} />
<Input label="Senha" type="password" {...form.register("password")} />
<FormActions align="end">
<Button type="submit">Criar conta</Button>
</FormActions>
</Form>;Grid — side-by-side columns:
import { Form, FormActions, Input, Button } from "tempest-react-sdk";
<Form layout="grid" columns={2} gap={4} onSubmit={onSubmit}>
<Input label="Nome" {...register("name")} />
<Input label="Sobrenome" {...register("last_name")} />
<Input label="Email" type="email" {...register("email")} />
<Input label="Telefone" {...register("phone")} />
<FormActions align="end" style={{ gridColumn: "1 / -1" }}>
<Button type="submit">Salvar</Button>
</FormActions>
</Form>;columns accepts a number (repeat(N, minmax(0, 1fr))) or a raw grid-template-columns string (e.g. "2fr 1fr").
Inline — search-style filter row:
import { Form, Input, Select, Button } from "tempest-react-sdk";
<Form layout="inline" gap={2} onSubmit={onSubmit}>
<Input label="Buscar" placeholder="nome…" />
<Select label="Status" options={statusOptions} />
<Button type="submit">Filtrar</Button>
</Form>;inline aligns children at flex-end and wraps — perfect for filter bars or short login forms.
Sections + grouped rows:
FormSection lets you nest layouts (e.g. a stacked form with a 3-column "Address" group inside):
import { Form, FormSection, FormRow, FormActions, Input, Button } from "tempest-react-sdk";
<Form layout="stack" gap={5}>
<Input label="Email" {...register("email")} />
<FormSection title="Endereço" description="Usado para entrega" layout="grid" columns={3} gap={3}>
<Input label="CEP" {...register("cep")} />
<Input label="Cidade" {...register("city")} />
<Input label="UF" {...register("state")} />
<Input label="Rua" style={{ gridColumn: "1 / -1" }} {...register("street")} />
</FormSection>
<FormRow>
<Input label="Validade" placeholder="MM/AA" {...register("expiry")} />
<Input label="CVV" {...register("cvv")} />
</FormRow>
<FormActions align="between">
<Button variant="ghost" type="button" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit">Salvar</Button>
</FormActions>
</Form>;| Component | Default layout | What it does |
| ------------- | ------------------------- | --------------------------------------------------------------------------------------------- |
| Form | stack | <form> element + flex/grid container. onSubmit works as expected. |
| FormSection | stack body | Titled subgroup with its own independent layout / columns / gap. |
| FormRow | always horizontal | Forces a wrapping side-by-side row regardless of parent layout. Children share width equally. |
| FormActions | horizontal, align="end" | Footer button row. align accepts start / center / end / between. |
Both Form and FormSection accept gap (number → multiple of 4px, or any CSS length string) and columns (number → repeat(N, minmax(0, 1fr)), or any grid-template-columns string).
Forms (zod) recipe
Three levels of integration — pick the one that fits the form complexity.
1. Standalone validation — independent of any form library:
import { validateForm } from "tempest-react-sdk";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const result = validateForm(schema, formValues);
if (!result.success) {
setErrors(result.errors); // { email: "...", password: "..." }
return;
}
await login(result.data);2. react-hook-form resolver — drop-in replacement for @hookform/resolvers/zod:
import { useForm } from "react-hook-form";
import { zodResolver } from "tempest-react-sdk";
const form = useForm<LoginForm>({ resolver: zodResolver(loginSchema) });3. All-in-one hook — the schema infers the form type:
import { useZodForm } from "tempest-react-sdk";
function LoginForm() {
const form = useZodForm(loginSchema, {
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={form.handleSubmit((data) => login(data))}>
<Input {...form.register("email")} label="Email" />
<Input {...form.register("password")} type="password" label="Senha" />
<Button type="submit" loading={form.formState.isSubmitting}>
Entrar
</Button>
</form>
);
}react-hook-form is an optional peer dep — only install when you use zodResolver or useZodForm.
BR validators & masked inputs recipe
Algorithmic validators (full check-digit math, rejects all-same-digit edge cases) plus masked inputs that play nicely with react-hook-form:
import { validateCPF, validateCNPJ, formatCEP, formatCNPJ, unmask } from "tempest-react-sdk";
validateCPF("000.000.000-00"); // false (all-same)
validateCPF("12345678909"); // true
validateCNPJ("11.222.333/0001-81"); // true
formatCEP("01001000"); // "01001-000"
unmask("(11) 99876-5432"); // "11998765432"import { CPFInput, PhoneInput, CEPInput, CNPJInput, MoneyInput } from "tempest-react-sdk";
import { Controller, useForm } from "react-hook-form";
function CheckoutForm() {
const { control, register } = useForm<Checkout>();
return (
<>
<CPFInput {...register("cpf")} label="CPF" />
<PhoneInput {...register("phone")} label="Telefone" />
<CEPInput {...register("cep")} label="CEP" />
<Controller
name="total"
control={control}
render={({ field }) => <MoneyInput {...field} label="Total" currency="BRL" />}
/>
</>
);
}MoneyInput exposes a numeric value to your form state while rendering a formatted string in the input.
ViaCEP lookup recipe
import { useViaCEP } from "tempest-react-sdk";
function AddressFields({ form }: { form: UseFormReturn<Address> }) {
const cep = form.watch("cep");
const { result, loading, error } = useViaCEP(cep);
useEffect(() => {
if (result) {
form.setValue("street", result.logradouro);
form.setValue("neighborhood", result.bairro);
form.setValue("city", result.localidade);
form.setValue("state", result.uf);
}
}, [result]);
return <CEPInput {...form.register("cep")} loading={loading} error={error?.message} />;
}useViaCEP debounces requests, caches by CEP, and ignores partial input (length < 8).
WebSocket recipe
Wrapper around WebSocket with exponential reconnect (up to 10 attempts), optional ping heartbeat, JSON parsing, and send that no-ops while the socket isn't open.
import { useWebSocket } from "tempest-react-sdk";
type ChatEvent = { type: "message"; user: string; text: string };
function Chat({ apiUrl, enabled }: { apiUrl: string; enabled: boolean }) {
const ws = useWebSocket<ChatEvent>(`${apiUrl}/chat`, {
enabled,
pingInterval: 30_000,
onMessage: ({ data }) => console.log(data),
});
return (
<button disabled={ws.status !== "open"} onClick={() => ws.send(JSON.stringify({ text: "hi" }))}>
Enviar
</button>
);
}Imperative (outside React):
import { createWebSocket } from "tempest-react-sdk";
const socket = createWebSocket(`${apiUrl}/chat`, {
pingInterval: 30_000,
onMessage: ({ data }) => console.log(data),
});
socket.send("hello");
socket.close();Server-Sent Events (SSE) recipe
Stream with exponential reconnect (up to 10 attempts), ping heartbeat, JSON parsing by default. For cookie-auth endpoints, pass withCredentials: true.
import { useEventStream } from "tempest-react-sdk";
import { useNotificationsStore } from "@/store/notifications";
type StreamEvent =
| { type: "NOTIFY"; message: string }
| { type: "PAYMENT-SUCCESS"; order_id: string }
| { type: "PING" };
export function NotificationsListener({ apiUrl, enabled }: { apiUrl: string; enabled: boolean }) {
const add = useNotificationsStore((s) => s.add);
useEventStream<StreamEvent>(`${apiUrl}/notifications/stream`, {
enabled,
withCredentials: true,
namedEvents: ["notification", "payment"],
onMessage: ({ data }) => {
if (data.type === "PING") return;
add(data);
},
});
return null;
}Imperative form (e.g., outside React, in a worker, in tests):
import { createEventStream } from "tempest-react-sdk";
const stream = createEventStream(`${apiUrl}/notifications/stream`, {
withCredentials: true,
onMessage: ({ data }) => console.log(data),
onError: (event) => console.warn("SSE error", event),
});
stream.close();Web Push recipe
The SDK owns the browser-side flow: permission, pushManager.subscribe, reading the active subscription, and unsubscribing. The endpoint is your responsibility — you supply onSubscribe / onUnsubscribe callbacks that POST/DELETE to your API.
Pre-requisite: register the service worker before calling subscribe() (via vite-plugin-pwa, registerServiceWorker, or raw navigator.serviceWorker.register).
import { usePushSubscription, Button } from "tempest-react-sdk";
import { api } from "@/services/api";
export function PushToggle() {
const push = usePushSubscription({
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
onSubscribe: (subscription) => api.post("/webpush/subscribe", { body: subscription }),
onUnsubscribe: () => api.delete("/webpush/my"),
});
if (!push.supported) return <p>Push não suportado neste navegador.</p>;
return (
<Button
loading={push.loading}
variant={push.subscribed ? "danger" : "primary"}
onClick={() => (push.subscribed ? push.unsubscribe() : push.subscribe())}
>
{push.subscribed ? "Desinscrever notificações" : "Receber notificações"}
</Button>
);
}Imperative version:
import { WebPushClient } from "tempest-react-sdk";
const push = new WebPushClient({
vapidPublicKey: VAPID_PUBLIC_KEY,
onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
onUnsubscribe: () => api.delete("/webpush/my"),
});
await push.subscribe();
const active = await push.isSubscribed();
await push.unsubscribe();Errors thrown: WebPushUnsupportedError, WebPushPermissionDeniedError. Anything else bubbles up as the underlying browser exception.
Service Worker helpers recipe
Main thread — register the SW, react to updates, expose a "skip waiting" hook:
import { registerServiceWorker, skipWaiting } from "tempest-react-sdk";
registerServiceWorker({
url: "/sw.js",
onUpdate: (waiting) => {
if (confirm("Nova versão disponível. Recarregar?")) {
skipWaiting(waiting);
window.location.reload();
}
},
onError: (err) => console.error("SW falhou", err),
});Worker thread — inside sw.ts/sw.js, install handlers for push, notification click, and the skip-waiting message:
/// <reference lib="webworker" />
import {
installPushHandler,
installNotificationClickHandler,
installSkipWaitingListener,
} from "tempest-react-sdk";
installSkipWaitingListener();
installPushHandler({
defaultTitle: "Tempest",
defaultIcon: "/icons/Logo.png",
transform: (payload) => {
if (payload.tag === "silent-ping") return null; // drop silently
return payload;
},
});
installNotificationClickHandler({
focusOrOpenWindow: true,
fallbackUrl: "/",
});The SDK does not ship the worker file or the bundler config — pair it with vite-plugin-pwa (injectManifest) or a separately bundled worker.
Audio playback recipe
playAudio for one-shot sounds (notification chime, payment success), createAudioPlayer for isolated channels, useAudio for a per-component player.
import { playAudio } from "tempest-react-sdk";
await playAudio("/audio/plim.wav", { volume: 0.4 });import { useAudio } from "tempest-react-sdk";
function NotifyBell() {
const audio = useAudio();
return <button onClick={() => audio.play("/audio/bell.wav")}>Notify</button>;
}Browsers block autoplay until the user interacts with the page. playAudio resolves to null when blocked — the UI is expected to "unlock" on first click.
Offline storage (IndexedDB / Dexie) recipe
Owner-scoped store per domain. Persists SSE history, drafts, offline cache. dexie is an optional peer — npm i dexie only when you import this module.
import { createOfflineStore } from "tempest-react-sdk";
type Notification = {
message_id: string;
owner_id: string;
type: "NOTIFY" | "PAYMENT-SUCCESS";
message: string;
created_at: string;
read: boolean;
};
export const notificationsStore = createOfflineStore<Notification, string>({
databaseName: "TempestNotifications",
version: 1,
tableName: "notifications",
indexes: "&message_id, owner_id, read, created_at",
keyPath: "message_id",
ownerField: "owner_id",
});
await notificationsStore.put(
{
/* … */
} as Notification,
"u1",
);
const items = await notificationsStore.list("u1", {
orderBy: "created_at",
reverse: true,
limit: 50,
});
await notificationsStore.updateMany("u1", { read: true });
await notificationsStore.clear("u1");API: put / bulkPut / get / list / update / updateMany / delete / clear / count. raw (Dexie table) and db (Dexie instance) are exposed for advanced queries.
Error boundary recipe
Renders a fallback (static element or render-prop), auto-resets via resetKeys, forwards errors to onError for telemetry. useErrorHandler re-throws async errors inside the nearest boundary.
import { ErrorBoundary, ErrorState } from "tempest-react-sdk";
import { useLocation } from "react-router-dom";
export function AppShell({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<ErrorBoundary
resetKeys={[location.pathname]}
onError={(err, info) => reportToSentry(err, info)}
fallback={({ error, reset }) => <ErrorState description={error.message} onRetry={reset} />}
>
{children}
</ErrorBoundary>
);
}import { useErrorHandler } from "tempest-react-sdk";
function Streamer() {
const throwError = useErrorHandler();
useEffect(() => {
const stream = openSocket();
stream.onerror = (err) => throwError(err);
return () => stream.close();
}, [throwError]);
return null;
}Toast notifications recipe
import { ToastProvider, useToast, Button } from "tempest-react-sdk";
// Wrap app once (already in the Quickstart):
<ToastProvider placement="top-right" duration={4000}>
<App />
</ToastProvider>;
// Use anywhere:
function SaveButton() {
const toast = useToast();
return (
<Button
onClick={async () => {
try {
await save();
toast.success("Alterações salvas");
} catch (error) {
toast.error(String(error));
}
}}
>
Salvar
</Button>
);
}useToast() returns { show, success, info, warning, error, dismiss } — show takes the full ToastOptions.
Modal & ConfirmDialog recipe
import { useState } from "react";
import { Modal, ConfirmDialog, Button } from "tempest-react-sdk";
function DeleteUser({ user }: { user: User }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="danger" onClick={() => setOpen(true)}>
Excluir
</Button>
<ConfirmDialog
open={open}
title="Excluir usuário"
description={`Esta ação é permanente. Excluir ${user.name}?`}
confirmLabel="Sim, excluir"
cancelLabel="Cancelar"
tone="danger"
onConfirm={async () => {
await deleteUser(user.id);
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
</>
);
}Modal is the lower-level primitive (focus trap + scroll lock + ESC to close + backdrop click). ConfirmDialog is the opinionated yes/no wrapper.
Tables & pagination recipe
import { Table, Pagination, usePagination } from "tempest-react-sdk";
const columns = [
{ key: "id", label: "ID", align: "right" as const },
{ key: "name", label: "Nome" },
{ key: "email", label: "Email" },
{
key: "actions",
label: "",
render: (row) => (
<Button size="sm" onClick={() => edit(row.id)}>
Editar
</Button>
),
},
];
function UsersList() {
const { page, size, setPage, setSize } = usePagination({ initialSize: 20 });
const { data } = useQuery({
queryKey: userKeys.list({ page, size }),
queryFn: () => api.get<Paginated<User>>(`/users?page=${page}&size=${size}`),
});
return (
<>
<Table columns={columns} rows={data?.items ?? []} loading={!data} />
<Pagination
page={page}
pageSize={size}
total={data?.total ?? 0}
onPageChange={setPage}
onPageSizeChange={setSize}
/>
</>
);
}Layout primitives recipe
Stack, Grid, and Container are zero-dependency layout helpers (flex, grid, max-width).
import { Container, Stack, Grid, Card } from "tempest-react-sdk";
<Container size="lg">
<Stack gap="md" direction="column">
<h1>Dashboard</h1>
<Grid columns={3} gap="md">
<Card>Visitas</Card>
<Card>Pedidos</Card>
<Card>Receita</Card>
</Grid>
</Stack>
</Container>;Virtual list recipe
Render thousands of rows without blowing the DOM. Built on top of useResizeObserver — supports dynamic row heights.
import { VirtualList } from "tempest-react-sdk";
<VirtualList
items={messages}
estimatedItemHeight={64}
overscan={5}
renderItem={(message) => <MessageRow key={message.id} message={message} />}
/>;Theming (light / dark) recipe
import { ThemeProvider, useTheme } from "tempest-react-sdk";
<ThemeProvider defaultMode="system" persistKey="tempest-theme">
<App />
</ThemeProvider>;
function ThemeToggle() {
const { mode, setMode, resolved } = useTheme();
return (
<select value={mode} onChange={(e) => setMode(e.target.value as ThemeMode)}>
<option value="light">Claro</option>
<option value="dark">Escuro</option>
<option value="system">Sistema ({resolved})</option>
</select>
);
}ThemeProvider writes data-tempest-theme="dark" (or removes it) on the root element. To avoid the white flash on initial paint, inline themeInitScript() in <head> before the React bundle:
<script>
__INIT_THEME__;
</script>// build.ts
import { themeInitScript } from "tempest-react-sdk";
const html = template.replace("__INIT_THEME__", themeInitScript());i18n recipe
Minimal in-house i18n (~1.5 KB gzip). Use this when you need light interpolation + a couple of locales; reach for i18next when you need plural rules, namespaces, or async loaders.
import { createI18n } from "tempest-react-sdk";
const i18n = createI18n({
locale: "pt-BR",
fallback: "en",
messages: {
"pt-BR": {
greeting: "Olá, {name}",
inbox: { empty: "Caixa vazia" },
},
en: {
greeting: "Hello, {name}",
inbox: { empty: "Empty inbox" },
},
},
persistKey: "tempest-locale",
});import { I18nProvider, useTranslate, useI18n } from "tempest-react-sdk";
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>;
function Header() {
const t = useTranslate();
const { locale, setLocale } = useI18n();
return (
<header>
<span>{t("greeting", { name: "Mauricio" })}</span>
<button onClick={() => setLocale(locale === "pt-BR" ? "en" : "pt-BR")}>
{locale === "pt-BR" ? "EN" : "PT"}
</button>
</header>
);
}Feature flags recipe
FeatureFlagsProvider takes an adapter matching the FeatureFlagsAdapter interface (isEnabled, get, onChange?). Ship the InMemory adapter while you build, swap for GrowthBook / LaunchDarkly when you're ready.
import {
FeatureFlagsProvider,
useFeatureFlag,
useFlagValue,
createInMemoryFlags,
} from "tempest-react-sdk";
const flags = createInMemoryFlags({
initial: { "new-checkout": true, "max-items": 10 },
});
<FeatureFlagsProvider adapter={flags}>
<App />
</FeatureFlagsProvider>;
function CheckoutButton() {
const isNew = useFeatureFlag("new-checkout");
const maxItems = useFlagValue<number>("max-items", 5);
return isNew ? <NewCheckout maxItems={maxItems} /> : <LegacyCheckout />;
}GrowthBook adapter — wraps a GrowthBook instance. The app initialises GrowthBook (so it controls apiHost, clientKey, attributes, loadFeatures()), the adapter only routes lookups.
import { GrowthBook } from "@growthbook/growthbook";
import { FeatureFlagsProvider, createGrowthBookFeatureFlagsAdapter } from "tempest-react-sdk";
const gb = new GrowthBook({
apiHost: import.meta.env.VITE_GROWTHBOOK_API_HOST,
clientKey: import.meta.env.VITE_GROWTHBOOK_KEY,
attributes: { id: userId },
});
await gb.loadFeatures();
const adapter = createGrowthBookFeatureFlagsAdapter({ growthbook: gb });
<FeatureFlagsProvider adapter={adapter}>
<App />
</FeatureFlagsProvider>;Mapping: isEnabled(key) → growthbook.isOn(key); get(key, default) → growthbook.getFeatureValue(key, default); onChange(listener) → growthbook.setRenderer(...) (installed lazily on first subscription, multiplexes to all listeners).
LaunchDarkly adapter — wraps launchdarkly-js-client-sdk.
import * as LDClient from "launchdarkly-js-client-sdk";
import { FeatureFlagsProvider, createLaunchDarklyFeatureFlagsAdapter } from "tempest-react-sdk";
const client = LDClient.initialize(import.meta.env.VITE_LD_CLIENT_ID, {
kind: "user",
key: userId,
});
await client.waitUntilReady();
const adapter = createLaunchDarklyFeatureFlagsAdapter({ client });
<FeatureFlagsProvider adapter={adapter}>
<App />
</FeatureFlagsProvider>;Mapping: isEnabled(key) → client.variation(key, default) === true; get(key, default) → client.variation(key, default); onChange(listener) → client.on("change", listener) + client.off on unsubscribe.
Neither @growthbook/growthbook nor launchdarkly-js-client-sdk is declared as a peer dep — the adapter only touches the instance you hand it. Install whichever you opt into: npm install @growthbook/growthbook or npm install launchdarkly-js-client-sdk.
The FeatureFlagsAdapter interface is intentionally tiny — any third-party SDK can be wrapped into an adapter in ~20 lines.
Telemetry recipe
TelemetryProvider accepts an adapter matching TelemetryAdapter (init?, identify, track, captureException, flush?). The bundled consoleTelemetryAdapter logs every event — useful for dev and tests.
import { TelemetryProvider, useTelemetry, consoleTelemetryAdapter } from "tempest-react-sdk";
<TelemetryProvider adapter={consoleTelemetryAdapter}>
<App />
</TelemetryProvider>;
function CheckoutForm() {
const telemetry = useTelemetry();
return (
<Button
onClick={() => {
telemetry?.track({ name: "checkout.completed", properties: { total: 100 } });
}}
>
Pagar
</Button>
);
}useTelemetry() returns null when no provider is mounted — call sites should optional-chain (telemetry?.track(...)).
Sentry adapter — wraps @sentry/browser so the SDK never depends on Sentry directly. The Sentry namespace is supplied by the caller; if the app already initialises Sentry at startup, just pass that instance.
import * as Sentry from "@sentry/browser";
import { createSentryTelemetryAdapter, TelemetryProvider } from "tempest-react-sdk";
const adapter = createSentryTelemetryAdapter({
sentry: Sentry,
initOptions: {
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
tracesSampleRate: 0.1,
},
flushTimeout: 2000,
breadcrumbCategory: "app",
});
<TelemetryProvider adapter={adapter}>
<App />
</TelemetryProvider>;Mapping:
| TelemetryAdapter call | @sentry/browser API |
| -------------------------------- | ------------------------------------------------------------------ |
| init() | Sentry.init(initOptions) (only when initOptions is provided) |
| identify(user) | Sentry.setUser({ id, email, username, ...traits }) |
| track({ name, properties }) | Sentry.addBreadcrumb({ category, message, level: "info", data }) |
| captureException(err, context) | Sentry.captureException(err, { extra: context }) |
| flush() | Sentry.flush(flushTimeout) |
@sentry/browser is not declared as a peer dep — the adapter only ever touches the namespace you hand it, so apps that don't use Sentry never pay for it. Install Sentry yourself when you opt in: npm install @sentry/browser.
PostHog adapter — wraps posthog-js.
import posthog from "posthog-js";
import { createPostHogTelemetryAdapter, TelemetryProvider } from "tempest-react-sdk";
const adapter = createPostHogTelemetryAdapter({
posthog,
init: {
apiKey: import.meta.env.VITE_POSTHOG_KEY,
options: { api_host: "https://us.i.posthog.com" },
},
});
<TelemetryProvider adapter={adapter}>
<App />
</TelemetryProvider>;Mapping:
| TelemetryAdapter call | posthog-js API |
| ----------------------------- | ------------------------------------------------------------------------------------------------ |
| init() | posthog.init(apiKey, options) (only when init is provided) |
| identify({id, ...}) | posthog.identify(id, { email, name, ...traits }) |
| identify(null) | posthog.reset() |
| track({ name, properties }) | posthog.capture(name, properties) |
| captureException(err, ctx) | posthog.captureException(err, ctx) when available, else posthog.capture("$exception", { … }) |
posthog-js is not declared as a peer dep — install only when you opt in: npm install posthog-js.
Concrete adapters for Datadog / Amplitude / others are part of the v0.2 roadmap — for now you can write one in ~20 lines following the Sentry / PostHog adapters as templates.
Logger recipe
Leveled logger with pluggable sinks:
import { createLogger, consoleSink } from "tempest-react-sdk";
export const log = createLogger({
level: "info",
sinks: [consoleSink({ pretty: true })],
context: { app: "alofans", version: __APP_VERSION__ },
});
log.info("user.signed-in", { user_id: user.id });
log.error("payment.failed", { reason }, error);A sink is any function (entry: LogEntry) => void — wire a sink that POSTs to your log ingestion endpoint, batches every 5 seconds, etc.
Web Share API recipe
Share via the OS share sheet on mobile and supported desktop browsers. Falls back gracefully when navigator.share is missing.
import { share, isShareSupported } from "tempest-react-sdk";
if (!isShareSupported()) {
copyToClipboard(url);
return;
}
const result = await share({ title: "Tempest", text: "Check this out", url });
if (result.shared) toast.success("Compartilhado");
if (result.cancelled) {
/* user dismissed */
}
if (result.unsupported) {
/* defensive — should not happen after isShareSupported */
}Hooks catalogue recipe
| Hook | Purpose |
| --------------------------------------------- | -------------------------------------------------------------------------------- |
| useDebounce(value, delay) | Debounce a value (search bars, autosave). |
| usePagination({ initialPage, initialSize }) | page/size/setPage/setSize triplet with bounds. |
| useClientFilter(items, predicate) | Memoised in-memory filter. |
| useMediaQuery(query) | Subscribe to a matchMedia query ((min-width: 1024px)). |
| useOnline() | Returns true/false from navigator.onLine + online/offline events. |
| useDocumentVisibility() | "visible" / "hidden", subscribing to visibilitychange. |
| useIntersectionObserver(ref, opts) | Returns the latest IntersectionObserverEntry. |
| useResizeObserver(ref) | Returns the latest width/height. |
| useClipboard() | { copy(text), copied, error } with execCommand fallback. |
| useKeyboardShortcut(shortcut, handler) | "Meta+K" / "Ctrl+Shift+P" patterns. |
| useBeforeInstallPrompt() | Capture the PWA install prompt and trigger it later. |
| useIdle(timeout) | true after timeout ms of no input. |
| useGeolocation() | One-shot or watch position with permission state. |
| useScrollLock(active) | Lock body scroll while a modal is open. |
| useFocusTrap(ref, active) | Trap focus inside a container. |
| useStableCallback(fn) | A useCallback that always points at the latest fn without churning identity. |
| useDeepMemo(value) | useMemo with deep equality. |
Each hook is independently importable and tested — see docs/hooks.md for full signatures and edge cases.
Utility helpers recipe
import {
cn,
formatCurrency,
formatDate,
formatDateTime,
formatPhone,
formatCPF,
formatPercent,
storage,
} from "tempest-react-sdk";
cn("btn", isPrimary && "btn-primary", { "btn-disabled": disabled });
formatCurrency(1234.5, "BRL"); // "R$ 1.234,50"
formatDate("2026-05-17"); // "17/05/2026"
formatDateTime("2026-05-17T14:30Z"); // "17/05/2026 11:30"
formatPhone("11998765432"); // "(11) 99876-5432"
formatCPF("12345678909"); // "123.456.789-09"
formatPercent(0.1234, 1); // "12,3%"
storage.set("draft", { title: "..." });
const draft = storage.get<Draft>("draft");
storage.remove("draft");storage is an SSR-safe wrapper over localStorage — every call is try/catch-protected and returns null when window is unavailable or quota is exceeded.
Theming reference
All tokens live on :root (light) and [data-tempest-theme="dark"] (dark). Override globally or in a subtree:
:root {
--tempest-primary: #ff3366;
--tempest-radius-md: 6px;
--tempest-font-family: "Inter", system-ui, sans-serif;
}
[data-tempest-theme="dark"] {
--tempest-bg-default: #0b0e14;
--tempest-text-primary: #e6e6e6;
}
[data-tempest-theme="dark"] .my-card {
/* per-subtree dark override */
}Categories of tokens:
- Color:
--tempest-primary,--tempest-success,--tempest-warning,--tempest-danger,--tempest-info,--tempest-bg-*,--tempest-text-*,--tempest-border-*. - Radius:
--tempest-radius-sm/-md/-lg/-full. - Shadow:
--tempest-shadow-sm/-md/-lg. - Spacing:
--tempest-space-1…--tempest-space-8. - Typography:
--tempest-font-family,--tempest-font-size-sm/-md/-lg,--tempest-line-height-base.
Tokens are stable public API — breaking changes always bump the SDK minor (or major, on rename).
Conventions
These conventions are enforced across the SDK source and are the same patterns the SDK encourages in consumer apps.
- TypeScript strict — no
anyimplicit,verbatimModuleSyntax, every export typed. - Double quotes everywhere —
"foo"never'foo'. - JSDoc in English on every public export (description +
@example). - CSS Modules with the
tempest_prefix — no
