@keboola/design 9.1.0 → 10.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.
Files changed (84) hide show
  1. package/AGENTS.md +117 -40
  2. package/COMPONENT-API.md +31 -9
  3. package/README.md +3 -3
  4. package/dist/MermaidDiagram-EOKJHQQV.js +10 -0
  5. package/dist/MermaidDiagram-EOKJHQQV.js.map +1 -0
  6. package/dist/charts.d.ts +31 -2
  7. package/dist/charts.js +6 -9
  8. package/dist/charts.js.map +1 -1
  9. package/dist/chunk-63NSN2VL.js +274 -0
  10. package/dist/chunk-63NSN2VL.js.map +1 -0
  11. package/dist/{rjsf.cjs → chunk-6FPEI4EM.js} +1491 -5701
  12. package/dist/chunk-6FPEI4EM.js.map +1 -0
  13. package/dist/chunk-7PUUHEJQ.js +1313 -0
  14. package/dist/chunk-7PUUHEJQ.js.map +1 -0
  15. package/dist/chunk-BOHNXTMM.js +606 -0
  16. package/dist/chunk-BOHNXTMM.js.map +1 -0
  17. package/dist/{testing.cjs → chunk-C3L3TBBT.js} +33 -164
  18. package/dist/chunk-C3L3TBBT.js.map +1 -0
  19. package/dist/chunk-CTCFYWJC.js +407 -0
  20. package/dist/chunk-CTCFYWJC.js.map +1 -0
  21. package/dist/chunk-ESBL3IPF.js +23 -0
  22. package/dist/chunk-ESBL3IPF.js.map +1 -0
  23. package/dist/chunk-IJTHFNNJ.js +261 -0
  24. package/dist/chunk-IJTHFNNJ.js.map +1 -0
  25. package/dist/chunk-JP7PNHLH.js +40 -0
  26. package/dist/chunk-JP7PNHLH.js.map +1 -0
  27. package/dist/chunk-KPNAW5MU.js +229 -0
  28. package/dist/chunk-KPNAW5MU.js.map +1 -0
  29. package/dist/chunk-L37JJ6MR.js +152 -0
  30. package/dist/chunk-L37JJ6MR.js.map +1 -0
  31. package/dist/chunk-Q7NWXLCT.js +90 -0
  32. package/dist/chunk-Q7NWXLCT.js.map +1 -0
  33. package/dist/chunk-SYIEF644.js +1016 -0
  34. package/dist/chunk-SYIEF644.js.map +1 -0
  35. package/dist/chunk-VMFSRV5H.js +16 -0
  36. package/dist/chunk-VMFSRV5H.js.map +1 -0
  37. package/dist/chunk-VPPM7UUQ.js +47 -0
  38. package/dist/chunk-VPPM7UUQ.js.map +1 -0
  39. package/dist/chunk-XV3UUOYF.js +845 -0
  40. package/dist/chunk-XV3UUOYF.js.map +1 -0
  41. package/dist/editors.d.ts +27 -3
  42. package/dist/editors.js +85 -2081
  43. package/dist/editors.js.map +1 -1
  44. package/dist/icons.d.ts +29 -8
  45. package/dist/icons.js +2 -361
  46. package/dist/icons.js.map +1 -1
  47. package/dist/index.d.ts +709 -425
  48. package/dist/index.js +5042 -13885
  49. package/dist/index.js.map +1 -1
  50. package/dist/markdown-editor.js +41 -3426
  51. package/dist/markdown-editor.js.map +1 -1
  52. package/dist/mermaid.js +8 -1129
  53. package/dist/mermaid.js.map +1 -1
  54. package/dist/rjsf.js +180 -9381
  55. package/dist/rjsf.js.map +1 -1
  56. package/dist/styles.css +1 -1
  57. package/dist/testing.js +5 -357
  58. package/dist/testing.js.map +1 -1
  59. package/package.json +28 -73
  60. package/src/styles.css +60 -3
  61. package/src/theme.css +1 -0
  62. package/dist/charts.cjs +0 -268
  63. package/dist/charts.cjs.map +0 -1
  64. package/dist/charts.d.cts +0 -181
  65. package/dist/constants-D3gaMY-W.d.cts +0 -39
  66. package/dist/editors.cjs +0 -2440
  67. package/dist/editors.cjs.map +0 -1
  68. package/dist/editors.d.cts +0 -165
  69. package/dist/icons.cjs +0 -23888
  70. package/dist/icons.cjs.map +0 -1
  71. package/dist/icons.d.cts +0 -20
  72. package/dist/index.cjs +0 -24894
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.d.cts +0 -10438
  75. package/dist/markdown-editor.cjs +0 -4636
  76. package/dist/markdown-editor.cjs.map +0 -1
  77. package/dist/markdown-editor.d.cts +0 -75
  78. package/dist/mermaid.cjs +0 -1138
  79. package/dist/mermaid.cjs.map +0 -1
  80. package/dist/mermaid.d.cts +0 -15
  81. package/dist/rjsf.cjs.map +0 -1
  82. package/dist/rjsf.d.cts +0 -55
  83. package/dist/testing.cjs.map +0 -1
  84. package/dist/testing.d.cts +0 -25
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @keboola/design
2
2
 
3
- Consumer usage contract for the Keboola design system — shadcn-aligned React primitives (Radix underneath), Keboola-branded blocks, Lucide icons, and TanStack-Form bindings.
3
+ Consumer usage contract for the Keboola design system — shadcn-aligned React primitives (Radix under most, Base UI for newer ones such as `Combobox`), Keboola-branded blocks, Lucide icons, and TanStack-Form bindings.
4
4
 
5
5
  This file ships **inside the npm tarball** and is version-pinned to the package you installed — it is the source of truth for **which** component to use and how to wire theming. For the exact **prop signature** of the most-used components, see the generated [`COMPONENT-API.md`](./COMPONENT-API.md) (also shipped in the tarball).
6
6
 
