najm-kit 2.8.2 → 2.10.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 CHANGED
@@ -123,7 +123,7 @@ Changing the state updates the complete theme immediately. Use
123
123
  `stringifyNajmThemeConfig(theme)` when persisting it, and parse settings loaded
124
124
  from an API or local storage with `parseNajmThemeConfig` before applying them.
125
125
 
126
- ## Components
126
+ ## Components
127
127
 
128
128
  Import from `najm-kit`:
129
129
 
@@ -145,44 +145,172 @@ import { Form, FormInput, useNForm } from 'najm-kit';
145
145
  | Feedback | Alert, Badge, Progress, Spinner, Toast |
146
146
  | Layout | Card, Sheet, Dialog, Popover, DropdownMenu, Tabs |
147
147
  | Data | Table (NTable), StatCard, DetailList |
148
- | Overlays | Command palette, Tooltip, Toast |
149
-
150
- ## Global form development tools
151
-
152
- Enable schema-driven test values once on the full application provider. Every
153
- `NForm` and `WizardForm` below it then fills from its Zod schema when F8 is
154
- pressed; applications do not need a second provider or a form-fill helper.
155
-
156
- ```tsx
157
- import { NajmAppProvider } from "najm-kit/app";
158
-
159
- <NajmAppProvider formDevTools>
160
- <App />
161
- </NajmAppProvider>;
162
- ```
163
-
164
- Pass a boolean to control it from application settings:
165
-
166
- ```tsx
167
- <NajmAppProvider formDevTools={formFillEnabled}>
168
- <App />
169
- </NajmAppProvider>
170
- ```
171
-
172
- Forms with live relation options can override only those fields. The provider
173
- still owns enablement and Najm Kit still owns schema traversal and generation.
174
-
175
- ```tsx
176
- <NForm
177
- schema={orderSchema}
178
- devTools={{ overrides: { customerId: customerOptions } }}
179
- onSubmit={saveOrder}
180
- >
181
- {/* fields */}
182
- </NForm>
183
- ```
184
-
185
- ## ImageInput and AvatarInput
148
+ | Overlays | Command palette, Tooltip, Toast |
149
+
150
+ ## Images and avatars
151
+
152
+ Three components, one fallback rule. Each tries its sources in order, tries a
153
+ source at most once, and discards what it knows about a failure the moment the
154
+ sources change.
155
+
156
+ ### `NImage` — plain `<img>`
157
+
158
+ For a logo or an icon whose box the caller's CSS already owns. No layout is
159
+ invented, and `onError` is forwarded rather than swallowed.
160
+
161
+ ```tsx
162
+ import { NImage } from 'najm-kit';
163
+
164
+ <NImage src={logo} fallback="/brand/logo.svg" alt="Acme" className="h-8 w-auto" />
165
+ ```
166
+
167
+ ### `NAvatar` — person or record
168
+
169
+ The image is a native `<img>` loaded directly by the browser, so a same-origin
170
+ protected route works with the session the page already has, and the package
171
+ needs no knowledge of which routes are protected.
172
+
173
+ ```tsx
174
+ import { NAvatar } from 'najm-kit';
175
+
176
+ <NAvatar
177
+ src={member.image}
178
+ fallbackSrc={stockPortrait}
179
+ version={member.imageRevision}
180
+ title={member.name}
181
+ subtitle={member.role}
182
+ size="lg"
183
+ />
184
+ ```
185
+
186
+ - The primary source is tried first, then `fallbackSrc`, then the initials.
187
+ - `version` (or `srcVersion`) is appended as `?v=…` to every remote source, so a
188
+ re-upload is not served from cache. `data:` and `blob:` sources are left alone.
189
+ - Initials stay visible until an image paints and come back if every source
190
+ fails — a transparent PNG never shows letters through itself.
191
+ - `imageProps` reaches the element for `loading`, `sizes`, `crossOrigin`,
192
+ `referrerPolicy`, and the load/error handlers. It defaults to `loading="lazy"`,
193
+ and supplied handlers are composed with the fallback chain rather than
194
+ replacing it.
195
+
196
+ ### `NNextImage` — optimized, from `najm-kit/next`
197
+
198
+ Same fallback contract with Next's optimizer, layout reservation, `fill`, and
199
+ `sizes`. It lives only in the `najm-kit/next` entry, because the root package
200
+ stays installable without Next.
201
+
202
+ ```tsx
203
+ import { NNextImage } from 'najm-kit/next';
204
+
205
+ // A public asset: let the optimizer resize and re-encode it.
206
+ <NNextImage src="/covers/spring.png" alt="Spring" width={64} height={64} />
207
+ ```
208
+
209
+ For an asset the browser must fetch directly — one behind an authenticated route,
210
+ typically — the *application* says so:
211
+
212
+ ```tsx
213
+ <NNextImage
214
+ src={record.image}
215
+ alt={record.name}
216
+ fill
217
+ sizes="64px"
218
+ unoptimized
219
+ />
220
+ ```
221
+
222
+ `unoptimized` is passed at the call site rather than inferred from the URL:
223
+ which routes are protected is the application's fact, not something a package
224
+ can read off a path. It changes delivery mechanics only — session validation,
225
+ permissions, privacy projection, and what bytes come back all remain the
226
+ backend's.
227
+
228
+ ## Status badges
229
+
230
+ `<NBadge status="…" />` already maps a broad lifecycle vocabulary onto the
231
+ semantic colors, so it is correct without configuration:
232
+
233
+ ```tsx
234
+ import { NBadge } from 'najm-kit';
235
+
236
+ <NBadge status="out_for_delivery" /> // warning, "Out For Delivery"
237
+ <NBadge status="nebulous" /> // neutral, "Nebulous"
238
+ ```
239
+
240
+ What an application usually adds on top is the same three things at every call
241
+ site: its look, its shape, and its own translated label. Declare them once:
242
+
243
+ ```tsx
244
+ <NajmAppProvider
245
+ badgeDefaults={{
246
+ look: 'soft',
247
+ shape: 'pill',
248
+ statusLabelKeys: {
249
+ active: 'status.active',
250
+ out_for_delivery: 'status.outForDelivery',
251
+ },
252
+ }}
253
+ >
254
+ ```
255
+
256
+ `badgeDefaults` lives on `NajmUIProvider` and is inherited by
257
+ `NajmNextUIProvider` and `NajmAppProvider`, so there is one place to set it.
258
+ The keys are the application's catalog keys, resolved through the same `t` the
259
+ provider already has — this package ships no status catalog. A language change
260
+ recomputes every label without a remount.
261
+
262
+ Resolution, most specific first:
263
+
264
+ 1. An explicit prop beats every provider default.
265
+ 2. `label` beats string children; string children beat the provider's label.
266
+ 3. A `statusLabels` literal beats a `statusLabelKeys` catalog lookup.
267
+ 4. An unmapped status is humanized (`pending_review` → `Pending Review`).
268
+ 5. A per-instance `statusMap`/`iconMap` merges over the provider's, so
269
+ overriding one status costs one status.
270
+ 6. Provider status defaults apply **only** when `status` is set —
271
+ `<NBadge>Beta</NBadge>` keeps the ordinary content-badge look.
272
+
273
+ Statuses are matched through one rule, exported as `normalizeStatusToken`, so
274
+ `Out-For-Delivery `, `out for delivery`, and `out_for_delivery` are the same
275
+ key for colors, icons, and labels alike. Badge text is presentation: it renames
276
+ nothing in the backend and validates no lifecycle transition.
277
+
278
+ ## Global form development tools
279
+
280
+ Enable schema-driven test values once on the full application provider. Every
281
+ `NForm` and `WizardForm` below it then fills from its Zod schema when F8 is
282
+ pressed; applications do not need a second provider or a form-fill helper.
283
+
284
+ ```tsx
285
+ import { NajmAppProvider } from "najm-kit/app";
286
+
287
+ <NajmAppProvider formDevTools>
288
+ <App />
289
+ </NajmAppProvider>;
290
+ ```
291
+
292
+ Pass a boolean to control it from application settings:
293
+
294
+ ```tsx
295
+ <NajmAppProvider formDevTools={formFillEnabled}>
296
+ <App />
297
+ </NajmAppProvider>
298
+ ```
299
+
300
+ Forms with live relation options can override only those fields. The provider
301
+ still owns enablement and Najm Kit still owns schema traversal and generation.
302
+
303
+ ```tsx
304
+ <NForm
305
+ schema={orderSchema}
306
+ devTools={{ overrides: { customerId: customerOptions } }}
307
+ onSubmit={saveOrder}
308
+ >
309
+ {/* fields */}
310
+ </NForm>
311
+ ```
312
+
313
+ ## ImageInput and AvatarInput
186
314
 
