@karni/ui 0.3.1 → 0.4.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 (127) hide show
  1. package/README.md +712 -72
  2. package/dist/index.cjs +5 -5
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.js +8660 -4725
  5. package/dist/index.js.map +1 -1
  6. package/dist/manifest.json +18 -1
  7. package/dist/script-BVJ9BbPm.cjs +2 -0
  8. package/dist/script-BVJ9BbPm.cjs.map +1 -0
  9. package/dist/script-D1fn4Dm4.js +6 -0
  10. package/dist/script-D1fn4Dm4.js.map +1 -0
  11. package/dist/src/auth/AuthGuard.d.ts +69 -4
  12. package/dist/src/auth/LoginForm.d.ts +41 -7
  13. package/dist/src/auth/engine.d.ts +15 -13
  14. package/dist/src/auth/provider.d.ts +10 -10
  15. package/dist/src/auth/storage.d.ts +1 -1
  16. package/dist/src/auth/types.d.ts +3 -3
  17. package/dist/src/components/brand/karni-mark.d.ts +4 -4
  18. package/dist/src/components/chart/data-chart.d.ts +66 -11
  19. package/dist/src/components/empty-state/empty-state.d.ts +22 -2
  20. package/dist/src/components/file-upload/FileUpload.d.ts +67 -3
  21. package/dist/src/components/layout/layout.d.ts +104 -9
  22. package/dist/src/components/layout/route-focus.d.ts +43 -0
  23. package/dist/src/components/modal/modal.d.ts +49 -8
  24. package/dist/src/components/page/page.d.ts +36 -5
  25. package/dist/src/components/primitives/button.d.ts +1 -0
  26. package/dist/src/components/primitives/checkbox.d.ts +4 -1
  27. package/dist/src/components/primitives/dropdown-menu.d.ts +1 -1
  28. package/dist/src/components/primitives/icons.d.ts +9 -1
  29. package/dist/src/components/primitives/input.d.ts +4 -1
  30. package/dist/src/components/primitives/label.d.ts +4 -1
  31. package/dist/src/components/primitives/select.d.ts +37 -6
  32. package/dist/src/components/primitives/skeleton.d.ts +4 -1
  33. package/dist/src/components/primitives/switch.d.ts +4 -1
  34. package/dist/src/components/primitives/tabs.d.ts +2 -2
  35. package/dist/src/components/primitives/textarea.d.ts +4 -1
  36. package/dist/src/components/resource/ResourceDetail.d.ts +3 -3
  37. package/dist/src/components/resource/ResourceForm.d.ts +4 -3
  38. package/dist/src/components/resource/ResourceSelect.d.ts +3 -1
  39. package/dist/src/components/resource/ResourceTable.d.ts +9 -3
  40. package/dist/src/components/resource/audit-fields.d.ts +1 -1
  41. package/dist/src/components/resource/detail/ResourceDetail.d.ts +109 -11
  42. package/dist/src/components/resource/empty-value.d.ts +17 -0
  43. package/dist/src/components/resource/form/ResourceForm.d.ts +65 -6
  44. package/dist/src/components/resource/form/field-inputs.d.ts +15 -1
  45. package/dist/src/components/resource/form/form-states.d.ts +87 -0
  46. package/dist/src/components/resource/form/prepare.d.ts +3 -3
  47. package/dist/src/components/resource/form/server-errors.d.ts +14 -0
  48. package/dist/src/components/resource/history/ChangeHistory.d.ts +46 -4
  49. package/dist/src/components/resource/import/ResourceImport.d.ts +73 -7
  50. package/dist/src/components/resource/nouns.d.ts +12 -0
  51. package/dist/src/components/resource/query-state.d.ts +20 -0
  52. package/dist/src/components/resource/resource-names.d.ts +23 -0
  53. package/dist/src/components/resource/select/ResourceSelect.d.ts +39 -8
  54. package/dist/src/components/resource/select/select-states.d.ts +68 -0
  55. package/dist/src/components/resource/select/use-option-pages.d.ts +37 -0
  56. package/dist/src/components/resource/select/venue-backend.d.ts +144 -0
  57. package/dist/src/components/resource/table/BulkActionsBar.d.ts +62 -5
  58. package/dist/src/components/resource/table/CardList.d.ts +31 -0
  59. package/dist/src/components/resource/table/ConfirmDialog.d.ts +28 -0
  60. package/dist/src/components/resource/table/ExportButtons.d.ts +2 -2
  61. package/dist/src/components/resource/table/FilterBar.d.ts +3 -3
  62. package/dist/src/components/resource/table/PaginationControls.d.ts +15 -3
  63. package/dist/src/components/resource/table/ResourceTable.d.ts +79 -14
  64. package/dist/src/components/resource/table/RowCard.d.ts +31 -0
  65. package/dist/src/components/resource/table/TableView.d.ts +29 -0
  66. package/dist/src/components/resource/table/ViewOptions.d.ts +19 -0
  67. package/dist/src/components/resource/table/access.d.ts +12 -0
  68. package/dist/src/components/resource/table/derive-columns.d.ts +2 -2
  69. package/dist/src/components/resource/table/grid-navigation.d.ts +23 -0
  70. package/dist/src/components/resource/table/layout.d.ts +37 -0
  71. package/dist/src/components/resource/table/preferences.d.ts +12 -0
  72. package/dist/src/components/resource/table/slots.d.ts +41 -0
  73. package/dist/src/components/resource/table/states.d.ts +91 -0
  74. package/dist/src/components/resource/table/story-fixtures.d.ts +25 -0
  75. package/dist/src/components/resource/table/structural-columns.d.ts +25 -0
  76. package/dist/src/components/resource/table/table-icons.d.ts +16 -0
  77. package/dist/src/components/resource/table/table-utils.d.ts +27 -3
  78. package/dist/src/components/resource/table/url-state.d.ts +67 -0
  79. package/dist/src/components/resource/table/use-container-width.d.ts +10 -0
  80. package/dist/src/components/resource/testapp-fixtures.d.ts +78 -0
  81. package/dist/src/components/stat/stat.d.ts +36 -6
  82. package/dist/src/components/toast/toast-store.d.ts +1 -1
  83. package/dist/src/components/toast/toaster.d.ts +39 -6
  84. package/dist/src/components/toast/use-toast.d.ts +1 -1
  85. package/dist/src/hooks/keys.d.ts +4 -4
  86. package/dist/src/hooks/multipart.d.ts +9 -9
  87. package/dist/src/hooks/params.d.ts +1 -1
  88. package/dist/src/hooks/types.d.ts +3 -3
  89. package/dist/src/hooks/useChangeHistory.d.ts +3 -2
  90. package/dist/src/hooks/useResourceImport.d.ts +2 -2
  91. package/dist/src/hooks/useResourceMutations.d.ts +2 -2
  92. package/dist/src/hooks/useUnsavedChangesGuard.d.ts +31 -0
  93. package/dist/src/index.d.ts +49 -31
  94. package/dist/src/lib/roles.d.ts +4 -4
  95. package/dist/src/lib/variants.d.ts +1 -1
  96. package/dist/src/primitives/registry.d.ts +81 -0
  97. package/dist/src/schema/client.d.ts +10 -10
  98. package/dist/src/schema/parse.d.ts +2 -2
  99. package/dist/src/schema/provider.d.ts +10 -3
  100. package/dist/src/schema/types.d.ts +18 -9
  101. package/dist/src/schema/useSchema.d.ts +5 -1
  102. package/dist/src/states/ErrorState.d.ts +43 -0
  103. package/dist/src/states/PendingButton.d.ts +16 -0
  104. package/dist/src/states/describe-error.d.ts +25 -0
  105. package/dist/src/states/hooks.d.ts +10 -0
  106. package/dist/src/theme/script.d.ts +12 -0
  107. package/dist/src/theme/theme.d.ts +45 -0
  108. package/dist/styles.css +174 -13
  109. package/dist/tailwind-preset.cjs +2 -2
  110. package/dist/tailwind-preset.cjs.map +1 -1
  111. package/dist/tailwind-preset.d.ts +83 -1
  112. package/dist/tailwind-preset.js +181 -62
  113. package/dist/tailwind-preset.js.map +1 -1
  114. package/dist/tailwind-v4.css +31 -0
  115. package/dist/vite-plugin.cjs +2 -0
  116. package/dist/vite-plugin.cjs.map +1 -0
  117. package/dist/vite-plugin.d.ts +19 -0
  118. package/dist/vite-plugin.js +16 -0
  119. package/dist/vite-plugin.js.map +1 -0
  120. package/eslint-plugin/index.js +4 -0
  121. package/eslint-plugin/rules/max-page-lines.js +1 -1
  122. package/eslint-plugin/rules/no-deep-imports.js +1 -1
  123. package/eslint-plugin/rules/no-direct-http.js +1 -1
  124. package/eslint-plugin/rules/no-raw-elements.js +1 -1
  125. package/eslint-plugin/rules/no-style-literals.js +222 -0
  126. package/eslint-plugin/rules.json +10 -4
  127. package/package.json +22 -19
