najm-kit 2.9.0 → 2.11.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
 
@@ -142,47 +142,297 @@ import { Form, FormInput, useNForm } from 'najm-kit';
142
142
  |----------|-----------|
143
143
  | Actions | NButton, IconButton, toggleVariants |
144
144
  | Forms | Input, Textarea, Label, Select, Checkbox, RadioGroup, Switch, DateInput, FileInput, ImageInput, AvatarInput |
145
- | Feedback | Alert, Badge, Progress, Spinner, Toast |
145
+ | Feedback | Alert, Badge, Progress, Spinner, Toast, NLoadingState, NErrorState, NEmptyState, NForbiddenState, NNotFoundState |
146
146
  | Layout | Card, Sheet, Dialog, Popover, DropdownMenu, Tabs |
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
147
+ | Data | Table (NTable), StatCard, DetailList, CredentialsCard |
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
+ ## Feedback states
279
+
280
+ Five public state components cover the reusable cases every application
281
+ otherwise repeats: `NLoadingState`, `NErrorState`, `NEmptyState`,
282
+ `NForbiddenState`, and `NNotFoundState`. They share one layout frame and one
283
+ provider-defaults channel, so an application configures its copy once and
284
+ every consumer below inherits it.
285
+
286
+ ### Surfaces
287
+
288
+ Three layouts, one prop. `surface` selects the frame:
289
+
290
+ | `surface` | Use it for | What it does |
291
+ | --- | --- | --- |
292
+ | `"inline"` (default) | A small slot inside an existing component | Legacy sizing, no landmark |
293
+ | `"panel"` | A table body, card body, dialog, or sheet | Centered with a minimum height, no page gutter, no landmark |
294
+ | `"page"` | A real route-level state | Uses page spacing from the design config; renders through a non-`<main>` root |
295
+
296
+ `NLoadingState.fullScreen` keeps its fixed viewport overlay regardless of
297
+ surface — it always wins.
298
+
299
+ ```tsx
300
+ import { NLoadingState, NErrorState, NEmptyState } from 'najm-kit';
301
+
302
+ // Inline (default): drop into a card or section.
303
+ <NLoadingState label="Loading orders..." />
304
+
305
+ // Panel: table body or dialog content.
306
+ <NEmptyState surface="panel" title="No orders yet" icon={Inbox} />
307
+
308
+ // Page: route-level empty state. Never introduces a second <main>.
309
+ <NErrorState
310
+ surface="page"
311
+ title="Dashboard unavailable"
312
+ message="We are working on it."
313
+ onRetry={() => refetch()}
314
+ />
315
+ ```
316
+
317
+ ### Provider defaults
318
+
319
+ Pass one `feedbackDefaults` map to `NajmUIProvider` (or to `NajmAppProvider`
320
+ through it) and every feedback state beneath uses it. There is one place for
321
+ loading, empty, error, retry, forbidden, and not-found labels, and a single
322
+ language change recomputes them all without remounting the tree.
323
+
324
+ ```tsx
325
+ import { NajmAppProvider } from 'najm-kit/app';
326
+
327
+ <NajmAppProvider
328
+ feedbackDefaults={{
329
+ labels: {
330
+ loadingLabel: 'Chargement…',
331
+ emptyTitle: 'Aucune donnée',
332
+ errorTitle: 'Une erreur est survenue',
333
+ retryLabel: 'Réessayer',
334
+ forbiddenTitle: 'Accès refusé',
335
+ forbiddenDescription: 'Vous n\'avez pas la permission.',
336
+ notFoundTitle: 'Page introuvable',
337
+ notFoundDescription: 'La page demandée n\'existe pas.',
338
+ },
339
+ labelKeys: {
340
+ emptyTitle: 'common.empty',
341
+ errorTitle: 'common.error',
342
+ },
343
+ }}
344
+ >
345
+ <App />
346
+ </NajmAppProvider>
347
+ ```
348
+
349
+ Resolution order, most specific first:
350
+
351
+ 1. An explicit component prop.
352
+ 2. A literal in `feedbackDefaults.labels`.
353
+ 3. A translated `feedbackDefaults.labelKeys` value resolved through the
354
+ provider's existing structural `t` function.
355
+ 4. The current packaged English fallback, when that field has one.
356
+
357
+ Generic `NErrorState.message` and `NEmptyState.description` deliberately have
358
+ no packaged fallback — the no-provider render must look the same as it did
359
+ before this contract shipped. A configured `errorMessage` opts the generic
360
+ error state into rendering a body; absent that opt-in, the existing
361
+ no-body render is preserved.
362
+
363
+ ### Forbidden and not-found
364
+
365
+ Two first-class preset states for the routes every application grows:
366
+
367
+ ```tsx
368
+ import { NForbiddenState, NNotFoundState } from 'najm-kit';
369
+
370
+ // Forbidden: provider copy + ShieldOff icon + page surface by default.
371
+ <NForbiddenState
372
+ action={<Link href="/dashboard">Back to dashboard</Link>}
373
+ />
374
+
375
+ // Not found: provider copy + Compass icon + page surface by default.
376
+ <NNotFoundState
377
+ action={<Link href="/dashboard">Back to dashboard</Link>}
378
+ />
379
+ ```
380
+
381
+ Both are presentation only. They do not know the dashboard URL, render a
382
+ Next `Link`, redirect, or write route metadata — those belong to the
383
+ application's `not-found.tsx` / `forbidden/page.tsx` files.
384
+
385
+ ### Root and `najm-kit/app` imports
386
+
387
+ Both entries export the same five state components. Pick the one that matches
388
+ your boundary:
389
+
390
+ ```tsx
391
+ // Client feature code: import from the root barrel.
392
+ import { NEmptyState } from 'najm-kit';
393
+
394
+ // Next Server Component route: import from najm-kit/app, which is the
395
+ // Client Component boundary. A route file can render a state component
396
+ // without authoring a local "use client" wrapper.
397
+ import { NNotFoundState } from 'najm-kit/app';
398
+ ```
399
+
400
+ ## Global form development tools
401
+
402
+ Enable schema-driven test values once on the full application provider. Every
403
+ `NForm` and `WizardForm` below it then fills from its Zod schema when F8 is
404
+ pressed; applications do not need a second provider or a form-fill helper.
405
+
406
+ ```tsx
407
+ import { NajmAppProvider } from "najm-kit/app";
408
+
409
+ <NajmAppProvider formDevTools>
410
+ <App />
411
+ </NajmAppProvider>;
412
+ ```
413
+
414
+ Pass a boolean to control it from application settings:
415
+
416
+ ```tsx
417
+ <NajmAppProvider formDevTools={formFillEnabled}>
418
+ <App />
419
+ </NajmAppProvider>
420
+ ```
421
+
422
+ Forms with live relation options can override only those fields. The provider
423
+ still owns enablement and Najm Kit still owns schema traversal and generation.
424
+
425
+ ```tsx
426
+ <NForm
427
+ schema={orderSchema}
428
+ devTools={{ overrides: { customerId: customerOptions } }}
429
+ onSubmit={saveOrder}
430
+ >
431
+ {/* fields */}
432
+ </NForm>
433
+ ```
434
+
435
+ ## ImageInput and AvatarInput
186
436
 