187
315
  `ImageInput` and `AvatarInput` ship with a resilient preview contract so
188
316
  consumers do not need to wrap them with application-specific preview
@@ -247,93 +375,93 @@ Key behaviors:
247
375
  a newer value, and object URLs created by the component are tracked so
248
376
  consumer-owned blob URLs are never revoked.
249
377
 
250
- `AvatarInput` forwards every preview and accessibility prop unchanged while
251
- preserving its circular, size, fill, and camera-icon defaults.
252
-
253
- ## Formatting
254
-
255
- Pure formatters are available from the server-safe `najm-kit/format` entry.
256
- Money values are integer minor units and use the currency's own exponent (for
257
- example MAD has two decimals, JPY zero, and KWD three).
258
-
259
- ```ts
260
- import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
261
-
262
- formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
263
- formatDate('2026-08-08T20:00:00Z', {
264
- locale: 'fr-MA',
265
- timeZone: 'Africa/Casablanca',
266
- });
267
- slugify('Najm Format & Pagination');
268
- ```
269
-
270
- Client code can use the active locale, time zone, currency, and placeholder
271
- through `useNajmFormat`. `NajmAppProvider` mounts the format provider for you:
272
-
273
- ```tsx
274
- import { NajmAppProvider } from 'najm-kit/app';
275
- import { useNajmFormat } from 'najm-kit';
276
-
277
- <NajmAppProvider
278
- translations={translations}
279
- currency="MAD"
280
- locales={{ en: 'en-MA', fr: 'fr-MA' }}
281
- >
282
- <App />
283
- </NajmAppProvider>
284
-
285
- function Total({ value }: { value: number }) {
286
- return <span>{useNajmFormat().money(value)}</span>;
287
- }
288
- ```
289
-
290
- ## Offset pagination and queries
291
-
292
- `najm-kit/pagination` is server-safe and framework-independent. It accepts
293
- endpoints that return either `{ rows, total }` or a bare row array. When no
294
- total exists it probes for one extra row; when a total exists continuation is
295
- calculated without another request.
296
-
297
- ```ts
298
- import {
299
- createOffsetPagination,
300
- fetchOffsetPage,
301
- } from 'najm-kit/pagination';
302
-
303
- const pagination = createOffsetPagination(pageIndex, pageSize);
304
- const page = await fetchOffsetPage(
305
- ({ limit, offset }) => api.orders.list({ limit, offset }),
306
- pagination,
307
- );
308
- ```
309
-
310
- React Query consumers install the optional `@tanstack/react-query` peer and use
311
- the isolated `najm-kit/query` entry. `useResponsiveOffsetList` resolves numbered
312
- desktop paging versus card continuation and exposes props that plug directly
313
- into `NTable` and `createCardPagination`.
314
-
315
- ```tsx
316
- import { NTable, createCardPagination } from 'najm-kit';
317
- import { useResponsiveOffsetList } from 'najm-kit/query';
318
-
319
- const list = useResponsiveOffsetList({
320
- queryKey: ['orders'],
321
- fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
322
- strategy: 'paged',
323
- });
324
-
325
- <NTable
326
- data={list.data}
327
- columns={columns}
328
- manualPagination
329
- pageCount={list.pageCount}
330
- pagination={list.pagination}
331
- onPaginationChange={list.onPaginationChange}
332
- cardPagination={createCardPagination(list, labels)}
333
- />
334
- ```
335
-
336
- ## Hooks
378
+ `AvatarInput` forwards every preview and accessibility prop unchanged while
379
+ preserving its circular, size, fill, and camera-icon defaults.
380
+
381
+ ## Formatting
382
+
383
+ Pure formatters are available from the server-safe `najm-kit/format` entry.
384
+ Money values are integer minor units and use the currency's own exponent (for
385
+ example MAD has two decimals, JPY zero, and KWD three).
386
+
387
+ ```ts
388
+ import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
389
+
390
+ formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
391
+ formatDate('2026-08-08T20:00:00Z', {
392
+ locale: 'fr-MA',
393
+ timeZone: 'Africa/Casablanca',
394
+ });
395
+ slugify('Najm Format & Pagination');
396
+ ```
397
+
398
+ Client code can use the active locale, time zone, currency, and placeholder
399
+ through `useNajmFormat`. `NajmAppProvider` mounts the format provider for you:
400
+
401
+ ```tsx
402
+ import { NajmAppProvider } from 'najm-kit/app';
403
+ import { useNajmFormat } from 'najm-kit';
404
+
405
+ <NajmAppProvider
406
+ translations={translations}
407
+ currency="MAD"
408
+ locales={{ en: 'en-MA', fr: 'fr-MA' }}
409
+ >
410
+ <App />
411
+ </NajmAppProvider>
412
+
413
+ function Total({ value }: { value: number }) {
414
+ return <span>{useNajmFormat().money(value)}</span>;
415
+ }
416
+ ```
417
+
418
+ ## Offset pagination and queries
419
+
420
+ `najm-kit/pagination` is server-safe and framework-independent. It accepts
421
+ endpoints that return either `{ rows, total }` or a bare row array. When no
422
+ total exists it probes for one extra row; when a total exists continuation is
423
+ calculated without another request.
424
+
425
+ ```ts
426
+ import {
427
+ createOffsetPagination,
428
+ fetchOffsetPage,
429
+ } from 'najm-kit/pagination';
430
+
431
+ const pagination = createOffsetPagination(pageIndex, pageSize);
432
+ const page = await fetchOffsetPage(
433
+ ({ limit, offset }) => api.orders.list({ limit, offset }),
434
+ pagination,
435
+ );
436
+ ```
437
+
438
+ React Query consumers install the optional `@tanstack/react-query` peer and use
439
+ the isolated `najm-kit/query` entry. `useResponsiveOffsetList` resolves numbered
440
+ desktop paging versus card continuation and exposes props that plug directly
441
+ into `NTable` and `createCardPagination`.
442
+
443
+ ```tsx
444
+ import { NTable, createCardPagination } from 'najm-kit';
445
+ import { useResponsiveOffsetList } from 'najm-kit/query';
446
+
447
+ const list = useResponsiveOffsetList({
448
+ queryKey: ['orders'],
449
+ fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
450
+ strategy: 'paged',
451
+ });
452
+
453
+ <NTable
454
+ data={list.data}
455
+ columns={columns}
456
+ manualPagination
457
+ pageCount={list.pageCount}
458
+ pagination={list.pagination}
459
+ onPaginationChange={list.onPaginationChange}
460
+ cardPagination={createCardPagination(list, labels)}
461
+ />
462
+ ```
463
+
464
+ ## Hooks
337
465
 
