tempest-react-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1375 @@
1
+ # tempest-react-sdk
2
+
3
+ [![npm version](https://img.shields.io/npm/v/tempest-react-sdk.svg)](https://www.npmjs.com/package/tempest-react-sdk)
4
+ [![CI](https://github.com/mauriciobenjamin700/tempest-react-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/mauriciobenjamin700/tempest-react-sdk/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+ [![React 18 / 19](https://img.shields.io/badge/react-18%20%7C%2019-61dafb.svg?logo=react)](https://react.dev)
7
+ [![TypeScript](https://img.shields.io/badge/types-TypeScript-3178c6.svg?logo=typescript)](https://www.typescriptlang.org/)
8
+ [![Bundle size](https://img.shields.io/bundlephobia/minzip/tempest-react-sdk?label=gzip)](https://bundlephobia.com/package/tempest-react-sdk)
9
+
10
+ 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.).
11
+
12
+ 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.
13
+
14
+ ---
15
+
16
+ ## Table of contents
17
+
18
+ - [Install](#install)
19
+ - [Peer dependencies](#peer-dependencies)
20
+ - [CSS import](#css-import)
21
+ - [What's inside](#whats-inside)
22
+ - [Architecture overview](#architecture-overview)
23
+ - [Quickstart — wiring the app providers](#quickstart--wiring-the-app-providers)
24
+ - [Recipes](#recipes)
25
+ - [HTTP client](#http-client-recipe)
26
+ - [Response parsing with zod](#response-parsing-with-zod-recipe)
27
+ - [Upload with progress](#upload-with-progress-recipe)
28
+ - [Polling](#polling-recipe)
29
+ - [Retry & idempotency](#retry--idempotency-recipe)
30
+ - [Auth store (Zustand)](#auth-store-recipe)
31
+ - [Route guard](#route-guard-recipe)
32
+ - [JWT helpers & refresh queue](#jwt-helpers--refresh-queue-recipe)
33
+ - [Code-splitting with retry](#code-splitting-with-retry-recipe)
34
+ - [React Query](#react-query-recipe)
35
+ - [Forms (zod)](#forms-zod-recipe)
36
+ - [BR validators & masked inputs](#br-validators--masked-inputs-recipe)
37
+ - [ViaCEP lookup](#viacep-lookup-recipe)
38
+ - [WebSocket](#websocket-recipe)
39
+ - [Server-Sent Events (SSE)](#server-sent-events-sse-recipe)
40
+ - [Web Push](#web-push-recipe)
41
+ - [Service Worker helpers](#service-worker-helpers-recipe)
42
+ - [Audio playback](#audio-playback-recipe)
43
+ - [Offline storage (IndexedDB / Dexie)](#offline-storage-indexeddb--dexie-recipe)
44
+ - [Error boundary](#error-boundary-recipe)
45
+ - [Toast notifications](#toast-notifications-recipe)
46
+ - [Modal & ConfirmDialog](#modal--confirmdialog-recipe)
47
+ - [Tables & pagination](#tables--pagination-recipe)
48
+ - [Layout primitives](#layout-primitives-recipe)
49
+ - [Virtual list](#virtual-list-recipe)
50
+ - [Theming (light / dark)](#theming-light--dark-recipe)
51
+ - [i18n](#i18n-recipe)
52
+ - [Feature flags](#feature-flags-recipe)
53
+ - [Telemetry](#telemetry-recipe)
54
+ - [Logger](#logger-recipe)
55
+ - [Web Share API](#web-share-api-recipe)
56
+ - [Hooks catalogue](#hooks-catalogue-recipe)
57
+ - [Utility helpers (`cn`, `format*`, `storage`)](#utility-helpers-recipe)
58
+ - [Theming reference](#theming-reference)
59
+ - [Conventions](#conventions)
60
+ - [Development](#development)
61
+ - [Release](#release)
62
+ - [License](#license)
63
+
64
+ ---
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ npm install tempest-react-sdk
70
+ ```
71
+
72
+ Via `package.json`:
73
+
74
+ ```json
75
+ {
76
+ "dependencies": {
77
+ "tempest-react-sdk": "^0.1.0"
78
+ }
79
+ }
80
+ ```
81
+
82
+ Requires React `>=18` and Node `>=20.19` to build.
83
+
84
+ ### Peer dependencies
85
+
86
+ `react` and `react-dom` are **required** peer dependencies. Everything else is **optional** — install only the packages the modules you import actually need. Importing `tempest-react-sdk` without an optional peer never throws; the failure surfaces only when you instantiate the helper that uses it.
87
+
88
+ | Peer | Required by | Status |
89
+ | --------------------------------------------- | ------------------------------------------------------------------- | ------------ |
90
+ | `react`, `react-dom` (`^18.0.0 \|\| ^19.0.0`) | Everything | **Required** |
91
+ | `@tanstack/react-query` (`^5`) | `QueryProvider`, `createQueryKeys` | Optional |
92
+ | `zod` (`^3.23 \|\| ^4`) | `parseResponse`, `validateForm`, `zodResolver`, `useZodForm` | Optional |
93
+ | `zustand` (`^4 \|\| ^5`) | `createAuthStore` | Optional |
94
+ | `dexie` (`^4.4`) | `createOfflineStore` | Optional |
95
+ | `react-hook-form` (`^7.76`) | `zodResolver`, `useZodForm`, masked inputs | Optional |
96
+ | `lucide-react` (`>=0.400`) | Component icons (`leftIcon`/`rightIcon` on `Input`, `Button`, etc.) | Optional |
97
+
98
+ Quick recipe for a "typical" app that uses HTTP + Query + Auth + Forms:
99
+
100
+ ```bash
101
+ npm install tempest-react-sdk react react-dom \
102
+ @tanstack/react-query zod zustand react-hook-form lucide-react
103
+ ```
104
+
105
+ If a module is missing its peer dep at runtime, the bundler will flag the missing import at build time — there is no "silent fallback" behaviour. Add only what you actually consume.
106
+
107
+ ### CSS import
108
+
109
+ Import the base stylesheet **once** at the entry of your app (e.g. `main.tsx` / `src/index.tsx`):
110
+
111
+ ```ts
112
+ import "tempest-react-sdk/styles.css";
113
+ ```
114
+
115
+ 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](#theming-reference)).
116
+
117
+ The styles ship hashed under the `tempest_` namespace — they do **not** collide with Tailwind, Stitches, Linaria, or app-level CSS Modules.
118
+
119
+ ---
120
+
121
+ ## What's inside
122
+
123
+ Every module is re-exported from the package root — `import { Button, useDebounce, createApiClient } from "tempest-react-sdk"` always works.
124
+
125
+ | Module | Exports |
126
+ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `components` | `Avatar`, `Badge`, `Breadcrumbs`, `Button`, `Card`, `Checkbox`, `ChipInput`, `ConfirmDialog`, `Container`, `DatePicker`, `Drawer`, `EmptyState`, `ErrorState`, `FileUpload`, `Grid`, `Input`, `Modal`, `Pagination`, `Progress`, `Radio`, `RadioGroup`, `SearchBar`, `Select`, `Skeleton`, `Spinner`, `Stack`, `Stepper`, `Switch`, `Table`, `Tabs`, `Textarea`, `Toast` (`ToastProvider`, `useToast`), `Tooltip`, `VirtualList` |
128
+ | `hooks` | `useDebounce`, `usePagination`, `useClientFilter`, `useMediaQuery`, `useOnline`, `useDocumentVisibility`, `useIntersectionObserver`, `useResizeObserver`, `useClipboard`, `useKeyboardShortcut`, `useBeforeInstallPrompt`, `useIdle`, `useGeolocation`, `useScrollLock`, `useFocusTrap`, `useStableCallback`, `useDeepMemo` |
129
+ | `http` | `createApiClient`, `parseResponse`, `uploadWithProgress`, `retry`, `generateIdempotencyKey`, `usePoll`, types: `ApiClient`, `ApiClientConfig`, `ApiError`, `RequestOptions`, `RetryOptions`, `UploadProgressEvent`, `UploadWithProgressOptions`, `UsePollOptions`, `UsePollResult` |
130
+ | `auth` _(peer: `zustand`)_ | `createAuthStore`, `AuthGuard`, `decodeJWT`, `isJWTExpired`, `lazyWithRetry`, `createRefreshQueue`, types: `AuthState`, `CreateAuthStoreOptions`, `AuthGuardProps`, `DecodedJWT`, `LazyWithRetryOptions` |
131
+ | `query` _(peer: `@tanstack/react-query`)_ | `QueryProvider`, `createQueryKeys`, `STALE_TIME`, `CACHE_TIME`, `REFETCH_TIME` |
132
+ | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
133
+ | `sse` | `createEventStream`, `useEventStream` |
134
+ | `ws` | `createWebSocket`, `useWebSocket` |
135
+ | `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
136
+ | `sw` | `registerServiceWorker`, `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener` |
137
+ | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
138
+ | `offline` _(peer: `dexie`)_ | `createOfflineStore`, types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions` |
139
+ | `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
140
+ | `theme` | `ThemeProvider`, `useTheme`, `getInitialTheme`, `themeInitScript`, types: `ThemeMode`, `ResolvedTheme` |
141
+ | `i18n` | `createI18n`, `I18nProvider`, `useI18n`, `useTranslate`, types: `Catalog`, `Messages`, `I18n`, `InterpolationValues` |
142
+ | `logger` | `createLogger`, `consoleSink`, types: `Logger`, `LogEntry`, `LogLevel`, `LoggerSink` |
143
+ | `telemetry` | `TelemetryProvider`, `useTelemetry`, `consoleTelemetryAdapter`, types: `TelemetryAdapter`, `TelemetryEvent`, `TelemetryUser` |
144
+ | `feature-flags` | `FeatureFlagsProvider`, `useFeatureFlag`, `useFlagValue`, `createInMemoryFlags`, types: `FeatureFlagsAdapter`, `FlagValue` |
145
+ | `share` | `share`, `isShareSupported`, types: `SharePayload`, `ShareResult` |
146
+ | `utils` | `cn`, `formatCurrency`, `formatDate`, `formatDateTime`, `formatPhone`, `formatCPF`, `formatPercent`, `storage` |
147
+
148
+ Full per-module docs in [`docs/`](./docs) (one markdown per module + draw.io diagrams in [`docs/diagrams/`](./docs/diagrams)).
149
+
150
+ A demo app exercising every module lives in [`examples/gallery`](./examples/gallery) — `cd examples/gallery && npm install && npm run dev`.
151
+
152
+ ---
153
+
154
+ ## Architecture overview
155
+
156
+ The SDK is a layered set of building blocks. Apps wire the layers together; the SDK never owns the app shell.
157
+
158
+ ```text
159
+ ┌──────────────────────────────────────────────────────────────┐
160
+ │ App entry (main.tsx) │
161
+ │ ├── import "tempest-react-sdk/styles.css" │
162
+ │ └── <ThemeProvider> │
163
+ │ <I18nProvider> │
164
+ │ <FeatureFlagsProvider> │
165
+ │ <TelemetryProvider> │
166
+ │ <QueryProvider> │
167
+ │ <ToastProvider> │
168
+ │ <ErrorBoundary> │
169
+ │ <RouterProvider … /> │
170
+ └─────────────────────────────┬────────────────────────────────┘
171
+
172
+ ┌───────────────┼───────────────┐
173
+ ▼ ▼ ▼
174
+ ┌────────────┐ ┌────────────┐ ┌────────────┐
175
+ │ Pages │ │ Features │ │ Layouts │
176
+ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
177
+ │ │ │
178
+ └───────┬───────┴───────┬───────┘
179
+ ▼ ▼
180
+ ┌────────────────┐ ┌────────────────┐
181
+ │ Components + │ │ Hooks + │
182
+ │ Forms │ │ Stores │
183
+ └───────┬────────┘ └───────┬────────┘
184
+ │ │
185
+ └────────┬─────────┘
186
+
187
+ ┌────────────────────────┐
188
+ │ HTTP client + zod + │
189
+ │ SSE / WS / WebPush / │
190
+ │ Offline / Audio │
191
+ └────────────────────────┘
192
+ ```
193
+
194
+ - **Presentation layer** (`components`): purely visual; uncontrolled-or-controlled, accessible by default, themable via CSS tokens.
195
+ - **Behaviour layer** (`hooks`, `forms`, `auth`, `error-boundary`): React-aware helpers that orchestrate state.
196
+ - **Transport layer** (`http`, `sse`, `ws`, `push`, `sw`): everything that touches the network or the service worker.
197
+ - **Persistence layer** (`offline`, `auth` persist, `utils/storage`, `i18n` persist): client-side storage abstractions.
198
+ - **Observability layer** (`telemetry`, `logger`, `error-boundary` `onError`): everywhere the app reports on itself.
199
+
200
+ The SDK ships **no `App.tsx`, no router, no global store**. Each layer is opt-in.
201
+
202
+ ---
203
+
204
+ ## Quickstart — wiring the app providers
205
+
206
+ A minimal `main.tsx` for an app using HTTP + Query + Auth + Toast + Theme + i18n:
207
+
208
+ ```tsx
209
+ import { StrictMode } from "react";
210
+ import { createRoot } from "react-dom/client";
211
+ import { BrowserRouter } from "react-router-dom";
212
+ import {
213
+ ThemeProvider,
214
+ I18nProvider,
215
+ QueryProvider,
216
+ ToastProvider,
217
+ ErrorBoundary,
218
+ ErrorState,
219
+ createI18n,
220
+ themeInitScript,
221
+ } from "tempest-react-sdk";
222
+
223
+ import "tempest-react-sdk/styles.css";
224
+ import { App } from "./App";
225
+
226
+ const i18n = createI18n({
227
+ locale: "pt-BR",
228
+ fallback: "en",
229
+ messages: {
230
+ "pt-BR": { hello: "Olá, {name}" },
231
+ en: { hello: "Hello, {name}" },
232
+ },
233
+ });
234
+
235
+ // No-flash dark mode script. Inject in <head> via index.html, OR call here:
236
+ document.head.insertAdjacentHTML("afterbegin", `<script>${themeInitScript()}</script>`);
237
+
238
+ createRoot(document.getElementById("root")!).render(
239
+ <StrictMode>
240
+ <ThemeProvider>
241
+ <I18nProvider i18n={i18n}>
242
+ <QueryProvider>
243
+ <ToastProvider>
244
+ <ErrorBoundary
245
+ fallback={({ error, reset }) => (
246
+ <ErrorState description={error.message} onRetry={reset} />
247
+ )}
248
+ >
249
+ <BrowserRouter>
250
+ <App />
251
+ </BrowserRouter>
252
+ </ErrorBoundary>
253
+ </ToastProvider>
254
+ </QueryProvider>
255
+ </I18nProvider>
256
+ </ThemeProvider>
257
+ </StrictMode>,
258
+ );
259
+ ```
260
+
261
+ You only wrap with what you use — every provider is independent. The example above is the maximal case.
262
+
263
+ ---
264
+
265
+ ## Recipes
266
+
267
+ Each recipe is self-contained. Pick the ones you need.
268
+
269
+ ### HTTP client recipe
270
+
271
+ `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.
272
+
273
+ ```ts
274
+ import { createApiClient } from "tempest-react-sdk";
275
+ import { useAuthStore } from "@/store/auth";
276
+
277
+ export const api = createApiClient({
278
+ baseURL: import.meta.env.VITE_API_URL,
279
+ getToken: () => useAuthStore.getState().token,
280
+ onUnauthorized: () => useAuthStore.getState().logout(),
281
+ withCredentials: true,
282
+ defaultHeaders: { "X-App-Version": __APP_VERSION__ },
283
+ });
284
+
285
+ const user = await api.get<UserResponse>("/users/me");
286
+ await api.post("/orders", { body: { total: 100, items: [...] } });
287
+ ```
288
+
289
+ 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.
290
+
291
+ ### Response parsing with zod recipe
292
+
293
+ 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.
294
+
295
+ ```ts
296
+ import { createApiClient, parseResponse } from "tempest-react-sdk";
297
+ import { z } from "zod";
298
+
299
+ const userSchema = z.object({
300
+ id: z.string(),
301
+ name: z.string(),
302
+ email: z.string().email(),
303
+ });
304
+
305
+ export async function getUser(id: string) {
306
+ const raw = await api.get<unknown>(`/users/${id}`);
307
+ return parseResponse(userSchema, raw, `GET /users/${id}`);
308
+ }
309
+ ```
310
+
311
+ 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.
312
+
313
+ ### Upload with progress recipe
314
+
315
+ `fetch` cannot report upload progress in browsers. `uploadWithProgress` falls back to `XMLHttpRequest` internally while keeping the same `ApiError` contract:
316
+
317
+ ```ts
318
+ import { uploadWithProgress } from "tempest-react-sdk";
319
+
320
+ const formData = new FormData();
321
+ formData.append("file", file);
322
+ formData.append("alo_id", aloId);
323
+
324
+ const controller = new AbortController();
325
+
326
+ await uploadWithProgress<{ url: string }>({
327
+ url: `${API}/uploads`,
328
+ method: "POST",
329
+ body: formData,
330
+ withCredentials: true,
331
+ getToken: () => useAuthStore.getState().token,
332
+ signal: controller.signal,
333
+ onProgress: ({ fraction, loaded, total }) => {
334
+ if (fraction !== null) setProgress(Math.round(fraction * 100));
335
+ },
336
+ });
337
+
338
+ // Cancel:
339
+ controller.abort();
340
+ ```
341
+
342
+ `fraction` is `null` when `total` is unknown (chunked / unsized uploads).
343
+
344
+ ### Polling recipe
345
+
346
+ `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.
347
+
348
+ ```tsx
349
+ import { usePoll } from "tempest-react-sdk";
350
+
351
+ function ServerStatus() {
352
+ const poll = usePoll({
353
+ interval: 5_000,
354
+ callback: async () => {
355
+ const data = await api.get<Status>("/status");
356
+ setStatus(data);
357
+ },
358
+ immediate: true,
359
+ pauseWhenHidden: true,
360
+ });
361
+
362
+ return (
363
+ <Button onClick={poll.running ? poll.stop : poll.start}>
364
+ {poll.running ? "Pause" : "Resume"}
365
+ </Button>
366
+ );
367
+ }
368
+ ```
369
+
370
+ ### Retry & idempotency recipe
371
+
372
+ ```ts
373
+ import { retry, generateIdempotencyKey } from "tempest-react-sdk";
374
+
375
+ const idempotencyKey = generateIdempotencyKey();
376
+
377
+ const result = await retry(
378
+ () =>
379
+ api.post("/payments", {
380
+ body: payload,
381
+ headers: { "Idempotency-Key": idempotencyKey },
382
+ }),
383
+ {
384
+ retries: 3,
385
+ baseDelay: 400,
386
+ shouldRetry: (error) => error instanceof Error && /status (5\d\d|429)/.test(error.message),
387
+ },
388
+ );
389
+ ```
390
+
391
+ `generateIdempotencyKey` returns a v4 UUID using `crypto.randomUUID()` when available, with a `Math.random` fallback for older runtimes.
392
+
393
+ ### Auth store recipe
394
+
395
+ `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.
396
+
397
+ ```ts
398
+ import { createAuthStore } from "tempest-react-sdk";
399
+
400
+ type SessionUser = { id: string; name: string; is_admin: boolean };
401
+
402
+ export const useAuthStore = createAuthStore<SessionUser>({
403
+ name: "tempest-app-auth",
404
+ storage: "local",
405
+ });
406
+
407
+ // Anywhere:
408
+ useAuthStore.getState().setSession({ user, token });
409
+ const isAuthed = useAuthStore((s) => s.isAuthenticated);
410
+ useAuthStore.getState().logout();
411
+ ```
412
+
413
+ The store exposes `user`, `token`, `isAuthenticated`, `setSession`, `setUser`, `setToken`, and `logout`. `isAuthenticated` is derived from `token` and rehydrates correctly after page reload.
414
+
415
+ ### Route guard recipe
416
+
417
+ ```tsx
418
+ import { Navigate, Outlet } from "react-router-dom";
419
+ import { AuthGuard } from "tempest-react-sdk";
420
+ import { useAuthStore } from "@/store/auth";
421
+
422
+ export function ProtectedLayout() {
423
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
424
+ return (
425
+ <AuthGuard isAuthenticated={isAuthenticated} fallback={<Navigate to="/login" replace />}>
426
+ <Outlet />
427
+ </AuthGuard>
428
+ );
429
+ }
430
+ ```
431
+
432
+ `AuthGuard` is a pure render gate — no router coupling. Use any redirect mechanism (`react-router`, `next/navigation`, `wouter`, ...).
433
+
434
+ ### JWT helpers & refresh queue recipe
435
+
436
+ ```ts
437
+ import { decodeJWT, isJWTExpired, createRefreshQueue } from "tempest-react-sdk";
438
+
439
+ const decoded = decodeJWT<{ sub: string; exp: number; role: string }>(token);
440
+ const expired = isJWTExpired(token, 30); // 30-second skew
441
+
442
+ // Coalesce concurrent refreshes so only one network call runs at a time:
443
+ const refresh = createRefreshQueue(async () => {
444
+ const newToken = await api.post<{ token: string }>("/auth/refresh");
445
+ useAuthStore.getState().setToken(newToken.token);
446
+ return newToken.token;
447
+ });
448
+
449
+ // Two concurrent calls → one request, both get the same resolved value:
450
+ await Promise.all([refresh(), refresh()]);
451
+ ```
452
+
453
+ ### Code-splitting with retry recipe
454
+
455
+ `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`.
456
+
457
+ ```tsx
458
+ import { lazyWithRetry } from "tempest-react-sdk";
459
+
460
+ const Settings = lazyWithRetry(() => import("./Settings"), {
461
+ retries: 3,
462
+ initialDelay: 400,
463
+ reloadOnFinalFailure: true,
464
+ });
465
+
466
+ <Route
467
+ path="/settings"
468
+ element={
469
+ <Suspense fallback={<Spinner />}>
470
+ <Settings />
471
+ </Suspense>
472
+ }
473
+ />;
474
+ ```
475
+
476
+ ### React Query recipe
477
+
478
+ ```tsx
479
+ import { QueryProvider, createQueryKeys, STALE_TIME } from "tempest-react-sdk";
480
+
481
+ export function AppProviders({ children }: { children: React.ReactNode }) {
482
+ return (
483
+ <QueryProvider defaultOptions={{ queries: { staleTime: STALE_TIME.MEDIUM } }}>
484
+ {children}
485
+ </QueryProvider>
486
+ );
487
+ }
488
+ ```
489
+
490
+ `STALE_TIME`, `CACHE_TIME`, and `REFETCH_TIME` ship as named constants (`SHORT`, `MEDIUM`, `LONG`) so cache windows stay consistent across features.
491
+
492
+ Typed query-key factory:
493
+
494
+ ```ts
495
+ import { createQueryKeys } from "tempest-react-sdk";
496
+
497
+ export const eventKeys = createQueryKeys("event", {
498
+ all: ["all"] as const,
499
+ list: (filters: { page: number; size: number }) => ["list", filters] as const,
500
+ byId: (id: string) => [id] as const,
501
+ });
502
+
503
+ // eventKeys.list({ page: 1, size: 20 }) === ["event", "list", { page: 1, size: 20 }]
504
+ // eventKeys.byId("42") === ["event", "42"]
505
+ ```
506
+
507
+ ### Forms (zod) recipe
508
+
509
+ Three levels of integration — pick the one that fits the form complexity.
510
+
511
+ **1. Standalone validation** — independent of any form library:
512
+
513
+ ```ts
514
+ import { validateForm } from "tempest-react-sdk";
515
+ import { z } from "zod";
516
+
517
+ const schema = z.object({
518
+ email: z.string().email(),
519
+ password: z.string().min(8),
520
+ });
521
+
522
+ const result = validateForm(schema, formValues);
523
+ if (!result.success) {
524
+ setErrors(result.errors); // { email: "...", password: "..." }
525
+ return;
526
+ }
527
+ await login(result.data);
528
+ ```
529
+
530
+ **2. `react-hook-form` resolver** — drop-in replacement for `@hookform/resolvers/zod`:
531
+
532
+ ```ts
533
+ import { useForm } from "react-hook-form";
534
+ import { zodResolver } from "tempest-react-sdk";
535
+
536
+ const form = useForm<LoginForm>({ resolver: zodResolver(loginSchema) });
537
+ ```
538
+
539
+ **3. All-in-one hook** — the schema infers the form type:
540
+
541
+ ```tsx
542
+ import { useZodForm } from "tempest-react-sdk";
543
+
544
+ function LoginForm() {
545
+ const form = useZodForm(loginSchema, {
546
+ defaultValues: { email: "", password: "" },
547
+ });
548
+
549
+ return (
550
+ <form onSubmit={form.handleSubmit((data) => login(data))}>
551
+ <Input {...form.register("email")} label="Email" />
552
+ <Input {...form.register("password")} type="password" label="Senha" />
553
+ <Button type="submit" loading={form.formState.isSubmitting}>
554
+ Entrar
555
+ </Button>
556
+ </form>
557
+ );
558
+ }
559
+ ```
560
+
561
+ `react-hook-form` is an **optional peer dep** — only install when you use `zodResolver` or `useZodForm`.
562
+
563
+ ### BR validators & masked inputs recipe
564
+
565
+ Algorithmic validators (full check-digit math, rejects all-same-digit edge cases) plus masked inputs that play nicely with `react-hook-form`:
566
+
567
+ ```ts
568
+ import { validateCPF, validateCNPJ, formatCEP, formatCNPJ, unmask } from "tempest-react-sdk";
569
+
570
+ validateCPF("000.000.000-00"); // false (all-same)
571
+ validateCPF("12345678909"); // true
572
+ validateCNPJ("11.222.333/0001-81"); // true
573
+ formatCEP("01001000"); // "01001-000"
574
+ unmask("(11) 99876-5432"); // "11998765432"
575
+ ```
576
+
577
+ ```tsx
578
+ import { CPFInput, PhoneInput, CEPInput, CNPJInput, MoneyInput } from "tempest-react-sdk";
579
+ import { Controller, useForm } from "react-hook-form";
580
+
581
+ function CheckoutForm() {
582
+ const { control, register } = useForm<Checkout>();
583
+ return (
584
+ <>
585
+ <CPFInput {...register("cpf")} label="CPF" />
586
+ <PhoneInput {...register("phone")} label="Telefone" />
587
+ <CEPInput {...register("cep")} label="CEP" />
588
+ <Controller
589
+ name="total"
590
+ control={control}
591
+ render={({ field }) => <MoneyInput {...field} label="Total" currency="BRL" />}
592
+ />
593
+ </>
594
+ );
595
+ }
596
+ ```
597
+
598
+ `MoneyInput` exposes a numeric value to your form state while rendering a formatted string in the input.
599
+
600
+ ### ViaCEP lookup recipe
601
+
602
+ ```tsx
603
+ import { useViaCEP } from "tempest-react-sdk";
604
+
605
+ function AddressFields({ form }: { form: UseFormReturn<Address> }) {
606
+ const cep = form.watch("cep");
607
+ const { result, loading, error } = useViaCEP(cep);
608
+
609
+ useEffect(() => {
610
+ if (result) {
611
+ form.setValue("street", result.logradouro);
612
+ form.setValue("neighborhood", result.bairro);
613
+ form.setValue("city", result.localidade);
614
+ form.setValue("state", result.uf);
615
+ }
616
+ }, [result]);
617
+
618
+ return <CEPInput {...form.register("cep")} loading={loading} error={error?.message} />;
619
+ }
620
+ ```
621
+
622
+ `useViaCEP` debounces requests, caches by CEP, and ignores partial input (`length < 8`).
623
+
624
+ ### WebSocket recipe
625
+
626
+ 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.
627
+
628
+ ```tsx
629
+ import { useWebSocket } from "tempest-react-sdk";
630
+
631
+ type ChatEvent = { type: "message"; user: string; text: string };
632
+
633
+ function Chat({ apiUrl, enabled }: { apiUrl: string; enabled: boolean }) {
634
+ const ws = useWebSocket<ChatEvent>(`${apiUrl}/chat`, {
635
+ enabled,
636
+ pingInterval: 30_000,
637
+ onMessage: ({ data }) => console.log(data),
638
+ });
639
+
640
+ return (
641
+ <button disabled={ws.status !== "open"} onClick={() => ws.send(JSON.stringify({ text: "hi" }))}>
642
+ Enviar
643
+ </button>
644
+ );
645
+ }
646
+ ```
647
+
648
+ Imperative (outside React):
649
+
650
+ ```ts
651
+ import { createWebSocket } from "tempest-react-sdk";
652
+
653
+ const socket = createWebSocket(`${apiUrl}/chat`, {
654
+ pingInterval: 30_000,
655
+ onMessage: ({ data }) => console.log(data),
656
+ });
657
+
658
+ socket.send("hello");
659
+ socket.close();
660
+ ```
661
+
662
+ ### Server-Sent Events (SSE) recipe
663
+
664
+ Stream with exponential reconnect (up to 10 attempts), `ping` heartbeat, JSON parsing by default. For cookie-auth endpoints, pass `withCredentials: true`.
665
+
666
+ ```tsx
667
+ import { useEventStream } from "tempest-react-sdk";
668
+ import { useNotificationsStore } from "@/store/notifications";
669
+
670
+ type StreamEvent =
671
+ | { type: "NOTIFY"; message: string }
672
+ | { type: "PAYMENT-SUCCESS"; order_id: string }
673
+ | { type: "PING" };
674
+
675
+ export function NotificationsListener({ apiUrl, enabled }: { apiUrl: string; enabled: boolean }) {
676
+ const add = useNotificationsStore((s) => s.add);
677
+
678
+ useEventStream<StreamEvent>(`${apiUrl}/notifications/stream`, {
679
+ enabled,
680
+ withCredentials: true,
681
+ namedEvents: ["notification", "payment"],
682
+ onMessage: ({ data }) => {
683
+ if (data.type === "PING") return;
684
+ add(data);
685
+ },
686
+ });
687
+
688
+ return null;
689
+ }
690
+ ```
691
+
692
+ Imperative form (e.g., outside React, in a worker, in tests):
693
+
694
+ ```ts
695
+ import { createEventStream } from "tempest-react-sdk";
696
+
697
+ const stream = createEventStream(`${apiUrl}/notifications/stream`, {
698
+ withCredentials: true,
699
+ onMessage: ({ data }) => console.log(data),
700
+ onError: (event) => console.warn("SSE error", event),
701
+ });
702
+
703
+ stream.close();
704
+ ```
705
+
706
+ ### Web Push recipe
707
+
708
+ 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.
709
+
710
+ Pre-requisite: register the service worker before calling `subscribe()` (via `vite-plugin-pwa`, `registerServiceWorker`, or raw `navigator.serviceWorker.register`).
711
+
712
+ ```tsx
713
+ import { usePushSubscription, Button } from "tempest-react-sdk";
714
+ import { api } from "@/services/api";
715
+
716
+ export function PushToggle() {
717
+ const push = usePushSubscription({
718
+ vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
719
+ onSubscribe: (subscription) => api.post("/webpush/subscribe", { body: subscription }),
720
+ onUnsubscribe: () => api.delete("/webpush/my"),
721
+ });
722
+
723
+ if (!push.supported) return <p>Push não suportado neste navegador.</p>;
724
+
725
+ return (
726
+ <Button
727
+ loading={push.loading}
728
+ variant={push.subscribed ? "danger" : "primary"}
729
+ onClick={() => (push.subscribed ? push.unsubscribe() : push.subscribe())}
730
+ >
731
+ {push.subscribed ? "Desinscrever notificações" : "Receber notificações"}
732
+ </Button>
733
+ );
734
+ }
735
+ ```
736
+
737
+ Imperative version:
738
+
739
+ ```ts
740
+ import { WebPushClient } from "tempest-react-sdk";
741
+
742
+ const push = new WebPushClient({
743
+ vapidPublicKey: VAPID_PUBLIC_KEY,
744
+ onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
745
+ onUnsubscribe: () => api.delete("/webpush/my"),
746
+ });
747
+
748
+ await push.subscribe();
749
+ const active = await push.isSubscribed();
750
+ await push.unsubscribe();
751
+ ```
752
+
753
+ Errors thrown: `WebPushUnsupportedError`, `WebPushPermissionDeniedError`. Anything else bubbles up as the underlying browser exception.
754
+
755
+ ### Service Worker helpers recipe
756
+
757
+ **Main thread** — register the SW, react to updates, expose a "skip waiting" hook:
758
+
759
+ ```ts
760
+ import { registerServiceWorker, skipWaiting } from "tempest-react-sdk";
761
+
762
+ registerServiceWorker({
763
+ url: "/sw.js",
764
+ onUpdate: (waiting) => {
765
+ if (confirm("Nova versão disponível. Recarregar?")) {
766
+ skipWaiting(waiting);
767
+ window.location.reload();
768
+ }
769
+ },
770
+ onError: (err) => console.error("SW falhou", err),
771
+ });
772
+ ```
773
+
774
+ **Worker thread** — inside `sw.ts`/`sw.js`, install handlers for push, notification click, and the skip-waiting message:
775
+
776
+ ```ts
777
+ /// <reference lib="webworker" />
778
+ import {
779
+ installPushHandler,
780
+ installNotificationClickHandler,
781
+ installSkipWaitingListener,
782
+ } from "tempest-react-sdk";
783
+
784
+ installSkipWaitingListener();
785
+
786
+ installPushHandler({
787
+ defaultTitle: "Tempest",
788
+ defaultIcon: "/icons/Logo.png",
789
+ transform: (payload) => {
790
+ if (payload.tag === "silent-ping") return null; // drop silently
791
+ return payload;
792
+ },
793
+ });
794
+
795
+ installNotificationClickHandler({
796
+ focusOrOpenWindow: true,
797
+ fallbackUrl: "/",
798
+ });
799
+ ```
800
+
801
+ The SDK does **not** ship the worker file or the bundler config — pair it with `vite-plugin-pwa` (`injectManifest`) or a separately bundled worker.
802
+
803
+ ### Audio playback recipe
804
+
805
+ `playAudio` for one-shot sounds (notification chime, payment success), `createAudioPlayer` for isolated channels, `useAudio` for a per-component player.
806
+
807
+ ```ts
808
+ import { playAudio } from "tempest-react-sdk";
809
+
810
+ await playAudio("/audio/plim.wav", { volume: 0.4 });
811
+ ```
812
+
813
+ ```tsx
814
+ import { useAudio } from "tempest-react-sdk";
815
+
816
+ function NotifyBell() {
817
+ const audio = useAudio();
818
+ return <button onClick={() => audio.play("/audio/bell.wav")}>Notify</button>;
819
+ }
820
+ ```
821
+
822
+ 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.
823
+
824
+ ### Offline storage (IndexedDB / Dexie) recipe
825
+
826
+ 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.
827
+
828
+ ```ts
829
+ import { createOfflineStore } from "tempest-react-sdk";
830
+
831
+ type Notification = {
832
+ message_id: string;
833
+ owner_id: string;
834
+ type: "NOTIFY" | "PAYMENT-SUCCESS";
835
+ message: string;
836
+ created_at: string;
837
+ read: boolean;
838
+ };
839
+
840
+ export const notificationsStore = createOfflineStore<Notification, string>({
841
+ databaseName: "TempestNotifications",
842
+ version: 1,
843
+ tableName: "notifications",
844
+ indexes: "&message_id, owner_id, read, created_at",
845
+ keyPath: "message_id",
846
+ ownerField: "owner_id",
847
+ });
848
+
849
+ await notificationsStore.put(
850
+ {
851
+ /* … */
852
+ } as Notification,
853
+ "u1",
854
+ );
855
+ const items = await notificationsStore.list("u1", {
856
+ orderBy: "created_at",
857
+ reverse: true,
858
+ limit: 50,
859
+ });
860
+ await notificationsStore.updateMany("u1", { read: true });
861
+ await notificationsStore.clear("u1");
862
+ ```
863
+
864
+ API: `put` / `bulkPut` / `get` / `list` / `update` / `updateMany` / `delete` / `clear` / `count`. `raw` (Dexie table) and `db` (Dexie instance) are exposed for advanced queries.
865
+
866
+ ### Error boundary recipe
867
+
868
+ 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.
869
+
870
+ ```tsx
871
+ import { ErrorBoundary, ErrorState } from "tempest-react-sdk";
872
+ import { useLocation } from "react-router-dom";
873
+
874
+ export function AppShell({ children }: { children: React.ReactNode }) {
875
+ const location = useLocation();
876
+ return (
877
+ <ErrorBoundary
878
+ resetKeys={[location.pathname]}
879
+ onError={(err, info) => reportToSentry(err, info)}
880
+ fallback={({ error, reset }) => <ErrorState description={error.message} onRetry={reset} />}
881
+ >
882
+ {children}
883
+ </ErrorBoundary>
884
+ );
885
+ }
886
+ ```
887
+
888
+ ```tsx
889
+ import { useErrorHandler } from "tempest-react-sdk";
890
+
891
+ function Streamer() {
892
+ const throwError = useErrorHandler();
893
+ useEffect(() => {
894
+ const stream = openSocket();
895
+ stream.onerror = (err) => throwError(err);
896
+ return () => stream.close();
897
+ }, [throwError]);
898
+ return null;
899
+ }
900
+ ```
901
+
902
+ ### Toast notifications recipe
903
+
904
+ ```tsx
905
+ import { ToastProvider, useToast, Button } from "tempest-react-sdk";
906
+
907
+ // Wrap app once (already in the Quickstart):
908
+ <ToastProvider placement="top-right" duration={4000}>
909
+ <App />
910
+ </ToastProvider>;
911
+
912
+ // Use anywhere:
913
+ function SaveButton() {
914
+ const toast = useToast();
915
+ return (
916
+ <Button
917
+ onClick={async () => {
918
+ try {
919
+ await save();
920
+ toast.success("Alterações salvas");
921
+ } catch (error) {
922
+ toast.error(String(error));
923
+ }
924
+ }}
925
+ >
926
+ Salvar
927
+ </Button>
928
+ );
929
+ }
930
+ ```
931
+
932
+ `useToast()` returns `{ show, success, info, warning, error, dismiss }` — `show` takes the full `ToastOptions`.
933
+
934
+ ### Modal & ConfirmDialog recipe
935
+
936
+ ```tsx
937
+ import { useState } from "react";
938
+ import { Modal, ConfirmDialog, Button } from "tempest-react-sdk";
939
+
940
+ function DeleteUser({ user }: { user: User }) {
941
+ const [open, setOpen] = useState(false);
942
+ return (
943
+ <>
944
+ <Button variant="danger" onClick={() => setOpen(true)}>
945
+ Excluir
946
+ </Button>
947
+ <ConfirmDialog
948
+ open={open}
949
+ title="Excluir usuário"
950
+ description={`Esta ação é permanente. Excluir ${user.name}?`}
951
+ confirmLabel="Sim, excluir"
952
+ cancelLabel="Cancelar"
953
+ tone="danger"
954
+ onConfirm={async () => {
955
+ await deleteUser(user.id);
956
+ setOpen(false);
957
+ }}
958
+ onCancel={() => setOpen(false)}
959
+ />
960
+ </>
961
+ );
962
+ }
963
+ ```
964
+
965
+ `Modal` is the lower-level primitive (focus trap + scroll lock + ESC to close + backdrop click). `ConfirmDialog` is the opinionated yes/no wrapper.
966
+
967
+ ### Tables & pagination recipe
968
+
969
+ ```tsx
970
+ import { Table, Pagination, usePagination } from "tempest-react-sdk";
971
+
972
+ const columns = [
973
+ { key: "id", label: "ID", align: "right" as const },
974
+ { key: "name", label: "Nome" },
975
+ { key: "email", label: "Email" },
976
+ {
977
+ key: "actions",
978
+ label: "",
979
+ render: (row) => (
980
+ <Button size="sm" onClick={() => edit(row.id)}>
981
+ Editar
982
+ </Button>
983
+ ),
984
+ },
985
+ ];
986
+
987
+ function UsersList() {
988
+ const { page, size, setPage, setSize } = usePagination({ initialSize: 20 });
989
+ const { data } = useQuery({
990
+ queryKey: userKeys.list({ page, size }),
991
+ queryFn: () => api.get<Paginated<User>>(`/users?page=${page}&size=${size}`),
992
+ });
993
+
994
+ return (
995
+ <>
996
+ <Table columns={columns} rows={data?.items ?? []} loading={!data} />
997
+ <Pagination
998
+ page={page}
999
+ pageSize={size}
1000
+ total={data?.total ?? 0}
1001
+ onPageChange={setPage}
1002
+ onPageSizeChange={setSize}
1003
+ />
1004
+ </>
1005
+ );
1006
+ }
1007
+ ```
1008
+
1009
+ ### Layout primitives recipe
1010
+
1011
+ `Stack`, `Grid`, and `Container` are zero-dependency layout helpers (flex, grid, max-width).
1012
+
1013
+ ```tsx
1014
+ import { Container, Stack, Grid, Card } from "tempest-react-sdk";
1015
+
1016
+ <Container size="lg">
1017
+ <Stack gap="md" direction="column">
1018
+ <h1>Dashboard</h1>
1019
+ <Grid columns={3} gap="md">
1020
+ <Card>Visitas</Card>
1021
+ <Card>Pedidos</Card>
1022
+ <Card>Receita</Card>
1023
+ </Grid>
1024
+ </Stack>
1025
+ </Container>;
1026
+ ```
1027
+
1028
+ ### Virtual list recipe
1029
+
1030
+ Render thousands of rows without blowing the DOM. Built on top of `useResizeObserver` — supports dynamic row heights.
1031
+
1032
+ ```tsx
1033
+ import { VirtualList } from "tempest-react-sdk";
1034
+
1035
+ <VirtualList
1036
+ items={messages}
1037
+ estimatedItemHeight={64}
1038
+ overscan={5}
1039
+ renderItem={(message) => <MessageRow key={message.id} message={message} />}
1040
+ />;
1041
+ ```
1042
+
1043
+ ### Theming (light / dark) recipe
1044
+
1045
+ ```tsx
1046
+ import { ThemeProvider, useTheme } from "tempest-react-sdk";
1047
+
1048
+ <ThemeProvider defaultMode="system" persistKey="tempest-theme">
1049
+ <App />
1050
+ </ThemeProvider>;
1051
+
1052
+ function ThemeToggle() {
1053
+ const { mode, setMode, resolved } = useTheme();
1054
+ return (
1055
+ <select value={mode} onChange={(e) => setMode(e.target.value as ThemeMode)}>
1056
+ <option value="light">Claro</option>
1057
+ <option value="dark">Escuro</option>
1058
+ <option value="system">Sistema ({resolved})</option>
1059
+ </select>
1060
+ );
1061
+ }
1062
+ ```
1063
+
1064
+ `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:
1065
+
1066
+ ```html
1067
+ <script>
1068
+ __INIT_THEME__;
1069
+ </script>
1070
+ ```
1071
+
1072
+ ```ts
1073
+ // build.ts
1074
+ import { themeInitScript } from "tempest-react-sdk";
1075
+ const html = template.replace("__INIT_THEME__", themeInitScript());
1076
+ ```
1077
+
1078
+ ### i18n recipe
1079
+
1080
+ 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.
1081
+
1082
+ ```ts
1083
+ import { createI18n } from "tempest-react-sdk";
1084
+
1085
+ const i18n = createI18n({
1086
+ locale: "pt-BR",
1087
+ fallback: "en",
1088
+ messages: {
1089
+ "pt-BR": {
1090
+ greeting: "Olá, {name}",
1091
+ inbox: { empty: "Caixa vazia" },
1092
+ },
1093
+ en: {
1094
+ greeting: "Hello, {name}",
1095
+ inbox: { empty: "Empty inbox" },
1096
+ },
1097
+ },
1098
+ persistKey: "tempest-locale",
1099
+ });
1100
+ ```
1101
+
1102
+ ```tsx
1103
+ import { I18nProvider, useTranslate, useI18n } from "tempest-react-sdk";
1104
+
1105
+ <I18nProvider i18n={i18n}>
1106
+ <App />
1107
+ </I18nProvider>;
1108
+
1109
+ function Header() {
1110
+ const t = useTranslate();
1111
+ const { locale, setLocale } = useI18n();
1112
+ return (
1113
+ <header>
1114
+ <span>{t("greeting", { name: "Mauricio" })}</span>
1115
+ <button onClick={() => setLocale(locale === "pt-BR" ? "en" : "pt-BR")}>
1116
+ {locale === "pt-BR" ? "EN" : "PT"}
1117
+ </button>
1118
+ </header>
1119
+ );
1120
+ }
1121
+ ```
1122
+
1123
+ ### Feature flags recipe
1124
+
1125
+ `FeatureFlagsProvider` takes an `adapter` matching the `FeatureFlagsAdapter` interface (`isEnabled`, `get`, `subscribe`). Ship the `InMemory` adapter while you build, swap for GrowthBook / LaunchDarkly when you're ready.
1126
+
1127
+ ```tsx
1128
+ import {
1129
+ FeatureFlagsProvider,
1130
+ useFeatureFlag,
1131
+ useFlagValue,
1132
+ createInMemoryFlags,
1133
+ } from "tempest-react-sdk";
1134
+
1135
+ const flags = createInMemoryFlags({
1136
+ flags: { "new-checkout": true, "max-items": 10 },
1137
+ });
1138
+
1139
+ <FeatureFlagsProvider adapter={flags}>
1140
+ <App />
1141
+ </FeatureFlagsProvider>;
1142
+
1143
+ function CheckoutButton() {
1144
+ const isNew = useFeatureFlag("new-checkout");
1145
+ const maxItems = useFlagValue<number>("max-items", 5);
1146
+ return isNew ? <NewCheckout maxItems={maxItems} /> : <LegacyCheckout />;
1147
+ }
1148
+ ```
1149
+
1150
+ The interface is intentionally tiny — any third-party SDK can be wrapped into an adapter in ~20 lines.
1151
+
1152
+ ### Telemetry recipe
1153
+
1154
+ `TelemetryProvider` accepts an adapter matching `TelemetryAdapter` (`identify`, `track`, `captureException`, `flush`). The default `consoleTelemetryAdapter` logs every event — useful for dev and tests.
1155
+
1156
+ ```tsx
1157
+ import { TelemetryProvider, useTelemetry, consoleTelemetryAdapter } from "tempest-react-sdk";
1158
+
1159
+ <TelemetryProvider adapter={consoleTelemetryAdapter()}>
1160
+ <App />
1161
+ </TelemetryProvider>;
1162
+
1163
+ function CheckoutForm() {
1164
+ const telemetry = useTelemetry();
1165
+ return (
1166
+ <Button
1167
+ onClick={() => {
1168
+ telemetry.track("checkout.completed", { total: 100 });
1169
+ }}
1170
+ >
1171
+ Pagar
1172
+ </Button>
1173
+ );
1174
+ }
1175
+ ```
1176
+
1177
+ Concrete adapters for Sentry / PostHog / Datadog are part of the v0.2 roadmap — for now you can write one in ~20 lines (see [`docs/telemetry.md`](./docs/telemetry.md)).
1178
+
1179
+ ### Logger recipe
1180
+
1181
+ Leveled logger with pluggable sinks:
1182
+
1183
+ ```ts
1184
+ import { createLogger, consoleSink } from "tempest-react-sdk";
1185
+
1186
+ export const log = createLogger({
1187
+ level: "info",
1188
+ sinks: [consoleSink({ pretty: true })],
1189
+ context: { app: "alofans", version: __APP_VERSION__ },
1190
+ });
1191
+
1192
+ log.info("user.signed-in", { user_id: user.id });
1193
+ log.error("payment.failed", { reason }, error);
1194
+ ```
1195
+
1196
+ A sink is any function `(entry: LogEntry) => void` — wire a sink that POSTs to your log ingestion endpoint, batches every 5 seconds, etc.
1197
+
1198
+ ### Web Share API recipe
1199
+
1200
+ Share via the OS share sheet on mobile and supported desktop browsers. Falls back gracefully when `navigator.share` is missing.
1201
+
1202
+ ```ts
1203
+ import { share, isShareSupported } from "tempest-react-sdk";
1204
+
1205
+ if (!isShareSupported()) {
1206
+ copyToClipboard(url);
1207
+ return;
1208
+ }
1209
+
1210
+ const result = await share({ title: "Tempest", text: "Check this out", url });
1211
+ if (result.shared) toast.success("Compartilhado");
1212
+ if (result.cancelled) {
1213
+ /* user dismissed */
1214
+ }
1215
+ if (result.unsupported) {
1216
+ /* defensive — should not happen after isShareSupported */
1217
+ }
1218
+ ```
1219
+
1220
+ ### Hooks catalogue recipe
1221
+
1222
+ | Hook | Purpose |
1223
+ | --------------------------------------------- | -------------------------------------------------------------------------------- |
1224
+ | `useDebounce(value, delay)` | Debounce a value (search bars, autosave). |
1225
+ | `usePagination({ initialPage, initialSize })` | `page`/`size`/`setPage`/`setSize` triplet with bounds. |
1226
+ | `useClientFilter(items, predicate)` | Memoised in-memory filter. |
1227
+ | `useMediaQuery(query)` | Subscribe to a `matchMedia` query (`(min-width: 1024px)`). |
1228
+ | `useOnline()` | Returns `true`/`false` from `navigator.onLine` + online/offline events. |
1229
+ | `useDocumentVisibility()` | `"visible"` / `"hidden"`, subscribing to `visibilitychange`. |
1230
+ | `useIntersectionObserver(ref, opts)` | Returns the latest `IntersectionObserverEntry`. |
1231
+ | `useResizeObserver(ref)` | Returns the latest `width`/`height`. |
1232
+ | `useClipboard()` | `{ copy(text), copied, error }` with `execCommand` fallback. |
1233
+ | `useKeyboardShortcut(shortcut, handler)` | `"Meta+K"` / `"Ctrl+Shift+P"` patterns. |
1234
+ | `useBeforeInstallPrompt()` | Capture the PWA install prompt and trigger it later. |
1235
+ | `useIdle(timeout)` | `true` after `timeout` ms of no input. |
1236
+ | `useGeolocation()` | One-shot or watch position with permission state. |
1237
+ | `useScrollLock(active)` | Lock body scroll while a modal is open. |
1238
+ | `useFocusTrap(ref, active)` | Trap focus inside a container. |
1239
+ | `useStableCallback(fn)` | A `useCallback` that always points at the latest `fn` without churning identity. |
1240
+ | `useDeepMemo(value)` | `useMemo` with deep equality. |
1241
+
1242
+ Each hook is independently importable and tested — see `docs/hooks.md` for full signatures and edge cases.
1243
+
1244
+ ### Utility helpers recipe
1245
+
1246
+ ```ts
1247
+ import {
1248
+ cn,
1249
+ formatCurrency,
1250
+ formatDate,
1251
+ formatDateTime,
1252
+ formatPhone,
1253
+ formatCPF,
1254
+ formatPercent,
1255
+ storage,
1256
+ } from "tempest-react-sdk";
1257
+
1258
+ cn("btn", isPrimary && "btn-primary", { "btn-disabled": disabled });
1259
+ formatCurrency(1234.5, "BRL"); // "R$ 1.234,50"
1260
+ formatDate("2026-05-17"); // "17/05/2026"
1261
+ formatDateTime("2026-05-17T14:30Z"); // "17/05/2026 11:30"
1262
+ formatPhone("11998765432"); // "(11) 99876-5432"
1263
+ formatCPF("12345678909"); // "123.456.789-09"
1264
+ formatPercent(0.1234, 1); // "12,3%"
1265
+
1266
+ storage.set("draft", { title: "..." });
1267
+ const draft = storage.get<Draft>("draft");
1268
+ storage.remove("draft");
1269
+ ```
1270
+
1271
+ `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.
1272
+
1273
+ ---
1274
+
1275
+ ## Theming reference
1276
+
1277
+ All tokens live on `:root` (light) and `[data-tempest-theme="dark"]` (dark). Override globally or in a subtree:
1278
+
1279
+ ```css
1280
+ :root {
1281
+ --tempest-primary: #ff3366;
1282
+ --tempest-radius-md: 6px;
1283
+ --tempest-font-family: "Inter", system-ui, sans-serif;
1284
+ }
1285
+
1286
+ [data-tempest-theme="dark"] {
1287
+ --tempest-bg-default: #0b0e14;
1288
+ --tempest-text-primary: #e6e6e6;
1289
+ }
1290
+
1291
+ [data-tempest-theme="dark"] .my-card {
1292
+ /* per-subtree dark override */
1293
+ }
1294
+ ```
1295
+
1296
+ Categories of tokens:
1297
+
1298
+ - **Color**: `--tempest-primary`, `--tempest-success`, `--tempest-warning`, `--tempest-danger`, `--tempest-info`, `--tempest-bg-*`, `--tempest-text-*`, `--tempest-border-*`.
1299
+ - **Radius**: `--tempest-radius-sm` / `-md` / `-lg` / `-full`.
1300
+ - **Shadow**: `--tempest-shadow-sm` / `-md` / `-lg`.
1301
+ - **Spacing**: `--tempest-space-1` … `--tempest-space-8`.
1302
+ - **Typography**: `--tempest-font-family`, `--tempest-font-size-sm` / `-md` / `-lg`, `--tempest-line-height-base`.
1303
+
1304
+ Tokens are stable public API — breaking changes always bump the SDK minor (or major, on rename).
1305
+
1306
+ ---
1307
+
1308
+ ## Conventions
1309
+
1310
+ These conventions are enforced across the SDK source and are the same patterns the SDK encourages in consumer apps.
1311
+
1312
+ - **TypeScript strict** — no `any` implicit, `verbatimModuleSyntax`, every export typed.
1313
+ - **Double quotes** everywhere — `"foo"` never `'foo'`.
1314
+ - **JSDoc in English** on every public export (description + `@example`).
1315
+ - **CSS Modules** with the `tempest_` prefix — no global class names, no collisions with consumer apps.
1316
+ - **Empty results return `[]`** — never `null`/`undefined` for "no matches".
1317
+ - **No barrel default exports** — always named exports. Apps import from the package root, never from submodules.
1318
+ - **Optional peer deps** for everything that isn't React itself — apps install only what they consume.
1319
+ - **No Storybook** — `docs/` markdown + `examples/gallery` cover the documentation surface.
1320
+ - **Dark mode via `data-tempest-theme="dark"` attribute**, never a `class="dark"` toggle — allows scoped subtree overrides.
1321
+
1322
+ ---
1323
+
1324
+ ## Development
1325
+
1326
+ ```bash
1327
+ npm install
1328
+ npm run dev # vite build --watch
1329
+ npm run build # ESM + CJS + rolled-up d.ts + styles.css
1330
+ npm run typecheck # tsc -b --noEmit (checks tests too)
1331
+ npm run lint # eslint .
1332
+ npm run format # prettier --write .
1333
+ npm test # vitest (watch)
1334
+ npm run test:run # vitest run
1335
+ npm run test:coverage # vitest run --coverage
1336
+ npm run clean # rm -rf dist coverage
1337
+ ```
1338
+
1339
+ Snapshot of current health:
1340
+
1341
+ - 444 tests / 164 files. 95% line / 96% function coverage.
1342
+ - ESM 98 KB → 28 KB gzip. CJS 71 KB → 24 KB gzip. CSS 32 KB → 6 KB gzip.
1343
+ - Husky pre-commit runs `lint-staged` (eslint --fix + prettier --write) on staged files.
1344
+
1345
+ The demo gallery lives in `examples/gallery` and consumes the local SDK via `file:../..`:
1346
+
1347
+ ```bash
1348
+ cd examples/gallery
1349
+ npm install
1350
+ npm run dev # http://127.0.0.1:5173
1351
+ ```
1352
+
1353
+ Every gallery section maps 1-to-1 with a docs page — see [`docs/gallery.md`](./docs/gallery.md).
1354
+
1355
+ ---
1356
+
1357
+ ## Release
1358
+
1359
+ Versioning is managed with [Changesets](https://github.com/changesets/changesets):
1360
+
1361
+ ```bash
1362
+ npx changeset # describe the change (patch / minor / major)
1363
+ npx changeset version # bump versions + update CHANGELOG.md
1364
+ npm run release # build + changeset publish
1365
+ ```
1366
+
1367
+ CI workflow `.github/workflows/release.yml` publishes to npm whenever a "Version Packages" PR merges into `main`. The first publish is manual — set the `NPM_TOKEN` secret in _Settings → Secrets and variables → Actions_ and run `npm publish` once.
1368
+
1369
+ Tags follow `vMAJOR.MINOR.PATCH`. CSS tokens (`--tempest-*`) are treated as public API — renames bump the minor (or major, on removal).
1370
+
1371
+ ---
1372
+
1373
+ ## License
1374
+
1375
+ MIT © Mauricio Benjamin