@close-by/clay 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/api.md ADDED
@@ -0,0 +1,2136 @@
1
+ # API reference
2
+
3
+ All public APIs use **camelCase**. Import the facade from `@close-by/clay` for app code.
4
+
5
+ ```typescript
6
+ import { ui, reactive, subscribe } from '@close-by/clay';
7
+ // Still available from core: import { reactive, subscribe } from '@close-by/clay-core';
8
+ ```
9
+
10
+ ---
11
+
12
+ ## `ui` facade (`@close-by/clay`)
13
+
14
+ ### Lifecycle
15
+
16
+ #### `ui.page(path, fn, options?)`
17
+
18
+ Register a page builder. `fn` runs once per WebSocket session for that path.
19
+
20
+ ```typescript
21
+ ui.page('/dashboard', () => {
22
+ ui.label('Dashboard');
23
+ });
24
+
25
+ // Opt out of the global shell from `ui.run({ app })`:
26
+ ui.page('/login', () => {
27
+ ui.label('Sign in');
28
+ }, { shell: false });
29
+ ```
30
+
31
+ | Option | Type | Default | Description |
32
+ |--------|------|---------|-------------|
33
+ | `shell` | `boolean` | `true` when `ui.run({ app })` is set | Set `false` to skip the global page wrapper |
34
+
35
+ #### `ui.loadPages(dir)`
36
+
37
+ Dynamically import every `*.ts` / `*.tsx` under `dir` (Bun). Each module should call `ui.page` once; optional `export const pageMeta` is attached to the path registered by that import.
38
+
39
+ Skips `index.ts(x)`, `_*` files, and `*.test.*`.
40
+
41
+ ```typescript
42
+ await ui.loadPages(new URL('./pages', import.meta.url));
43
+ ```
44
+
45
+ #### `ui.navFromPages()`
46
+
47
+ Build primary `AppNavItem[]` from registered routes + collected `pageMeta` (label/icon/order). Home `/` sorts first; others by `order` (default `100`) then path. Missing meta falls back to a title-cased path segment and icon `'boxes'`. Set `nav: false` to register a route without a sidebar item (e.g. login/admin under Account).
48
+
49
+ Nest pages under collapsible sidebar groups with `pageMeta.group` / `groupIcon`, or pass `groupExamples: true` to auto-nest `/examples/*` (except `/examples/auth`) under **Examples**. Manual nav trees use `items` on `AppNavItem`.
50
+
51
+ ```typescript
52
+ export const pageMeta = { label: 'Charts', icon: 'chart-area', order: 90 };
53
+ // export const pageMeta = { nav: false }; // route only
54
+ // export const pageMeta = { label: 'Admin', roles: ['admin'] }; // nav UX filter
55
+ // export const pageMeta = { label: 'Guide', group: 'Docs', groupIcon: 'book' };
56
+
57
+ ui.navFromPages({ role: user.role, groupExamples: true });
58
+ ```
59
+
60
+ #### `ui.run(root?, config?)`
61
+
62
+ Start the Bun server. Optional `app` sets a global shell wrapper for every page (unless `shell: false`).
63
+
64
+ **Root overload** (NiceGUI-style single page): if `/` is not registered yet, `ui.run(() => { … })` registers that function as `/` then starts.
65
+
66
+ ```typescript
67
+ ui.run(() => {
68
+ ui.label('Hello');
69
+ });
70
+ ```
71
+
72
+ ```typescript
73
+ await ui.loadPages(new URL('./pages', import.meta.url));
74
+
75
+ ui.run({
76
+ port: 4000,
77
+ title: 'My App',
78
+ clientDir: '/absolute/path/to/client/dist', // optional
79
+ css: './globals.css', // optional extra stylesheet(s)
80
+ app: {
81
+ title: 'My App',
82
+ nav: ui.navFromPages(),
83
+ // user, navSecondary, documents, …
84
+ },
85
+ });
86
+ ```
87
+
88
+ | Option | Type | Default | Description |
89
+ |--------|------|---------|-------------|
90
+ | `port` | `number` | `3000` | HTTP + WS port |
91
+ | `title` | `string` | `'Clay'` | HTML `<title>` |
92
+ | `clientDir` | `string` | `resolveClayClientDir()` | Built Vite assets (`@close-by/clay-cli/client-dist` when installed). Prefer `resolveClayClientDir()` over hardcoding `node_modules` paths — see [Boot](./boot.md). |
93
+ | `css` | `string \| string[]` | | Extra CSS file path(s) served after the client bundle (absolute or cwd-relative). Override theme tokens without rebuilding the client. |
94
+ | `app` | `AppProps` | | Global dashboard shell; client keeps chrome sticky across navigate |
95
+ | `authSecret` | `string` | | Enables signed auth cookies (`POST`/`DELETE /auth/session`), `ui.establishAuthSession` / `ui.clearAuthSession`, and default `resolveUserId` from the cookie |
96
+ | `authCookieMaxAgeSec` | `number` | `43200` (12h) | Auth cookie Max-Age |
97
+ | `sessionIdleMs` | `number` | | Optional: clear auth + redirect after this idle time |
98
+ | `sessionAbsoluteMs` | `number` | | Optional: clear auth + redirect after this time since hello |
99
+ | `sessionExpiredPath` | `string` | `'/'` | SPA path after idle/absolute expiry |
100
+ | `resolveUserId` | `(ctx) => …` | cookie helper when `authSecret` set | Trusted identity on WebSocket hello |
101
+
102
+ #### Auth cookie recipe
103
+
104
+ ```typescript
105
+ ui.run({
106
+ authSecret: process.env.CLAY_AUTH_SECRET!,
107
+ sessionIdleMs: 30 * 60 * 1000,
108
+ sessionAbsoluteMs: 12 * 60 * 60 * 1000,
109
+ sessionExpiredPath: '/login',
110
+ });
111
+
112
+ // On successful login (server event handler):
113
+ ui.establishAuthSession(userId, { path: '/account' });
114
+ // → client POSTs signed token to /auth/session, then soft-reconnects so hello sees the cookie
115
+
116
+ // On logout:
117
+ ui.clearAuthSession({ path: '/login' });
118
+ ```
119
+
120
+ Password hashing, login rate limits, and `requireAuth` / `requireRole` live in optional `@close-by/clay-auth`. See `/examples/auth`.
121
+
122
+ Returns `ClayServer` with `.start()` / `.stop()` / `.port` (`.start()` is already called by `ui.run`).
123
+
124
+ Prefer the **`clay` CLI** for prototypes: `clay hello.ts` or `clay ./pages --app` (see [Getting started](./getting-started.md)).
125
+
126
+ Custom CSS is linked as `/assets/custom-0.css`, … after `/assets/index.css`. Use **shadcn-style** theme variables (`--background`, `--primary`, `--sidebar`, `--radius`, …, plus optional `.dark { … }`). App Tailwind utilities are auto-built by default from page modules — see [Tailwind](./tailwind.md).
127
+
128
+ #### `ui.refreshable(fn)`
129
+
130
+ Create a refreshable region. Returns `RefreshableElement` with `.refresh()`.
131
+
132
+ ```typescript
133
+ const panel = ui.refreshable(() => {
134
+ ui.label(`Items: ${items.length}`);
135
+ });
136
+ panel.refresh();
137
+ ```
138
+
139
+ ---
140
+
141
+ ### Typography and actions
142
+
143
+ #### `ui.label(text?, props?)`
144
+
145
+ | Prop | Type | Description |
146
+ |------|------|-------------|
147
+ | `text` | `string \| (() => string)` | Label content (also the first argument). A function uses `bindText` — re-runs when tracked `state` / `reactive` reads change |
148
+ | `icon` | `string` | Optional Lucide kebab-case icon before/after text |
149
+ | `iconPosition` | `'start' \| 'end'` | Default `'start'` |
150
+ | `iconClassName` | `string` | Extra classes on the icon |
151
+ | `className` | `string` | Extra classes |
152
+
153
+ ```typescript
154
+ const s = ui.state({ count: 0 });
155
+ ui.label(() => `Count: ${s.count}`);
156
+ ui.label('Orders', { icon: 'clipboard-list' });
157
+ ```
158
+
159
+ #### `ui.button(text?, props?)`
160
+
161
+ | Prop | Type | Default | Description |
162
+ |------|------|---------|-------------|
163
+ | `text` | `string \| (() => string)` | Button label (function → `bindText`) |
164
+ | `icon` | `string` | | Lucide kebab-case name (full set; same as `ui.icon` / nav) |
165
+ | `iconPosition` | `'start' \| 'end'` | `'start'` | Icon placement relative to label |
166
+ | `variant` | `'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'` | `'default'` | Visual style |
167
+ | `size` | `'default' \| 'sm' \| 'lg' \| 'icon'` | `'default'` | Size |
168
+ | `disabled` | `boolean` | `false` | Disabled state |
169
+ | `className` | `string` | | Extra classes |
170
+ | `onClick` | `() => void \| Promise<void>` | | Click handler |
171
+
172
+ ```typescript
173
+ ui.button('Copy', { icon: 'copy', variant: 'outline', onClick: () => ui.clipboard(id) });
174
+ ```
175
+
176
+ #### `ui.iconButton(props)`
177
+
178
+ Toolbar-friendly button with a required `icon`. If `label` is omitted, `size` defaults to `'icon'`.
179
+
180
+ | Prop | Type | Default | Description |
181
+ |------|------|---------|-------------|
182
+ | `icon` | `string` | | Lucide kebab-case name (required) |
183
+ | `label` | `string` | | Visible label; omit for icon-only |
184
+ | `iconPosition` | `'start' \| 'end'` | `'start'` | |
185
+ | `variant` / `size` / `disabled` / `className` / `onClick` | | | Same as `ui.button` |
186
+
187
+ ```typescript
188
+ ui.iconButton({ icon: 'copy', label: 'Copy', size: 'sm', onClick: () => ui.clipboard(id) });
189
+ ui.iconButton({ icon: 'refresh-cw', variant: 'ghost', onClick: () => void reload() });
190
+ ```
191
+
192
+ Types: `ButtonProps`, `IconButtonProps`, `ButtonVariant`, `ButtonSize` are exported from `@close-by/clay`.
193
+
194
+ #### `ui.link(text, href, props?)`
195
+
196
+ Client-side navigation for paths starting with `/`. Use `external: true` (or `ui.externalLink`) for new-tab http(s) URLs.
197
+
198
+ | Prop | Type | Description |
199
+ |------|------|-------------|
200
+ | `external` | `boolean` | Open in a new tab (`noopener`) instead of SPA navigate |
201
+ | `icon` / `iconPosition` / `iconClassName` | | Same as `ui.label` |
202
+ | `className` | `string` | Extra classes |
203
+
204
+ #### `ui.badge(text?, props?)`
205
+
206
+ | Prop | Type | Default |
207
+ |------|------|---------|
208
+ | `text` | `string \| (() => string)` | Chip text (function → `bindText`) |
209
+ | `icon` / `iconPosition` / `iconClassName` | | Optional Lucide icon in the chip |
210
+ | `variant` | `'default' \| 'secondary' \| 'destructive' \| 'outline'` | `'default'` |
211
+ | `size` | `'default' \| 'xs'` | `'default'` |
212
+ | `color` | `string` | Named (`green`, `red`, `amber`, …) or CSS (`#22c55e`). Overrides `variant` when set. |
213
+ | `className` | `string` | |
214
+
215
+ ```typescript
216
+ ui.badge('live', { size: 'xs', color: 'emerald', icon: 'radio' });
217
+ ui.badge(() => status, { icon: 'circle-check' });
218
+ ```
219
+
220
+ #### `ui.copyButton(content, props?)`
221
+
222
+ Icon button that calls `ui.clipboard(content)` and `ui.notify('Copied', 'success')`. Props match `ui.iconButton` (`label?`, `size?`, `variant?`, …) plus optional `notifyMessage`.
223
+
224
+ ```typescript
225
+ ui.copyButton(traceId);
226
+ ui.copyButton(traceId, { label: 'Copy', size: 'sm' });
227
+ ```
228
+
229
+ #### `ui.externalLink(text, url, props?)`
230
+
231
+ Same as `ui.link(text, url, { external: true })` — opens in a new tab. http(s) `ui.link` hrefs also open externally by default on the client.
232
+
233
+ #### `ui.alert(message?, props?)`
234
+
235
+ | Prop | Type | Default |
236
+ |------|------|---------|
237
+ | `variant` | `'default' \| 'destructive'` | `'default'` |
238
+ | `icon` / `iconPosition` / `iconClassName` | | Optional Lucide icon |
239
+ | `className` | `string` | |
240
+
241
+ ```typescript
242
+ ui.alert('Save failed', { variant: 'destructive', icon: 'triangle-alert' });
243
+ ```
244
+
245
+ #### `ui.spinner(props?)`
246
+
247
+ Loading indicator (Lucide spinner).
248
+
249
+ | Prop | Type |
250
+ |------|------|
251
+ | `className` | `string` |
252
+
253
+ #### `ui.skeleton(props?)`
254
+
255
+ Loading placeholder.
256
+
257
+ | Prop | Type | Default |
258
+ |------|------|---------|
259
+ | `className` | `string` | `'h-4 w-full'` |
260
+
261
+ #### `ui.avatar(props?)`
262
+
263
+ | Prop | Type | Default |
264
+ |------|------|---------|
265
+ | `src` | `string` | |
266
+ | `alt` | `string` | `''` |
267
+ | `fallback` | `string` | `'?'` |
268
+ | `size` | `'sm' \| 'default' \| 'lg'` | `'default'` |
269
+ | `className` | `string` | |
270
+
271
+ #### `ui.tooltip(props, fn)` / `ui.tooltip(fn, props)`
272
+
273
+ Wraps children in a ShadCN tooltip.
274
+
275
+ ```typescript
276
+ ui.tooltip({ text: 'Edit item', side: 'top' }, () => {
277
+ ui.button('', { size: 'icon', variant: 'ghost' });
278
+ });
279
+ ```
280
+
281
+ | Prop | Type | Default |
282
+ |------|------|---------|
283
+ | `text` | `string` | required |
284
+ | `side` | `'top' \| 'right' \| 'bottom' \| 'left'` | `'top'` |
285
+
286
+ #### `ui.progress(props?)`
287
+
288
+ | Prop | Type | Default |
289
+ |------|------|---------|
290
+ | `value` | `number` | `0` (0–100) |
291
+ | `className` | `string` | |
292
+
293
+ #### `ui.separator(props?)`
294
+
295
+ | Prop | Type | Default |
296
+ |------|------|---------|
297
+ | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` |
298
+ | `className` | `string` | |
299
+
300
+ #### `ui.icon(name, props?)`
301
+
302
+ Lucide icon by kebab-case name. The **full** Lucide set is bundled in the client (string keys can’t be tree-shaken). Same names as `AppNavItem.icon` / `button({ icon })` (e.g. `home`, `gauge`, `sparkles`, `refresh-cw`). Unknown names fall back to `boxes`.
303
+
304
+ | Prop | Type |
305
+ |------|------|
306
+ | `className` | `string` |
307
+
308
+ #### `ui.iconText(text?, props?)`
309
+
310
+ Inline icon + text row. Prefer over manual `ui.row(() => { ui.icon(); ui.label(); })`.
311
+
312
+ | Prop | Type | Default |
313
+ |------|------|---------|
314
+ | `text` | `string \| (() => string)` | Row text (function → `bindText`) |
315
+ | `icon` | `string` | Lucide name |
316
+ | `iconPosition` | `'start' \| 'end'` | `'start'` |
317
+ | `gap` | `1 \| 2 \| 3` | `2` (Tailwind `gap-*`) |
318
+ | `className` | `string` | |
319
+
320
+ ```typescript
321
+ ui.iconText('Refresh', { icon: 'refresh-cw' });
322
+ ui.iconText(() => `Rows: ${n}`, { icon: 'list' });
323
+ ```
324
+
325
+ #### `ui.statusDot(props?)`
326
+
327
+ Colored dot (or Lucide icon) + label for feeds, tables, and status lines — structured alternative to `ui.html` spans.
328
+
329
+ | Prop | Type | Default |
330
+ |------|------|---------|
331
+ | `label` | `string \| (() => string)` | Text beside the dot (function → `bindText`) |
332
+ | `color` | `'emerald' \| 'amber' \| 'red' \| 'blue' \| 'muted' \| string` | `'muted'` |
333
+ | `icon` | `string` | When set, renders icon instead of dot |
334
+ | `className` | `string` | |
335
+
336
+ ```typescript
337
+ ui.statusDot({ label: 'Healthy', color: 'emerald' });
338
+ ui.statusDot({ label: () => status, color: 'amber', icon: 'alert-circle' });
339
+ ```
340
+
341
+ #### `ui.feedList(props?, fn)` / `ui.feedRow(props, footer?)`
342
+
343
+ Live ops feed chrome — **not** `ui.list` (that widget owns grouped DnD state). Your app owns items, polling, and filters; wrap the loop in `ui.auto` or reactive-let.
344
+
345
+ **`ui.feedList`** — bordered, `divide-y` container (`width: full`, `rounded-md border`, …).
346
+
347
+ **`ui.feedRow`** — one selectable row:
348
+
349
+ | Prop | Type | Notes |
350
+ |------|------|--------|
351
+ | `selected` | `boolean` | Highlights row (`bg-primary/10`) |
352
+ | `status` | `{ color?, icon?, className? }` | Leading `ui.statusDot` (dot only) |
353
+ | `title` | `string \| (() => string)` | Link-style primary line |
354
+ | `onClick` | `() => void` | Title click |
355
+ | `meta` | `string \| (() => string)` | Muted line under title |
356
+ | `issue` | `string \| (() => string)` | Destructive line (errors) |
357
+ | `hint` | `string \| (() => string)` | Muted fallback when no issue/footer |
358
+ | `marker` | `string` | Small label beside title (e.g. `new`) |
359
+ | `trailing` | `string \| (() => string)` | Right column (usually time) |
360
+ | `onTrailingClick` | `() => void` | Trailing click |
361
+ | `footer` | `() => void` | Second arg — chips etc. when `issue` unset |
362
+
363
+ ```typescript
364
+ ui.feedList(() => {
365
+ for (const row of live.rows) {
366
+ ui.feedRow(
367
+ {
368
+ selected: row.id === live.selectedId,
369
+ status: { color: 'emerald' },
370
+ title: row.id,
371
+ meta: row.summary,
372
+ trailing: () => formatRelative(row.at),
373
+ onClick: () => { live.selectedId = row.id; },
374
+ },
375
+ () => ui.badge(row.status, { size: 'xs' }),
376
+ );
377
+ }
378
+ });
379
+ ```
380
+
381
+ See [Ops patterns — live feeds](./ops-patterns.md#live-feed-rows-not-uilist).
382
+
383
+ #### `ui.markdown(text?, props?)`
384
+
385
+ Client renders Markdown (`marked`) and sanitizes with DOMPurify. Update with `.setText(...)`.
386
+
387
+ | Prop | Type |
388
+ |------|------|
389
+ | `className` | `string` |
390
+
391
+ #### `ui.html(html?, props?)`
392
+
393
+ Trusted server HTML into a container (same XSS trust model as NiceGUI — only pass HTML you control). Update with `.update({ html })`.
394
+
395
+ | Prop | Type |
396
+ |------|------|
397
+ | `className` | `string` |
398
+
399
+ #### `ui.image(src, props?)`
400
+
401
+ | Prop | Type | Default |
402
+ |------|------|---------|
403
+ | `alt` | `string` | `''` |
404
+ | `width` / `height` | `number \| string` | |
405
+ | `className` | `string` | |
406
+
407
+ #### `ui.iframe(src, props?)`
408
+
409
+ First-class iframe embed (prefer over `ui.html(\`<iframe…>\`)`). Only embed origins you trust.
410
+
411
+ | Prop | Type | Default |
412
+ |------|------|---------|
413
+ | `title` | `string` | `''` |
414
+ | `width` / `height` | `number \| string` | |
415
+ | `allow` | `string` | |
416
+ | `sandbox` | `string \| string[]` | |
417
+ | `loading` | `'eager' \| 'lazy'` | |
418
+ | `referrerPolicy` | `string` | |
419
+ | `className` | `string` | |
420
+
421
+ ---
422
+
423
+ ### Form controls
424
+
425
+ Form controls support `bindValue(obj, key)` for two-way binding.
426
+
427
+ Optional `error?: string` on form controls shows an invalid state on the client (`aria-invalid` + message under the field). Empty/omitted means valid. Prefer `.setError()` / `ui.validate` at submit time over baking errors into the initial props.
428
+
429
+ #### `ui.input(props?)`
430
+
431
+ | Prop | Type | Default |
432
+ |------|------|---------|
433
+ | `value` | `string` | `''` |
434
+ | `placeholder` | `string` | `''` |
435
+ | `type` | `string` | `'text'` (e.g. `email`, `number`, `color`, `password`) |
436
+ | `label` | `string` | |
437
+ | `error` | `string` | |
438
+ | `disabled` | `boolean` | `false` |
439
+ | `className` | `string` | |
440
+ | `onInput` | `(value: string) => void` | |
441
+ | `onChange` | `(value: string) => void` | |
442
+
443
+ #### `ui.textArea(props?)`
444
+
445
+ | Prop | Type | Default |
446
+ |------|------|---------|
447
+ | `value` | `string` | `''` |
448
+ | `placeholder` | `string` | |
449
+ | `label` | `string` | |
450
+ | `error` | `string` | |
451
+ | `rows` | `number` | `3` |
452
+ | `disabled` | `boolean` | `false` |
453
+ | `onInput` / `onChange` | `(value: string) => void` | |
454
+
455
+ #### `ui.checkbox(props?)`
456
+
457
+ | Prop | Type | Default |
458
+ |------|------|---------|
459
+ | `checked` | `boolean` | `false` (stored as element `value`) |
460
+ | `label` | `string` | |
461
+ | `error` | `string` | |
462
+ | `disabled` | `boolean` | `false` |
463
+ | `onChange` | `(checked: boolean) => void` | |
464
+
465
+ #### `ui.switch(props?)`
466
+
467
+ Bound boolean toggle (optimistic like checkbox).
468
+
469
+ | Prop | Type | Default |
470
+ |------|------|---------|
471
+ | `checked` | `boolean` | `false` (stored as element `value`) |
472
+ | `label` | `string` | |
473
+ | `error` | `string` | |
474
+ | `disabled` | `boolean` | `false` |
475
+ | `size` | `'sm' \| 'default'` | `'default'` |
476
+ | `onChange` | `(checked: boolean) => void` | |
477
+
478
+ #### `ui.select(props)`
479
+
480
+ | Prop | Type | Required |
481
+ |------|------|----------|
482
+ | `options` | `{ value: string; label: string }[]` | yes |
483
+ | `value` | `string` | defaults to first option |
484
+ | `label` | `string` | |
485
+ | `error` | `string` | |
486
+ | `disabled` | `boolean` | |
487
+ | `onChange` | `(value: string) => void` | |
488
+
489
+ #### `ui.radioGroup(props)`
490
+
491
+ Exclusive choice (optimistic like select). Supports `bindValue`.
492
+
493
+ | Prop | Type | Required |
494
+ |------|------|----------|
495
+ | `options` | `{ value: string; label: string }[]` | yes |
496
+ | `value` | `string` | defaults to first option |
497
+ | `label` | `string` | |
498
+ | `error` | `string` | |
499
+ | `disabled` | `boolean` | |
500
+ | `orientation` | `'horizontal' \| 'vertical'` | `'vertical'` |
501
+ | `onChange` | `(value: string) => void` | |
502
+
503
+ #### `ui.combobox(props)`
504
+
505
+ Searchable select for large option lists (optimistic like select). Supports `bindValue`.
506
+
507
+ | Prop | Type | Required |
508
+ |------|------|----------|
509
+ | `options` | `{ value: string; label: string }[]` | yes |
510
+ | `value` | `string` | defaults to first option |
511
+ | `label` | `string` | |
512
+ | `placeholder` | `string` | `'Search…'` |
513
+ | `error` | `string` | |
514
+ | `disabled` | `boolean` | |
515
+ | `onChange` | `(value: string) => void` | |
516
+
517
+ #### `ui.date(props?)`
518
+
519
+ Calendar + popover date picker. Value is an ISO date string (`YYYY-MM-DD`). Supports `bindValue`.
520
+
521
+ | Prop | Type | Default |
522
+ |------|------|---------|
523
+ | `value` | `string` | `''` |
524
+ | `label` | `string` | |
525
+ | `placeholder` | `string` | `'Pick a date'` |
526
+ | `error` | `string` | |
527
+ | `disabled` | `boolean` | `false` |
528
+ | `onChange` | `(value: string) => void` | |
529
+
530
+ #### `ui.dateRange(props?)`
531
+
532
+ Range picker with preset shortcuts and dual-month calendar. Values are ISO `YYYY-MM-DD`.
533
+
534
+ | Prop | Type | Default |
535
+ |------|------|---------|
536
+ | `from` | `string` | `''` |
537
+ | `to` | `string` | `''` |
538
+ | `label` | `string` | |
539
+ | `placeholder` | `string` | `'Pick a date range'` |
540
+ | `presets` | `{ label, from, to }[]` | Last 7/30 days + this month |
541
+ | `error` | `string` | |
542
+ | `disabled` | `boolean` | `false` |
543
+ | `onChange` | `({ from, to }) => void` | |
544
+
545
+ #### `ui.timeline(props)`
546
+
547
+ Chronological events for deploy logs, order status, and activity feeds.
548
+
549
+ | Prop | Type | Default |
550
+ |------|------|---------|
551
+ | `items` | `TimelineItem[]` | required |
552
+ | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` |
553
+
554
+ Each item: `at?`, `title`, `description?`, `status?` (`pending` \| `active` \| `completed` \| `error`), `icon?`, `avatar?`, `badge?`, `badgeColor?`, `body?` (collapsible detail), `defaultOpen?`.
555
+
556
+ #### `ui.stepper(props?, fn)`
557
+
558
+ Inline multi-step flow (not modal). Server owns `index`; client emits `indexChange`.
559
+
560
+ | Prop | Type | Default |
561
+ |------|------|---------|
562
+ | `index` | `number` | `0` |
563
+ | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` |
564
+ | `showNav` | `boolean` | `true` when >1 step |
565
+ | `onIndexChange` | `(index: number) => void` | |
566
+
567
+ Use `s.step({ title, description?, icon?, status? }, () => { … })` inside the callback.
568
+
569
+ #### `ui.slider(props?)`
570
+
571
+ | Prop | Type | Default |
572
+ |------|------|---------|
573
+ | `min` | `number` | `0` |
574
+ | `max` | `number` | `100` |
575
+ | `step` | `number` | `1` |
576
+ | `value` | `number` | `0` |
577
+ | `label` | `string` | |
578
+ | `error` | `string` | |
579
+ | `showValue` | `boolean` | `false` |
580
+ | `disabled` | `boolean` | `false` |
581
+ | `onChange` | `(value: number) => void` | |
582
+
583
+ #### `ui.rating(props?)`
584
+
585
+ Star rating control. Supports `bindValue`.
586
+
587
+ | Prop | Type | Default |
588
+ |------|------|---------|
589
+ | `value` | `number` | `0` |
590
+ | `max` | `number` | `5` |
591
+ | `label` | `string` | |
592
+ | `error` | `string` | |
593
+ | `disabled` | `boolean` | `false` |
594
+ | `onChange` | `(value: number) => void` | |
595
+
596
+ #### `ui.colorPicker(props?)`
597
+
598
+ Hex color picker (swatches + native color input + text field). Supports `bindValue`.
599
+
600
+ | Prop | Type | Default |
601
+ |------|------|---------|
602
+ | `value` | `string` | `'#3b82f6'` |
603
+ | `label` | `string` | |
604
+ | `error` | `string` | |
605
+ | `disabled` | `boolean` | `false` |
606
+ | `onChange` | `(value: string) => void` | |
607
+
608
+ #### `ui.tags(props?)`
609
+
610
+ Multi-tag chip input. Supports `bindValue` (value is `string[]`).
611
+
612
+ | Prop | Type | Default |
613
+ |------|------|---------|
614
+ | `value` | `string[]` | `[]` |
615
+ | `options` | `{ value: string; label: string }[]` | `[]` |
616
+ | `creatable` | `boolean` | `true` |
617
+ | `label` | `string` | |
618
+ | `placeholder` | `string` | `'Add tag…'` |
619
+ | `error` | `string` | |
620
+ | `disabled` | `boolean` | `false` |
621
+ | `onChange` | `(value: string[]) => void` | |
622
+
623
+ #### `ui.codeBlock(props)`
624
+
625
+ Read-only syntax-highlighted code (Shiki on the client).
626
+
627
+ | Prop | Type | Default |
628
+ |------|------|---------|
629
+ | `code` | `string` | required |
630
+ | `language` | `string` | `'text'` |
631
+ | `showCopy` | `boolean` | `true` |
632
+ | `sensitive` | `boolean` | `false` |
633
+
634
+ When `sensitive` is true, the client blurs content until Reveal (logs/secrets). Scrubbing remains the app’s job — see [Ops patterns](./ops-patterns.md#logs--traces--sensitive-bodies).
635
+
636
+ #### `ui.ai.*`
637
+
638
+ AI-native **visual** primitives (props + events only — no model / streaming runtime). See [AI UI](./ai.md).
639
+
640
+ | Factory | Wire type | Highlights |
641
+ |---------|-----------|------------|
642
+ | `ui.ai.loader` | `aiLoader` | `label`, `variant` (`drive`/`dots`/`orbit`/`pixel`), `startedAt?` |
643
+ | `ui.ai.thinking` | `aiThinking` | `steps[]`, `open?`, `onToggle` |
644
+ | `ui.ai.message` | `aiMessage` | `role`, `text`, `streaming?`, sources/actions/followUps/thinking |
645
+ | `ui.ai.chat` | `aiChat` | Tabs + messages + composer; `AiChatElement.setMessages` / `setActiveTab` |
646
+ | `ui.ai.promptBar` | `aiPromptBar` | Sources, models, commands, dictate stub; `onSubmit` |
647
+ | `ui.ai.codeBlock` | `aiCodeBlock` | `filename?`, `streaming?` (+ Shiki) |
648
+ | `ui.ai.approval` | `aiApproval` | `question`, `options`, `onApprove` / `onReject` |
649
+ | `ui.ai.toolChips` | `aiToolChips` | Compact tool chips |
650
+ | `ui.ai.tasks` | `aiTasks` | Nested task rows (`running`/`failed`/`completed`) |
651
+ | `ui.ai.recommendation` | `aiRecommendation` | Confidence + alternatives |
652
+ | `ui.ai.context` | `aiContext` | Retrieved chunks |
653
+ | `ui.ai.diffTable` | `aiDiffTable` | Compact proposed edits (`changedKeys`) |
654
+ | `ui.ai.insights` | `aiInsights` | Paged insights |
655
+ | `ui.ai.selectionActions` | `aiSelectionActions` | Selection + action strip |
656
+ | `ui.ai.fineTune` | `aiFineTune` | Number/select/text fields → `onChange` |
657
+
658
+ ```ts
659
+ const chat = ui.ai.chat({
660
+ messages: [{ id: '1', role: 'user', text: 'Hi' }],
661
+ onSubmit: (text) => {
662
+ chat.setMessages([...chat.getMessages(), { id: String(Date.now()), role: 'user', text }]);
663
+ },
664
+ });
665
+ ```
666
+
667
+ #### `ui.tree(props)`
668
+
669
+ Nested tree with selection and expand/collapse. Optimistic `selected` / `expanded`.
670
+
671
+ | Prop | Type | Default |
672
+ |------|------|---------|
673
+ | `nodes` | `{ id: string; label: string; children?: … }[]` | required |
674
+ | `selected` | `string` | `''` |
675
+ | `expanded` | `string[]` | `[]` |
676
+ | `disabled` | `boolean` | `false` |
677
+ | `onSelect` | `(id: string) => void` | |
678
+ | `onExpand` | `(expanded: string[]) => void` | |
679
+
680
+ #### `ui.editor(props?)`
681
+
682
+ Rich text editor powered by Domternal (classic toolbar + bubble menu, StarterKit). Supports `bindValue`. Wire `value` is HTML by default, or Markdown when `format: 'markdown'`. Client debounces `change` (~200ms) and flushes on blur; server echoes that match the last emitted value do not reset the caret.
683
+
684
+ | Prop | Type | Default |
685
+ |------|------|---------|
686
+ | `value` | `string` | `''` |
687
+ | `format` | `'html' \| 'markdown'` | `'html'` |
688
+ | `placeholder` | `string` | |
689
+ | `disabled` | `boolean` | `false` |
690
+ | `onChange` | `(value: string) => void` | |
691
+
692
+ #### `ui.kanban(props)`
693
+
694
+ Kanban board with cross-column card drag (`@dnd-kit`), optional swimlanes, and a card detail drawer. **`KanbanElement` owns board state** (columns + card order + selection), similar to Flow/DataTable — default settle handlers update the model and patch the client without remounting. **Do not** wrap the whole board in `ui.auto` that tracks `columns` (that remounts on every drop). Prefer side effects in `onCardMove` / `onCardSelect`.
695
+
696
+ **Owned model + defaults**
697
+
698
+ - Initial `columns` / `lanes` / `selectedCardId` seed the owned model.
699
+ - Default settle always runs (even with no user callbacks): `cardMove` → `moveCard`, `cardSelect` → `selectCard`. User `onCardMove` / `onCardSelect` run **after** for side effects only.
700
+ - Clicking a card opens the detail drawer (optimistic `selectedCardId`). Optional `detail` builds a detached Element tree stamped as `__detail` (same pattern as DataTable).
701
+ - When `lanes` is set, cards use `laneId` and drag payloads include `fromLaneId` / `toLaneId` (index is lane-scoped within the column).
702
+ - Imperative APIs: `getColumns` / `setColumns`, `getLanes` / `setLanes`, `getSelectedCardId` / `selectCard` / `clearSelection`, `moveCard`, `addCard` / `removeCard`, `addColumn` / `removeColumn`, `addLane` / `removeLane`, `setDisabled`.
703
+ - Client keeps optimistic column order while dragging; settle patches owned `columns` via `updateProps`.
704
+
705
+ ```typescript
706
+ const status = ui.state({ lastMove: '', open: '' });
707
+
708
+ const board = ui.kanban({
709
+ columns: [
710
+ {
711
+ id: 'todo',
712
+ title: 'Todo',
713
+ cards: [{ id: 'c1', title: 'Sketch API', laneId: 'eng' }],
714
+ },
715
+ { id: 'done', title: 'Done', cards: [] },
716
+ ],
717
+ lanes: [
718
+ { id: 'eng', title: 'Engineering' },
719
+ { id: 'design', title: 'Design' },
720
+ ],
721
+ detail: (card, column) => {
722
+ ui.label(card.title);
723
+ ui.label(column.title).classes('text-muted-foreground');
724
+ },
725
+ onCardMove: (p) => {
726
+ status.lastMove = `${p.cardId} → ${p.toColumnId}`;
727
+ },
728
+ onCardSelect: (id) => {
729
+ status.open = id ?? '';
730
+ },
731
+ });
732
+
733
+ // Reset / mutate via element APIs (no outer ui.auto):
734
+ // board.setColumns([...]); board.selectCard('c1'); board.clearSelection();
735
+ ```
736
+
737
+ | Prop | Type | Default |
738
+ |------|------|---------|
739
+ | `columns` | `{ id, title, cards: { id, title, description?, laneId? }[] }[]` | `[]` |
740
+ | `lanes` | `{ id, title }[]` | `[]` (no swimlanes) |
741
+ | `selectedCardId` | `string \| null` | `null` |
742
+ | `disabled` | `boolean` | `false` |
743
+ | `detail` | `(card, column) => void` | default drawer body (title/description) |
744
+ | `onCardMove` | `(payload: { cardId, fromColumnId, toColumnId, index, fromLaneId?, toLaneId? }) => void` | after owned `moveCard` |
745
+ | `onCardSelect` | `(cardId: string \| null) => void` | after owned `selectCard` |
746
+ | `onCardClick` | `(cardId: string) => void` | |
747
+
748
+ Out of v1: persistence helpers, multiplayer cursors, inline card editing forms beyond `detail`.
749
+
750
+ #### `ui.relativeTime(props)`
751
+
752
+ Multi-timezone clock (display-only). When `date` is omitted, the client ticks every second using “now”.
753
+
754
+ | Prop | Type | Default |
755
+ |------|------|---------|
756
+ | `date` | `string \| number` | live now |
757
+ | `timezones` | `(string \| { zone: string; label?: string })[]` | required |
758
+ | `dateStyle` | `'full' \| 'long' \| 'medium' \| 'short'` | `'long'` |
759
+ | `timeStyle` | `'full' \| 'long' \| 'medium' \| 'short'` | `'medium'` |
760
+ | `className` | `string` | |
761
+
762
+ #### `ui.qrCode(props)`
763
+
764
+ SVG QR code from a string (display-only; `qrcode` on the client).
765
+
766
+ | Prop | Type | Default |
767
+ |------|------|---------|
768
+ | `value` | `string` | required |
769
+ | `size` | `number` | `160` |
770
+ | `level` | `'L' \| 'M' \| 'Q' \| 'H'` | `'M'` |
771
+ | `className` | `string` | |
772
+
773
+ #### `ui.imageZoom(props)`
774
+
775
+ Image with a click-to-zoom overlay. Prefer this when you want lightbox behavior; leave `ui.image` for plain `<img>`.
776
+
777
+ | Prop | Type | Default |
778
+ |------|------|---------|
779
+ | `src` | `string` | required |
780
+ | `alt` | `string` | `''` |
781
+ | `className` | `string` | |
782
+
783
+ #### `ui.list(props)`
784
+
785
+ Dense vertical grouped list with cross-group drag (`@dnd-kit`). Parallel to `ui.kanban`, but stacked groups instead of a board. **`ListElement` owns list state** (groups + item order), similar to Flow/DataTable — default settle handlers update the model and patch the client without remounting. **Do not** wrap the whole list in `ui.auto` that tracks `groups` (that remounts on every drop). Prefer side effects in `onItemMove`.
786
+
787
+ **Owned model + defaults**
788
+
789
+ - Initial `groups` seed the owned model.
790
+ - Default settle always runs (even with no user callbacks): `itemMove` → `moveItem`. User `onItemMove` runs **after** for side effects only.
791
+ - Imperative APIs: `getGroups` / `setGroups`, `moveItem`, `addItem` / `removeItem`, `addGroup` / `removeGroup`, `setDisabled`.
792
+ - Client keeps optimistic group order while dragging; settle patches owned `groups` via `updateProps`.
793
+
794
+ ```typescript
795
+ const status = ui.state({ lastMove: '' });
796
+
797
+ const board = ui.list({
798
+ groups: [
799
+ { id: 'inbox', title: 'Inbox', items: [{ id: 'i1', title: 'Review PR' }] },
800
+ { id: 'done', title: 'Done', items: [] },
801
+ ],
802
+ onItemMove: (p) => {
803
+ status.lastMove = `${p.itemId}: ${p.fromGroupId} → ${p.toGroupId}`;
804
+ },
805
+ });
806
+
807
+ // Reset / mutate via element APIs (no outer ui.auto):
808
+ // board.setGroups([...]); board.setDisabled(true);
809
+ ```
810
+
811
+ | Prop | Type | Default |
812
+ |------|------|---------|
813
+ | `groups` | `{ id, title, items: { id, title, description? }[] }[]` | `[]` |
814
+ | `disabled` | `boolean` | `false` |
815
+ | `onItemMove` | `(payload: { itemId, fromGroupId, toGroupId, index }) => void` | after owned `moveItem` |
816
+ | `onItemClick` | `(itemId: string) => void` | |
817
+
818
+ #### `ui.imageCrop(props)`
819
+
820
+ Interactive image cropper (`react-easy-crop`). Emits `crop` with `{ dataUrl }` (JPEG data URL). No upload pipeline in v1 — POST via `ui.upload` if needed.
821
+
822
+ | Prop | Type | Default |
823
+ |------|------|---------|
824
+ | `src` | `string` | required |
825
+ | `aspect` | `number` | free crop |
826
+ | `onCrop` | `(payload: { dataUrl: string }) => void` | |
827
+ | `className` | `string` | |
828
+
829
+ #### `ui.gantt(props)`
830
+
831
+ Project timeline with sidebar row labels, month axis, bars, today + custom markers, and optional finish-to-start dependency arrows. **`GanttElement` owns timeline state** (rows + item dates + markers + dependencies), similar to Flow/DataTable — default settle handlers update the model and patch the client without remounting. **Do not** wrap the whole chart in `ui.auto` that tracks `rows` (that remounts on every drag). Prefer side effects in `onItemMove` / `onMarkerAdd`.
832
+
833
+ **Owned model + defaults**
834
+
835
+ - Initial `rows` / optional `markers` / `dependencies` / `range` seed the owned model.
836
+ - Default settle always runs (even with no user callbacks): `itemMove` → `moveItem`, `markerAdd` → `addMarker`. User `onItemMove` / `onMarkerAdd` run **after** for side effects only.
837
+ - Imperative APIs: `getRows` / `setRows`, `moveItem`, `addItem` / `removeItem`, `addRow` / `removeRow`, `getMarkers` / `setMarkers` / `addMarker` / `removeMarker`, `getDependencies` / `setDependencies` / `addDependency` / `removeDependency`, `getRange` / `setRange`, `isReadonly` / `setReadonly`.
838
+ - Drag move/resize when not `readonly` (including **cross-row** move); client keeps optimistic dates/row while dragging; settle patches owned `rows` on pointer-up.
839
+ - Double-click the month header to create a marker at that date (emits `markerAdd`), or call `addMarker` from app code.
840
+
841
+ ```typescript
842
+ const status = ui.state({ lastMove: '' });
843
+
844
+ const timeline = ui.gantt({
845
+ rows: [
846
+ {
847
+ id: 'design',
848
+ title: 'Design',
849
+ items: [{ id: 'd1', title: 'Wireframes', start: '2026-07-20', end: '2026-08-02' }],
850
+ },
851
+ ],
852
+ markers: [{ id: 'beta', date: '2026-08-15', label: 'Beta' }],
853
+ dependencies: [{ id: 'dep1', from: 'd1', to: 'd2' }],
854
+ range: { start: '2026-07-15', end: '2026-09-05' },
855
+ onItemMove: (p) => {
856
+ status.lastMove = `${p.itemId} @ ${p.rowId}: ${p.start} → ${p.end}`;
857
+ },
858
+ onMarkerAdd: (m) => {
859
+ console.log('marker', m);
860
+ },
861
+ });
862
+
863
+ // Reset / mutate via element APIs (no outer ui.auto):
864
+ // timeline.setRows([...]); timeline.addMarker({ id, date, label });
865
+ // timeline.setDependencies([...]); timeline.setReadonly(true);
866
+ ```
867
+
868
+ | Prop | Type | Default |
869
+ |------|------|---------|
870
+ | `rows` | `{ id, title, items: { id, title, start, end }[] }[]` | `[]` |
871
+ | `markers` | `{ id, date, label? }[]` | |
872
+ | `dependencies` | `{ id, from, to }[]` | |
873
+ | `range` | `{ start, end }` | inferred from items/markers |
874
+ | `readonly` | `boolean` | `false` |
875
+ | `onItemMove` | `(payload: { itemId, rowId, start, end }) => void` | after owned `moveItem` |
876
+ | `onItemClick` | `(itemId: string) => void` | |
877
+ | `onMarkerAdd` | `(marker: { id, date, label? }) => void` | after owned `addMarker` |
878
+
879
+ Dates are ISO `YYYY-MM-DD` (or parseable datetime strings). `dependencies` are finish-to-start links (`from` bar end → `to` bar start). Out of v1: interactive dependency editing, collision layout polish, marker drag.
880
+
881
+ #### `ui.flow(props, fn)` / `ui.flow(fn, props?)`
882
+
883
+ Interactive flow diagram backed by `@xyflow/react`. **`FlowElement` owns diagram state** (edges + node positions), similar in spirit to DataTable owning rows — DataTable-style imperative APIs mutate the model and patch the client without remounting the React Flow shell. **Node interiors are live Clay element trees**.
884
+
885
+ **Owned model + defaults**
886
+
887
+ - Initial `edges` and each `flow.node({ position })` seed the owned model.
888
+ - Default settle handlers always run (even with no user callbacks): `nodeMove` → `moveNode`, `connect` → `addEdge`, `edgesDelete` / `nodesDelete` → remove from owned model. User `on*` handlers run **after** for side effects only.
889
+ - Imperative APIs on the returned `FlowElement`: `getEdges` / `setEdges`, `addEdge` / `removeEdges`, `moveNode`, `getPositions` / `getNodeIds`, `addNode` / `removeNode`, `group` / `addGroup`, `layout(opts?)`.
890
+ - **Do not** wrap the whole flow in `ui.auto` that tracks edges/positions — that remounts the diagram. Keep narrow `ui.auto` (or `bindText` / prop patches) **inside** individual nodes for control labels.
891
+
892
+ **Interaction model**
893
+
894
+ - Drag nodes by the card chrome (labels/empty space). Buttons, inputs, and other interactive controls are marked `nodrag` / `nopan` so clicks do not steal the drag.
895
+ - Positions stay optimistic on the client while dragging; **`nodeMove` fires once on drag-stop** and the flow persists position automatically.
896
+ - New connections emit **`connect`** once (payload includes a client-generated `id`); the flow appends that edge id so optimistic RF edges settle without remapping flicker. If ids ever diverge, BoundFlow also rematches by `source`/`target`/handles.
897
+ - Deletes (`Backspace` / `Delete`) emit **`nodesDelete`** / **`edgesDelete`** (owned model updates first). Removing a **group** also removes its `parentId` children.
898
+ - Viewport pan/zoom is client-local (not round-tripped).
899
+ - Client rebuilds RF nodes on topology changes (graph ids / handles / body child ids / parent / kind), and patches positions/edges in place when only those change.
900
+ - **`layout({ direction?, nodeWidth?, nodeHeight?, rankSep?, nodeSep?, origin?, nodes? })`** runs **dagre** (`@dagrejs/dagre`) and updates owned positions via `moveNode`. Top-level nodes are one graph; children with `parentId` get a nested pack (relative coords) inside their group.
901
+
902
+ ```typescript
903
+ const status = ui.state({ lastEvent: '' });
904
+
905
+ const diagram = ui.flow(
906
+ {
907
+ edges: [
908
+ {
909
+ id: 'e1',
910
+ source: 'a',
911
+ target: 'b',
912
+ sourceHandle: 'out',
913
+ targetHandle: 'in',
914
+ type: 'smoothstep',
915
+ label: 'raw',
916
+ variant: 'primary',
917
+ },
918
+ ],
919
+ fitView: true,
920
+ showMiniMap: true,
921
+ showControls: true,
922
+ defaultEdgeType: 'smoothstep',
923
+ onConnect: (e) => {
924
+ status.lastEvent = `connect ${e.source} → ${e.target}`;
925
+ },
926
+ onNodeMove: ({ nodeId, position }) => {
927
+ status.lastEvent = `move ${nodeId}`;
928
+ },
929
+ },
930
+ (flow) => {
931
+ flow.node(
932
+ {
933
+ id: 'a',
934
+ position: { x: 0, y: 0 },
935
+ handles: [{ id: 'out', type: 'source', position: 'right' }],
936
+ },
937
+ () => {
938
+ ui.label('Fetch');
939
+ ui.button('Run', { onClick: () => ui.notify('ran') });
940
+ },
941
+ );
942
+ flow.node(
943
+ {
944
+ id: 'b',
945
+ position: { x: 280, y: 0 },
946
+ handles: [{ id: 'in', type: 'target', position: 'left' }],
947
+ },
948
+ () => {
949
+ ui.label('Transform');
950
+ },
951
+ );
952
+ },
953
+ );
954
+
955
+ // Reset / mutate via element APIs (no outer ui.auto):
956
+ // diagram.setEdges([...]); diagram.moveNode('a', { x: 0, y: 0 });
957
+ // diagram.layout({ direction: 'LR' });
958
+ // diagram.addNode({ id: 'c', position: { x: 560, y: 0 } }, () => { ui.label('C'); });
959
+ ```
960
+
961
+ | Prop | Type | Default |
962
+ |------|------|---------|
963
+ | `edges` | `{ id, source, target, sourceHandle?, targetHandle?, type?, label?, animated?, variant? }[]` | `[]` |
964
+ | `fitView` | `boolean` | `true` |
965
+ | `showMiniMap` | `boolean` | `true` |
966
+ | `showControls` | `boolean` | `true` |
967
+ | `defaultEdgeType` | built-in path \| custom edgeType key | |
968
+ | `defaultEdgeAnimated` | `boolean` | |
969
+ | `defaultEdgeVariant` | `'default' \| 'primary' \| 'muted' \| 'destructive'` | |
970
+ | `customNodeTypes` | `string[]` | |
971
+ | `customEdgeTypes` | `string[]` | |
972
+ | `onConnect` | `(payload: { id?, source, target, sourceHandle?, targetHandle? }) => void` | after owned `addEdge` |
973
+ | `onNodeMove` | `(payload: { nodeId, position: { x, y } }) => void` | after owned `moveNode` |
974
+ | `onNodesDelete` | `(ids: string[]) => void` | after owned `removeNode` |
975
+ | `onEdgesDelete` | `(ids: string[]) => void` | after owned `removeEdges` |
976
+ | `onSelectionChange` | `(payload: { nodeIds, edgeIds }) => void` | |
977
+
978
+ Edge `type` maps to React Flow built-in path kinds (`default` / `straight` / `step` / `smoothstep` / `simplebezier`) **or** a custom edgeType registry key. `variant` sets stroke/label colors (`primary` / `muted` / `destructive`). `label` / `animated` pass through to RF.
979
+
980
+ `flow.node(opts, fn)` options: `id` (graph id), `position`, optional `handles`, `className`, `nodeType`, `kind` (`'default' \| 'group'`), `parentId`, `width` / `height`, `extent` (`'parent'` \| `null`). When `handles` is omitted, default left-target / right-source ports are used. `kind: 'group'` (or `flow.group`) uses built-in `clayGroup` chrome; children set `parentId` (relative positions; drag defaults to `extent: 'parent'`). Nested Clay handlers work inside the node body. Use `flow.addNode` / `flow.addGroup` / `flow.removeNode` for runtime topology. Use `flow.layout()` for dagre packing.
981
+
982
+ **Custom React node/edge types (client registry)**
983
+
984
+ React components cannot cross the Clay wire. Apps that customize the client register components once:
985
+
986
+ ```ts
987
+ import {
988
+ registerFlowNodeTypes,
989
+ registerFlowEdgeTypes,
990
+ BaduiLabeledEdge,
991
+ } from './BoundFlow'; // custom client build
992
+
993
+ registerFlowNodeTypes({ fancy: FancyNode });
994
+ registerFlowEdgeTypes({ labeled: BaduiLabeledEdge });
995
+ ```
996
+
997
+ Then reference those keys from the server: `flow.node({ nodeType: 'fancy', … })` / edge `type: 'labeled'`, and optionally list them on the flow as `customNodeTypes` / `customEdgeTypes`.
998
+
999
+ See Flow Demo (`/examples/flow`) for richer patterns:
1000
+
1001
+ 1. **ETL pipeline** — select/switch/progress/buttons inside nodes; labeled smoothstep edges + dagre auto-layout
1002
+ 2. **Branching approval** — multi-handle triage (`yes` / `no` sources), rating + badges
1003
+ 3. **Fan-in / fan-out + dynamic stages** — `addNode` / `removeNode` without wrapping the flow in `ui.auto`
1004
+ 4. **Group nodes** — `flow.group` + `parentId` children (visual nesting / subflow containers)
1005
+
1006
+ Deferred: drill-in nested editing (separate canvas inside a group), ELK layouts.
1007
+
1008
+ #### `ui.validate(rules)`
1009
+
1010
+ Light submit-gate helper. Runs each rule’s `check`, calls `.setError` on the field (clears when `check` returns null/undefined), and returns `true` only if every rule passes. No schema library — keep rules explicit in the handler.
1011
+
1012
+ ```typescript
1013
+ ui.button('Save', {
1014
+ onClick: () => {
1015
+ const ok = ui.validate([
1016
+ { el: nameInput, check: () => (form.name.trim() ? null : 'Name is required') },
1017
+ { el: emailInput, check: () => (/@/.test(form.email) ? null : 'Enter a valid email') },
1018
+ { el: termsBox, check: () => (form.terms ? null : 'Accept the terms') },
1019
+ ]);
1020
+ if (!ok) {
1021
+ ui.notify('Fix the highlighted fields', 'warning');
1022
+ return;
1023
+ }
1024
+ ui.notify('Saved', 'success');
1025
+ },
1026
+ });
1027
+ ```
1028
+
1029
+ | Param | Type | Description |
1030
+ |-------|------|-------------|
1031
+ | `rules` | `{ el: Element; check: () => string \| null \| undefined }[]` | One entry per field |
1032
+
1033
+ Also available as `validate` from `@close-by/clay-core` / `@close-by/clay`.
1034
+
1035
+ ---
1036
+
1037
+ ### Data display
1038
+
1039
+ #### `ui.stat(items, props?)`
1040
+
1041
+ ```typescript
1042
+ ui.stat([
1043
+ {
1044
+ title: 'Total Revenue',
1045
+ value: '$1,250.00',
1046
+ trend: '+12.5%',
1047
+ footer: 'Trending up this month',
1048
+ description: 'Visitors for the last 6 months',
1049
+ },
1050
+ ]);
1051
+ ```
1052
+
1053
+ #### `ui.areaChart(props)`
1054
+
1055
+ Stacked area chart (Recharts). Optional card chrome via `title` / `description`. Set `interactive: true` with ISO date `xKey` values for client-side period tabs (default 7d / 30d / 90d; override with `periods`). Optional `headline: { value, trend?, trendDirection? }` for a KPI row above the chart; `loading: true` shows a skeleton. Legend is always shown. Pass `stacked: false` to overlay series instead of stacking (default `true`).
1056
+
1057
+ ```typescript
1058
+ ui.areaChart({
1059
+ title: 'Total Visitors',
1060
+ description: 'Last 3 months',
1061
+ interactive: true,
1062
+ headline: { value: '12,450', trend: '+8.2%', trendDirection: 'up' },
1063
+ data: visitors,
1064
+ xKey: 'date',
1065
+ series: [
1066
+ { key: 'mobile', label: 'Mobile' },
1067
+ { key: 'desktop', label: 'Desktop' },
1068
+ ],
1069
+ });
1070
+ ```
1071
+
1072
+ #### `ui.barChart(props)`
1073
+
1074
+ Bar chart with the same cartesian props as area (`data`, `xKey`, `series`, title/description/height/interactive, `headline`, `periods`, `loading`). Optional `stacked` (default `false`) and `layout: 'vertical' | 'horizontal'` (default `'vertical'` — category on X).
1075
+
1076
+ ```typescript
1077
+ ui.barChart({
1078
+ title: 'Desktop vs mobile',
1079
+ data: monthly,
1080
+ xKey: 'month',
1081
+ series: [
1082
+ { key: 'mobile', label: 'Mobile' },
1083
+ { key: 'desktop', label: 'Desktop' },
1084
+ ],
1085
+ stacked: true,
1086
+ layout: 'horizontal',
1087
+ });
1088
+ ```
1089
+
1090
+ #### `ui.lineChart(props)`
1091
+
1092
+ Line chart with the same cartesian props as area (including interactive date ranges, headline, periods, loading).
1093
+
1094
+ ```typescript
1095
+ ui.lineChart({
1096
+ title: 'Traffic trend',
1097
+ data: monthly,
1098
+ xKey: 'month',
1099
+ series: [
1100
+ { key: 'mobile', label: 'Mobile' },
1101
+ { key: 'desktop', label: 'Desktop' },
1102
+ ],
1103
+ });
1104
+ ```
1105
+
1106
+ #### `ui.sparkline(props)`
1107
+
1108
+ Compact KPI card with an inline area or line mini chart — useful in dashboards and table footers. Pass `headline` or shorthand `value` / `trend` / `trendDirection`. `type` is `'area'` (default) or `'line'`.
1109
+
1110
+ ```typescript
1111
+ ui.sparkline({
1112
+ title: 'Daily visitors',
1113
+ value: '1,284',
1114
+ trend: '+12.5%',
1115
+ trendDirection: 'up',
1116
+ data: dailyRows,
1117
+ xKey: 'date',
1118
+ yKey: 'total',
1119
+ height: 48,
1120
+ });
1121
+ ```
1122
+
1123
+ #### `ui.composedChart(props)`
1124
+
1125
+ Mixed-geometry cartesian chart (bars, lines, areas in one plot). Series may set `type` and `yAxisId: 'left' | 'right'` for dual-axis layouts. Optional `referenceLine` and `referenceArea` for goals and forecast bands. Supports the same chrome props as area/line (`headline`, `periods`, `loading`, `interactive`).
1126
+
1127
+ ```typescript
1128
+ ui.composedChart({
1129
+ title: 'Revenue vs margin',
1130
+ data: rows,
1131
+ xKey: 'month',
1132
+ series: [
1133
+ { key: 'revenue', label: 'Revenue', type: 'bar', yAxisId: 'left' },
1134
+ { key: 'margin', label: 'Margin %', type: 'line', yAxisId: 'right' },
1135
+ ],
1136
+ referenceLine: { value: 18, label: 'Goal', yAxisId: 'right' },
1137
+ referenceArea: { y1: 200, y2: 280, label: 'Forecast band' },
1138
+ });
1139
+ ```
1140
+
1141
+ #### `ui.pieChart(props)`
1142
+
1143
+ Pie or donut chart. Pass either row-wise `nameKey` + `valueKey`, or `series` over a single aggregated row. Set `innerRadius` for a donut. Legend is always shown.
1144
+
1145
+ ```typescript
1146
+ ui.pieChart({
1147
+ title: 'Browser share',
1148
+ data: [
1149
+ { browser: 'Chrome', visitors: 275 },
1150
+ { browser: 'Safari', visitors: 200 },
1151
+ ],
1152
+ nameKey: 'browser',
1153
+ valueKey: 'visitors',
1154
+ });
1155
+
1156
+ ui.pieChart({
1157
+ title: 'Traffic mix',
1158
+ innerRadius: 60,
1159
+ data: [{ mobile: 320, desktop: 480 }],
1160
+ series: [
1161
+ { key: 'mobile', label: 'Mobile' },
1162
+ { key: 'desktop', label: 'Desktop' },
1163
+ ],
1164
+ });
1165
+ ```
1166
+
1167
+ #### `ui.radarChart(props)`
1168
+
1169
+ Radar (spider) chart. Pass `angleKey` for polar categories and `series` for each polygon. Optional `fillOpacity` (default `0.6`).
1170
+
1171
+ ```typescript
1172
+ ui.radarChart({
1173
+ title: 'Desktop vs mobile',
1174
+ data: monthly,
1175
+ angleKey: 'month',
1176
+ series: [
1177
+ { key: 'mobile', label: 'Mobile' },
1178
+ { key: 'desktop', label: 'Desktop' },
1179
+ ],
1180
+ });
1181
+ ```
1182
+
1183
+ #### `ui.radialChart(props)`
1184
+
1185
+ Radial bar chart. Pass either row-wise `nameKey` + `valueKey`, or `series` over a single row (stacked segments). Optional `innerRadius` / `outerRadius`, `startAngle` / `endAngle`, and `centerValue` / `centerLabel` for the ShadCN radial-text pattern.
1186
+
1187
+ ```typescript
1188
+ ui.radialChart({
1189
+ title: 'Browser share',
1190
+ data: [
1191
+ { browser: 'Chrome', visitors: 275 },
1192
+ { browser: 'Safari', visitors: 200 },
1193
+ ],
1194
+ nameKey: 'browser',
1195
+ valueKey: 'visitors',
1196
+ });
1197
+
1198
+ ui.radialChart({
1199
+ title: 'Traffic mix',
1200
+ endAngle: 180,
1201
+ centerValue: 800,
1202
+ centerLabel: 'Visitors',
1203
+ data: [{ mobile: 320, desktop: 480 }],
1204
+ series: [
1205
+ { key: 'mobile', label: 'Mobile' },
1206
+ { key: 'desktop', label: 'Desktop' },
1207
+ ],
1208
+ });
1209
+ ```
1210
+
1211
+ #### Structured charts (`ui.chart`)
1212
+
1213
+ Optional sugar over the props APIs above. Mode-first builders compile to the same `Element` types — legacy `ui.areaChart` / `ui.pieChart` / etc. remain fully supported.
1214
+
1215
+ | Entry | Purpose | Compiles to |
1216
+ |-------|---------|-------------|
1217
+ | `ui.chart.categories(data)` | Cartesian (category axis) | `areaChart` / `barChart` / `lineChart` |
1218
+ | `ui.chart.timeSeries(data)` | ISO-date x-axis + 7d/30d/90d filter | `areaChart` / `lineChart` with `interactive: true` |
1219
+ | `ui.chart.pie.fromRows(data, keys)` | Many rows, label/value fields | `pieChart` with `nameKey`/`valueKey` |
1220
+ | `ui.chart.pie.fromMetrics(row, series)` | One aggregated row | `pieChart` with `series` |
1221
+ | `ui.chart.radial.fromRows(data, keys)` | Multi-row radial bars | `radialChart` with `nameKey`/`valueKey` |
1222
+ | `ui.chart.radial.stackedGauge(row, series, gauge)` | Gauge recipe | `radialChart` with bundled defaults |
1223
+ | `ui.chart.radar(data, angleKey)` | Polar categories | `radarChart` |
1224
+
1225
+ ```typescript
1226
+ ui.chart.categories(monthly)
1227
+ .x('month')
1228
+ .series(['mobile', 'desktop']) // shorthand → { key, label }
1229
+ .area({ title: 'Traffic', stacked: true });
1230
+
1231
+ ui.chart.timeSeries(visitors).x('date').series(series).area({ title: 'Visitors' });
1232
+
1233
+ ui.chart.pie.fromRows(rows, { name: 'browser', value: 'visitors' }).title('Share').build();
1234
+ ui.chart.pie.fromMetrics({ mobile: 320, desktop: 480 }, series).donut(60);
1235
+
1236
+ ui.chart.radial.stackedGauge(
1237
+ { mobile: 320, desktop: 480 },
1238
+ series,
1239
+ {
1240
+ center: { value: 800, label: 'Visitors' },
1241
+ arc: { end: 180 },
1242
+ radius: { inner: 80, outer: 110 },
1243
+ },
1244
+ );
1245
+
1246
+ ui.chart.radar(monthly, 'month').series(series).title('Skills').build();
1247
+ ```
1248
+
1249
+ #### `ui.dataTable(data, props?)`
1250
+
1251
+ Server-owned table with sorting, global search, pagination, and row actions. Returns a `DataTableElement` with `setRows` / `getRows` / `getQuery` (`DataTableQuery`: `{ page, pageSize, filter, columnFilters, sorts }`) / `getPage` / `getPageSize` / `getFilter` / `getColumnFilters` / `setLoading` / `withLoading` / `setTotalRows` / `getTotalRows` / `setDensity` / `setZebra` / `setSorts` / `getSorts`.
1252
+
1253
+ `data` may be:
1254
+
1255
+ - an **array of objects** → one row per item; columns inferred from keys when `columns` omitted
1256
+ - a **plain key-value object** → Key / Value rows (nested values are JSON-stringified)
1257
+ - omitted / empty → empty table
1258
+
1259
+ When `keyField` is omitted, each row is stamped with an internal `__rowId` (`0..n-1`) used for actions and client keys (not shown as a column).
1260
+
1261
+ ```typescript
1262
+ ui.dataTable(tasks); // infer columns + __rowId
1263
+ ui.dataTable({ host: 'localhost', port: 4000 }); // Key / Value
1264
+
1265
+ const table = ui.dataTable(tasks, {
1266
+ keyField: 'id',
1267
+ columns: [
1268
+ { key: 'title', header: 'Title', sortable: true },
1269
+ { key: 'status', header: 'Status' },
1270
+ { key: 'hours', header: 'Hours', align: 'right' },
1271
+ ],
1272
+ searchable: true,
1273
+ searchPlaceholder: 'Search…',
1274
+ pageSize: 8, // 0 = no pagination
1275
+ actions: [
1276
+ { id: 'edit', label: 'Edit', icon: 'pencil' },
1277
+ { id: 'delete', label: 'Delete', icon: 'trash-2', variant: 'destructive' },
1278
+ ],
1279
+ onAction: (actionId, row) => {
1280
+ if (actionId === 'delete') table.setRows(tasks.filter((t) => t.id !== row.id));
1281
+ },
1282
+ });
1283
+ ```
1284
+
1285
+ | Prop | Type | Default | Description |
1286
+ |------|------|---------|-------------|
1287
+ | `columns` | `TableColumn[]` | inferred from data | Column defs |
1288
+ | `keyField` | `string` | `'__rowId'` | Row identity for actions |
1289
+ | `searchable` | `boolean` | `true` | Global filter input |
1290
+ | `searchPlaceholder` | `string` | `'Search…'` | Filter placeholder |
1291
+ | `filterDebounceMs` | `number` | `300` | Debounce before applying global search and text column filters (`0` = immediate) |
1292
+ | `columnFilterable` | `boolean` | `true` | Per-column filters (header popovers: text and/or facet) |
1293
+ | `columnToggle` | `boolean` | `true` | Columns visibility menu |
1294
+ | `exportable` | `boolean` | `true` | Export / copy menu (CSV, TSV, JSON) |
1295
+ | `exportFilename` | `string` | `'data'` | Download base name (no extension) |
1296
+ | `loading` | `boolean` | `false` | Show a spinner in the table body (takes precedence over empty). `setLoading` / `withLoading` toggle it |
1297
+ | `emptyTitle` | `string` | `'No rows'` | Empty-state title (only when not loading and rows are empty) |
1298
+ | `emptyDescription` | `string` | search/filter hint | Empty-state description (only when not loading and rows are empty) |
1299
+ | `pageSize` | `number` | `10` | Rows per page; `0` disables pagination |
1300
+ | `pageSizeOptions` | `number[]` | `[10, 20, 30, 40, 50]` | Footer page-size select |
1301
+ | `manualPagination` | `boolean` | `false` | Treat `data` / `setRows` as the **current page**; skip local filter/sort/group/slice; use `totalRows` for the pager. Forces `manualFiltering` + `manualSorting` |
1302
+ | `manualFiltering` | `boolean` | `false` | Skip local search/column filters; keep filter state and emit `filter` / `columnFilter` (+ callbacks). Always on when `manualPagination` is set; alone = hybrid (local sort/page still run) |
1303
+ | `manualSorting` | `boolean` | `false` | Skip local sort; keep `sorts` and emit `sort` (+ `onSortChange`). Always on when `manualPagination` is set; alone = hybrid (local filter/page still run) |
1304
+ | `totalRows` | `number` | | Total count across pages when `manualPagination` is true (`setTotalRows` / `getTotalRows`) |
1305
+ | `defaultSorts` | `DataTableSort[]` | `[]` | Initial ordered multi-sort (`{ key, dir }[]`). Mirrored as `sortKey` / `sortDir` from the first entry |
1306
+ | `density` | `'compact' \| 'default' \| 'comfortable'` | `'default'` | Row / cell spacing (`setDensity`) |
1307
+ | `zebra` | `boolean` | `false` | Alternate-row striping (`setZebra`) |
1308
+ | `selectable` | `boolean` | `false` | Row checkboxes + selection events |
1309
+ | `reorderable` | `boolean` | `false` | Drag handle to reorder rows (global order; page slice updates relative order without scrambling off-page rows). Works with row virtualization: only mounted rows are drop targets — drag near the scroll edge to window more rows |
1310
+ | `groupBy` | `string \| (row) => unknown` | | Partition rows into collapsible groups (column key or derived value). Ignored when `manualPagination` is true |
1311
+ | `defaultCollapsed` | `boolean` | `false` | Start with every group collapsed (client-owned) |
1312
+ | `onGroupToggle` | `(groupKey, collapsed) => void` | | After a group header is expanded/collapsed |
1313
+ | `views` | `DataTableView[]` | | Tabbed views; each may include a row `filter` |
1314
+ | `defaultView` | `string` | first view id | Initial active view |
1315
+ | `onViewChange` | `(viewId) => void` | | Fires after the active view changes |
1316
+ | `primaryAction` | `{ id?, label }` | | Toolbar primary button |
1317
+ | `onPrimaryAction` | `() => void` | | Primary button handler |
1318
+ | `detail` | `(row) => void` | | Build detached UI for the row detail drawer |
1319
+ | `onReorder` | `(orderedKeys) => void` | | After drag-reorder (receives the reordered page-slice keys) |
1320
+ | `onSelectionChange` | `(keys) => void` | | After selection changes |
1321
+ | `onPageChange` | `(page) => void` | | After footer page change. Remote: refetch (`getQuery()`) |
1322
+ | `onPageSizeChange` | `(pageSize) => void` | | After footer page-size change (page → 1). Remote: refetch |
1323
+ | `onSortChange` | `(sorts) => void` | | After sort changes. Remote: refetch with `sorts` |
1324
+ | `onFilterChange` | `(filter) => void` | | After global search (page → 1). Remote: refetch |
1325
+ | `onColumnFilterChange` | `(filters) => void` | | After column filters change (full map; page → 1). Remote: refetch |
1326
+ | `onCellChange` | `(rowKey, columnKey, value) => void` | | After inline editor commit |
1327
+ | `actions` | `DataTableAction[]` | `[]` | Per-row actions — always shown in a **⋯** overflow menu |
1328
+ | `onAction` | `(actionId, row) => void` | | Row action handler |
1329
+ | `bulkActions` | `DataTableAction[]` | `[]` | Toolbar actions for the current selection (requires `selectable`) |
1330
+ | `onBulkAction` | `(actionId, rowKeys) => void` | | Bulk action handler |
1331
+ | `className` | `string` | | Extra classes |
1332
+
1333
+ **Remote / server-paged contract** (`manualPagination: true`):
1334
+
1335
+ | | Table does | App must |
1336
+ |--|------------|----------|
1337
+ | Rows | Treats `data` / `setRows` as the **current page** (no local filter/sort/group/slice) | Fetch the page and call `setRows` |
1338
+ | Totals | Pager uses `totalRows` / `setTotalRows` (`getTotalRows`) | Return the full matching count from the server |
1339
+ | Chrome state | Keeps `page`, `pageSize`, `filter`, `columnFilters`, `sorts` in props; `getQuery()` snapshots them | Apply that query on the server |
1340
+ | Events | Emits `page` / `pageSize` / `filter` / `columnFilter` / `sort` and the `on*` callbacks below (filter/sort/pageSize reset `page` to 1) | Refetch on each change |
1341
+ | Loading / empty | `loading` shows a body spinner and **hides** empty; `emptyTitle` / `emptyDescription` only when settled empty | `setLoading(true)` **before** await (keep previous rows), then `setRows` + `setTotalRows`, then `setLoading(false)` — or use `withLoading` |
1342
+
1343
+ | Event / callback | Payload | App should |
1344
+ |------------------|---------|------------|
1345
+ | `page` / `onPageChange` | page number, or `{ page }` | Fetch that page, `setRows` |
1346
+ | `pageSize` / `onPageSizeChange` | page size (or `{ pageSize }`) | Reset page, fetch, `setRows` + `setTotalRows` |
1347
+ | `filter` / `onFilterChange` | search string | Reset page, fetch with query |
1348
+ | `columnFilter` / `onColumnFilterChange` | `{ key, value }` / full `filters` map | Reset page, fetch with filters |
1349
+ | `sort` / `onSortChange` | `{ key, dir?, multi? }` or `{ sorts }` / `DataTableSort[]` | Reset page, fetch with `sorts` |
1350
+
1351
+ Hybrid mode: set `manualFiltering` and/or `manualSorting` **without** `manualPagination` to skip only those local stages while still paging locally. Export uses the current page rows only when `manualPagination` is true. Footer aggregates (`column.aggregate`) run over the **filtered** row set locally, or over the **provided / current-page** rows when `manualPagination` is true. Facet option `count`s in remote mode are derived from the supplied page unless you pass `facetOptions` with server-computed counts.
1352
+
1353
+ **Client chrome** (renderer behavior; no server API): column headers are resizable (drag the right edge). Sortable headers show a tooltip (`Click to sort · Shift+click for multi-sort`); Shift+click adds/toggles multi-sort, and active sorts show priority badges when more than one column is sorted. Header ⋯ / pin controls expose pin left/right/clear (and an aggregate hint when `column.aggregate` is set); pin state is reflected in `aria-label` / `aria-pressed`. Footer aggregate cells announce via `aria-label` plus a polite live region. Inline editors: text/number/date — **Enter** commits, **Esc** cancels (blur still commits); select/boolean commit on change (**Esc** restores focus). Row virtualization kicks in when body items ≥ **40** (including when `reorderable` is true). With reorder + virtualization, only windowed rows are active drop targets — use drag auto-scroll near the viewport edge to reach farther rows.
1354
+
1355
+ | View field | Type | Notes |
1356
+ |------------|------|-------|
1357
+ | `id` | `string` | Tab value |
1358
+ | `label` | `string` | Tab label |
1359
+ | `count` | `number` | Optional badge override; otherwise derived from matching rows |
1360
+ | `filter` | `(row) => boolean` | Optional display lens over source rows while this view is active |
1361
+
1362
+ Every view tab shows the same table chrome; switching views applies that view’s `filter` (if any) before search/column filters. Badge counts stay live after `setRows` when `count` is omitted.
1363
+
1364
+ When `groupBy` is set, filter/sort run first, then rows are stably partitioned so each group is contiguous. The client renders collapsible group headers plus **Collapse all** / **Expand all** in the toolbar (collapse state is client-owned, like selection); `onGroupToggle` fires for app logic. Empty group values show as `(Empty)`.
1365
+
1366
+ | Column field | Type | Notes |
1367
+ |--------------|------|-------|
1368
+ | `key` | `string` | Row field / column id |
1369
+ | `header` | `string` | Header label |
1370
+ | `align` | `'left' \| 'right' \| 'center'` | Cell alignment |
1371
+ | `sortable` | `boolean` | Default `true` |
1372
+ | `filter` | `'text' \| 'facet'` | Default text substring in a header popover; `'facet'` is multi-select exact match in a header popover |
1373
+ | `facetOptions` | `{ value, label }[]` | Facet choices; when set without `filter`, implies `filter: 'facet'`. When omitted with `filter: 'facet'`, distinct values are derived. Server adds live `count`s. |
1374
+ | `value` | `(row) => unknown` | Computed scalar for sort / filter / export / default display |
1375
+ | `render` | `(row) => Element \| scalar` | Optional cell UI (e.g. `ui.badge(...)`); display-only |
1376
+ | `editor` | `'text' \| 'select' \| 'number' \| 'date' \| 'boolean'` | Inline editor on the client (`number` commits a finite number; `date` ISO `YYYY-MM-DD`; `boolean` a switch). Text/number/date: Enter commits, Esc cancels; select/boolean commit on change (Esc restores focus) |
1377
+ | `editorOptions` | `{ value, label }[]` | Options when `editor` is `'select'` |
1378
+ | `detailTrigger` | `boolean` | Cell opens the row detail drawer |
1379
+ | `aggregate` | `'sum' \| 'avg' \| 'count' \| 'min' \| 'max' \| (rows, col) => unknown` | Footer total for this column (filtered rows locally; provided/current-page rows when `manualPagination`). Announced in the footer / live region |
1380
+ | `pin` | `'left' \| 'right'` | Sticky column while scrolling horizontally (header ⋯ menu can change this at runtime; pin control exposes current state to AT) |
1381
+
1382
+ Facet filters store selected values as a JSON string array in `columnFilters[key]` (e.g. `'["todo","done"]'`). Text and facet columns both filter from a dashed ListFilter button in the header (popover input vs multi-select).
1383
+
1384
+ Server-side column callbacks (not Vue/NiceGUI slots):
1385
+
1386
+ ```typescript
1387
+ {
1388
+ key: 'status',
1389
+ header: 'Status',
1390
+ value: (row) => row.status,
1391
+ render: (row) =>
1392
+ ui.badge(String(row.status), {
1393
+ color: row.status === 'done' ? 'green' : 'amber',
1394
+ }),
1395
+ },
1396
+ {
1397
+ key: 'billable',
1398
+ header: 'Billable',
1399
+ value: (row) => Number(row.hours) * 50,
1400
+ }
1401
+ ```
1402
+
1403
+ | Action field | Type |
1404
+ |--------------|------|
1405
+ | `id` | `string` |
1406
+ | `label` | `string` |
1407
+ | `icon` | Lucide kebab-case name (`pencil`, `trash-2`, `sparkles`, …) |
1408
+ | `variant` | Button variant (e.g. `destructive`, `ghost`) |
1409
+
1410
+ Client emits `sort` / `filter` / `columnFilter` / `columnVisibility` / `columnPin` / `export` / `page` / `pageSize` / `action` / `bulkAction` / `reorder` / `selectionChange` / `cellChange` / `viewChange` / `groupToggle` / `primaryAction`. `page` accepts a page number or `{ page }`. `pageSize` accepts a number or `{ pageSize }`. `sort` payload is `{ key, dir?, multi? }` or `{ sorts }` (full ordered list). `bulkAction` payload is `{ actionId, rowKeys }`. `columnPin` payload is `{ key, pin: 'left' \| 'right' \| null }`. Export uses filtered + sorted rows and **visible** columns only (full result set, not just the current page — except in `manualPagination` mode, where export is the current page), then the server sends `download` or `clipboard` protocol messages. Cell `render` output is not exported — only `value` / field scalars.
1411
+
1412
+ #### Structured tables (`ui.table`)
1413
+
1414
+ Optional sugar over `ui.dataTable`. Staged methods bundle related props; `.build()` calls `dataTable(data, props)` and returns the same `DataTableElement`. Legacy props APIs remain supported.
1415
+
1416
+ | Method | Bundles |
1417
+ |--------|---------|
1418
+ | `.id(keyField)` | `keyField` |
1419
+ | `.columns(cols)` | `columns` |
1420
+ | `.search(placeholder?)` | `searchable: true`, `searchPlaceholder` |
1421
+ | `.filterDebounce(ms)` | `filterDebounceMs` |
1422
+ | `.pageSize(n, options?)` | `pageSize`, `pageSizeOptions` |
1423
+ | `.manualPagination(totalRows?)` | `manualPagination: true`, optional `totalRows` (forces remote filter/sort) |
1424
+ | `.manualFiltering(enabled?)` | `manualFiltering` (default `true`; hybrid without `.manualPagination()`) |
1425
+ | `.manualSorting(enabled?)` | `manualSorting` (default `true`; hybrid without `.manualPagination()`) |
1426
+ | `.density(value)` | `density` |
1427
+ | `.zebra(enabled?)` | `zebra` (default `true`) |
1428
+ | `.groupBy(key, opts?)` | `groupBy`, `defaultCollapsed` |
1429
+ | `.views(items, defaultId?)` | `views`, `defaultView` |
1430
+ | `.rowActions(actions, onAction)` | `actions`, `onAction` |
1431
+ | `.primaryAction(action \| label, onPrimaryAction?)` | `primaryAction`, `onPrimaryAction` |
1432
+ | `.bulkActions(actions, onBulkAction)` | `bulkActions`, `onBulkAction`, `selectable: true` |
1433
+ | `.detail(fn)` | `detail` (use `detailTrigger` on columns) |
1434
+ | `.selectable(onChange?)` | `selectable`, `onSelectionChange` |
1435
+ | `.reorderable(onReorder?)` | `reorderable`, `onReorder` |
1436
+ | `.export(filename?)` | `exportable: true`, `exportFilename` |
1437
+ | `.build()` | returns `DataTableElement` |
1438
+
1439
+ ```typescript
1440
+ ui.table(tasks)
1441
+ .id('id')
1442
+ .columns([...])
1443
+ .search('Search tasks…')
1444
+ .groupBy('status')
1445
+ .pageSize(8, { options: [5, 8, 10, 20] })
1446
+ .density('compact')
1447
+ .zebra()
1448
+ .rowActions(actions, handleAction)
1449
+ .primaryAction('Add task', () => { /* … */ })
1450
+ .bulkActions(
1451
+ [{ id: 'archive', label: 'Archive' }],
1452
+ (actionId, rowKeys) => { /* … */ },
1453
+ )
1454
+ .build();
1455
+ ```
1456
+
1457
+ #### `ui.dialog(props, fn)` / `ui.dialog(fn, props?)`
1458
+
1459
+ Server-owned modal overlay. Returns a `DialogElement` with `open()`, `close()`, and `setOpen(boolean)`.
1460
+
1461
+ ```typescript
1462
+ const dlg = ui.dialog({ title: 'Delete task?', open: false }, () => {
1463
+ ui.label('This cannot be undone.');
1464
+ ui.row(() => {
1465
+ ui.button('Cancel', { variant: 'outline', onClick: () => dlg.close() });
1466
+ ui.button('Delete', { variant: 'destructive', onClick: () => { /* … */ dlg.close(); } });
1467
+ });
1468
+ });
1469
+
1470
+ dlg.open();
1471
+ ```
1472
+
1473
+ | Prop | Type | Default | Description |
1474
+ |------|------|---------|-------------|
1475
+ | `title` | `string` | | Dialog heading |
1476
+ | `open` | `boolean` | `false` | Visibility |
1477
+ | `className` | `string` | | Extra classes on the panel |
1478
+ | `onClose` | `() => void` | | Runs when the client emits `close` (backdrop / Escape), then `open` is set to `false` |
1479
+
1480
+ Client emits `close` on backdrop click or Escape. The server always clears `open` on that event.
1481
+
1482
+ #### `ui.dialogStack(props, fn)` / `ui.dialogStack(fn, props?)`
1483
+
1484
+ Server-owned stacked multi-step modal. Returns a `DialogStackElement` with `open()`, `close()`, `setOpen(boolean)`, and `setIndex(number)`. Children are steps via `stack.step({ title? }, fn)`.
1485
+
1486
+ ```typescript
1487
+ const wizard = ui.dialogStack({ title: 'Onboarding', open: false, index: 0 }, (stack) => {
1488
+ stack.step({ title: 'Account' }, () => {
1489
+ ui.input({ label: 'Email' });
1490
+ });
1491
+ stack.step({ title: 'Confirm' }, () => {
1492
+ ui.button('Done', { onClick: () => wizard.close() });
1493
+ });
1494
+ });
1495
+
1496
+ wizard.open();
1497
+ ```
1498
+
1499
+ | Prop | Type | Default | Description |
1500
+ |------|------|---------|-------------|
1501
+ | `title` | `string` | | Stack-level heading (fallback when a step has no title) |
1502
+ | `open` | `boolean` | `false` | Visibility |
1503
+ | `index` | `number` | `0` | Active step index (clamped to step count) |
1504
+ | `className` | `string` | | Extra classes on the active panel |
1505
+ | `onClose` | `() => void` | | Runs when the client emits `close`, then `open` is set to `false` |
1506
+ | `onIndexChange` | `(index: number) => void` | | Runs when the client emits `indexChange`, then `index` is updated |
1507
+
1508
+ Client emits `close` (backdrop / Escape / close button) and `indexChange` (Back / Next / step dots). The server owns `open` and `index`.
1509
+
1510
+ #### `ui.alertDialog(props?)`
1511
+
1512
+ Server-owned confirm / destructive modal. Returns an `AlertDialogElement` with `open()`, `close()`, and `setOpen(boolean)`.
1513
+
1514
+ ```typescript
1515
+ const danger = ui.alertDialog({
1516
+ title: 'Delete item?',
1517
+ description: 'This cannot be undone.',
1518
+ confirmLabel: 'Delete',
1519
+ confirmVariant: 'destructive',
1520
+ onConfirm: () => { /* … */ },
1521
+ });
1522
+ danger.open();
1523
+ ```
1524
+
1525
+ | Prop | Type | Default | Description |
1526
+ |------|------|---------|-------------|
1527
+ | `title` | `string` | | Heading |
1528
+ | `description` | `string` | | Body copy |
1529
+ | `confirmLabel` | `string` | `'OK'` | Confirm button |
1530
+ | `cancelLabel` | `string` | `'Cancel'` | Cancel button |
1531
+ | `confirmVariant` | button variant | `'default'` | Confirm button style |
1532
+ | `open` | `boolean` | `false` | Visibility |
1533
+ | `onConfirm` | `() => void` | | Confirm click |
1534
+ | `onClose` | `() => void` | | Cancel / Escape / backdrop |
1535
+
1536
+ `ui.confirm(...)` uses this element under the hood.
1537
+
1538
+ #### `ui.dropdownMenu(props, fn)` / `ui.dropdownMenu(fn, props?)`
1539
+
1540
+ ShadCN dropdown. Children: `m.item(value, label, opts?)` and `m.separator()`.
1541
+
1542
+ ```typescript
1543
+ ui.dropdownMenu({ label: 'Actions', variant: 'outline' }, (m) => {
1544
+ m.item('edit', 'Edit', { onSelect: () => {} });
1545
+ m.separator();
1546
+ m.item('delete', 'Delete', { variant: 'destructive', onSelect: () => {} });
1547
+ });
1548
+ ```
1549
+
1550
+ #### `ui.breadcrumb(items, props?)`
1551
+
1552
+ ```typescript
1553
+ ui.breadcrumb([
1554
+ { label: 'Home', href: '/' },
1555
+ { label: 'Settings' },
1556
+ ]);
1557
+ ```
1558
+
1559
+ Items with `href` use SPA `pushState` (same as `ui.link`). The last item (or any without `href`) renders as the current page.
1560
+
1561
+ #### `ui.sheet(props, fn)` / `ui.sheet(fn, props?)`
1562
+
1563
+ Server-owned side panel (parallel to dialog). Returns a `SheetElement` with `open()`, `close()`, and `setOpen(boolean)`.
1564
+
1565
+ ```typescript
1566
+ const panel = ui.sheet({ title: 'Filters', side: 'right' }, () => {
1567
+ ui.label('Sheet body');
1568
+ ui.button('Done', { onClick: () => panel.close() });
1569
+ });
1570
+ panel.open();
1571
+ ```
1572
+
1573
+ | Prop | Type | Default | Description |
1574
+ |------|------|---------|-------------|
1575
+ | `title` | `string` | | Heading |
1576
+ | `description` | `string` | | Muted subtitle |
1577
+ | `open` | `boolean` | `false` | Visibility |
1578
+ | `side` | `'top' \| 'right' \| 'bottom' \| 'left'` | `'right'` | Edge |
1579
+ | `onClose` | `() => void` | | Runs on client `close`, then `open` is cleared |
1580
+
1581
+ #### `ui.drawer(props, fn)` / `ui.drawer(fn, props?)`
1582
+
1583
+ Server-owned Vaul drawer (mobile-friendly overlay). Same open API as dialog/sheet.
1584
+
1585
+ | Prop | Type | Default |
1586
+ |------|------|---------|
1587
+ | `title` / `description` | `string` | |
1588
+ | `open` | `boolean` | `false` |
1589
+ | `direction` | `'top' \| 'bottom' \| 'left' \| 'right'` | `'bottom'` |
1590
+ | `onClose` | `() => void` | |
1591
+
1592
+ #### `ui.tabs(props, fn)` / `ui.tabs(fn, props?)`
1593
+
1594
+ Server-owned active tab. Panels are built via `t.tab(...)`.
1595
+
1596
+ ```typescript
1597
+ ui.tabs({ value: 'one', onChange: (v) => console.log(v) }, (t) => {
1598
+ t.tab('one', 'One', () => {
1599
+ ui.label('First panel');
1600
+ });
1601
+ t.tab('two', () => {
1602
+ // label defaults to value
1603
+ ui.label('Second panel');
1604
+ });
1605
+ });
1606
+ ```
1607
+
1608
+ | Prop / method | Type | Description |
1609
+ |---------------|------|-------------|
1610
+ | `value` | `string` | Active tab (defaults to first panel) |
1611
+ | `onChange` | `(value: string) => void` | Tab change |
1612
+ | `t.tab(value, fn)` | | Panel; label = `value` |
1613
+ | `t.tab(value, label, fn)` | | Panel with explicit label |
1614
+ | `t.tab(..., { icon? })` | | Optional Lucide kebab-case name |
1615
+
1616
+ Supports `bindValue` / `setValue` like other value controls.
1617
+
1618
+ #### `ui.accordion(props, fn)` / `ui.accordion(fn, props?)`
1619
+
1620
+ Disclosure panels (optimistic `value`).
1621
+
1622
+ ```typescript
1623
+ ui.accordion({ type: 'single' }, (a) => {
1624
+ a.item('faq1', 'What is Clay?', () => {
1625
+ ui.label('A server-driven UI toolkit.');
1626
+ });
1627
+ a.item('faq2', 'Is it reactive?', () => {
1628
+ ui.label('Yes — bindValue and refreshable.');
1629
+ });
1630
+ });
1631
+ ```
1632
+
1633
+ | Prop / method | Type | Description |
1634
+ |---------------|------|-------------|
1635
+ | `type` | `'single' \| 'multiple'` | Default `'single'` |
1636
+ | `value` | `string \| string[]` | Open panel(s); defaults to first item when single |
1637
+ | `collapsible` | `boolean` | Single mode: allow collapse (default `true`) |
1638
+ | `onChange` | `(value: string \| string[]) => void` | |
1639
+ | `a.item(value, fn)` | | Panel; title = `value` |
1640
+ | `a.item(value, title, fn)` | | Panel with title |
1641
+
1642
+ #### `ui.collapsible(props, fn)` / `ui.collapsible(fn, props?)`
1643
+
1644
+ Single expandable section. Open state is stored as element `value` (boolean) for `bindValue`.
1645
+
1646
+ ```typescript
1647
+ ui.collapsible({ title: 'Advanced', open: false }, () => {
1648
+ ui.label('Hidden by default');
1649
+ });
1650
+ ```
1651
+
1652
+ | Prop | Type | Default |
1653
+ |------|------|---------|
1654
+ | `title` | `string` | `'Toggle'` |
1655
+ | `open` | `boolean` | `false` |
1656
+ | `onChange` | `(open: boolean) => void` | |
1657
+
1658
+ #### `ui.scrollArea(fn, props?)` / `ui.scrollArea(props, fn)`
1659
+
1660
+ Scrollable viewport (ShadCN ScrollArea). Optional `onNearEnd` for infinite scroll / load-more: fires when the user scrolls within `nearEndThreshold` px of the bottom, then re-arms after they scroll away.
1661
+
1662
+ ```typescript
1663
+ ui.scrollArea(
1664
+ {
1665
+ className: 'h-48',
1666
+ nearEndThreshold: 64,
1667
+ onNearEnd: () => {
1668
+ rows.n += 20;
1669
+ },
1670
+ },
1671
+ () => {
1672
+ for (let i = 0; i < rows.n; i++) ui.label(`Row ${i}`);
1673
+ },
1674
+ );
1675
+ ```
1676
+
1677
+ | Prop | Type | Default |
1678
+ |------|------|---------|
1679
+ | `className` | `string` | |
1680
+ | `onNearEnd` | `() => void \| Promise<void>` | — client emits `nearEnd` |
1681
+ | `nearEndThreshold` | `number` | `80` (px from bottom) |
1682
+
1683
+ #### `ui.viewportEnter(fn, props?)` / `ui.viewportEnter(props, fn)`
1684
+
1685
+ IntersectionObserver wrapper. Fires `onEnter` when the element intersects the viewport — default **once**.
1686
+
1687
+ ```typescript
1688
+ ui.viewportEnter(
1689
+ {
1690
+ once: true,
1691
+ rootMargin: '80px',
1692
+ onEnter: () => void loadSection(),
1693
+ },
1694
+ () => {
1695
+ ui.label('Lazy block');
1696
+ },
1697
+ );
1698
+ ```
1699
+
1700
+ | Prop | Type | Default |
1701
+ |------|------|---------|
1702
+ | `className` | `string` | |
1703
+ | `onEnter` | `() => void \| Promise<void>` | — client emits `enter` |
1704
+ | `once` | `boolean` | `true` |
1705
+ | `rootMargin` | `string` | `'0px'` |
1706
+ | `threshold` | `number \| number[]` | `0` |
1707
+
1708
+ #### `ui.keybind(props)`
1709
+
1710
+ Headless keyboard chord listener. Renders nothing; while the node is in the tree the client listens on `window` `keydown`, matches a chord, and emits `press` → `onPress`.
1711
+
1712
+ ```typescript
1713
+ ui.keybind({
1714
+ keys: 'mod+s', // or ['mod+s', 'ctrl+s']
1715
+ onPress: async () => {
1716
+ await save();
1717
+ ui.notify('Saved', 'success');
1718
+ },
1719
+ });
1720
+ ```
1721
+
1722
+ | Prop | Type | Default |
1723
+ |------|------|---------|
1724
+ | `keys` | `string \| string[]` | required — `+`-joined tokens, case-insensitive |
1725
+ | `enabled` | `boolean` | `true` |
1726
+ | `preventDefault` | `boolean` | `true` |
1727
+ | `ignoreInput` | `boolean` | `true` — skip when focus is in `input` / `textarea` / `select` / `contenteditable` |
1728
+ | `onPress` | `() => void \| Promise<void>` | |
1729
+
1730
+ **Chord tokens:** modifiers `mod` (Meta on macOS, Ctrl elsewhere), `ctrl`/`control`, `meta`/`cmd`/`command`, `alt`/`option`, `shift`; key letter/digit, `escape`/`esc`, `enter`/`return`, `space`, `tab`, arrow names (`up`/`arrowup`, …), or a single printable char (`?`, `/`). Sequences (`g` then `g`) are not supported.
1731
+
1732
+ #### `ui.kbd(keys | props)`
1733
+
1734
+ Display-only keyboard chord glyphs (ShadCN `Kbd` / `KbdGroup`). Does not listen for keydowns — pair with `ui.keybind` for behavior. Uses the same chord token grammar; `mod` renders as `⌘` on Apple platforms and `Ctrl` elsewhere.
1735
+
1736
+ ```typescript
1737
+ ui.kbd('mod+k');
1738
+ ui.kbd({ keys: 'mod+s', className: 'ml-1' });
1739
+ ui.kbd(['mod+k', 'ctrl+k']); // alternate chords, separated by /
1740
+ ```
1741
+
1742
+ | Prop | Type | Default |
1743
+ |------|------|---------|
1744
+ | `keys` | `string \| string[]` | required |
1745
+ | `className` | `string` | |
1746
+
1747
+ ### Imperative helpers
1748
+
1749
+ These require an active session (typically inside an async event handler). Confirm / prompt / choose are **async** (WebSocket round-trip). Notify / navigate / download / clipboard are fire-and-forget.
1750
+
1751
+ ```typescript
1752
+ onClick: async () => {
1753
+ const sure = await ui.confirm('Delete this?', {
1754
+ confirmLabel: 'Delete',
1755
+ confirmVariant: 'destructive',
1756
+ });
1757
+ if (!sure) return;
1758
+
1759
+ const name = await ui.prompt('Name?', { defaultValue: 'Ada' });
1760
+ if (name == null) return;
1761
+
1762
+ const color = await ui.choose('Pick a color', ['Red', 'Green', 'Blue']);
1763
+ if (color == null) return;
1764
+
1765
+ ui.notify(`Hello ${name} (${color})`, 'success');
1766
+ // or: ui.notify('Sticky', { type: 'warning', duration: 0, position: 'top-right', description: '…' });
1767
+ }
1768
+ ```
1769
+
1770
+ | Helper | Returns | Notes |
1771
+ |--------|---------|-------|
1772
+ | `ui.confirm(message, options?)` | `Promise<boolean>` | Alert dialog; Cancel / Escape → `false` |
1773
+ | `ui.prompt(message, options?)` | `Promise<string \| null>` | Cancel → `null` |
1774
+ | `ui.choose(message, choices, options?)` | `Promise<string \| null>` | `choices` as strings or `{ value, label }` |
1775
+ | `ui.notify(message, typeOrOptions?)` | `void` | ShadCN Sonner toast; types `info\|success\|warning\|error` |
1776
+ | `ui.navigate(path)` | `void` | Client SPA navigate + same-WS `hello` remount (sticky `app` chrome) |
1777
+ | `ui.download(filename, mime, content)` | `void` | Trigger browser download |
1778
+ | `ui.clipboard(content)` | `void` | Write text to clipboard (prefer over `navigator.clipboard`) |
1779
+ | `ui.getUrlHash()` | `string` | Current hash without `#` (from hello / last set) |
1780
+ | `ui.setUrlHash(hash)` | `void` | Set `location.hash` on the client |
1781
+ | `ui.openExternal(url)` | `void` | `window.open` in a new tab (`noopener`) |
1782
+ | `ui.theme.set(mode)` | `void` | Push `light` \| `dark` \| `system` to this client |
1783
+ | `ui.theme.get()` | `ThemeMode \| null` | Last value set on this session |
1784
+ | `ui.runJavaScript(code)` | `void` | Eval trusted snippet in the browser |
1785
+ | `ui.scroll.to(opts)` | `void` | Scroll window (`top` / `bottom` / px) |
1786
+ | `ui.scroll.intoView(selector, opts?)` | `void` | `Element.scrollIntoView` via selector |
1787
+ | `ui.timer(interval, callback, options?)` | `TimerHandle` | Session-scoped timer; `interval` in **seconds**; `{ once? }`; `.activate()` / `.deactivate()` / `.cancel()` |
1788
+ | `ui.upload(props?)` | `Element` | Multipart HTTP upload; see below |
1789
+ | `ui.storage.tab` | | Per-browser-tab map (sessionStorage mirror) |
1790
+ | `ui.storage.browser` | | Mirrored to client `localStorage` |
1791
+ | `ui.storage.client` | | Mirrored to client `sessionStorage` |
1792
+ | `ui.storage.user` | | Per-browser-user JSON bag (file/Redis-backed) |
1793
+ | `ui.storage.app` | | Process-wide typed stores |
1794
+
1795
+ Never use `window` / `navigator` / `location` in page code — see [Browser APIs](./browser-apis.md).
1796
+
1797
+ `ui.notify` options: `{ type?, duration?, position?, description? }` — `duration: 0` is sticky; `description` is Sonner’s secondary line; positions `top-left` \| `top-right` \| `bottom-left` \| `bottom-right`.
1798
+
1799
+ ```typescript
1800
+ const clock = ui.timer(1, () => label.setText(new Date().toLocaleTimeString()));
1801
+ // clock.deactivate(); clock.activate(); clock.cancel();
1802
+ ui.timer(2, () => ui.notify('Once'), { once: true });
1803
+ ```
1804
+
1805
+ Timers are cleared on WebSocket session destroy.
1806
+
1807
+ #### `ui.upload({ onUpload, accept?, multiple?, label?, variant?, maxSizeBytes?, abortable?, onProgress?, onError?, onAbort? })`
1808
+
1809
+ Opens a file picker (`variant: 'button'`, default) or a drag-and-drop shell (`variant: 'dropzone'`). Selected files are posted to `POST /upload` (multipart) with XHR progress. The client emits WS events: `upload` per file `{ name, size, type, path }`, plus optional `progress` / `error` / `abort`. Client and server enforce `accept` / `maxSizeBytes` when set (`ui.run({ uploadMaxSizeBytes, uploadAccept })` for global server limits).
1810
+
1811
+ ```typescript
1812
+ ui.upload({
1813
+ accept: '.pdf,image/*',
1814
+ multiple: true,
1815
+ onUpload: async (file) => {
1816
+ ui.notify(`Saved ${file.name} → ${file.path}`, 'success');
1817
+ },
1818
+ });
1819
+
1820
+ ui.upload({
1821
+ variant: 'dropzone',
1822
+ label: 'Drop files here',
1823
+ multiple: true,
1824
+ onUpload: async (file) => {
1825
+ ui.notify(`Saved ${file.name}`, 'success');
1826
+ },
1827
+ });
1828
+ ```
1829
+
1830
+ #### `ui.storage`
1831
+
1832
+ NiceGUI-style storage scopes:
1833
+
1834
+ | API | Scope | Persistence |
1835
+ |-----|--------|-------------|
1836
+ | `ui.storage.tab.get/set/delete/clear/has` | This browser tab | Mirrored to client `sessionStorage` (`clay-tab-storage`); hydrated on `hello`; survives reconnect / navigate-hello; not cleared on disconnect — only via `tab.clear()` or tab close |
1837
+ | `ui.storage.browser.*` | Shared across tabs for the origin | Mirrored to client `localStorage` (hydrated on `hello`) |
1838
+ | `ui.storage.client.*` | This browser tab | Mirrored to client `sessionStorage` (hydrated on `hello`) |
1839
+ | `ui.storage.user.get/set/delete/clear/has` | Stable `userId` (hello / `resolveUserId`) | JSON bag via file or Redis adapter |
1840
+ | `ui.storage.app.create(key, initial, options?)` | Process-wide typed store (all sessions) | Optional file/Redis adapter via `appStorageDir` / `storage.configure({ app })` |
1841
+
1842
+ User storage requires a `userId` on the session. The built-in client sends an anonymous localStorage id on `hello`; override with `ui.run({ resolveUserId })` or `ui.run({ authSecret })` (signed cookie via `POST /auth/session` + soft-reconnect). For a full Account demo (hash, lockout, roles, audit), see `/examples/auth`. Configure dirs via `ui.run({ userStorageDir, appStorageDir, uploadDir, uploadMaxSizeBytes, uploadAccept })`.
1843
+
1844
+ ```typescript
1845
+ const messages = ui.storage.app.create<Message[]>('chatMessages', []);
1846
+ const online = ui.storage.app.create<string[]>('onlineUsers', [], { persist: false });
1847
+
1848
+ messages.subscribe((list) => {
1849
+ // update UI for this session
1850
+ });
1851
+
1852
+ await messages.set([...(await messages.get()), newMessage]);
1853
+ await messages.update((prev) => [...prev, newMessage]);
1854
+ ```
1855
+
1856
+ `get()` is always async for **user** / **app**. For **persisted** app stores it calls `adapter.load(key)` on every read so other processes’ writes are visible; then updates memory and notifies subscribers if the value changed. `set` / `update` write memory immediately and `await adapter.save` when persisted.
1857
+
1858
+ | Method | Description |
1859
+ |--------|-------------|
1860
+ | `storage.configure({ app?, user? })` | Set persistence adapters (partial updates leave the other unchanged) |
1861
+ | `storage.app.create(key, initial, options?)` | Get or create named store; `{ persist?: boolean }` |
1862
+ | `.get()` | `Promise<T>` — load from adapter when persisted |
1863
+ | `.set(v)` / `.update(fn)` | `Promise<void>` — save when persisted |
1864
+ | `.subscribe(listener)` | Listen for changes |
1865
+ | `storage.app.clearAll()` | Test helper (clears app stores only) |
1866
+ | `storage.clearAll()` | Test helper (clears app stores + user bags + both adapters) |
1867
+ | `createMemoryPersistence()` | In-memory adapter for tests (`@close-by/clay-core`) |
1868
+ | `createFilePersistence({ dir })` | File-backed adapter (`@close-by/clay-persistence-file`) |
1869
+ | `createRedisPersistence({ client, keyPrefix? })` | Redis adapter (`@close-by/clay-persistence-redis`) — multi-process app/user bags; keep WS sticky |
1870
+
1871
+ `PersistenceAdapter`:
1872
+
1873
+ ```typescript
1874
+ type PersistenceAdapter = {
1875
+ load(key: string): Promise<string | null>;
1876
+ save(key: string, json: string): Promise<void>;
1877
+ close?(): Promise<void>;
1878
+ };
1879
+ ```
1880
+
1881
+ `@close-by/clay-persistence-file` stores one JSON text file per key under `dir`. Core also provides `createMemoryPersistence()` for tests. Implement the interface yourself for Redis or other backends.
1882
+
1883
+ ---
1884
+
1885
+ ### Layout
1886
+
1887
+ All layout helpers accept either `(fn, props?)` or `(props, fn)`.
1888
+
1889
+ | Helper | Notes |
1890
+ |--------|--------|
1891
+ | `ui.row` | Horizontal flex |
1892
+ | `ui.column` | Vertical flex |
1893
+ | `ui.container` | Width-constrained wrapper |
1894
+ | `ui.hero` | Centered hero region |
1895
+ | `ui.card` | Card; callback receives the card element |
1896
+ | `ui.app` | SPA shell: sidebar nav + centered main content |
1897
+
1898
+ #### `ui.app(props, fn)`
1899
+
1900
+ Sidebar layout for multi-page apps. Prefer `ui.run({ app })` so every page inherits the shell; use `ui.app` directly for advanced/one-off cases (or when `shell: false`).
1901
+
1902
+ | Prop | Type | Description |
1903
+ |------|------|-------------|
1904
+ | `title` | `string` | Brand label in the sidebar |
1905
+ | `headerTitle` | `string` | Optional site header title (client defaults to active nav label) |
1906
+ | `nav` | `AppNavItem[]` | Primary sidebar links (SPA `pushState` for `/…`) |
1907
+ | `navSecondary` | `AppNavItem[]` | Optional secondary links (e.g. Settings) |
1908
+ | `documents` | `AppNavItem[]` | Optional documents group |
1909
+ | `primaryAction` | `{ label, href?, icon? }` | Optional CTA above primary nav (no default) |
1910
+ | `user` | `AppUser` | Optional user menu (`name`, `email`, `avatar?`) |
1911
+ | `variant` | `'sidebar' \| 'inset'` | Shell layout variant |
1912
+ | `collapsible` | `'offcanvas' \| 'icon' \| 'none'` | Sidebar collapse behavior |
1913
+ | `className` | `string` | Extra classes on the shell |
1914
+
1915
+ `AppNavItem`: `{ label, href, icon?, description?, items? }` — `icon` is a Lucide kebab-case name (full set; e.g. `home`, `gauge`, `sparkles`). Nested `items` render as collapsible sub-links in the sidebar.
1916
+
1917
+ ```typescript
1918
+ // Preferred: configure once at startup
1919
+ ui.run({
1920
+ app: { title: 'Clay', nav: ui.navFromPages() },
1921
+ });
1922
+
1923
+ // Advanced: wrap a single page manually
1924
+ ui.page('/examples/counter', () => {
1925
+ ui.app(
1926
+ {
1927
+ title: 'Clay',
1928
+ nav: [
1929
+ { label: 'Home', href: '/' },
1930
+ { label: 'Counter', href: '/examples/counter' },
1931
+ ],
1932
+ },
1933
+ () => {
1934
+ ui.label('Counter');
1935
+ },
1936
+ );
1937
+ }, { shell: false });
1938
+ ```
1939
+
1940
+ Shared layout props:
1941
+
1942
+ | Prop | Type | Default | Description |
1943
+ |------|------|---------|-------------|
1944
+ | `gap` | `number \| string` | `2` | Tailwind-style gap scale (`0`–`8`) |
1945
+ | `className` | `string` | | Extra classes |
1946
+ | `centered` | `boolean` | | `mx-auto` (container) |
1947
+ | `width` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl' \| '4xl' \| '5xl' \| '6xl' \| '7xl' \| 'full'` | `'lg'` for container | Max width |
1948
+
1949
+ Card also accepts `title?: string`.
1950
+
1951
+ ```typescript
1952
+ ui.container(() => {
1953
+ ui.card({ title: 'Settings' }, () => {
1954
+ ui.input({ label: 'Name' });
1955
+ });
1956
+ }, { centered: true, width: 'md' });
1957
+ ```
1958
+
1959
+ ---
1960
+
1961
+ ## `Element` methods (`@close-by/clay-core`)
1962
+
1963
+ Returned by every `ui.*` factory.
1964
+
1965
+ | Method | Description |
1966
+ |--------|-------------|
1967
+ | `.classes(...names)` | Append Tailwind/utility classes; patches `className` |
1968
+ | `.style(string \| Record)` | Set inline style string / object |
1969
+ | `.on(event, handler)` | Register additional event handler |
1970
+ | `.setText(text)` | Set `props.text` and patch |
1971
+ | `.setValue(value)` / `.set(value)` | Set `props.value` and patch |
1972
+ | `.setError(message \| null)` | Set or clear field `props.error` and patch |
1973
+ | `.getValue()` / `.get()` | Read `props.value` |
1974
+ | `.bindValue(obj, key)` | Two-way bind to reactive object property |
1975
+ | `.bindTextFrom(obj, key)` | One-way bind text from object property |
1976
+ | `.bindText(() => string)` | One-way bind text from a compute fn (tracks `state` reads) |
1977
+ | `.update(props?)` | Merge props or replace node |
1978
+ | `.add(child)` | Append child element |
1979
+ | `.refresh()` | **RefreshableElement only** — rebuild children (in-place props sync when shape is stable) |
1980
+
1981
+ Chaining:
1982
+
1983
+ ```typescript
1984
+ ui.label('Title')
1985
+ .classes('text-3xl', 'font-bold')
1986
+ .style({ marginBottom: '8px' });
1987
+ ```
1988
+
1989
+ ---
1990
+
1991
+ ## Reactivity
1992
+
1993
+ Prefer importing from `@close-by/clay` (also on `ui.reactive` / `ui.subscribe` / `ui.state` / `ui.auto`). Still exported from `@close-by/clay-core`.
1994
+
1995
+ Compile-time `let` rewrite (Phase 2): [`docs/reactive-let.md`](./reactive-let.md) and `@close-by/clay-compiler`.
1996
+
1997
+ ### `reactive(target)` / `ui.state(target)`
1998
+
1999
+ ```typescript
2000
+ import { reactive, ui } from '@close-by/clay';
2001
+
2002
+ const form = reactive({ name: '', agree: false });
2003
+ form.name = 'Ada'; // notifies subscribers
2004
+
2005
+ const s = ui.state({ count: 0 }); // alias of reactive
2006
+ ```
2007
+
2008
+ ### `ui.draft(key, defaults, opts?)`
2009
+
2010
+ Reactive object hydrated from sync storage (default `ui.storage.tab`) with write-through on each property change. Survives reconnect / `--reload`. Sync scopes only: `tab` | `client` | `browser` (not async `user` / `app`).
2011
+
2012
+ ```typescript
2013
+ const form = ui.draft('formDemo', { name: '', email: '' });
2014
+ // omit secrets: ui.draft('login', { user: '', password: '' }, { omit: ['password'] });
2015
+
2016
+ form.name = 'Ada'; // persisted
2017
+
2018
+ ui.draft.clear('formDemo'); // drop storage key; reset the object yourself if needed
2019
+ ```
2020
+
2021
+ See Form Demo (`/examples/form-demo`).
2022
+
2023
+ ### `ui.auto(fn)`
2024
+
2025
+ Rebuilds the block when `state` / `reactive` properties **read** during `fn` change. Keep mutable state outside the builder. When the child tree shape is unchanged, patches props in place (`updateProps`) instead of remounting via `setChildren`.
2026
+
2027
+ ```typescript
2028
+ const s = ui.state({ count: 0 });
2029
+ ui.auto(() => {
2030
+ ui.label(`Count: ${s.count}`);
2031
+ ui.button('+', { onClick: () => { s.count++; } });
2032
+ });
2033
+ ```
2034
+
2035
+ ### `ui.label(() => …)`
2036
+
2037
+ Computed label without an `auto` wrapper:
2038
+
2039
+ ```typescript
2040
+ const s = ui.state({ count: 0 });
2041
+ ui.label(() => `Count: ${s.count}`);
2042
+ ui.button('+', { onClick: () => { s.count++; } });
2043
+ ```
2044
+
2045
+ ### `subscribe(obj, key, listener)`
2046
+
2047
+ ```typescript
2048
+ import { subscribe } from '@close-by/clay';
2049
+
2050
+ subscribe(form, 'name', () => {
2051
+ summary.refresh();
2052
+ });
2053
+ ```
2054
+
2055
+ Returns an unsubscribe function.
2056
+
2057
+ ---
2058
+
2059
+ ## Helpers (`@close-by/clay-core`)
2060
+
2061
+ ### `notify(message, typeOrOptions?)`
2062
+
2063
+ Toast on the client via ShadCN Sonner. Prefer `ui.notify` from app code.
2064
+
2065
+ ```typescript
2066
+ notify('Saved!', 'success');
2067
+ notify('Heads up', {
2068
+ type: 'warning',
2069
+ duration: 0,
2070
+ position: 'top-right',
2071
+ description: 'Sticky until dismissed',
2072
+ });
2073
+ ```
2074
+
2075
+ Types: `'info' | 'success' | 'warning' | 'error'`.
2076
+
2077
+ ### `navigate(path)`
2078
+
2079
+ Tell the client to navigate; client updates the URL and sends `hello` for the new path on the same WebSocket. Matching `app` shell chrome stays mounted; only inset content remounts.
2080
+
2081
+ ### `download(filename, mime, content)`
2082
+
2083
+ Ask the client to download a file (base64 or text content as sent over the protocol). Prefer `ui.download` from app code.
2084
+
2085
+ ### `clipboard(content)`
2086
+
2087
+ Ask the client to copy text to the clipboard. Prefer `ui.clipboard` from app code.
2088
+
2089
+ ---
2090
+
2091
+ ## Direct imports
2092
+
2093
+ Advanced / library use:
2094
+
2095
+ ```typescript
2096
+ import {
2097
+ Element,
2098
+ RefreshableElement,
2099
+ ClientSession,
2100
+ page,
2101
+ getPage,
2102
+ getRegisteredPaths,
2103
+ setPageWrapper,
2104
+ reactive,
2105
+ subscribe,
2106
+ storage,
2107
+ AppStore,
2108
+ createMemoryPersistence,
2109
+ notify,
2110
+ navigate,
2111
+ download,
2112
+ clipboard,
2113
+ timer,
2114
+ TimerHandle,
2115
+ } from '@close-by/clay-core';
2116
+
2117
+ // App code prefers the facade (includes reactive / subscribe):
2118
+ import { ui, reactive, subscribe } from '@close-by/clay';
2119
+
2120
+ import { ClayServer } from '@close-by/clay-server';
2121
+ import { button, input /* … */ } from '@close-by/clay-components';
2122
+ ```
2123
+
2124
+ ---
2125
+
2126
+ ## DuckDB (`@close-by/clay-duckdb`)
2127
+
2128
+ See [DuckDB](./duckdb.md) for the multi-database wrapper (`connect`, `attach`, CRUD).
2129
+
2130
+ ## Kibana (`@close-by/clay-kibana`)
2131
+
2132
+ See [Kibana](./kibana.md) for REST access, Elasticsearch search via console proxy, and Saved Objects.
2133
+
2134
+ ## ClickHouse (`@close-by/clay-clickhouse`)
2135
+
2136
+ See [ClickHouse](./clickhouse.md) for the multi-connection wrapper (`connect`, `query`, CRUD, `stream`).