@@ -8,6 +8,7 @@ Working **on** the package (commands, layer boundary, token surface, the FA→Lu
8
8
 
9
9
  ## Import surface
10
10
 
11
+ - **ESM only.** The package ships no CommonJS build — no `*.cjs` and no `require` condition in the `exports` map. Consume it from an ESM-capable bundler (Vite, webpack, Rollup, esbuild) or `import` it from Node. A `require()` resolves to the ESM entry, so it works only where the runtime can require ESM (Node ≥ 22.12) and throws `ERR_REQUIRE_ESM` on older ones.
11
12
  - **Always** import from the package root: `import { Button } from '@keboola/design'`.
12
13
  - App entry points **must** import styles. Two paths, pick by build setup:
13
14
  - `import '@keboola/design/styles'` — Tailwind-extending consumers. Your PostCSS+Tailwind processes `@tailwind` directives plus design's `dist/` in your content scan. Avoids duplicate preflights/utilities. **Default for any Keboola app.**
@@ -43,7 +44,7 @@ The API is "shadcn-shaped" enough to invite copy-paste but different enough to b
43
44
  | `Select` | composed children `<Select><SelectTrigger/><SelectContent><SelectItem/></SelectContent></Select>` | data-driven — `Select` takes **`items`**, and the form connector `SelectField` delegates to it, so both take the same `items`: flat `{ value, label, icon?, disabled? }` or groups of them (`{ label?, items }`) |
44
45
  | `Sidebar.SearchInput` | `asChild`-wrappable trigger | concrete `<input>` (`ComponentProps<'input'>`), **no `asChild`** — put a custom search affordance in a plain element inside `Sidebar.Header` |
45
46
 
46
- Other compound (not named-export) components in the barrel: `Modal.*`, `Sheet.*`, `Card.*`, `Sidebar.*`, `PageHeader.Index` / `.Detail`, `EmptyState.*`, `Pagination.*`, `Popover.*` — reach for the dotted sub-parts, not flat `XContent` / `XTrigger` named imports. (The root `SearchInput` block is distinct from `Sidebar.SearchInput` and is fully composable.)
47
+ Other compound (not named-export) components in the barrel: `Modal.*`, `Sheet.*`, `Card.*`, `Sidebar.*`, `PageHeader.Index` / `.Detail`, `EmptyState.*`, `Pagination.*`, `Popover.*`, `Workbench.*` — reach for the dotted sub-parts, not flat `XContent` / `XTrigger` named imports. (The root `SearchInput` block is distinct from `Sidebar.SearchInput` and is fully composable.)
47
48
 
48
49
  ## Decision tree
49
50
 
@@ -85,10 +86,13 @@ This file answers **which** component to use; [`COMPONENT-API.md`](./COMPONENT-A
85
86
  | `useAppForm` | Full form layouts with validation (see Forms below) |
86
87
  | `useAppForm` connectors | Picking between `TextField` / `NumberField` / `SelectField` / … |
87
88
  | `OtpInput` | Fixed-length verification code (MFA, email confirm, sudo step-up) |
88
- | `FileUpload` | Argo-only drag-and-drop file picker; caller owns the file array |
89
+ | `FileUpload` | Drag-and-drop file picker box; caller owns the file array |
90
+ | `useFileDropTarget` | Make an existing subtree a drop target, styled by you |
89
91
 
90
92
  **Never** build custom label + input combinations — compose `Label` + `FormGroup.TextInput` + `FormGroup.Help` inside `FormGroup`.
91
93
 
94
+ `FileUpload` composes `FileUpload.DropZone` (the dashed box, owning the drag / error visuals) with `FileUpload.FileList` (selected files; `invalid` tints the rows). It deliberately ships **no** rejection copy — the wording is product copy, so read the current rejections with `useFileUploadRejections()` inside `FileUpload` and write the sentence yourself. Reach for `useFileDropTarget` only when there is no box to render: it returns `getRootProps()` / `isDragActive` for an affordance you style, and `noClick` / `noKeyboard` keep an already-interactive subtree (a composer, a form) from opening the file dialog on every click.
95
+
92
96
  ### Status surfaces
93
97
 
94
98
  | Use | When |
@@ -143,23 +147,36 @@ Charts ship from the **`@keboola/design/charts`** subpath (recharts under the ho
143
147
 
144
148
  `data: {…}[]` rows; `xKey` + `series: [{ dataKey, name?, color? }]` (cartesian) or `nameKey` + `dataKey` (pie). Each chart is self-contained (mounts its own responsive container) — give it a `height` (default 300). Pin a series to a palette slot with `color: 1..5`; otherwise it follows series order.
145
149
 
150
+ **Y-axis baseline — `yDomain`, on `LineChart` only.** Every chart starts the value axis at zero by default. `LineChart` takes `yDomain={[min, max]}` to move it: explicit numbers, `'auto'`, or padded data extremes — `['dataMin - 500', 'dataMax + 500']` (recharts pads the low end DOWN and the high end UP, so `'dataMax - 500'` is not a thing and won't type-check). It works with `showYAxis={false}` too.
151
+
152
+ ```tsx
153
+ // Credits used vs budgeted — both ~8,000, so a 0–10,000 axis hides the crossover.
154
+ <LineChart data={data} xKey="month" series={series} yDomain={['dataMin - 500', 'dataMax + 500']} />
155
+ ```
156
+
157
+ Reach for it when **two or more series of similar magnitude sit far from zero** and their difference is the story — actual vs target, used vs quota. Don't reach for it for a **single series whose magnitude the reader is meant to judge**: zooming the axis turns ordinary noise into a dramatic trend. And it is deliberately absent from `BarChart` / `AreaChart` — a line encodes value by position, so the baseline is just a viewport, but a bar's length and an area's fill are measured _from_ the baseline, and truncating those makes the geometry misstate the ratios. If an area chart needs a non-zero baseline, it is a line chart. (`ChartContainer` + a raw recharts chart remains the escape hatch for anything the wrappers don't model.)
158
+
146
159
  ### Layout
147
160
 
148
161
  **No** `Stack` / `Flex` / `Box` component exists, by design — shadcn doesn't ship them, and adding wrappers creates a third way to do layout. Use Tailwind utility classes directly.
149
162
 
150
163
  | Use | When |
151
164
  | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
152
- | Tailwind classes | All ad-hoc layout: `tw-flex tw-flex-col tw-gap-4`, `tw-grid …`, `tw-p-6` |
165
+ | Tailwind classes | All ad-hoc layout: `tw:flex tw:flex-col tw:gap-4`, `tw:grid …`, `tw:p-6` |
153
166
  | `AppShell` | App-root shell that follows the active brand (sidebar vs top-nav) |
154
167
  | `PageLayout` | Top-level page shape: header, tabs, toolbar, content, footer |
155
168
  | `PageToolbar` | Index-page toolbar: search + filters / sort / actions / view + chips row |
156
- | `Card` | Semantic content surface with header / content / footer slots |
169
+ | `Card` | Semantic content surface with `Card.Header` / `Card.Title` / `Card.Content` slots |
157
170
  | `Sidebar` | App-shell left navigation rail (host inside `AppShell.Sidebar`) |
171
+ | `SectionNav` | In-page navigation for **one page's** grouped sections (host in `PageLayout.Content nav`) — tree, dropdown when narrow |
158
172
  | `Navbar` | Top navigation bar (host inside `AppShell.Header`) — brand mark, primary nav, search / account. Pairs with the top-nav shell |
173
+ | `Workbench` | Bottom status rail plus a panel that expands over the page — VS Code's bottom dock, for tooling that outlives one page |
159
174
 
160
- Vertical rhythm: prefer `tw-gap-*` on a flex column parent over `tw-mt-*` on children — avoids margin-collapse. Page-level max width: use `PageLayout`'s `maxWidth` prop (`narrow` / `default` / `wide` / `full`), not `tw-max-w-[…]` literals.
175
+ Vertical rhythm: prefer `tw:gap-*` on a flex column parent over `tw:mt-*` on children — avoids margin-collapse. Page-level max width: use `PageLayout`'s `maxWidth` prop (`narrow` / `default` / `wide` / `full`), not `tw:max-w-[…]` literals.
161
176
 
162
- **Page gutter: `AppShell.Main` owns it, with an opinionated default.** `AppShell.Main` applies a default page gutter (`tw-p-6`) so content looks right out of the box. Because `cn` is tailwind-merge, override it from the call site when the app owns its own gutter — e.g. `className="tw-p-0"` (kbc-ui does this; it centralizes the gutter on its own shell wrapper). `PageLayout` still adds **no** gutter of its own, so header and content align inside whatever gutter `AppShell.Main` provides.
177
+ **`Card` padding: the root owns it, or the slots do — never both.** `Card padding` (default `small`) pads the root and gaps its slots, which is right for a plain content card. Set `padding="none"` when a slot must bleed to the card edge — a table, an `EmptyState`, or a header with a rule under it — and the root also drops its gap so the bands sit flush. In that mode the slots pad themselves: `Card.Header divider` becomes its own padded band with a full-bleed bottom rule, and `Card.Content padded` takes the standard body padding. `Card.Header` distributes its children by `layout`: **`row`** (default) pushes them apart, title left and actions right; **`inline`** keeps them together for a title with an icon or badge; **`stack`** puts a description under the title. Don't hand-roll these with `className` — `cva` here concatenates rather than tailwind-merges, so a `tw:justify-start` in `className` will not reliably beat the `row` default. Use the prop.
178
+
179
+ **Page gutter: `AppShell.Main` owns it, with an opinionated default.** `AppShell.Main` applies a default page gutter (`tw:p-6`) so content looks right out of the box. Because `cn` is tailwind-merge, override it from the call site when the app owns its own gutter — e.g. `className="tw:p-0"` (kbc-ui does this; it centralizes the gutter on its own shell wrapper). `PageLayout` still adds **no** gutter of its own, so header and content align inside whatever gutter `AppShell.Main` provides.
163
180
 
164
181
  ```tsx
165
182
  // Default gutter — nothing to add:
@@ -168,11 +185,21 @@ Vertical rhythm: prefer `tw-gap-*` on a flex column parent over `tw-mt-*` on chi
168
185
  </AppShell.Main>
169
186
 
170
187
  // Own your gutter — opt out and add your own:
171
- <AppShell.Main className="tw-p-0">
172
- <div className="tw-px-8 tw-py-6">{/* custom gutter */}</div>
188
+ <AppShell.Main className="tw:p-0">
189
+ <div className="tw:px-8 tw:py-6">{/* custom gutter */}</div>
173
190
  </AppShell.Main>
174
191
  ```
175
192
 
193
+ **Header width: `headerSpan` picks the row template — don't hand-roll `gridTemplateAreas`.** By default the sidebar geometry gives the header the middle column only (`"sidebar header aside"`), so both rails sit beside it and run the full height. Pass `headerSpan="full"` for a full-bleed header row above both rails (`"header header header" / "sidebar main aside"`), so the brand mark aligns to the viewport's left edge. Reach for it when the header carries app-wide identity — brand mark, project switcher, account menu — rather than page-level navigation. It is a no-op under the top-nav geometry, whose header already spans the width.
194
+
195
+ ```tsx
196
+ <AppShell headerSpan="full">
197
+ <AppShell.Header flush>{/* Navbar: brand mark, project switcher, account */}</AppShell.Header>
198
+ <AppShell.Sidebar>{/* left rail, below the header */}</AppShell.Sidebar>
199
+ <AppShell.Main>{/* … */}</AppShell.Main>
200
+ </AppShell>
201
+ ```
202
+
176
203
  ### Navigation / flow
177
204
 
178
205
  | Use | When |
@@ -184,6 +211,8 @@ Vertical rhythm: prefer `tw-gap-*` on a flex column parent over `tw-mt-*` on chi
184
211
 
185
212
  **Never** use `Stepper` for branching navigation (use `Tabs`) or an unordered status list (use `Timeline`).
186
213
 
214
+ **`SectionNav` is not `Sidebar`.** `Sidebar` is the app's left rail — global, one per shell, outside `PageLayout`, with `SidebarProvider` state and icon-collapse. `SectionNav` is scoped to a single page's sections, lives in `PageLayout.Content nav`, and holds nothing but an open/closed flag per group. Reach for `SectionNav` when a page has more sections than a tab row can hold, or sections that group; `Tabs` when they don't.
215
+
187
216
  ### Separators
188
217
 
189
218
  | Use | When |
@@ -260,7 +289,7 @@ const form = useAppForm({
260
289
  <AlertModal onConfirm={() => deleteBucket(bucketId)}>
261
290
  <AlertModal.Trigger asChild>
262
291
  <Button variant="danger">
263
- <Trash2 className="tw-size-4" /> Delete bucket
292
+ <Trash2 className="tw:size-4" /> Delete bucket
264
293
  </Button>
265
294
  </AlertModal.Trigger>
266
295
  <AlertModal.Content variant="danger" title="Delete bucket?" confirmLabel="Delete bucket">
@@ -271,17 +300,19 @@ const form = useAppForm({
271
300
 
272
301
  Controlled (opened from elsewhere, no `Trigger`): pass `open` + `onOpenChange` to `AlertModal` (it honors `defaultOpen` too).
273
302
 
303
+ A host that tracks the request itself rather than returning a promise passes `isLoading` to `AlertModal` for the same locked, spinning state; `AlertModal.Content` takes `confirmDisabled` to block the confirm action alone while a precondition is unmet. The children stay the description — reach for `Modal` when the dialog needs a form, a table, or anything else that isn't phrasing content.
304
+
274
305
  **Card with empty state** — branch on the collection length inside `Card.Content`:
275
306
 
276
307
  ```tsx
277
308
  <Card bordered padding="none">
278
- <Card.Header className="tw-flex tw-items-center tw-justify-between tw-p-4">
309
+ <Card.Header divider>
279
310
  <Card.Title>Tokens</Card.Title>
280
311
  <Button size="small">
281
- <Plus className="tw-size-4" /> New token
312
+ <Plus className="tw:size-4" /> New token
282
313
  </Button>
283
314
  </Card.Header>
284
- <Card.Content className="tw-p-0">
315
+ <Card.Content>
285
316
  {tokens.length === 0 ? (
286
317
  <EmptyState variant="compact">
287
318
  <EmptyState.Title>No tokens yet</EmptyState.Title>
@@ -299,13 +330,13 @@ Controlled (opened from elsewhere, no `Trigger`): pass `open` + `onOpenChange` t
299
330
  **Entity list** — `SearchInput` + `Table` + `Pagination` + `EmptyState`. Keep query/page in local state, filter then slice:
300
331
 
301
332
  ```tsx
302
- <div className="tw-flex tw-flex-col tw-gap-4">
333
+ <div className="tw:flex tw:flex-col tw:gap-4">
303
334
  <SearchInput
304
335
  allowClear
305
336
  placeholder="Search…"
306
337
  value={query}
307
338
  onValueChange={setQuery}
308
- className="tw-w-72"
339
+ className="tw:w-72"
309
340
  />
310
341
  {filtered.length === 0 ? (
311
342
  <EmptyState>
@@ -330,7 +361,7 @@ Controlled (opened from elsewhere, no `Trigger`): pass `open` + `onOpenChange` t
330
361
  ```tsx
331
362
  <Card bordered>
332
363
  <Card.Header>
333
- <Card.Title>{isLoading ? <Skeleton height="md" className="tw-w-32" /> : 'Storage'}</Card.Title>
364
+ <Card.Title>{isLoading ? <Skeleton height="md" className="tw:w-32" /> : 'Storage'}</Card.Title>
334
365
  </Card.Header>
335
366
  <Card.Content>{isLoading ? <SkeletonList groups={2} rowHeight="sm" /> : <Stats />}</Card.Content>
336
367
  </Card>
@@ -356,8 +387,8 @@ Controlled (opened from elsewhere, no `Trigger`): pass `open` + `onOpenChange` t
356
387
 
357
388
  ```tsx
358
389
  <Tooltip tooltip={canSave ? null : reason} placement="top" triggerAsChild>
359
- <span className={canSave ? undefined : 'tw-inline-block tw-cursor-not-allowed'}>
360
- <Button disabled={!canSave} className={canSave ? undefined : 'tw-pointer-events-none'}>
390
+ <span className={canSave ? undefined : 'tw:inline-block tw:cursor-not-allowed'}>
391
+ <Button disabled={!canSave} className={canSave ? undefined : 'tw:pointer-events-none'}>
361
392
  Save
362
393
  </Button>
363
394
  </span>
@@ -367,16 +398,16 @@ Controlled (opened from elsewhere, no `Trigger`): pass `open` + `onOpenChange` t
367
398
  **Info tooltip** — an icon-only `Button variant="inline"` next to a `Label`, with the hint on a `Tooltip`:
368
399
 
369
400
  ```tsx
370
- <div className="tw-flex tw-items-center tw-gap-1">
401
+ <div className="tw:flex tw:items-center tw:gap-1">
371
402
  <Label htmlFor={id}>{label}</Label>
372
403
  <Tooltip tooltip={hint} placement="right" triggerAsChild>
373
404
  <Button
374
405
  variant="inline"
375
406
  size="icon-3xs"
376
407
  aria-label={`More info about ${label}`}
377
- className="tw-text-muted-foreground"
408
+ className="tw:text-muted-foreground"
378
409
  >
379
- <Info className="tw-size-3" />
410
+ <Info className="tw:size-3" />
380
411
  </Button>
381
412
  </Tooltip>
382
413
  </div>
@@ -400,6 +431,33 @@ Controlled (opened from elsewhere, no `Trigger`): pass `open` + `onOpenChange` t
400
431
  </PageLayout>
401
432
  ```
402
433
 
434
+ **Page with a section nav** — `PageLayout.Header sticky` + `PageLayout.Content nav={<SectionNav …/>}`. The block and the slot are shipped; the **group model**, **visibility / permission filtering** and **all routing** are the composition and stay in your app. Build the model once and feed both the nav and any flat tab row from it, so the two can never disagree about what exists. `renderItem` is the router seam — return your router's link and spread `itemProps`:
435
+
436
+ ```tsx
437
+ <PageLayout>
438
+ <PageLayout.Header sticky>
439
+ <PageHeader.Index title="Settings" />
440
+ </PageLayout.Header>
441
+ <PageLayout.Content
442
+ nav={
443
+ <SectionNav
444
+ groups={groups} // already filtered for this user
445
+ activeId={pathname}
446
+ renderItem={({ targetId, children, itemProps }) => (
447
+ <Link to={targetId} {...itemProps}>
448
+ {children}
449
+ </Link>
450
+ )}
451
+ />
452
+ }
453
+ >
454
+ {page}
455
+ </PageLayout.Content>
456
+ </PageLayout>
457
+ ```
458
+
459
+ Both router wirings (TanStack Router, and a custom named router) are in [`RECIPES.md`](./RECIPES.md#section-nav).
460
+
403
461
  **Creatable combobox** — `Combobox` with `allowCreate` accepts values not in `items`:
404
462
 
405
463
  ```tsx
@@ -413,13 +471,13 @@ Controlled (opened from elsewhere, no `Trigger`): pass `open` + `onOpenChange` t
413
471
  />
414
472
  ```
415
473
 
416
- **Sheet** — use `Sheet.Header title="…" description="…"` for a styled title + close icon; do **not** hand-roll with the raw `Sheet.Title`, which renders an unstyled `<h2>`. `Sheet.Body` provides horizontal padding only (`tw-px-4`) — add your own vertical rhythm/gap:
474
+ **Sheet** — use `Sheet.Header title="…" description="…"` for a styled title + close icon; do **not** hand-roll with the raw `Sheet.Title`, which renders an unstyled `<h2>`. `Sheet.Body` provides horizontal padding only (`tw:px-4`) — add your own vertical rhythm/gap:
417
475
 
418
476
  ```tsx
419
477
  <Sheet open={open} onOpenChange={setOpen}>
420
478
  <Sheet.Content>
421
479
  <Sheet.Header title="Edit table" description="Adjust the table metadata." />
422
- <Sheet.Body className="tw-flex tw-flex-col tw-gap-4 tw-py-4">{/* fields */}</Sheet.Body>
480
+ <Sheet.Body className="tw:flex tw:flex-col tw:gap-4 tw:py-4">{/* fields */}</Sheet.Body>
423
481
  </Sheet.Content>
424
482
  </Sheet>
425
483
  ```
@@ -431,14 +489,15 @@ Canonical icon surface is `@keboola/design/icons` — a re-export of every [luci
431
489
  ```tsx
432
490
  import { ChevronDown, Keboola, Loader2 } from '@keboola/design/icons';
433
491
 
434
- <button>Toggle <ChevronDown className="tw-size-4" /></button>
435
- <Loader2 className="tw-size-4 tw-animate-spin" />
436
- <Keboola className="tw-size-6" />
492
+ <button>Toggle <ChevronDown className="tw:size-4" /></button>
493
+ <Loader2 className="tw:size-4 tw:animate-spin" />
494
+ <Keboola className="tw:size-6" />
437
495
  ```
438
496
 
439
497
  - **One import surface**: Lucide icons and Keboola-custom icons (e.g. `Keboola`) both export from `@keboola/design/icons` and share the `LucideIcon` type, so they're interchangeable in any API typed `icon: LucideIcon` (Button, etc.).
440
- - **Sizing**: prefixed Tailwind `tw-size-*` utility — `tw-size-4` = 16px (default for inline icons), `tw-size-5` = 20px, `tw-size-6` = 24px.
441
- - **Spinner animation**: explicit via `tw-animate-spin`.
498
+ - **Vendor marks** also ship from `@keboola/design/icons` — `Aws`, `Azure`, `ChatGpt`, `Claude`, `Cursor`, `Devin`, `Gcp`, `Google`, `Keboola`, `Make`, `Microsoft` — in their own brand colours by default; pass **`mono`** to render one in `currentColor` instead. The `Eu` / `Us` flags are colour-only.
499
+ - **Sizing**: prefixed Tailwind `tw:size-*` utility — `tw:size-4` = 16px (default for inline icons), `tw:size-5` = 20px, `tw:size-6` = 24px.
500
+ - **Spinner animation**: explicit via `tw:animate-spin`.
442
501
 
443
502
  **FontAwesome is fully removed.** Every `icon` prop takes a Lucide `ReactNode`. (Maintainers: the canonical FA→Lucide mapping table lives in `MAINTAINING.md`.)
444
503
 
@@ -446,9 +505,9 @@ import { ChevronDown, Keboola, Loader2 } from '@keboola/design/icons';
446
505
 
447
506
  Layer-0 colour usage splits into three buckets.
448
507
 
449
- **Chrome — follows the brand.** Surface, structure, navigation: Card body, Modal frame, Input border, Button outline, focus rings, Sidebar, generic text. Re-skins when a customer brand is applied. **Rule:** use semantic tokens (`tw-bg-background`, `tw-text-foreground`, `tw-border-border`, `tw-bg-muted`, `tw-text-muted-foreground`, `tw-bg-accent`, `tw-bg-destructive`, …). Customers override these via `@keboola/brand-registry`.
508
+ **Chrome — follows the brand.** Surface, structure, navigation: Card body, Modal frame, Input border, Button outline, focus rings, Sidebar, generic text. Re-skins when a customer brand is applied. **Rule:** use semantic tokens (`tw:bg-background`, `tw:text-foreground`, `tw:border-border`, `tw:bg-muted`, `tw:text-muted-foreground`, `tw:bg-accent`, `tw:bg-destructive`, …). Customers override these via `@keboola/brand-registry`.
450
509
 
451
- **Status — themeable under argo, with AA-legible defaults.** The four semantic states — `success`, `warning`, `info`, and `destructive` (used for "error") — are brand-overridable tokens, not fixed scales. Alert `variant`, `HelpBlock` / field-error state, `ErrorState`, and `Stepper` status read them, so a brand recolours every status surface at once. **Rule:** consume the token as **tint + text/icon** — `tw-bg-<role>/10` for the surface, `tw-text-<role>` / `tw-border-<role>/25` for the mark. The default token values ship dark enough to be AA (≥4.5:1) as text/icon on their own tint, in light **and** dark mode — a brand overriding them should preserve that. Avoid solid fills with a white foreground (`tw-bg-<role>` + white): the token flips light in dark mode, so white-on-token fails there. A brand _can_ recolour "error" away from red — that is now allowed; the accessible-on-tint contract is what must hold, not the hue. **Legacy exception:** the themeable treatment is gated to argo (`data-[argo=true]:` / `group-data-[argo=true]/…:`). Under legacy chrome (`useArgoDesign()` → `false`) Alert and `ErrorState` keep their original fixed status scales (`blue-*`/`green-*`/`warning-*`/`error-*`), and the legacy brand retains its original status token hues — so legacy status colour is unchanged.
510
+ **Status — themeable under argo, with AA-legible defaults.** The four semantic states — `success`, `warning`, `info`, and `destructive` (used for "error") — are brand-overridable tokens, not fixed scales. Alert `variant`, `HelpBlock` / field-error state, `ErrorState`, and `Stepper` status read them, so a brand recolours every status surface at once. **Rule:** consume the token as **tint + text/icon** — `tw:bg-<role>/10` for the surface, `tw:text-<role>` / `tw:border-<role>/25` for the mark. The default token values ship dark enough to be AA (≥4.5:1) as text/icon on their own tint, in light **and** dark mode — a brand overriding them should preserve that. Avoid solid fills with a white foreground (`tw:bg-<role>` + white): the token flips light in dark mode, so white-on-token fails there. A brand _can_ recolour "error" away from red — that is now allowed; the accessible-on-tint contract is what must hold, not the hue. **Legacy exception:** the themeable treatment is gated to argo (`data-[argo=true]:` / `group-data-[argo=true]/…:`). Under legacy chrome (`useArgoDesign()` → `false`) Alert and `ErrorState` keep their original fixed status scales (`blue-*`/`green-*`/`warning-*`/`error-*`), and the legacy brand retains its original status token hues — so legacy status colour is unchanged.
452
511
 
453
512
  **Decorative — stays fixed across brands.** The non-semantic palette that encodes category rather than state: `Badge` decorative colours (`purple`, `cyan`, `teal`, `blue`, …), chart colours, decorative tag palette. **Rule:** keep using primitive scales (`purple-500`, `cyan-500`, …). These are an identity/category contract with the consumer and do not re-skin.
454
513
 
@@ -466,22 +525,38 @@ import { useAppForm, withForm, setServerFieldErrors } from '@keboola/design';
466
525
 
467
526
  ## Theming (brand + colorScheme)
468
527
 
469
- Wrap your app once in `<ThemeProvider>` from `@keboola/design`. It reads a `Brand` from `@keboola/brand-registry`, sets `data-brand` on `<html>`, and injects the matching `--color-*` / `--radius-*` CSS variables.
528
+ Wrap your app once in `<ThemeProvider>` from `@keboola/design`. You give it the set of `Brand`s it may activate and the id of the active one; it sets `data-brand` and `data-theme` on `<html>` and injects the matching `--color-*` / `--radius-*` CSS variables.
470
529
 
471
530
  ```tsx
472
531
  import { ThemeProvider } from '@keboola/design';
532
+ import { keboola } from '@keboola/brand-registry';
473
533
 
474
- <ThemeProvider brand={brand} colorScheme="system">
534
+ <ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="system">
475
535
  <App />
476
536
  </ThemeProvider>;
477
537
  ```
478
538
 
479
- - **`brand`** — the active brand decides the chrome. **argo styling is brand-driven**: any non-`keboola-legacy` brand renders argo chrome automatically (`renderArgo = !isLegacy`), so a default `keboola` / `example-customer` brand needs no per-component flags. The `keboola-legacy` brand renders the pre-modernization chrome. With no `brand` prop, `ThemeProvider` defaults to the canonical `keboola` brand.
480
- - **`colorScheme`** — `"light"` (default) / `"dark"` / `"system"`. Dark mode is set **here, on the root provider** — not per component. `"system"` follows the OS preference. Render the user-facing light/dark/system control with the **`ColorModeSwitch`** block (`<ColorModeSwitch value={scheme} onValueChange={setScheme} />`) — a controlled icon-button + dropdown; feed the same state to `ThemeProvider`'s `colorScheme`.
539
+ - **`brands`** — the brands this provider knows about; the active id resolves against them. Defaults to an empty list, in which case the active brand falls back to a `design-system` sentinel whose empty theme injects nothing, so the stylesheet's own `:root` defaults provide every token.
540
+ - **`brandId` / `defaultBrandId` / `onBrandIdChange`** — the active brand, controlled or uncontrolled. An unknown id (including the sentinel) falls back to that same empty theme rather than throwing.
541
+ - **`scheme` / `defaultScheme` / `onSchemeChange`** — `"light"` (default) / `"dark"` / `"system"`, controlled or uncontrolled. Dark mode is set **here, on the root provider** — not per component. `"system"` follows `prefers-color-scheme` and resolves reactively. Render the user-facing control with the **`ColorModeSwitch`** block (`<ColorModeSwitch value={scheme} onValueChange={setScheme} />`) and feed the same state back in.
481
542
 
482
- Chrome is driven **entirely** by the active brand — there is no per-call-site `argo` prop or `variant="argo"` escape hatch. To render a subtree under a different brand (e.g. an argo section inside a `keboola-legacy` app), wrap it in a nested `<ThemeProvider brand={…}>`; `useIsLegacyBrand()` reads the nearest provider, so the override is scoped to that subtree.
543
+ Read the resolved values with `useTheme()` (`brandId`, `currentTheme`, `themes`, `scheme`, `resolvedScheme`, and the setters). With no provider above it, it answers the defaults rather than throwing.
483
544
 
484
- Define a custom brand with `@keboola/brand-registry`'s `brandSchema` (Zod) and pass it to `ThemeProvider`; or override the `--color-*` / `--radius-*` CSS variables directly on `:root`.
545
+ ### The argo/legacy switch is separate from the brand
546
+
547
+ `ThemeProvider` does **not** decide whether a subtree renders the argo design language — that is a kbc-ui migration concern, so it rides its own context:
548
+
549
+ ```tsx
550
+ import { ArgoDesignContext, useArgoDesign } from '@keboola/design';
551
+
552
+ <ArgoDesignContext value={false}>{/* legacy chrome for this subtree */}</ArgoDesignContext>;
553
+ ```
554
+
555
+ `ArgoDesignContext` defaults to `true` (argo — the modern default; legacy chrome is the exception), so an app that never provides it gets argo everywhere. kbc-ui provides `false` for its legacy brands and overrides per subtree as pages are migrated; components read it with `useArgoDesign()`. It is **temporary** and will be deleted once every kbc-ui surface is migrated — don't build on it in a new app.
556
+
557
+ Because the value is read from the nearest provider, a nested override scopes to that subtree. The build ships a single instance of both contexts across every subpath entry, so a provider mounted from the root barrel is visible to components that ship in `@keboola/design/rjsf`, `/markdown-editor`, `/mermaid` and `/editors` too.
558
+
559
+ Define a custom brand with `@keboola/brand-registry`'s `brandSchema` (Zod) and pass it in `brands`; or override the `--color-*` / `--radius-*` CSS variables directly on `:root`.
485
560
 
486
561
  ## Testing (Vitest)
487
562
 
@@ -514,8 +589,10 @@ pnpm exec ui-gen-bench eval --serve dist --route /tokens --brief "Tokens list wi
514
589
  ## Common Tailwind patterns
515
590
 
516
591
  ```
517
- not-disabled:hover:tw-bg-accent
518
- disabled:tw-opacity-40 tw-cursor-not-allowed
519
- focus-visible:tw-ring-2 tw-ring-primary
520
- [&_svg]:tw-text-muted-foreground
592
+ tw:not-disabled:hover:bg-accent
593
+ tw:disabled:opacity-40 tw:cursor-not-allowed
594
+ tw:focus-visible:ring-2 tw:ring-primary
595
+ tw:[&_svg]:text-muted-foreground
521
596
  ```
597
+
598
+ The `tw:` prefix always comes **first**, ahead of every variant — `tw:not-disabled:hover:bg-accent`, never `not-disabled:hover:tw-bg-accent`. Tailwind v4 makes the prefix a variant of its own, so it leads the chain and the utility keeps its bare name.
package/COMPONENT-API.md CHANGED
@@ -382,9 +382,12 @@ Source: `src/components/Card/Card.tsx`
382
382
 
383
383
  Source: `src/components/Card/CardHeader.tsx`
384
384
 
385
- > Native `<div>` attributes only.
385
+ > Own props below, plus native `<div>` attributes.
386
386
 
387
- _No own props — see note above for passthrough attributes._
387
+ | Prop | Type | Required | Default | Description |
388
+ | --------- | ------------------------------ | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
389
+ | `divider` | `boolean` | no | `false` | Adds a bottom rule, the header's own padding, and a 64px height floor so the band stays the same height with or without an action button. The rule spans the full card width — pair with `Card padding="none"`; on a padded root it is inset. |
390
+ | `layout` | `"inline" \| "row" \| "stack"` | no | `row` | How the header distributes its children. `row` (default) pushes them apart — title left, actions right. `inline` keeps them together on one line, for a title with an adornment such as an icon or badge. `stack` puts a description under the title. |
388
391
 
389
392
  ### `Card.Title`
390
393
 
@@ -400,9 +403,11 @@ Source: `src/components/Card/CardTitle.tsx`
400
403
 
401
404
  Source: `src/components/Card/CardContent.tsx`
402
405
 
403
- > Native `<div>` attributes only.
406
+ > Own props below, plus native `<div>` attributes.
404
407
 
405
- _No own props — see note above for passthrough attributes._
408
+ | Prop | Type | Required | Default | Description |
409
+ | -------- | --------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- |
410
+ | `padded` | `boolean` | no | `false` | Applies the standard body padding. Use with `Card padding="none"`, where the root adds none — otherwise the padding is doubled. |
406
411
 
407
412
  ## Theming
408
413
 
@@ -415,7 +420,7 @@ Source: `src/components/ThemeProvider/ThemeProvider.tsx`
415
420
  | Prop | Type | Required | Default | Description |
416
421
  | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
417
422
  | `brandId` | `string` | no | — | Controlled active brand id. When set, the parent owns the value and `setBrand` only notifies via `onBrandIdChange` (it does not mutate internal state). |
418
- | `brands` | `readonly { id: string; name: string; theme: { colors?: { foreground?: string; background?: string; muted?: string \| undefined; mutedForeground?: string \| undefined; border?: string \| undefined; ... 22 more ...; sidebar?: { ...; } \| undefined; } \| undefined; ... 6 more ...; gradient?: { ...; }...` | no | `[]` | Brands this provider knows about; the active brand id resolves against these. |
423
+ | `brands` | `readonly { id: string; name: string; theme: { colors?: { foreground?: string; background?: string; muted?: string \| undefined; mutedForeground?: string \| undefined; border?: string \| undefined; ... 23 more ...; sidebar?: { ...; } \| undefined; } \| undefined; ... 6 more ...; gradient?: { ...; }...` | no | `[]` | Brands this provider knows about; the active brand id resolves against these. |
419
424
  | `defaultBrandId` | `string` | no | — | Initial active brand id for uncontrolled use. Defaults to the design-system sentinel (`DEFAULT_BRAND_ID`) — no brand tokens injected, so the stylesheet defaults apply. |
420
425
  | `defaultScheme` | `ColorScheme` | no | — | Initial colour scheme for uncontrolled use. Defaults to `light` (`DEFAULT_COLOR_SCHEME`). |
421
426
  | `onBrandIdChange` | `((brandId: string) => void)` | no | — | Called with the next id whenever `setBrand` runs — required to persist the choice in controlled mode. |
@@ -439,19 +444,21 @@ Source: `src/components/BrandLogo/BrandLogo.tsx`
439
444
 
440
445
  Source: `src/blocks/AppShell/AppShell.tsx`
441
446
 
442
- > Compound app-root shell that follows the active brand (sidebar vs top-nav). Subcomponents: `AppShell.Sidebar`, `AppShell.Header`, `AppShell.Main`. `AppShell.Main` owns the default page gutter (`tw-p-6`) — override with `className="tw-p-0"`. Native `<div>` attributes.
447
+ > Compound app-root shell that follows the active brand (sidebar vs top-nav). Subcomponents: `AppShell.Sidebar`, `AppShell.Header`, `AppShell.Aside`, `AppShell.Main`. `AppShell.Main` owns the default page gutter (`tw:p-6`) — override with `className="tw:p-0"`. `AppShell.Header` and `AppShell.Aside` take `flush` — pass it when the slot hosts a chrome component that paints itself (`Navbar`, `Sidebar side="right"`), so the slot drops its own padding, background and border instead of insetting the child. Native `<div>` attributes.
443
448
 
444
- _No own props — see note above for passthrough attributes._
449
+ | Prop | Type | Required | Default | Description |
450
+ | ------------ | -------------------- | -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
451
+ | `headerSpan` | `AppShellHeaderSpan` | no | `content` | How far the header row stretches. `content` (default) gives it the middle column only, with both rails beside it; `full` spans all three columns and drops the rails below it, so the brand mark aligns to the viewport edge. No-op under the top-nav geometry, whose header is already full width. Reach for `full` when the header carries app-wide identity (brand mark, project switcher, account menu) rather than page-level navigation. |
445
452
 
446
453
  ### `PageLayout`
447
454
 
448
455
  Source: `src/blocks/PageLayout/PageLayout.tsx`
449
456
 
450
- > Compound page shape: header, tabs, toolbar, content, footer. Subcomponents: `PageLayout.Header`, `PageLayout.Content`, `PageLayout.Footer`. `maxWidth` is `narrow` | `default` | `wide` | `full`. Adds no gutter of its own. Native `<div>` attributes.
457
+ > Compound page shape: header, tabs, toolbar, content, footer. Subcomponents: `PageLayout.Header`, `PageLayout.Content`, `PageLayout.Footer`. `maxWidth` is `narrow` | `default` | `wide` | `full`. Adds no gutter of its own. `PageLayout.Header` takes `sticky` (pins + paints the header, and publishes its measured height as `--page-header-height` on the root); `PageLayout.Content` takes `nav` (a `SectionNav`) for the two-column in-page-navigation layout, and behaves exactly as before without it. Native `<div>` attributes.
451
458
 
452
459
  | Prop | Type | Required | Default | Description |
453
460
  | ---------- | ------------------------------------------- | -------- | ------- | ----------- |
454
- | `maxWidth` | `"default" \| "narrow" \| "wide" \| "full"` | no | `full` | |
461
+ | `maxWidth` | `"default" \| "full" \| "narrow" \| "wide"` | no | `full` | |
455
462
 
456
463
  ### `PageHeader.Index`
457
464
 
@@ -507,6 +514,21 @@ Source: `src/components/Sidebar/Sidebar.tsx`
507
514
  | `side` | `SidebarSide` | no | `left` | |
508
515
  | `style` | `CSSProperties` | no | — | |
509
516
 
517
+ ### `SectionNav`
518
+
519
+ Source: `src/blocks/SectionNav/SectionNav.tsx`
520
+
521
+ > In-page navigation for one page's grouped sections — a tree above the container breakpoint, a dropdown below it. **Not** `Sidebar`: page-scoped, hosted in `PageLayout.Content nav`, no global state. Router-agnostic — `renderItem` returns your router's link and receives `{ targetId, isActive, children, itemProps }`; spread `itemProps` whole. Visibility / permission filtering and the group model stay in the app (see RECIPES.md#section-nav).
522
+
523
+ | Prop | Type | Required | Default | Description |
524
+ | ------------------ | ------------------------------------------------- | -------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
525
+ | `groups` | `SectionNavGroup[]` | yes | — | The sections to show, already filtered. Visibility, permissions and feature flags stay in the app — this block renders exactly what it is handed, in order. |
526
+ | `renderItem` | `(props: SectionNavItemRenderProps) => ReactNode` | yes | — | Renders the clickable element for one row, so routing never enters the design system. Return a `NavLink` / TanStack `Link` / `<a>` pointing at `targetId` and spread `itemProps` onto it. |
527
+ | `activeId` | `string` | no | — | `id` of the current section: a child's, or a childless group's. |
528
+ | `className` | `string` | no | — | Applied to the tree's `<nav>`. The column's own geometry belongs to `PageLayout.Content nav`. |
529
+ | `label` | `string` | no | `Sections` | Accessible name for the tree's `<nav>`, and the collapsed label with nothing active. |
530
+ | `showDescriptions` | `boolean` | no | `false` | Print each section's `description` under its title. Off — the tree stays scannable without it. |
531
+
510
532
  ### `Navbar`
511
533
 
512
534
  Source: `src/components/Navbar/Navbar.tsx`
package/README.md CHANGED
@@ -130,13 +130,13 @@ Coverage is **measured, not gated** — it gives a baseline for the story suite.
130
130
  pnpm --filter @keboola/design test:stories:coverage
131
131
  ```
132
132
 
133
- Same prerequisites as `test:stories` (build workspace deps, install Chromium). Reports land in `packages/design/coverage/` (gitignored): a `text` summary in the console and a browsable `html` report at `coverage/index.html`.
133
+ Same prerequisites as `test:stories` (build workspace deps, install Chromium). Reports land in `packages/design/coverage/stories/` (gitignored): a `text` summary in the console and a browsable `html` report at `coverage/stories/index.html`. The jsdom unit lane reports separately into `coverage/unit/` — it uses the shared v8 preset (`@keboola/vitest-config`), so the two providers don't overwrite each other.
134
134
 
135
135
  **Why istanbul, not v8:** this is a browser-mode Vitest project. v8 coverage relies on Node's inspector, which isn't available in a Playwright-driven browser, so the [istanbul provider](https://vitest.dev/guide/coverage#coverage-providers) (`@vitest/coverage-istanbul`, pinned to the Vitest version) instruments the source at transform time and works in both Node and the browser.
136
136
 
137
- **What's covered:** `src/**` component source, excluding stories (`*.stories.tsx`), unit tests (`*.test.{ts,tsx}`), barrels (`index.ts`), type decls, `src/docs/**`, and `src/icons/**` (curated lucide re-exports plus custom SVG marks like `Keboola` — trivial visual surface, no logic to gate).
137
+ **What's covered:** `src/**` component source, excluding stories (`*.stories.tsx`), unit tests (`*.test.{ts,tsx}`), type decls, `src/docs/**`, and `src/icons/**` (curated lucide re-exports plus custom SVG marks like `Keboola` — trivial visual surface, no logic to gate).
138
138
 
139
- **What the numbers reflect:** only some story files carry a `play()` interaction test; the rest contribute just their render/smoke path, so branch coverage trails statement coverage. The lowest-covered areas are the interaction-heavy blocks that still lack a `play()`. Run `test:stories:coverage` and open `coverage/index.html` for the current per-file numbers — they're not pinned here because they drift with every story and `play()` added.
139
+ **What the numbers reflect:** only some story files carry a `play()` interaction test; the rest contribute just their render/smoke path, so branch coverage trails statement coverage. The lowest-covered areas are the interaction-heavy blocks that still lack a `play()`. Run `test:stories:coverage` and open `coverage/stories/index.html` for the current per-file numbers — they're not pinned here because they drift with every story and `play()` added.
140
140
 
141
141
  ### Icons
142
142
 
@@ -0,0 +1,10 @@
1
+ export { MermaidDiagram } from './chunk-KPNAW5MU.js';
2
+ import './chunk-IJTHFNNJ.js';
3
+ import './chunk-VPPM7UUQ.js';
4
+ import './chunk-L37JJ6MR.js';
5
+ import './chunk-CTCFYWJC.js';
6
+ import './chunk-BOHNXTMM.js';
7
+ import './chunk-63NSN2VL.js';
8
+ import './chunk-JP7PNHLH.js';
9
+ //# sourceMappingURL=MermaidDiagram-EOKJHQQV.js.map
10
+ //# sourceMappingURL=MermaidDiagram-EOKJHQQV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"MermaidDiagram-EOKJHQQV.js"}
package/dist/charts.d.ts CHANGED
@@ -75,6 +75,17 @@ type ChartSeries = {
75
75
  /** Pin to a specific palette token (1–5). Defaults to the series position. */
76
76
  color?: ChartColorToken;
77
77
  };
78
+ /**
79
+ * One end of a value-axis domain: an explicit number, `'auto'` (let the axis
80
+ * pick), or a data extreme with an optional padding offset.
81
+ *
82
+ * The offset forms mirror what recharts actually parses — it pads the low end
83
+ * DOWN (`'dataMin - 500'`) and the high end UP (`'dataMax + 500'`); the mirrored
84
+ * spellings are silently ignored there, so they are not typed here either.
85
+ */
86
+ type ChartAxisBound = number | 'auto' | 'dataMin' | 'dataMax' | `dataMin - ${number}` | `dataMax + ${number}`;
87
+ /** `[min, max]` bounds for a value axis. */
88
+ type ChartAxisDomain = readonly [ChartAxisBound, ChartAxisBound];
78
89
  /** Props shared by the cartesian charts (line / bar / area). */
79
90
  type CartesianChartProps = {
80
91
  /** The rows to plot. */
@@ -100,6 +111,21 @@ type CartesianChartProps = {
100
111
  className?: string;
101
112
  };
102
113
 
114
+ type LineChartProps = CartesianChartProps & {
115
+ /**
116
+ * `[min, max]` bounds for the value (y) axis — numbers, `'auto'`, or padded
117
+ * data extremes (`['dataMin - 500', 'dataMax + 500']`). Defaults to a zero
118
+ * baseline.
119
+ *
120
+ * Only the line chart takes this. A line encodes value by POSITION, so moving
121
+ * the baseline rescales it without misstating it; bars and areas encode by
122
+ * length and filled area from the baseline, and truncating those makes the
123
+ * geometry lie about the ratios. Use it to compare two series of similar
124
+ * magnitude far from zero — not for a single series whose magnitude the
125
+ * reader is meant to judge.
126
+ */
127
+ yDomain?: ChartAxisDomain;
128
+ };
103
129
  /**
104
130
  * A themed multi-series line chart. Each series' color comes from the semantic
105
131
  * `--chart-*` palette (by order, or pinned via `series[].color`), which
@@ -112,8 +138,11 @@ type CartesianChartProps = {
112
138
  * xKey="month"
113
139
  * series={[{ dataKey: 'revenue', name: 'Revenue' }, { dataKey: 'cost' }]}
114
140
  * />
141
+ *
142
+ * @example Two close-but-high series, on a baseline that shows the gap
143
+ * <LineChart data={data} xKey="month" series={series} yDomain={[7000, 9000]} />
115
144
  */
116
- declare const LineChart: ({ data, xKey, series, height, width, className, ...chrome }: CartesianChartProps) => react_jsx_runtime.JSX.Element;
145
+ declare const LineChart: ({ data, xKey, series, height, width, className, ...chrome }: LineChartProps) => react_jsx_runtime.JSX.Element;
117
146
 
118
147
  type BarChartProps = CartesianChartProps & {
119
148
  /** Stack the series into a single bar per category. Defaults to grouped. */
@@ -178,4 +207,4 @@ type PieChartProps = {
178
207
  */
179
208
  declare const PieChart: ({ data, nameKey, dataKey, innerRadius, height, width, className, showLegend, showTooltip, valueFormatter, }: PieChartProps) => react_jsx_runtime.JSX.Element;
180
209
 
181
- export { AreaChart, type AreaChartProps, BarChart, type BarChartProps, type CartesianChartProps, type ChartColorToken, ChartContainer, type ChartContainerProps, type ChartDatum, type ChartDimension, type ChartSeries, ChartTooltip, ChartTooltipContent, type ChartTooltipProps, LineChart, PieChart, type PieChartProps, chartTokenColor };
210
+ export { AreaChart, type AreaChartProps, BarChart, type BarChartProps, type CartesianChartProps, type ChartAxisBound, type ChartAxisDomain, type ChartColorToken, ChartContainer, type ChartContainerProps, type ChartDatum, type ChartDimension, type ChartSeries, ChartTooltip, ChartTooltipContent, type ChartTooltipProps, LineChart, type LineChartProps, PieChart, type PieChartProps, chartTokenColor };
package/dist/charts.js CHANGED
@@ -1,13 +1,7 @@
1
+ import { cn } from './chunk-JP7PNHLH.js';
1
2
  import { ResponsiveContainer, Tooltip, LineChart as LineChart$1, Line, BarChart as BarChart$1, Bar, AreaChart as AreaChart$1, Area, PieChart as PieChart$1, Legend, Pie, Cell, CartesianGrid, XAxis, YAxis } from 'recharts';
2
- import { clsx } from 'clsx';
3
- import { extendTailwindMerge } from 'tailwind-merge';
4
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
4
 
6
- // src/blocks/Charts/ChartContainer.tsx
7
- var twMerge = extendTailwindMerge({
8
- prefix: "tw"
9
- });
10
- var cn = (...classes) => twMerge(clsx(...classes));
11
5
  var ChartContainer = ({
12
6
  height = 300,
13
7
  width = "100%",
@@ -111,13 +105,16 @@ var cartesianChrome = ({
111
105
  showYAxis = true,
112
106
  showLegend = false,
113
107
  showTooltip = true,
114
- valueFormatter
108
+ valueFormatter,
109
+ yDomain
115
110
  }) => /* @__PURE__ */ jsxs(Fragment, { children: [
116
111
  showGrid && /* @__PURE__ */ jsx(CartesianGrid, { strokeDasharray: "3 3", vertical: false }),
117
112
  /* @__PURE__ */ jsx(XAxis, { dataKey: xKey, ...AXIS_PROPS }),
118
- showYAxis && /* @__PURE__ */ jsx(
113
+ (showYAxis || yDomain !== void 0) && /* @__PURE__ */ jsx(
119
114
  YAxis,
120
115
  {
116
+ hide: !showYAxis,
117
+ domain: yDomain,
121
118
  width: 48,
122
119
  ...AXIS_PROPS,
123
120
  tickFormatter: valueFormatter ? (value) => formatValue(value, valueFormatter) : void 0