187
437
  `ImageInput` and `AvatarInput` ship with a resilient preview contract so
188
438
  consumers do not need to wrap them with application-specific preview
@@ -247,93 +497,157 @@ Key behaviors:
247
497
  a newer value, and object URLs created by the component are tracked so
248
498
  consumer-owned blob URLs are never revoked.
249
499
 
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
500
+ `AvatarInput` forwards every preview and accessibility prop unchanged while
501
+ preserving its circular, size, fill, and camera-icon defaults.
502
+
503
+ ## Credentials handover
504
+
505
+ `NCredentialsCard` renders the recurring "show a freshly generated secret
506
+ once, let the operator hand it over, never show it again" surface. It owns
507
+ the frame, the description-list semantics, the copy flow, the failure
508
+ handling, and the accessible feedback. Every domain label — title,
509
+ description, field labels, action labels, and any translated toast — stays
510
+ with the application.
511
+
512
+ ```tsx
513
+ import { NCredentialsCard, NButton } from "najm-kit";
514
+ import { KeyRound, Phone } from "lucide-react";
515
+
516
+ <NCredentialsCard
517
+ title={t("staff.access.created")}
518
+ description={t("staff.access.oneTimeHint")}
519
+ fields={[
520
+ { label: t("common.phone"), value: credentials.phone, icon: Phone },
521
+ { label: t("staff.access.initialPassword"), value: credentials.password, icon: KeyRound },
522
+ ]}
523
+ copyLabel={t("common.copyDetails")}
524
+ copiedLabel={t("common.copied")}
525
+ copyErrorLabel={t("common.copyError")}
526
+ actions={<NButton onClick={() => pop()}>{t("common.done")}</NButton>}
527
+ />
528
+ ```
529
+
530
+ Behaviour worth knowing:
531
+
532
+ - `fields` renders as a `<dl>` of `<dt>`/`<dd>` pairs. Values default to
533
+ monospaced and mid-string wrapping so secrets stay readable on every
534
+ width.
535
+ - The header icon defaults to a check mark and is always rendered when a
536
+ header is shown. Pass `icon={SomeLucideIcon}` to replace it; pass any
537
+ supported `NIconSource` to swap in a logo, image, or remote URL.
538
+ - The Copy button resolves text through `copyText` when supplied, otherwise
539
+ joins `${label}: ${value}` with `\n` in field order. The button is
540
+ disabled while the clipboard write is pending. Success swaps the label to
541
+ `copiedLabel` and a check icon; failure swaps to `copyErrorLabel` and a
542
+ warning icon. Either state reverts to idle after roughly two seconds.
543
+ - The copy button renders before any consumer `actions` so a Done-style
544
+ dismiss stays the last tab stop and never gets pressed before the secret
545
+ is actually copied.
546
+ - Missing `navigator.clipboard`, rejected `writeText`, and synchronously
547
+ thrown `copyText` / `writeText` all land in the error state and call
548
+ `onCopyError` instead of rethrowing. State updates and revert timers are
549
+ guarded, so unmounting during a pending copy, or starting a second copy
550
+ while the first success state is still showing, never fire stale setters.
551
+ - Status is announced through a polite `aria-live` region. The visible swap
552
+ is the primary feedback — no toast is emitted by the component.
553
+ - Packaged English fallbacks exist for `copyLabel`, `copiedLabel`, and
554
+ `copyErrorLabel` only. Title, description, and every field label are the
555
+ application's text; a consumer that omits them gets no text, not English.
556
+ - Spacing uses logical properties only, so a `dir="rtl"` tree needs no
557
+ override. Each value also carries `dir="auto"`, isolating it from the
558
+ surrounding paragraph direction: a phone number or password inherited into an
559
+ RTL tree otherwise *paints* reordered (`+1 555 0100` as `0100 555 1+`) even
560
+ though the DOM and the copied text are correct. A value whose first strong
561
+ character is Arabic still renders right-to-left.
562
+
563
+ When you only want consumer buttons and no built-in copy, pass
564
+ `hideCopyAction`. Pass `copyText` to format the copied text differently
565
+ (one CSV line per field, a JSON blob, a single concatenated value, …).
566
+
567
+ ## Formatting
568
+
569
+ Pure formatters are available from the server-safe `najm-kit/format` entry.
570
+ Money values are integer minor units and use the currency's own exponent (for
571
+ example MAD has two decimals, JPY zero, and KWD three).
572
+
573
+ ```ts
574
+ import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
575
+
576
+ formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
577
+ formatDate('2026-08-08T20:00:00Z', {
578
+ locale: 'fr-MA',
579
+ timeZone: 'Africa/Casablanca',
580
+ });
581
+ slugify('Najm Format & Pagination');
582
+ ```
583
+
584
+ Client code can use the active locale, time zone, currency, and placeholder
585
+ through `useNajmFormat`. `NajmAppProvider` mounts the format provider for you:
586
+
587
+ ```tsx
588
+ import { NajmAppProvider } from 'najm-kit/app';
589
+ import { useNajmFormat } from 'najm-kit';
590
+
591
+ <NajmAppProvider
592
+ translations={translations}
593
+ currency="MAD"
594
+ locales={{ en: 'en-MA', fr: 'fr-MA' }}
595
+ >
596
+ <App />
597
+ </NajmAppProvider>
598
+
599
+ function Total({ value }: { value: number }) {
600
+ return <span>{useNajmFormat().money(value)}</span>;
601
+ }
602
+ ```
603
+
604
+ ## Offset pagination and queries
605
+
606
+ `najm-kit/pagination` is server-safe and framework-independent. It accepts
607
+ endpoints that return either `{ rows, total }` or a bare row array. When no
608
+ total exists it probes for one extra row; when a total exists continuation is
609
+ calculated without another request.
610
+
611
+ ```ts
612
+ import {
613
+ createOffsetPagination,
614
+ fetchOffsetPage,
615
+ } from 'najm-kit/pagination';
616
+
617
+ const pagination = createOffsetPagination(pageIndex, pageSize);
618
+ const page = await fetchOffsetPage(
619
+ ({ limit, offset }) => api.orders.list({ limit, offset }),
620
+ pagination,
621
+ );
622
+ ```
623
+
624
+ React Query consumers install the optional `@tanstack/react-query` peer and use
625
+ the isolated `najm-kit/query` entry. `useResponsiveOffsetList` resolves numbered
626
+ desktop paging versus card continuation and exposes props that plug directly
627
+ into `NTable` and `createCardPagination`.
628
+
629
+ ```tsx
630
+ import { NTable, createCardPagination } from 'najm-kit';
631
+ import { useResponsiveOffsetList } from 'najm-kit/query';
632
+
633
+ const list = useResponsiveOffsetList({
634
+ queryKey: ['orders'],
635
+ fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
636
+ strategy: 'paged',
637
+ });
638
+
639
+ <NTable
640
+ data={list.data}
641
+ columns={columns}
642
+ manualPagination
643
+ pageCount={list.pageCount}
644
+ pagination={list.pagination}
645
+ onPaginationChange={list.onPaginationChange}
646
+ cardPagination={createCardPagination(list, labels)}
647
+ />
648
+ ```
649
+
650
+ ## Hooks
337
651
 