package/README.md CHANGED
@@ -1,18 +1,18 @@
1
1
  # @karni/ui
2
2
 
3
3
  Schema-driven React components for [Karni](https://github.com/cbsshekhawat18-lab/karni#readme) backends. Point it at a
4
- Karni API's OpenAPI schema and get working tables, forms, detail views and auth —
5
- plus the themable primitives they are built from — without regenerating boilerplate.
4
+ Karni API's OpenAPI schema and you get working tables, forms, detail views and
5
+ auth, plus the themable primitives they are built from, without regenerating boilerplate.
6
6
 
7
- - **Resource components** — `ResourceTable`, `ResourceForm`, `ResourceDetail`,
7
+ - **Resource components**: `ResourceTable`, `ResourceForm`, `ResourceDetail`,
8
8
  `ResourceSelect` render themselves from the backend schema (`/api/schema/`).
9
- - **Hooks** — `useResourceList`, `useResourceItem`, `useResourceMutations`,
9
+ - **Hooks**: `useResourceList`, `useResourceItem`, `useResourceMutations`,
10
10
  `useSchema` on TanStack Query, with a shared `karniKeys` key factory.
11
- - **Auth** — `AuthProvider`, `useAuth`, `AuthGuard`, `usePermission` for Karni's
11
+ - **Auth**: `AuthProvider`, `useAuth`, `AuthGuard`, `usePermission` for Karni's
12
12
  JWT or session auth modes.
13
- - **Primitives** — Button, Input, Textarea, Select, Checkbox, Switch, Label,
14
- Tabs, DropdownMenu, Skeleton: Radix-based, token-themed, `forwardRef`,
15
- `className`-mergeable.
13
+ - **Primitives**: Button, Input, Textarea, Select, Checkbox, Switch, Label,
14
+ Tabs, DropdownMenu, Skeleton. They are Radix-based, token-themed, `forwardRef`
15
+ and `className`-mergeable.
16
16
 
17
17
  The package ships **dual ESM/CJS** output plus `.d.ts` files: `import` resolves
18
18
  `dist/index.js`, `require()` resolves `dist/index.cjs` (same for
@@ -28,8 +28,8 @@ npm install @karni/ui @tanstack/react-query @tanstack/react-table react-hook-for
28
28
 
29
29
  Peer dependencies: `react` / `react-dom` (18 or 19), `@tanstack/react-query` v5,
30
30
  `@tanstack/react-table` v8, `react-hook-form` v7, `@hookform/resolvers` and `zod` v3.
31
- The project also needs `tailwindcss` (v3.4+ or v4) as a build tool — a
32
- devDependency, as `karni new` sets it up: it compiles the classes the
31
+ The project also needs `tailwindcss` (v3.4+ or v4) as a build tool. It is a
32
+ devDependency, which is how `karni new` sets it up. It compiles the classes the
33
33
  components use and is never imported at runtime, so it is not a peer.
34
34
 
35
35
  ## Quick start
@@ -53,14 +53,27 @@ export function App() {
53
53
 
54
54
  `KarniProvider` creates a TanStack `QueryClient` when you do not pass one
55
55
  (`queryClient` prop is the escape hatch). `AuthProvider` reads the auth mode from
56
- the schema root (`x-karni-auth-mode`); override it with `mode="jwt" | "session"`.
56
+ the schema root (`x-karni-auth-mode`). Override it with `mode="jwt" | "session"`.
57
57
 
58
58
  ## Styling
59
59
 
60
- All component colors resolve through `--karni-*` CSS custom properties defined in
61
- `@karni/ui/styles.css` (light values on `:root`, dark values under `.dark`).
62
- Import it once, then wire Tailwind so the token-backed utility classes
63
- (`bg-primary`, `text-muted-foreground`, …) exist in your build.
60
+ Every visual value in every component resolves from a `--karni-*` CSS custom
61
+ property defined in `@karni/ui/styles.css`:
62
+
63
+ | Family | What it holds |
64
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
65
+ | `--karni-color-*` | colours: background, foreground, card, popover, primary, secondary, muted, accent, destructive, border, input, ring, overlay, chart-1 … chart-5, and the derived `-foreground`, `-hover`, `-subtle` and `-border` shades |
66
+ | `--karni-space-*` | the spacing scale: `--karni-space-unit` (0.25rem), or any step `--karni-space-<n>` (`--karni-space-4` is 1rem) |
67
+ | `--karni-radius-*` | `sm`, `md`, `lg`, `xl`, `full` (derived from `--karni-radius`) |
68
+ | `--karni-font-*` | `sans`, `mono`, `size-xs` … `size-2xl` with `leading-*`, `weight-*`, `tracking-*` |
69
+ | `--karni-shadow-*` | `sm`, `md`, `lg` (and bare `--karni-shadow`) |
70
+ | other | `--karni-z-*`, `--karni-opacity-*`, `--karni-duration` and `--karni-ease` (0ms under `prefers-reduced-motion`), `--karni-border-width*`, `--karni-ring-*`, `--karni-table-max-height` (ResourceTable's scroll box) |
71
+
72
+ The Tailwind preset maps its scales onto these properties, so `bg-primary`,
73
+ `p-4` or `rounded-md`, in a component or in your own code, reads them. Import
74
+ the stylesheet once, then wire Tailwind (3.4 or later) so the classes exist in
75
+ your build. The rule `karni/no-style-literals` (in the ESLint plugin) and a
76
+ test that compiles every component class keep literals out of the library.
64
77
 
65
78
  ### Tailwind v3 (preset)
66
79
 
@@ -84,14 +97,36 @@ import "@karni/ui/styles.css";
84
97
  import "./index.css"; // your @tailwind base/components/utilities file
85
98
  ```
86
99
 
87
- ### Tailwind v4 (CSS-first recipe)
100
+ ### Tailwind v4 (what `karni new` sets up)
101
+
102
+ Keep the same `tailwind.config.ts` (the preset and the content paths) and load
103
+ it with `@config`. Add the `@tailwindcss/vite` plugin to `vite.config.ts`:
104
+
105
+ ```css
106
+ /* src/index.css (main.tsx imports @karni/ui/styles.css first) */
107
+ @import "tailwindcss";
108
+ @import "@karni/ui/tailwind-v4.css";
109
+ @config "../tailwind.config.ts";
110
+ ```
111
+
112
+ `@karni/ui/tailwind-v4.css` keeps a bare `border` on `--karni-color-border`,
113
+ as Tailwind 3 does (Tailwind 4 defaults borders to `currentColor`), and points
114
+ Tailwind 4's default transition timing at `--karni-duration` and
115
+ `--karni-ease`. A Tailwind 3 project must not import it. With it, every
116
+ Storybook story and the pages `karni new` generates render pixel-identical
117
+ under Tailwind 3 and 4, light and dark.
88
118
 
89
- Tailwind v4 has no `presets` array; map the tokens in CSS instead:
119
+ #### Alternative: no JavaScript config
120
+
121
+ Map the tokens in CSS instead of loading the preset. This covers colours,
122
+ spacing, type, radius, shadow and motion. The named stacking classes
123
+ (`z-popover`, …) and the token-backed opacity steps come only with the preset.
90
124
 
91
125
  ```css
92
126
  /* app.css */
93
127
  @import "tailwindcss";
94
128
  @import "@karni/ui/styles.css";
129
+ @import "@karni/ui/tailwind-v4.css";
95
130
 
96
131
  /* Scan the library's shipped output for class names. */
97
132
  @source "../node_modules/@karni/ui/dist";
@@ -99,58 +134,211 @@ Tailwind v4 has no `presets` array; map the tokens in CSS instead:
99
134
  /* Dark mode is class-based: add `dark` to <html> (or any ancestor). */
100
135
  @custom-variant dark (&:where(.dark, .dark *));
101
136
 
102
- /* Map Tailwind color/radius theme slots onto the --karni-* tokens. */
103
137
  @theme inline {
104
- --color-background: hsl(var(--karni-background));
105
- --color-foreground: hsl(var(--karni-foreground));
106
- --color-card: hsl(var(--karni-card));
107
- --color-card-foreground: hsl(var(--karni-card-foreground));
108
- --color-popover: hsl(var(--karni-popover));
109
- --color-popover-foreground: hsl(var(--karni-popover-foreground));
110
- --color-primary: hsl(var(--karni-primary));
111
- --color-primary-foreground: hsl(var(--karni-primary-foreground));
112
- --color-secondary: hsl(var(--karni-secondary));
113
- --color-secondary-foreground: hsl(var(--karni-secondary-foreground));
114
- --color-muted: hsl(var(--karni-muted));
115
- --color-muted-foreground: hsl(var(--karni-muted-foreground));
116
- --color-accent: hsl(var(--karni-accent));
117
- --color-accent-foreground: hsl(var(--karni-accent-foreground));
118
- --color-destructive: hsl(var(--karni-destructive));
119
- --color-destructive-foreground: hsl(var(--karni-destructive-foreground));
120
- --color-border: hsl(var(--karni-border));
121
- --color-input: hsl(var(--karni-input));
122
- --color-ring: hsl(var(--karni-ring));
123
- --color-chart-1: hsl(var(--karni-chart-1));
124
- --color-chart-2: hsl(var(--karni-chart-2));
125
- --color-chart-3: hsl(var(--karni-chart-3));
126
- --color-chart-4: hsl(var(--karni-chart-4));
127
- --color-chart-5: hsl(var(--karni-chart-5));
128
- --radius-lg: var(--karni-radius);
129
- --radius-md: calc(var(--karni-radius) - 2px);
130
- --radius-sm: calc(var(--karni-radius) - 4px);
138
+ --color-background: var(--karni-color-background);
139
+ --color-foreground: var(--karni-color-foreground);
140
+ --color-card: var(--karni-color-card);
141
+ --color-card-foreground: var(--karni-color-card-foreground);
142
+ --color-popover: var(--karni-color-popover);
143
+ --color-popover-foreground: var(--karni-color-popover-foreground);
144
+ --color-primary: var(--karni-color-primary);
145
+ --color-primary-foreground: var(--karni-color-primary-foreground);
146
+ --color-primary-hover: var(--karni-color-primary-hover);
147
+ --color-secondary: var(--karni-color-secondary);
148
+ --color-secondary-foreground: var(--karni-color-secondary-foreground);
149
+ --color-secondary-hover: var(--karni-color-secondary-hover);
150
+ --color-muted: var(--karni-color-muted);
151
+ --color-muted-foreground: var(--karni-color-muted-foreground);
152
+ --color-muted-subtle: var(--karni-color-muted-subtle);
153
+ --color-accent: var(--karni-color-accent);
154
+ --color-accent-foreground: var(--karni-color-accent-foreground);
155
+ --color-destructive: var(--karni-color-destructive);
156
+ --color-destructive-foreground: var(--karni-color-destructive-foreground);
157
+ --color-destructive-hover: var(--karni-color-destructive-hover);
158
+ --color-destructive-subtle: var(--karni-color-destructive-subtle);
159
+ --color-destructive-border: var(--karni-color-destructive-border);
160
+ --color-border: var(--karni-color-border);
161
+ --color-input: var(--karni-color-input);
162
+ --color-ring: var(--karni-color-ring);
163
+ --color-overlay: var(--karni-color-overlay);
164
+ --color-chart-1: var(--karni-color-chart-1);
165
+ --color-chart-2: var(--karni-color-chart-2);
166
+ --color-chart-3: var(--karni-color-chart-3);
167
+ --color-chart-4: var(--karni-color-chart-4);
168
+ --color-chart-5: var(--karni-color-chart-5);
169
+ --spacing: var(--karni-space-unit);
170
+ --radius-sm: var(--karni-radius-sm);
171
+ --radius-md: var(--karni-radius-md);
172
+ --radius-lg: var(--karni-radius-lg);
173
+ --radius-xl: var(--karni-radius-xl);
174
+ --font-sans: var(--karni-font-sans);
175
+ --font-mono: var(--karni-font-mono);
176
+ --text-xs: var(--karni-font-size-xs);
177
+ --text-xs--line-height: var(--karni-font-leading-xs);
178
+ --text-sm: var(--karni-font-size-sm);
179
+ --text-sm--line-height: var(--karni-font-leading-sm);
180
+ --text-base: var(--karni-font-size-base);
181
+ --text-base--line-height: var(--karni-font-leading-base);
182
+ --text-lg: var(--karni-font-size-lg);
183
+ --text-lg--line-height: var(--karni-font-leading-lg);
184
+ --text-xl: var(--karni-font-size-xl);
185
+ --text-xl--line-height: var(--karni-font-leading-xl);
186
+ --text-2xl: var(--karni-font-size-2xl);
187
+ --text-2xl--line-height: var(--karni-font-leading-2xl);
188
+ --font-weight-normal: var(--karni-font-weight-normal);
189
+ --font-weight-medium: var(--karni-font-weight-medium);
190
+ --font-weight-semibold: var(--karni-font-weight-semibold);
191
+ --font-weight-bold: var(--karni-font-weight-bold);
192
+ --tracking-tight: var(--karni-font-tracking-tight);
193
+ --tracking-wide: var(--karni-font-tracking-wide);
194
+ --shadow-sm: var(--karni-shadow-sm);
195
+ --shadow-md: var(--karni-shadow-md);
196
+ --shadow-lg: var(--karni-shadow-lg);
131
197
  }
132
198
  ```
133
199
 
134
200
  ### Theming and dark mode
135
201
 
136
- Override any token after importing the stylesheet — components pick it up
137
- everywhere, in both Tailwind versions:
202
+ Override any token after importing the stylesheet. Colours take any CSS colour
203
+ (`hsl()`, `oklch()`, hex, or `var()` of your own design system's variable):
138
204
 
139
205
  ```css
140
206
  :root {
141
- --karni-primary: 262 83% 58%; /* HSL triplet, no hsl() wrapper */
207
+ --karni-color-primary: oklch(0.55 0.22 285);
208
+ --karni-color-primary-foreground: white;
142
209
  --karni-radius: 0.75rem;
210
+ --karni-space-unit: 0.225rem; /* a denser spacing scale everywhere */
211
+ --karni-font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
212
+ }
213
+ .dark {
214
+ --karni-color-primary: oklch(0.7 0.18 285);
143
215
  }
144
216
  ```
145
217
 
146
- Toggle dark mode by adding/removing the `dark` class on `<html>`. Chart series
147
- colors are `--karni-chart-1` … `--karni-chart-5`.
218
+ The derived shades (`-hover`, `-subtle`, `-border`) follow the colour they
219
+ derive from unless you set them too.
220
+
221
+ Dark mode is the `dark` class on `<html>`. `ThemeProvider` manages it: it
222
+ follows the system until the user chooses, keeps the choice per browser, and
223
+ `ThemeToggle` is a button that cycles Light, Dark and System. The first paint
224
+ needs the theme before React starts: in a Vite app, `karniTheme()` puts
225
+ `KARNI_THEME_SCRIPT` first in `index.html`'s `<head>` (a project from
226
+ `karni new` has all three wired already):
227
+
228
+ ```ts
229
+ // vite.config.ts
230
+ import { karniTheme } from "@karni/ui/vite";
231
+ export default defineConfig({ plugins: [react(), karniTheme()] });
232
+ ```
233
+
234
+ ```tsx
235
+ <ThemeProvider>
236
+ <App /> {/* <ThemeToggle /> in the header */}
237
+ </ThemeProvider>
238
+ ```
239
+
240
+ Without Vite, put `KARNI_THEME_SCRIPT` in an inline `<script>` in `<head>`
241
+ yourself.
242
+
243
+ The Django side of a Karni project reads the same tokens: the admin and the
244
+ welcome page load a copy of this stylesheet, so the same overrides, added to
245
+ the admin's `UNFOLD["STYLES"]`, retheme the admin too
246
+ ([admin theming](https://cbsshekhawat18-lab.github.io/karni/admin/#theming)).
247
+
248
+ The 0.3 names (`--karni-primary: 262 83% 58%`, an HSL triplet) still work:
249
+ each `--karni-color-*` defaults to `hsl()` of its 0.3 name. They are
250
+ deprecated in 0.4 and removed in 1.0 ([migration note](https://cbsshekhawat18-lab.github.io/karni/migrations/0.4/)).
251
+
252
+ ### Adopting your design system: `karni ui detect`
253
+
254
+ A project that already has a design system does not retype it. Run
255
+ `karni ui detect` in the frontend (or the Karni project root):
256
+
257
+ ```sh
258
+ karni ui detect --dry-run # the report and the file, nothing written
259
+ karni ui detect # write src/karni-theme.css (and wire it in)
260
+ ```
261
+
262
+ It maps the first theme it finds onto the tokens above and writes one file
263
+ the project owns, `karni-theme.css`, imported after `@karni/ui/styles.css`:
264
+
265
+ 1. **shadcn/ui** (`components.json`): every token references the project's
266
+ own variable, `--karni-color-primary: var(--primary)` (or
267
+ `hsl(var(--primary))` for shadcn's Tailwind 3 HSL channels), declared on
268
+ `:root, .dark` so the project's theme and dark mode flow through live. It
269
+ also writes `karni-primitives.tsx`, registering the project's shadcn
270
+ components as Karni's primitives (below).
271
+ 2. **A Tailwind theme**: `@theme` blocks (Tailwind 4), or the
272
+ `tailwind.config` Node evaluates in the project (Tailwind 3).
273
+ 3. **A Material UI, Chakra UI or Mantine theme**: the literal values of its
274
+ `createTheme` / `extendTheme` / `createSystem` call.
275
+ 4. **Nothing**: Karni's defaults, listed commented, for you to edit.
276
+
277
+ The report lists every token mapped (from, to, value) and every value not
278
+ mapped with the reason (a palette is never turned into `primary`
279
+ silently: the line to add is printed). Re-running never overwrites a file
280
+ you edited unless you pass `--force`. `--json` prints the report for agents.
281
+ See [Theming](https://cbsshekhawat18-lab.github.io/karni/theming/) for each
282
+ source in detail.
283
+
284
+ ## Unhappy states
285
+
286
+ Every component words failures the same way, through `describeError(error,
287
+ { action, resource })`: a 403 reads "You can't delete events", a dropped
288
+ connection "You're offline", a 5xx "Something went wrong on our side". A status
289
+ code is never shown, but the server's own explanation is, when it gave one. The
290
+ building blocks are exported for your own screens:
291
+
292
+ - `ErrorState`: what failed and what to do, with "Try again" when retrying can
293
+ help (`onRetry`, `retrying`), announced to screen readers.
294
+ - `PermissionDenied`: which action the role does not allow, in plain words.
295
+ - `PendingButton`: disabled while `pending`, shows progress after 500ms
296
+ (`SLOW_AFTER_MS`) and keeps its width.
297
+ - `useDelayedFlag(active)` (true once work has run for 500ms) and
298
+ `useOnlineStatus()`.
299
+
300
+ A visible field with no value (`null`, `""`, an empty list) renders the same
301
+ everywhere, as `EmptyValue`: a muted en dash that screen readers read as
302
+ "empty" (`label` changes the word). ResourceTable's cells, ResourceDetail's
303
+ rows and ChangeHistory's before/after values all use it. A field the role may
304
+ not read (`Karni.field_roles`) is different: it is absent from the per-role
305
+ schema and from the payload, so it gets no row and no cell at all. It never
306
+ gets a dash that would hint it exists.
307
+
308
+ ### Replacing a state: `slots`
309
+
310
+ The resource and auth components take `slots`, an object of components that
311
+ replace one part each. `defaultXSlots` (`defaultResourceDetailSlots`, ...)
312
+ holds Karni's own, so a project can wrap one instead of rewriting it. Every
313
+ `Error` slot receives `ErrorSlotProps` (`error`, `context`, `onRetry`,
314
+ `retrying`), so one project error panel fits all of them.
315
+
316
+ ```tsx
317
+ <ResourceDetail
318
+ resource="events"
319
+ id={id}
320
+ slots={{ Error: MyErrorPanel, EmptyValue: ({ field }) => <i>not set</i> }}
321
+ />
322
+ ```
323
+
324
+ | Component | Slots |
325
+ | ---------------- | ---------------------------------------------------------------------------------------- |
326
+ | `ResourceDetail` | `Loading`, `Error` (+ `back`), `NotFound`, `EmptyValue`, `DeleteDialog`, `Deleted` |
327
+ | `ResourceImport` | `Loading`, `Error`, `Progress`, `Summary`, `Problems`, `ConfirmCommit` |
328
+ | `ChangeHistory` | `Loading`, `Error`, `Empty`, `Entry` |
329
+ | `LoginForm` | `Error` (+ `id` for `aria-describedby`, and `described`: the failure worded for sign-in) |
330
+ | `AuthGuard` | `Anonymous` (not signed in: a sign-in prompt), `Denied` (signed in, role lacks access) |
331
+
332
+ Loading placeholders take the real layout from the start (ResourceDetail
333
+ draws one row per field the schema declares, plus the title and action
334
+ buttons) but are drawn only once loading is slow (500ms), so a fast load
335
+ never flashes them and nothing moves when the data arrives.
148
336
 
149
337
  ## Resource components
150
338
 
151
339
  Every resource component takes the backend's resource name (the router prefix)
152
- and renders from the schema. They are defaults, not cages — each one exposes
153
- override props, and you can always drop down to the hooks + primitives.
340
+ and renders from the schema. These are defaults. Each one exposes override
341
+ props, and you can always drop down to the hooks + primitives.
154
342
 
155
343
  ```tsx
156
344
  // Table: server-driven pagination, sorting, search and filters
@@ -173,10 +361,47 @@ override props, and you can always drop down to the hooks + primitives.
173
361
  <ResourceSelect resource="venues" value={venueId} onChange={setVenueId} />
174
362
  ```
175
363
 
176
- Audit fields — `AUDIT_FIELDS`: `id`, `created_at`, `updated_at`, `created_by`,
177
- `updated_by`, which every Karni resource carries — never lead the screen.
364
+ `ResourceDetail` also renders its own header and actions:
365
+
366
+ ```tsx
367
+ <ResourceDetail
368
+ resource="events"
369
+ id={eventId}
370
+ title={(event) => event.title} // a function waits for the record (a placeholder stands in)
371
+ back={
372
+ <Button variant="outline" onClick={() => navigate("/events")}>
373
+ Back to events
374
+ </Button>
375
+ }
376
+ onEdit={(event) => navigate(`/events/${event.id}/edit`)}
377
+ canEdit={hasRole("organizer")} // or (event) => boolean
378
+ enableDelete // asks for confirmation; a refusal is shown in the dialog
379
+ canDelete={(event) => event.status === "draft"}
380
+ onDeleted={() => navigate("/events")}
381
+ />
382
+ ```
383
+
384
+ A 404 reads "Couldn't find this event" with the `back` action and no retry.
385
+ Karni answers 404 for a record outside the user's scope too, so the words never
386
+ say the record exists. A 403 reads "You can't view this event". Other failures
387
+ offer "Try again" (it refetches the schema too when that is what failed).
388
+ Offline, it says so and reloads when the connection returns. `resourceLabel`
389
+ ("event") names the resource in these messages when the router prefix does not
390
+ singularize well.
391
+
392
+ `ResourceImport` shows no upload form when the role may not import ("You
393
+ can't import events"). A failed upload keeps the chosen file, moves focus to
394
+ the error and offers "Try again" when it can help. A preview's rows with errors
395
+ are a captioned table. Commit asks for confirmation with the counts
396
+ (created, updated, unchanged, failed). Background progress is a
397
+ `progressbar` announced by the quarter, not every poll. `ChangeHistory` words
398
+ its failures the same way ("You can't view the history of this event").
399
+
400
+ Audit fields never lead the screen. These are the `AUDIT_FIELDS` (`id`,
401
+ `created_at`, `updated_at`, `created_by`, `updated_by`), which every Karni
402
+ resource carries.
178
403
  `ResourceTable` hides their columns by default (domain fields come first, in
179
- schema order; rows are still keyed by `id`). Show one by naming it in `columns`
404
+ schema order, and rows are still keyed by `id`). Show one by naming it in `columns`
180
405
  (`{ field: "created_at" }`), or show all of them after the domain columns with
181
406
  `showAuditFields`. `ResourceDetail` lists them last, in a muted "Record" group,
182
407
  and still honours `fields` overrides for them. `ResourceForm` never renders them
@@ -186,9 +411,239 @@ A create form starts from the schema's defaults (a model's `default=`), and a
186
411
  cleared optional field is left out of the request rather than sent as `null`
187
412
  (`null` is sent only for nullable fields).
188
413
 
189
- Validation schemas are generated at runtime with `zodFor(schema, "create" | "update")`;
414
+ Validation schemas are generated at runtime with `zodFor(schema, "create" | "update")`.
190
415
  `displayLabel(obj)` picks a human label for relation objects.
191
416
 
417
+ ### ResourceTable in every state
418
+
419
+ Each state is its own screen, and each is a slot you can replace:
420
+
421
+ | State | What the user sees | Slot |
422
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
423
+ | first load | the real layout in outline: header with the real column count, a full page of rows, the pagination row. Nothing moves when data arrives | `Loading` |
424
+ | no records yet | "No events yet", and the create action when `onCreate` is set and the role may create | `NoRecords` |
425
+ | nothing matches | "No events match", what is narrowing the list (search, each filter) and "Clear filters" (pinned `params` stay) | `NoResults` |
426
+ | the list failed | `ErrorState`: plain words, "Try again" when it can help | `Error` |
427
+ | a refresh failed | the rows stay, and one line above them says so, with "Try again" | `RefetchError` |
428
+ | 403 on the list | "You can't view events", no retry (4xx are not retried, except 408 and 429) | `Forbidden` |
429
+ | offline | a line over the rows it has, or the offline panel. The list reloads by itself when the connection is back | `Offline` |
430
+ | slow (500ms, `SLOW_AFTER_MS`) | a progress bar, dimmed rows, "Loading events…" announced. The clicked control shows progress and ignores repeat clicks | none |
431
+
432
+ The other slots are `Pagination`, `BulkActions` and `RowCard`. Every slot
433
+ receives the props type exported next to it (`ResourceTableLoadingProps`,
434
+ `ResourceTableNoResultsProps`, …, while `Error` and `RefetchError` take
435
+ `ErrorSlotProps`), and the defaults are exported as
436
+ `defaultResourceTableSlots`, so a replacement can wrap one:
437
+
438
+ ```tsx
439
+ <ResourceTable
440
+ resource="events"
441
+ onCreate={() => navigate("/events/new")} // "New event" in the toolbar and the empty screen
442
+ slots={{
443
+ NoRecords: (props) => <OurEmptyState {...props} />,
444
+ Error: (props) => <defaultResourceTableSlots.Error {...props} />,
445
+ }}
446
+ />
447
+ ```
448
+
449
+ The `empty` prop still replaces both empty screens, but the `NoRecords` and
450
+ `NoResults` slots are preferred, because they tell "nothing yet" from
451
+ "nothing matches". `resourceLabel` ("events") and `itemLabel` ("event") word
452
+ every message. By default they come from the resource name.
453
+
454
+ **Permissions.** Columns follow the role's schema (a field the role cannot
455
+ read is not in its schema, so its column and filter are absent). Row actions
456
+ follow the schema's `x-karni-access` rules for the signed-in user (from
457
+ `AuthProvider`): Edit shows on the rows the update rule allows (for example
458
+ `Owned("organizer") | HasRole("staff")` shows it on the user's own rows),
459
+ Delete on the rows the delete rule allows, and the create action only when
460
+ the create rule allows it. Rules only the server can answer (grants, custom
461
+ `Where` functions) keep the action, and a refusal is reported in plain words.
462
+ `canCreate`, `canEdit` and `canDelete` (a boolean, or a function of the row)
463
+ replace the schema's answer.
464
+
465
+ **Deletes and bulk actions.** `enableDelete` asks in a dialog ("Delete
466
+ “Spring Gala”?"). A failure is reported in the dialog, with "Try again" when
467
+ it can help. A destructive bulk action (or one with `confirm: true`) asks
468
+ first with the count ("Delete 3 events?"). An action may return a promise:
469
+ the button shows progress after 500ms, the rows that succeeded are
470
+ deselected, and a resolved `{ failed: [{ id, reason }] }` marks each failed
471
+ row with its reason. `onClick(ids)` returning nothing keeps working as
472
+ before. "Select all" selects the rows on this page, and the bar says so when
473
+ more rows match.
474
+
475
+ **At real size.** Only the current page is fetched: a page size choice
476
+ (`pageSizeOptions`, default 10/25/50/100), first, previous, next, last and a
477
+ page number to jump to. Wide tables scroll inside their own box (the page
478
+ never scrolls sideways): the header sticks, the first column stays put and
479
+ shows an edge once the table scrolls. The box is at most
480
+ `--karni-table-max-height` (70vh) tall. Set it to `none` to let the page
481
+ scroll. The "View" menu toggles compact rows and hides columns, and both are
482
+ remembered per user (in `localStorage`, under the signed-in user's id).
483
+ `density` / `onDensityChange` control density from outside. Below
484
+ `cardBreakpoint` (640px of the table's own width, not the viewport's, and `0`
485
+ turns cards off) each row is a card: its title, then label and value pairs,
486
+ with its checkbox and actions, and a sort choice above the cards. The sort
487
+ choice keeps keyboard focus while the sorted page loads (the list says it is
488
+ busy instead), and when the control that opened a dialog is gone afterwards
489
+ (a deleted row, the bulk toolbar once its action ran) focus lands on the list,
490
+ as it lands on the scroll region in the table layout.
491
+
492
+ **View state in the URL.** Page, page size, ordering, search and filters are
493
+ query parameters under the table's namespace (`?events.page=2&events.ordering=-starts_at`),
494
+ so a view can be shared and Back returns to the previous one. A value equal
495
+ to the default is left out, and pinned `params` never appear. `urlNamespace`
496
+ keeps two tables on one page apart, `syncUrl={false}` keeps the view in
497
+ component state, and `stateStore` plugs in a router. A store reads the
498
+ current query values, writes changes (`null` removes a key, `push` adds a
499
+ history entry) and reports outside changes:
500
+
501
+ ```tsx
502
+ // TanStack Router: the table's keys live in the route's search params.
503
+ function useRouterTableStore(): ResourceTableStateStore {
504
+ const search = useSearch({ strict: false }) as Record<string, unknown>;
505
+ const navigate = useNavigate();
506
+ return React.useMemo(
507
+ () => ({
508
+ read: () =>
509
+ Object.fromEntries(Object.entries(search).map(([key, value]) => [key, String(value)])),
510
+ write: (changes, { push }) =>
511
+ void navigate({
512
+ to: ".",
513
+ replace: !push,
514
+ search: (previous: Record<string, unknown>) => {
515
+ const next = { ...previous };
516
+ for (const [key, value] of Object.entries(changes)) {
517
+ if (value === null) delete next[key];
518
+ else next[key] = value;
519
+ }
520
+ return next;
521
+ },
522
+ }),
523
+ // The store object changes with `search`, so the table re-reads it.
524
+ subscribe: () => () => undefined,
525
+ }),
526
+ [search, navigate],
527
+ );
528
+ }
529
+ ```
530
+
531
+ **Keyboard and screen readers.** The table is a WAI-ARIA grid, named by a
532
+ caption (`caption`, default the capitalised `resourceLabel`). The whole table
533
+ is one Tab stop, so a 50-row page costs one Tab press instead of one per
534
+ control. Tab enters at the first row (or at the cell you left) and the next
535
+ Tab leaves the table. Inside, the arrow keys move between rows and cells,
536
+ Home and End go to the ends of a row, and Ctrl+Home and Ctrl+End go to the
537
+ first and last cell of the grid. Each control in a cell is a stop of its own:
538
+ the checkbox, the row's button, links, Edit, Delete and your `rowActions`. A
539
+ control keeps its own keys: a button activates, a menu trigger opens its menu
540
+ with ArrowDown, and Ctrl or Cmd+Enter opens a link in a new tab. On a cell,
541
+ Enter opens the row (`onRowClick`) and Space selects it (`bulkActions`).
542
+ Enter on a row's checkbox opens the row too. Sortable headers are buttons
543
+ with `aria-sort` in the grid's first row. Each checkbox is named after its row
544
+ ("Select Spring Gala"), and a selected row has `aria-selected`. The mouse
545
+ works as before. `keyboardNavigation="native"` turns the grid off: a plain
546
+ table where every control is its own Tab stop and the scroll box is
547
+ focusable. Below `cardBreakpoint` the cards are a list, and their controls
548
+ are Tab stops as before. Page changes, sorting, the selection count and slow
549
+ loading are announced in a polite live region. Errors are announced as
550
+ alerts.
551
+
552
+ ### ResourceForm: states, errors and the keyboard
553
+
554
+ - **Loading** draws the form's own layout: once the schema is known (edit
555
+ mode while the row loads) it has the real rows, labels and submit row, so
556
+ nothing moves when the values arrive.
557
+ - **A failed load** (schema or row) is an `ErrorState` with "Try again". A 403
558
+ on the row reads "You can't edit this event" (`PermissionDenied`). Fields a
559
+ role may not see or write are absent from its schema, so the form has no
560
+ row for them: no blank row, no dash.
561
+ - **Validation runs when a field loses focus**, not on every keystroke. A
562
+ field that shows an error re-checks on every change, so the error clears as
563
+ soon as it is fixed. `validationMode` and `reValidateMode` (react-hook-form's
564
+ `mode` / `reValidateMode`) change it.
565
+ - **Server errors** land on their field for every DRF shape (field lists,
566
+ strings, nested serializers, list serializers, `non_field_errors`, `detail`,
567
+ keys the form does not show). When more than one thing fails, a summary at
568
+ the top lists them, each a link to its field, and takes focus. With one
569
+ failing field, focus moves to it. Errors are announced, and every control
570
+ carries `aria-invalid`, `aria-describedby` (help text and error) and, where
571
+ its role allows, `aria-required`.
572
+ - **Any other failure** (offline, 401, 403 "You can't save this event", 409
573
+ "This changed while you were working" with "Load the latest version", 429,
574
+ 5xx) is a banner in plain words, with "Try again" when it can help. **The
575
+ typed values are never lost**, whatever the failure.
576
+ - **Submitting** disables the fields and the button (`PendingButton`: progress
577
+ after 500ms, same width). Enter submits from a one-line input, and Ctrl/Cmd+Enter
578
+ from anywhere (a textarea included). Enter inside an open relation picker
579
+ chooses an option. The first field takes focus on create, and `autoFocus`
580
+ changes that. Edit mode does not focus a field unless asked.
581
+
582
+ Replace any state through `slots` (`Loading`, `Error`, `Forbidden`,
583
+ `ErrorSummary`, `SubmitError`). The defaults are exported as
584
+ `defaultResourceFormSlots` to wrap. `submitLabel` renames the button and
585
+ `resourceLabel={{ singular: "person", plural: "people" }}` fixes the
586
+ resource's name in messages.
587
+
588
+ **Unsaved changes.** A dirty form warns before the page unloads
589
+ (`guardUnsavedChanges={false}` opts out). In-app navigation belongs to your
590
+ router: `useUnsavedChangesGuard()` holds the state and gives the router's
591
+ blocker its answer. With TanStack Router (what `karni new` sets up):
592
+
593
+ ```tsx
594
+ import { useBlocker, useNavigate } from "@tanstack/react-router";
595
+ import { ResourceForm, useUnsavedChangesGuard } from "@karni/ui";
596
+
597
+ function EditEvent({ id }: { id: string }) {
598
+ const navigate = useNavigate();
599
+ const guard = useUnsavedChangesGuard();
600
+ // Asks "You have unsaved changes. Leave without saving them?" while dirty.
601
+ useBlocker({ shouldBlockFn: guard.shouldBlock, enableBeforeUnload: false });
602
+ return (
603
+ <ResourceForm
604
+ resource="events"
605
+ id={id}
606
+ onDirtyChange={guard.setDirty}
607
+ onSuccess={() => navigate({ to: "/events/$id", params: { id } })}
608
+ />
609
+ );
610
+ }
611
+ ```
612
+
613
+ ResourceForm reports itself clean just before `onSuccess`, so navigating there
614
+ is never blocked. `useUnsavedChangesGuard({ confirm })` swaps
615
+ `window.confirm` for your own dialog, and `useUnsavedChangesGuard({ dirty })`
616
+ guards a form of your own.
617
+
618
+ ### ResourceSelect: thousands of options
619
+
620
+ - Options load a page at a time from the related list (its own `next` link,
621
+ page-number or cursor), more as the list scrolls, through "Load more", or
622
+ when the keyboard reaches the end. Nothing fetches every row, and long lists
623
+ render only the rows in view. The list says how many there are: "Showing 50
624
+ of 10,000. Type to narrow."
625
+ - Typing searches (`?search=`, debounced by `searchDebounceMs`), keeping the
626
+ previous results on screen and saying "Searching…". A related viewset with
627
+ no `search_fields` documents no `search` parameter. Then typing narrows the
628
+ loaded rows, and the list says search is not available. `searchable`
629
+ overrides what the schema says.
630
+ - Empty reads "No venues yet" or "No venues match “ber”". A failure reads "You
631
+ can't view venues", "Couldn't reach the server" or "Something went wrong on
632
+ our side", with "Try again" when it can help.
633
+ - Preselected ids are labelled in one request when the list documents
634
+ `id__in` (Karni's derived filters do not include it, so add it on the viewset
635
+ with `filterset_fields = {"id": ["exact", "in"]}`), otherwise one detail
636
+ request per id, in parallel. `idsFilter` names another filter or (`false`)
637
+ forces per-id requests. An id that cannot be loaded says "Couldn't load venue
638
+ 12" instead of showing the bare id.
639
+ - It is an ARIA 1.2 combobox (`aria-expanded`, `aria-controls`,
640
+ `aria-activedescendant`). Arrows, Home/End and PageUp/PageDown move through
641
+ every loaded page. Chips remove with their button, Backspace/Delete, or
642
+ Backspace in the empty input. The ref is the input, and `onBlur`,
643
+ `aria-describedby`, `aria-invalid` and `aria-required` make it a form field.
644
+ `slots` replaces `Option`, `Chip`, `Loading`, `Empty` and `Error`
645
+ (`defaultResourceSelectSlots`).
646
+
192
647
  ## Hooks
193
648
 
194
649
  ```tsx
@@ -206,7 +661,7 @@ const { create, update, remove } = useResourceMutations<Event>("events");
206
661
 
207
662
  List responses are DRF pages: `{ count, next, previous, results }`
208
663
  (the `Paginated<T>` type). Mutations invalidate `karniKeys.resource(resource)`
209
- on success; pass `invalidateKeys` to invalidate more. Use `karniKeys` for any
664
+ on success. Pass `invalidateKeys` to invalidate more. Use `karniKeys` for any
210
665
  manual cache work so your keys and the library's keys never drift.
211
666
 
212
667
  ## Auth
@@ -215,15 +670,31 @@ manual cache work so your keys and the library's keys never drift.
215
670
  const { user, status, login, logout } = useAuth();
216
671
  const { hasRole, isAuthenticated } = usePermission();
217
672
 
218
- <AuthGuard roles={["admin"]} fallback={<LoginPage />}>
673
+ <AuthGuard roles={["admin"]} explain>
219
674
  <AdminPanel />
220
675
  </AuthGuard>;
221
676
  ```
222
677
 
223
- In JWT mode the refresh token is stored via a pluggable `TokenStorage`; the
224
- default is `localStorage` (simple, but readable by any XSS on your page — use
225
- `createMemoryTokenStorage()` or your own adapter for stricter setups, at the
226
- cost of logout-on-reload). Session mode uses cookies + CSRF headers and needs no
678
+ With `explain`, `AuthGuard` says why it refuses. A visitor who is not signed in
679
+ sees a sign-in prompt with a `LoginForm` (signing in reveals the content in
680
+ place). A signed-in user whose role lacks access sees `PermissionDenied` naming
681
+ who is signed in. Replace one case with `anonymous={...}` or `denied={...}` (or
682
+ the `Anonymous` / `Denied` slots). `fallback` still covers both, and
683
+ `fallback={null}` renders nothing. Without any of these a guard renders
684
+ nothing, as in 0.3, and warns once in development: 1.0 makes `explain` the
685
+ default. To hide a piece of UI from a role, prefer `usePermission().hasRole(...)`.
686
+
687
+ `LoginForm` tells wrong credentials ("That email and password do not match")
688
+ from throttling ("Too many sign-in attempts. Wait 42 seconds"), a server error
689
+ and a dropped connection. The message is announced. Both fields point at it
690
+ (`aria-describedby`) and are marked invalid when they are the problem. The
691
+ email stays after a failure, and wrong credentials clear and focus the password.
692
+ The email field takes focus on mount (`autoFocus={false}` turns that off).
693
+
694
+ In JWT mode the refresh token is stored through a pluggable `TokenStorage`.
695
+ The default is `localStorage`, which is simple but readable by any XSS on your
696
+ page. For stricter setups use `createMemoryTokenStorage()` or your own adapter,
697
+ at the cost of logout-on-reload. Session mode uses cookies + CSRF headers and needs no
227
698
  token storage.
228
699
 
229
700
  ## Primitives and escape hatches
@@ -234,32 +705,201 @@ three rules:
234
705
  1. **`className` always merges** via `cn()` (clsx + tailwind-merge), so your
235
706
  classes win over the component's own.
236
707
  2. **Refs are forwarded** to the underlying element.
237
- 3. **Composition over configuration** — e.g. `buttonVariants()` is exported to
708
+ 3. **Composition over configuration**: for example, `buttonVariants()` is exported to
238
709
  style any element as a button, and the styled Radix parts
239
710
  (`SelectRoot`/`SelectTrigger`/`SelectContent`/`SelectItem`, the
240
711
  `DropdownMenu*` family, `Tabs*`) are exported for fully custom composition.
241
712
 
242
713
  `Select` note: Radix reserves the empty string, so the composed `Select` maps
243
714
  "no selection" through an internal `"__karni_empty__"` sentinel. Consumers only
244
- ever see `null`; pass `clearable` to render a clear item that emits `null`.
715
+ ever see `null`. Pass `clearable` to render a clear item that emits `null`.
245
716
  Option values `""` and `"__karni_empty__"` are reserved.
246
717
 
247
718
  The `variants()` helper (a dependency-free cva-style utility) and `cn()` are
248
719
  exported for building your own components in the same style.
249
720
 
721
+ Leaf primitives have no `slots`: there is nothing inside them to replace, and
722
+ each one is replaced whole through the registry below (or restyled with
723
+ `className`). `Input`, `Textarea`, `SelectTrigger` (and the composed `Select`)
724
+ and `Checkbox` show a destructive border while `aria-invalid` is true, so a
725
+ form marks a field invalid with the same attribute screen readers announce.
726
+ The composed `Select` passes `aria-invalid`, `aria-describedby` and
727
+ `aria-labelledby` to its trigger.
728
+
729
+ ### Your design system's primitives
730
+
731
+ A project with its own design system registers its primitives once, and every
732
+ Karni component renders them in place of Karni's, keeping its schema-driven
733
+ behaviour:
734
+
735
+ ```tsx
736
+ import { Button } from "@/components/ui/button";
737
+ import { Input } from "@/components/ui/input";
738
+ import * as select from "@/components/ui/select";
739
+
740
+ <KarniProvider
741
+ client={client}
742
+ primitives={{
743
+ button: Button,
744
+ input: Input,
745
+ select: {
746
+ Root: select.Select,
747
+ Trigger: select.SelectTrigger,
748
+ Value: select.SelectValue,
749
+ Content: select.SelectContent,
750
+ Item: select.SelectItem,
751
+ Group: select.SelectGroup,
752
+ Label: select.SelectLabel,
753
+ Separator: select.SelectSeparator,
754
+ },
755
+ }}
756
+ >
757
+ ```
758
+
759
+ The roles are `button`, `input`, `textarea`, `checkbox`, `switch`, `label`,
760
+ `skeleton` and the `select` family. A registered component receives the props
761
+ of Karni's primitive (`ButtonProps`, `InputProps`, …) and its ref. Components
762
+ from shadcn/ui take them as they are, and another design system registers a small
763
+ adapter (its own button with Karni's `variant` and `size` names mapped onto
764
+ its own). `select` is replaced as a whole family because Radix parts share a
765
+ context. A registered component may wrap Karni's own primitive: inside it, the
766
+ role resolves to Karni's again. `KarniPrimitivesProvider` changes the registry
767
+ for a subtree.
768
+
769
+ In a shadcn/ui project, `karni ui detect` writes this registry for you as
770
+ `karni-primitives.tsx` (a file the project owns), with only the roles whose
771
+ shadcn component exists, and the `select` family only when `select.tsx`
772
+ exports all eight parts on Radix. It adds `primitives={primitives}` to the
773
+ scaffold's `<KarniProvider client={client}>`, or prints the line to add.
774
+
775
+ ## App shell and support components: slots
776
+
777
+ `Layout`, `Page`, `Modal`, `Toaster`, `EmptyState`, `Stat`, `DataChart` and
778
+ `FileUpload` take `className` (merged onto their root with `cn()`) and
779
+ `slots`: replace one internal piece and keep the rest of the component's
780
+ behaviour. Each component exports its slot contract (`LayoutSlots`,
781
+ `PageSlots`, ...), the props each slot receives, and its defaults
782
+ (`defaultLayoutSlots`, ...) so a replacement can wrap the original:
783
+
784
+ ```tsx
785
+ import { Layout, type LayoutNavLinkProps } from "@karni/ui";
786
+ import { Link } from "@tanstack/react-router";
787
+
788
+ function NavLink({ item, href, onClick, ...props }: LayoutNavLinkProps) {
789
+ // onClick closes the mobile drawer; className carries the active style and focus ring.
790
+ return <Link to={href} onClick={onClick} {...props} />;
791
+ }
792
+
793
+ <Layout navItems={nav} slots={{ NavLink }}>
794
+ …
795
+ </Layout>;
796
+ ```
797
+
798
+ | Component | Slots |
799
+ | ------------ | ---------------------------------------------------------- |
800
+ | `Layout` | `Header`, `Sidebar`, `NavLink`, `MobileDrawer`, `SkipLink` |
801
+ | `Page` | `Header`, `Title`, `Description`, `Actions` |
802
+ | `Modal` | `Header`, `Footer`, `CloseButton` |
803
+ | `Toaster` | `Title`, `Description`, `CloseButton` |
804
+ | `EmptyState` | `Icon`, `Title`, `Description`, `Actions` |
805
+ | `Stat` | `Label`, `Value`, `Delta`, `Icon` |
806
+ | `DataChart` | `Tooltip`, `Legend`, `Empty`, `Loading` |
807
+ | `FileUpload` | `Dropzone`, `FileRow` |
808
+
809
+ Where a slot does what an older prop did, the prop keeps working and the slot
810
+ is preferred: `Layout`'s `renderNavItem` (use `NavLink`), `Page`'s `header`
811
+ (use `Header`, which still receives the title, description and actions),
812
+ `DataChart`'s `renderTooltip` (use `Tooltip`), `Toaster`'s `viewportClassName`
813
+ (use `className`). A custom `Modal` `Header` renders `ModalTitle` (and
814
+ `ModalDescription`) so the dialog stays labelled. A custom `CloseButton` wraps
815
+ its button in `ModalClose`.
816
+
817
+ Other additions: `Layout` takes `mobileNavOpen`, `defaultMobileNavOpen` and
818
+ `onMobileNavOpenChange` to control the drawer. `Stat` and `DataChart` take
819
+ `loading`, and `DataChart` also takes `summary`. `FileUpload` takes `error`,
820
+ `uploading`, `rejectionMessage`, `aria-invalid`, `aria-describedby` and
821
+ `aria-labelledby`.
822
+
823
+ ## Accessibility
824
+
825
+ The app shell, the support components and the primitives target WCAG 2.2 AA.
826
+ What each one does for keyboard and screen-reader users:
827
+
828
+ - **Layout**: a "Skip to content" link is the first Tab stop and moves focus
829
+ to `<main>`. Below `md` the sidebar is a modal drawer (Radix Dialog): named
830
+ "Navigation", focus moves into it and stays there, Escape, the backdrop, the
831
+ close button or a link close it, and focus returns to the menu button. The
832
+ menu button's `aria-controls` names the drawer only while it is open. The
833
+ drawer closes itself if the window grows past `md`. Nav links show a focus
834
+ ring and mark the current page with `aria-current="page"`. After a
835
+ client-side navigation (from the drawer too), focus moves to the new
836
+ page's `<h1>` (else `<main>`) and a polite live region says the page
837
+ title. Nothing moves on the first load (a redirect before the user acts
838
+ included), when only search params change (filters, pagination, sorting),
839
+ or when the page placed focus itself (an autofocused field, a control
840
+ inside `<main>`, a dialog). No router is needed: the route is the
841
+ URL's path, and a hash route like `#/events` counts. Render `Layout` once,
842
+ around your router's outlet, as `karni new` does. `routeFocus={false}`
843
+ turns this off, and `routeFocus={{ routeKey, getTarget, getAnnouncement }}`
844
+ sets the route (for a memory router), the element to focus and the words.
845
+ - **ResourceTable**: the table is a grid with one Tab stop. The arrow keys
846
+ move inside it, Enter opens a row and Space selects it, so a long page no
847
+ longer costs a Tab press per control. `keyboardNavigation="native"` gives
848
+ every control its own Tab stop again. The details are under "Keyboard and
849
+ screen readers" in the ResourceTable section.
850
+ - **Modal**: named by its title and described by its description. Focus moves
851
+ in, Tab stays inside, Escape closes, and focus returns to what opened it.
852
+ - **Toaster**: an error toast is announced at once (assertive), the others
853
+ politely. F8 moves focus to the toasts, Tab walks them and their buttons,
854
+ Escape dismisses the focused one. A toast's timer pauses while the pointer is
855
+ over the toasts or focus is inside them.
856
+ - **FileUpload**: the dropzone is a button (Enter or Space opens the file
857
+ picker), named by its text, `aria-label`, `aria-labelledby` or a
858
+ `<label htmlFor={id}>`. A refused file ("notes.txt isn't an accepted file
859
+ type.") and the `error` prop are announced and linked with
860
+ `aria-describedby`, and `aria-invalid` gives the invalid style. While
861
+ `uploading` it is busy and says so. Removing a file returns focus to the
862
+ dropzone.
863
+ - **DataChart**: an image named by its title and described by its description
864
+ plus a generated summary (each series' lowest and highest value). Every
865
+ value is in a visually hidden table. Tab focuses the chart. The arrow keys,
866
+ Home and End move the tooltip from point to point and announce each one, and
867
+ Escape hides it. On a narrow screen the tick labels stay at 11px.
868
+ - **Stat**: a change is spoken ("Increased by", "Decreased by", "Unchanged")
869
+ and drawn with an arrow or a dash, not by colour alone. `loading` sets
870
+ `aria-busy`.
871
+ - **Primitives**: every control shows a focus ring on keyboard focus
872
+ (`focus-visible`, the `--karni-color-ring` token). Tabs, DropdownMenu,
873
+ Select, Checkbox and Switch follow the WAI-ARIA keyboard patterns through
874
+ Radix.
875
+
876
+ Contrast: every text and background pair the components use reaches 4.5:1 in
877
+ both themes, and field boundaries, the switch track, focus rings, icon-only
878
+ buttons and chart series reach 3:1. `tests/contrast.test.ts` computes the
879
+ ratios from the tokens in `styles.css`, so a token change cannot lower one
880
+ unnoticed. If you override tokens, check the same pairs. Motion reads
881
+ `--karni-duration`, which is 0ms under `prefers-reduced-motion`, and nothing
882
+ animates from JavaScript.
883
+
884
+ `tests/a11y.test.tsx` runs axe-core (the WCAG A and AA rules) over every
885
+ story of these components.
886
+
250
887
  ## Manifest for coding agents
251
888
 
252
- The published package contains `dist/manifest.json` —
253
- `{ "components": [...], "hooks": [...], "roles": {...} }` — generated from the
254
- public barrel (and a static scan of the `registerComponentRole` call sites) at
255
- build time. AI coding agents can read it (export path
889
+ The published package contains `dist/manifest.json`, shaped
890
+ `{ "components": [...], "hooks": [...], "roles": {...} }`. It is generated from
891
+ the public barrel (and a static scan of the `registerComponentRole` call sites)
892
+ at build time. AI coding agents can read it (export path
256
893
  `@karni/ui/manifest.json`) to discover what exists instead of crawling source.
257
894
 
258
895
  ## ESLint plugin
259
896
 
260
897
  `@karni/ui/eslint-plugin` ships the Karni frontend enforcement rules
261
898
  (`karni/no-raw-elements`, `karni/max-page-lines`, `karni/no-deep-imports`,
262
- `karni/no-direct-http`) as a flat-config preset for ESLint >= 9:
899
+ `karni/no-direct-http`) as a flat-config preset for ESLint >= 9. A fifth rule,
900
+ `karni/no-style-literals`, is not in the preset: it fails on any visual value
901
+ that does not resolve from a `--karni-*` token, and a project whose own code
902
+ should follow the theme turns it on with `"karni/no-style-literals": "error"`.
263
903
 
264
904
  ```js
265
905
  // eslint.config.js
@@ -267,7 +907,7 @@ import karni from "@karni/ui/eslint-plugin";
267
907
  export default [...karni.configs.recommended];
268
908
  ```
269
909
 
270
- Every rule is an error; opt-outs are disable comments with a REQUIRED reason
910
+ Every rule is an error. Opt-outs are disable comments with a REQUIRED reason
271
911
  (`// eslint-disable-next-line karni/no-direct-http -- <reason>`). See
272
912
  `docs/enforcement.md` at the repo root.
273
913
 
@@ -284,6 +924,6 @@ npm run storybook # Storybook 8 (react-vite) with a light/dark toolbar
284
924
  Before the first `npm run storybook`, generate the MSW worker once:
285
925
  `npx msw init public/`.
286
926
 
287
- The source of truth for color discipline is enforced by tests: no Tailwind
288
- palette classes or raw color literals in component source — tokens only
927
+ Tests enforce the color discipline: component source uses tokens only, with no
928
+ Tailwind palette classes or raw color literals
289
929
  (see `src/components/primitives/no-color-literals.test.ts`).