338
466
  ```tsx
339
467
  import { useKeyboard } from 'najm-kit';
@@ -397,116 +525,116 @@ Notes:
397
525
  - The columns the TanStack table receives are already filtered, so the
398
526
  settings menu will not list `visible: false` columns.
399
527
 
400
- If you need to inspect or build your own effective column list, the same
401
- pure helper is exported as `filterResponsiveColumns`. The literal class
402
- map is also exported as `hiddenBelowClasses`, and
403
- `resolveHiddenBelowClass(breakpoint)` returns the class for a single
404
- breakpoint or `undefined` when no breakpoint is set.
405
-
406
- ## NTable responsive cards, loading, and pagination
407
-
408
- Responsive row actions are visible by default on phone, tablet, and coarse or
409
- non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
410
- keyboard focus always reveals the action. Applications still decide which menu
411
- items exist through `menu`, `onView`, `onEdit`, and `onDelete`; visibility does
412
- not grant an action or replace server authorization.
413
-
414
- When `dynamicHeight` is enabled, table and card loading skeletons measure the
415
- available body. Table rows use the same header/row geometry as dynamic page
416
- sizing, while cards measure the active grid columns, card height, and gap. The
417
- loading surface also follows the loaded `bordered`, design recipe, radius,
418
- border color, shadow, and `classNames.content`/`classNames.cards` contract.
419
-
420
- Use `cardPagination` to choose pagination presentation whenever the effective
421
- rendered mode is cards:
422
-
423
- - `{ mode: "paged" }` (the default) preserves existing pagination.
424
- - `{ mode: "all" }` renders every row already supplied and hides the footer.
425
- - `{ mode: "load-more", ... }` renders every supplied row and provides a
426
- guarded, keyboard-operable Load more/Retry control with polite loading,
427
- appended-result, and end-of-list announcements.
428
-
429
- `showPagination={false}` remains an absolute presentation override and hides
430
- both numbered controls and Load more. In table mode, existing controlled and
431
- manual server pagination remains unchanged.
432
-
433
- ```tsx
434
- import { NTable, type NTableCardPagination } from "najm-kit";
435
-
436
- const cardPagination: NTableCardPagination = {
437
- mode: "load-more",
438
- hasNextPage: query.hasNextPage,
439
- loadingMore: query.isFetchingNextPage,
440
- loadMoreError: query.isFetchNextPageError
441
- ? "The next page could not be loaded."
442
- : undefined,
443
- onLoadMore: () => query.fetchNextPage(),
444
- loadMoreLabel: "Load more",
445
- loadingMoreLabel: "Loading more...",
446
- retryLabel: "Retry",
447
- endLabel: "No more results.",
448
- };
449
-
450
- <NTable
451
- data={query.data?.pages.flatMap((page) => page.rows) ?? []}
452
- columns={columns}
453
- getRowId={(row) => row.id}
454
- renderCard={ResultCard}
455
- cardPagination={cardPagination}
456
- />
457
- ```
458
-
459
- The application owns the query, cursor/offset, accumulated pages, cache
460
- invalidation, search/filter/sort semantics, authorization, and privacy
461
- projection. Najm Kit never imports React Query, calls an endpoint, invents a
462
- page size, or treats supplied rows as proof that every database row is loaded.
463
- Client sorting and filtering cover the rows currently supplied unless the
464
- application implements matching server-side behavior.
465
-
466
- For a responsive screen that uses current-page data in desktop table mode and
467
- accumulated pages in card mode, keep those two query shapes in the application
468
- and pass the appropriate `data`. Crossing the `<640px` responsive-card
469
- breakpoint does not overwrite the user's chosen view, pagination position,
470
- sorting, filters, expansion, or row selection.
471
-
472
- ## Theme-backed charts
473
-
474
- `NBarChart`, `NLineChart`, `NPieChart`, and `NStatusBreakdown` accept generic
475
- caller-formatted data and use `--chart-1` through `--chart-5` by default.
476
- Colors repeat deterministically after the fifth series or item; set `color` on
477
- an exceptional series/item to override that one value. Each chart accepts
478
- `loading`/`loadingLabel` and renders an accessible shape-matched skeleton.
479
- `NPieChart` and `NDonutCard` accept `size="sm" | "md" | "lg"` or a numeric
480
- pixel diameter and shrink within narrow containers.
481
-
482
- ```tsx
483
- import { NBarChart, NPieChart } from "najm-kit";
484
-
485
- const data = [
486
- { id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
487
- { id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
488
- ];
489
-
490
- <NBarChart
491
- title="Monthly activity"
492
- data={data}
493
- series={[
494
- { id: "received", label: "Received" },
495
- { id: "refunded", label: "Refunded" },
496
- ]}
497
- valueFormatter={(value) => `${value} MAD`}
498
- />
499
-
500
- <NPieChart
501
- title="Status"
502
- size={132}
503
- items={[
504
- { id: "active", label: "Active", value: 8 },
505
- { id: "pending", label: "Pending", value: 3 },
506
- ]}
507
- />
508
- ```
509
-
528
+ If you need to inspect or build your own effective column list, the same
529
+ pure helper is exported as `filterResponsiveColumns`. The literal class
530
+ map is also exported as `hiddenBelowClasses`, and
531
+ `resolveHiddenBelowClass(breakpoint)` returns the class for a single
532
+ breakpoint or `undefined` when no breakpoint is set.
533
+
534
+ ## NTable responsive cards, loading, and pagination
535
+
536
+ Responsive row actions are visible by default on phone, tablet, and coarse or
537
+ non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
538
+ keyboard focus always reveals the action. Applications still decide which menu
539
+ items exist through `menu`, `onView`, `onEdit`, and `onDelete`; visibility does
540
+ not grant an action or replace server authorization.
541
+
542
+ When `dynamicHeight` is enabled, table and card loading skeletons measure the
543
+ available body. Table rows use the same header/row geometry as dynamic page
544
+ sizing, while cards measure the active grid columns, card height, and gap. The
545
+ loading surface also follows the loaded `bordered`, design recipe, radius,
546
+ border color, shadow, and `classNames.content`/`classNames.cards` contract.
547
+
548
+ Use `cardPagination` to choose pagination presentation whenever the effective
549
+ rendered mode is cards:
550
+
551
+ - `{ mode: "paged" }` (the default) preserves existing pagination.
552
+ - `{ mode: "all" }` renders every row already supplied and hides the footer.
553
+ - `{ mode: "load-more", ... }` renders every supplied row and provides a
554
+ guarded, keyboard-operable Load more/Retry control with polite loading,
555
+ appended-result, and end-of-list announcements.
556
+
557
+ `showPagination={false}` remains an absolute presentation override and hides
558
+ both numbered controls and Load more. In table mode, existing controlled and
559
+ manual server pagination remains unchanged.
560
+
561
+ ```tsx
562
+ import { NTable, type NTableCardPagination } from "najm-kit";
563
+
564
+ const cardPagination: NTableCardPagination = {
565
+ mode: "load-more",
566
+ hasNextPage: query.hasNextPage,
567
+ loadingMore: query.isFetchingNextPage,
568
+ loadMoreError: query.isFetchNextPageError
569
+ ? "The next page could not be loaded."
570
+ : undefined,
571
+ onLoadMore: () => query.fetchNextPage(),
572
+ loadMoreLabel: "Load more",
573
+ loadingMoreLabel: "Loading more...",
574
+ retryLabel: "Retry",
575
+ endLabel: "No more results.",
576
+ };
577
+
578
+ <NTable
579
+ data={query.data?.pages.flatMap((page) => page.rows) ?? []}
580
+ columns={columns}
581
+ getRowId={(row) => row.id}
582
+ renderCard={ResultCard}
583
+ cardPagination={cardPagination}
584
+ />
585
+ ```
586
+
587
+ The application owns the query, cursor/offset, accumulated pages, cache
588
+ invalidation, search/filter/sort semantics, authorization, and privacy
589
+ projection. Najm Kit never imports React Query, calls an endpoint, invents a
590
+ page size, or treats supplied rows as proof that every database row is loaded.
591
+ Client sorting and filtering cover the rows currently supplied unless the
592
+ application implements matching server-side behavior.
593
+
594
+ For a responsive screen that uses current-page data in desktop table mode and
595
+ accumulated pages in card mode, keep those two query shapes in the application
596
+ and pass the appropriate `data`. Crossing the `<640px` responsive-card
597
+ breakpoint does not overwrite the user's chosen view, pagination position,
598
+ sorting, filters, expansion, or row selection.
599
+
600
+ ## Theme-backed charts
601
+
602
+ `NBarChart`, `NLineChart`, `NPieChart`, and `NStatusBreakdown` accept generic
603
+ caller-formatted data and use `--chart-1` through `--chart-5` by default.
604
+ Colors repeat deterministically after the fifth series or item; set `color` on
605
+ an exceptional series/item to override that one value. Each chart accepts
606
+ `loading`/`loadingLabel` and renders an accessible shape-matched skeleton.
607
+ `NPieChart` and `NDonutCard` accept `size="sm" | "md" | "lg"` or a numeric
608
+ pixel diameter and shrink within narrow containers.
609
+
610
+ ```tsx
611
+ import { NBarChart, NPieChart } from "najm-kit";
612
+
613
+ const data = [
614
+ { id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
615
+ { id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
616
+ ];
617
+
618
+ <NBarChart
619
+ title="Monthly activity"
620
+ data={data}
621
+ series={[
622
+ { id: "received", label: "Received" },
623
+ { id: "refunded", label: "Refunded" },
624
+ ]}
625
+ valueFormatter={(value) => `${value} MAD`}
626
+ />
627
+
628
+ <NPieChart
629
+ title="Status"
630
+ size={132}
631
+ items={[
632
+ { id: "active", label: "Active", value: 8 },
633
+ { id: "pending", label: "Pending", value: 3 },
634
+ ]}
635
+ />
636
+ ```
637
+
510
638
  ### Server-backed combobox search
511
639
 
512
640
  `ComboboxInput` and `FormInput type="combobox"` can delegate filtering to a
@@ -600,4 +728,116 @@ only, after a real `image` and before the role's gender variant:
600
728
  ```ts
601
729
  getPersonImage({ image: child.image, role: "child", gender: child.gender, fallback: child.placeholder });
602
730
  ```
603
-
731
+
732
+
733
+ ## Server UI bootstrap (`najm-kit/server`, `najm-kit/server/react`)
734
+
735
+ An application that renders its own theme and its own logos on the server ends
736
+ up writing the same module every time: fetch the public endpoints, unwrap the
737
+ `data` envelope, validate the payload, fall back to the built-in assets when
738
+ any of that fails, and run the resources in parallel. These two entries own
739
+ that mechanism. What stays with the application is what is genuinely
740
+ application-specific — how a request reaches its own backend, which paths it
741
+ serves, what a valid payload looks like, what the factory values are, and where
742
+ a diagnostic goes.
743
+
744
+ Neither entry is re-exported from `najm-kit`, `najm-kit/next`, or
745
+ `najm-kit/app`. `najm-kit/server` imports no React at all, so a route handler
746
+ or a plain script can use it.
747
+
748
+ ### The application's one server module
749
+
750
+ ```ts
751
+ // src/lib/serverLoader.ts
752
+ import "server-only";
753
+
754
+ import { parseNajmDesignConfig } from "najm-kit/server";
755
+ import { createReactServerUiBootstrap } from "najm-kit/server/react";
756
+
757
+ export const serverUi = createReactServerUiBootstrap({
758
+ fetcher: async (path) => {
759
+ const { server } = await import("@app/server");
760
+ return server.fetch(new Request(`http://internal${path}`));
761
+ },
762
+ resources: {
763
+ appearance: {
764
+ path: "/api/appearance",
765
+ parse: parseAppearance, // returns undefined or throws to reject
766
+ fallback: getFactoryAppearance, // called per load
767
+ },
768
+ branding: {
769
+ path: "/api/branding",
770
+ parse: parseBranding,
771
+ fallback: getFactoryBranding,
772
+ },
773
+ },
774
+ onDiagnostic: (diagnostic) => {
775
+ console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
776
+ },
777
+ });
778
+
779
+ export const loadServerUiBootstrap = serverUi.load;
780
+ export const { appearance: loadServerAppearance, branding: loadServerBranding } =
781
+ serverUi.loaders;
782
+ ```
783
+
784
+ `load()` resolves every resource; `loaders.<name>()` and `loadResource(name)`
785
+ read one off the same resolution. Resource names, payload types, and the number
786
+ of resources are the application's — the snapshot type is inferred from the
787
+ `resources` object, so `snapshot.branding` is your branding type and not a
788
+ package interface.
789
+
790
+ ### Call the factory once, at module scope
791
+
792
+ `createReactServerUiBootstrap()` builds one `React.cache()` entry. Calling it
793
+ inside a layout, page, or component builds a fresh one per call and shares
794
+ nothing. Every server boundary in a render must import the same module.
795
+
796
+ The cache is React's, so it is request-scoped and nothing else: separate
797
+ requests never see each other's snapshot or each other's failure, and a
798
+ transient outage is retried on the next request rather than pinned into a
799
+ process-global. That also rules out a module `Map`, a module promise,
800
+ `unstable_cache`, `"use cache"`, or a durable cache here — every one of them
801
+ would leak one visitor's render into another's.
802
+
803
+ The snapshot is deliberately stable for the length of one render. A settings
804
+ surface that saves appearance or branding updates the client provider and then
805
+ refreshes or navigates into a new render to observe the persisted result.
806
+
807
+ Outside a render — route handlers, server actions, scripts — use
808
+ `createUiBootstrapLoader()` from `najm-kit/server` directly. There is no request
809
+ cache for `cache()` to write to there, so the adapter would silently re-fetch
810
+ per call.
811
+
812
+ ### Failure behaviour
813
+
814
+ Resources fall back independently: a branding outage never discards a valid
815
+ appearance. Each failure calls `onDiagnostic` once with a `reason` of
816
+ `fetch-failed`, `response-not-ok`, `invalid-json`, `invalid-envelope`, or
817
+ `invalid-payload`, plus the path and — for a non-success response — the status.
818
+ Diagnostics never carry response bodies, headers, cookies, or raw thrown
819
+ values; `error` is a normalized `"<name>: <message>"` for an `Error` and the
820
+ value's type for anything else.
821
+
822
+ A `fallback()` that throws is **not** caught. A missing factory theme is the
823
+ application's configuration error, and a second fallback would only hide it.
824
+
825
+ Falling back is right for *public* appearance and branding, where the worst case
826
+ is a visitor seeing the built-in logo. It is not a general rule: do not route
827
+ authenticated, financial, or privacy-sensitive reads through this, because a
828
+ silent fallback there hides an outage behind plausible-looking data.
829
+
830
+ ### Envelopes
831
+
832
+ `select` defaults to Najm's `{ data }` envelope. Applications behind a different
833
+ envelope pass their own at the loader level or per resource; returning the
834
+ payload unchanged is a valid selector, and throwing rejects the response as
835
+ `invalid-envelope`.
836
+
837
+ ### Client Components
838
+
839
+ `najm-kit/server/react` maps the `browser` export condition to a module that
840
+ throws, so importing it from a Client Component fails the build with an
841
+ explanation rather than shipping the application's fetcher and factory values
842
+ into a browser bundle. Seed the client from the server snapshot through
843
+ `NajmAppProvider` instead.