338
652
  ```tsx
339
653
  import { useKeyboard } from 'najm-kit';
@@ -397,116 +711,116 @@ Notes:
397
711
  - The columns the TanStack table receives are already filtered, so the
398
712
  settings menu will not list `visible: false` columns.
399
713
 
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
-
714
+ If you need to inspect or build your own effective column list, the same
715
+ pure helper is exported as `filterResponsiveColumns`. The literal class
716
+ map is also exported as `hiddenBelowClasses`, and
717
+ `resolveHiddenBelowClass(breakpoint)` returns the class for a single
718
+ breakpoint or `undefined` when no breakpoint is set.
719
+
720
+ ## NTable responsive cards, loading, and pagination
721
+
722
+ Responsive row actions are visible by default on phone, tablet, and coarse or
723
+ non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
724
+ keyboard focus always reveals the action. Applications still decide which menu
725
+ items exist through `menu`, `onView`, `onEdit`, and `onDelete`; visibility does
726
+ not grant an action or replace server authorization.
727
+
728
+ When `dynamicHeight` is enabled, table and card loading skeletons measure the
729
+ available body. Table rows use the same header/row geometry as dynamic page
730
+ sizing, while cards measure the active grid columns, card height, and gap. The
731
+ loading surface also follows the loaded `bordered`, design recipe, radius,
732
+ border color, shadow, and `classNames.content`/`classNames.cards` contract.
733
+
734
+ Use `cardPagination` to choose pagination presentation whenever the effective
735
+ rendered mode is cards:
736
+
737
+ - `{ mode: "paged" }` (the default) preserves existing pagination.
738
+ - `{ mode: "all" }` renders every row already supplied and hides the footer.
739
+ - `{ mode: "load-more", ... }` renders every supplied row and provides a
740
+ guarded, keyboard-operable Load more/Retry control with polite loading,
741
+ appended-result, and end-of-list announcements.
742
+
743
+ `showPagination={false}` remains an absolute presentation override and hides
744
+ both numbered controls and Load more. In table mode, existing controlled and
745
+ manual server pagination remains unchanged.
746
+
747
+ ```tsx
748
+ import { NTable, type NTableCardPagination } from "najm-kit";
749
+
750
+ const cardPagination: NTableCardPagination = {
751
+ mode: "load-more",
752
+ hasNextPage: query.hasNextPage,
753
+ loadingMore: query.isFetchingNextPage,
754
+ loadMoreError: query.isFetchNextPageError
755
+ ? "The next page could not be loaded."
756
+ : undefined,
757
+ onLoadMore: () => query.fetchNextPage(),
758
+ loadMoreLabel: "Load more",
759
+ loadingMoreLabel: "Loading more...",
760
+ retryLabel: "Retry",
761
+ endLabel: "No more results.",
762
+ };
763
+
764
+ <NTable
765
+ data={query.data?.pages.flatMap((page) => page.rows) ?? []}
766
+ columns={columns}
767
+ getRowId={(row) => row.id}
768
+ renderCard={ResultCard}
769
+ cardPagination={cardPagination}
770
+ />
771
+ ```
772
+
773
+ The application owns the query, cursor/offset, accumulated pages, cache
774
+ invalidation, search/filter/sort semantics, authorization, and privacy
775
+ projection. Najm Kit never imports React Query, calls an endpoint, invents a
776
+ page size, or treats supplied rows as proof that every database row is loaded.
777
+ Client sorting and filtering cover the rows currently supplied unless the
778
+ application implements matching server-side behavior.
779
+
780
+ For a responsive screen that uses current-page data in desktop table mode and
781
+ accumulated pages in card mode, keep those two query shapes in the application
782
+ and pass the appropriate `data`. Crossing the `<640px` responsive-card
783
+ breakpoint does not overwrite the user's chosen view, pagination position,
784
+ sorting, filters, expansion, or row selection.
785
+
786
+ ## Theme-backed charts
787
+
788
+ `NBarChart`, `NLineChart`, `NPieChart`, and `NStatusBreakdown` accept generic
789
+ caller-formatted data and use `--chart-1` through `--chart-5` by default.
790
+ Colors repeat deterministically after the fifth series or item; set `color` on
791
+ an exceptional series/item to override that one value. Each chart accepts
792
+ `loading`/`loadingLabel` and renders an accessible shape-matched skeleton.
793
+ `NPieChart` and `NDonutCard` accept `size="sm" | "md" | "lg"` or a numeric
794
+ pixel diameter and shrink within narrow containers.
795
+
796
+ ```tsx
797
+ import { NBarChart, NPieChart } from "najm-kit";
798
+
799
+ const data = [
800
+ { id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
801
+ { id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
802
+ ];
803
+
804
+ <NBarChart
805
+ title="Monthly activity"
806
+ data={data}
807
+ series={[
808
+ { id: "received", label: "Received" },
809
+ { id: "refunded", label: "Refunded" },
810
+ ]}
811
+ valueFormatter={(value) => `${value} MAD`}
812
+ />
813
+
814
+ <NPieChart
815
+ title="Status"
816
+ size={132}
817
+ items={[
818
+ { id: "active", label: "Active", value: 8 },
819
+ { id: "pending", label: "Pending", value: 3 },
820
+ ]}
821
+ />
822
+ ```
823
+
510
824
  ### Server-backed combobox search
511
825
 
512
826
  `ComboboxInput` and `FormInput type="combobox"` can delegate filtering to a
@@ -600,116 +914,116 @@ only, after a real `image` and before the role's gender variant:
600
914
  ```ts
601
915
  getPersonImage({ image: child.image, role: "child", gender: child.gender, fallback: child.placeholder });
602
916
  ```
603
-
604
-
605
- ## Server UI bootstrap (`najm-kit/server`, `najm-kit/server/react`)
606
-
607
- An application that renders its own theme and its own logos on the server ends
608
- up writing the same module every time: fetch the public endpoints, unwrap the
609
- `data` envelope, validate the payload, fall back to the built-in assets when
610
- any of that fails, and run the resources in parallel. These two entries own
611
- that mechanism. What stays with the application is what is genuinely
612
- application-specific — how a request reaches its own backend, which paths it
613
- serves, what a valid payload looks like, what the factory values are, and where
614
- a diagnostic goes.
615
-
616
- Neither entry is re-exported from `najm-kit`, `najm-kit/next`, or
617
- `najm-kit/app`. `najm-kit/server` imports no React at all, so a route handler
618
- or a plain script can use it.
619
-
620
- ### The application's one server module
621
-
622
- ```ts
623
- // src/lib/serverLoader.ts
624
- import "server-only";
625
-
626
- import { parseNajmDesignConfig } from "najm-kit/server";
627
- import { createReactServerUiBootstrap } from "najm-kit/server/react";
628
-
629
- export const serverUi = createReactServerUiBootstrap({
630
- fetcher: async (path) => {
631
- const { server } = await import("@app/server");
632
- return server.fetch(new Request(`http://internal${path}`));
633
- },
634
- resources: {
635
- appearance: {
636
- path: "/api/appearance",
637
- parse: parseAppearance, // returns undefined or throws to reject
638
- fallback: getFactoryAppearance, // called per load
639
- },
640
- branding: {
641
- path: "/api/branding",
642
- parse: parseBranding,
643
- fallback: getFactoryBranding,
644
- },
645
- },
646
- onDiagnostic: (diagnostic) => {
647
- console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
648
- },
649
- });
650
-
651
- export const loadServerUiBootstrap = serverUi.load;
652
- export const { appearance: loadServerAppearance, branding: loadServerBranding } =
653
- serverUi.loaders;
654
- ```
655
-
656
- `load()` resolves every resource; `loaders.<name>()` and `loadResource(name)`
657
- read one off the same resolution. Resource names, payload types, and the number
658
- of resources are the application's — the snapshot type is inferred from the
659
- `resources` object, so `snapshot.branding` is your branding type and not a
660
- package interface.
661
-
662
- ### Call the factory once, at module scope
663
-
664
- `createReactServerUiBootstrap()` builds one `React.cache()` entry. Calling it
665
- inside a layout, page, or component builds a fresh one per call and shares
666
- nothing. Every server boundary in a render must import the same module.
667
-
668
- The cache is React's, so it is request-scoped and nothing else: separate
669
- requests never see each other's snapshot or each other's failure, and a
670
- transient outage is retried on the next request rather than pinned into a
671
- process-global. That also rules out a module `Map`, a module promise,
672
- `unstable_cache`, `"use cache"`, or a durable cache here — every one of them
673
- would leak one visitor's render into another's.
674
-
675
- The snapshot is deliberately stable for the length of one render. A settings
676
- surface that saves appearance or branding updates the client provider and then
677
- refreshes or navigates into a new render to observe the persisted result.
678
-
679
- Outside a render — route handlers, server actions, scripts — use
680
- `createUiBootstrapLoader()` from `najm-kit/server` directly. There is no request
681
- cache for `cache()` to write to there, so the adapter would silently re-fetch
682
- per call.
683
-
684
- ### Failure behaviour
685
-
686
- Resources fall back independently: a branding outage never discards a valid
687
- appearance. Each failure calls `onDiagnostic` once with a `reason` of
688
- `fetch-failed`, `response-not-ok`, `invalid-json`, `invalid-envelope`, or
689
- `invalid-payload`, plus the path and — for a non-success response — the status.
690
- Diagnostics never carry response bodies, headers, cookies, or raw thrown
691
- values; `error` is a normalized `"<name>: <message>"` for an `Error` and the
692
- value's type for anything else.
693
-
694
- A `fallback()` that throws is **not** caught. A missing factory theme is the
695
- application's configuration error, and a second fallback would only hide it.
696
-
697
- Falling back is right for *public* appearance and branding, where the worst case
698
- is a visitor seeing the built-in logo. It is not a general rule: do not route
699
- authenticated, financial, or privacy-sensitive reads through this, because a
700
- silent fallback there hides an outage behind plausible-looking data.
701
-
702
- ### Envelopes
703
-
704
- `select` defaults to Najm's `{ data }` envelope. Applications behind a different
705
- envelope pass their own at the loader level or per resource; returning the
706
- payload unchanged is a valid selector, and throwing rejects the response as
707
- `invalid-envelope`.
708
-
709
- ### Client Components
710
-
711
- `najm-kit/server/react` maps the `browser` export condition to a module that
712
- throws, so importing it from a Client Component fails the build with an
713
- explanation rather than shipping the application's fetcher and factory values
714
- into a browser bundle. Seed the client from the server snapshot through
715
- `NajmAppProvider` instead.
917
+
918
+
919
+ ## Server UI bootstrap (`najm-kit/server`, `najm-kit/server/react`)
920
+
921
+ An application that renders its own theme and its own logos on the server ends
922
+ up writing the same module every time: fetch the public endpoints, unwrap the
923
+ `data` envelope, validate the payload, fall back to the built-in assets when
924
+ any of that fails, and run the resources in parallel. These two entries own
925
+ that mechanism. What stays with the application is what is genuinely
926
+ application-specific — how a request reaches its own backend, which paths it
927
+ serves, what a valid payload looks like, what the factory values are, and where
928
+ a diagnostic goes.
929
+
930
+ Neither entry is re-exported from `najm-kit`, `najm-kit/next`, or
931
+ `najm-kit/app`. `najm-kit/server` imports no React at all, so a route handler
932
+ or a plain script can use it.
933
+
934
+ ### The application's one server module
935
+
936
+ ```ts
937
+ // src/lib/serverLoader.ts
938
+ import "server-only";
939
+
940
+ import { parseNajmDesignConfig } from "najm-kit/server";
941
+ import { createReactServerUiBootstrap } from "najm-kit/server/react";
942
+
943
+ export const serverUi = createReactServerUiBootstrap({
944
+ fetcher: async (path) => {
945
+ const { server } = await import("@app/server");
946
+ return server.fetch(new Request(`http://internal${path}`));
947
+ },
948
+ resources: {
949
+ appearance: {
950
+ path: "/api/appearance",
951
+ parse: parseAppearance, // returns undefined or throws to reject
952
+ fallback: getFactoryAppearance, // called per load
953
+ },
954
+ branding: {
955
+ path: "/api/branding",
956
+ parse: parseBranding,
957
+ fallback: getFactoryBranding,
958
+ },
959
+ },
960
+ onDiagnostic: (diagnostic) => {
961
+ console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
962
+ },
963
+ });
964
+
965
+ export const loadServerUiBootstrap = serverUi.load;
966
+ export const { appearance: loadServerAppearance, branding: loadServerBranding } =
967
+ serverUi.loaders;
968
+ ```
969
+
970
+ `load()` resolves every resource; `loaders.<name>()` and `loadResource(name)`
971
+ read one off the same resolution. Resource names, payload types, and the number
972
+ of resources are the application's — the snapshot type is inferred from the
973
+ `resources` object, so `snapshot.branding` is your branding type and not a
974
+ package interface.
975
+
976
+ ### Call the factory once, at module scope
977
+
978
+ `createReactServerUiBootstrap()` builds one `React.cache()` entry. Calling it
979
+ inside a layout, page, or component builds a fresh one per call and shares
980
+ nothing. Every server boundary in a render must import the same module.
981
+
982
+ The cache is React's, so it is request-scoped and nothing else: separate
983
+ requests never see each other's snapshot or each other's failure, and a
984
+ transient outage is retried on the next request rather than pinned into a
985
+ process-global. That also rules out a module `Map`, a module promise,
986
+ `unstable_cache`, `"use cache"`, or a durable cache here — every one of them
987
+ would leak one visitor's render into another's.
988
+
989
+ The snapshot is deliberately stable for the length of one render. A settings
990
+ surface that saves appearance or branding updates the client provider and then
991
+ refreshes or navigates into a new render to observe the persisted result.
992
+
993
+ Outside a render — route handlers, server actions, scripts — use
994
+ `createUiBootstrapLoader()` from `najm-kit/server` directly. There is no request
995
+ cache for `cache()` to write to there, so the adapter would silently re-fetch
996
+ per call.
997
+
998
+ ### Failure behaviour
999
+
1000
+ Resources fall back independently: a branding outage never discards a valid
1001
+ appearance. Each failure calls `onDiagnostic` once with a `reason` of
1002
+ `fetch-failed`, `response-not-ok`, `invalid-json`, `invalid-envelope`, or
1003
+ `invalid-payload`, plus the path and — for a non-success response — the status.
1004
+ Diagnostics never carry response bodies, headers, cookies, or raw thrown
1005
+ values; `error` is a normalized `"<name>: <message>"` for an `Error` and the
1006
+ value's type for anything else.
1007
+
1008
+ A `fallback()` that throws is **not** caught. A missing factory theme is the
1009
+ application's configuration error, and a second fallback would only hide it.
1010
+
1011
+ Falling back is right for *public* appearance and branding, where the worst case
1012
+ is a visitor seeing the built-in logo. It is not a general rule: do not route
1013
+ authenticated, financial, or privacy-sensitive reads through this, because a
1014
+ silent fallback there hides an outage behind plausible-looking data.
1015
+
1016
+ ### Envelopes
1017
+
1018
+ `select` defaults to Najm's `{ data }` envelope. Applications behind a different
1019
+ envelope pass their own at the loader level or per resource; returning the
1020
+ payload unchanged is a valid selector, and throwing rejects the response as
1021
+ `invalid-envelope`.
1022
+
1023
+ ### Client Components
1024
+
1025
+ `najm-kit/server/react` maps the `browser` export condition to a module that
1026
+ throws, so importing it from a Client Component fails the build with an
1027
+ explanation rather than shipping the application's fetcher and factory values
1028
+ into a browser bundle. Seed the client from the server snapshot through
1029
+ `NajmAppProvider` instead.