@close-by/clay 0.2.7 → 0.2.9

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.
@@ -0,0 +1,206 @@
1
+ # Elements
2
+
3
+ Each factory creates an `Element` with a wire `type` string. The React client maps that type to a ShadCN (or plain HTML) control.
4
+
5
+ ## Type map
6
+
7
+ | `ui.*` | Wire `type` | Client rendering |
8
+ |--------|-------------|------------------|
9
+ | `label` | `label` | Text div (optional `icon`) |
10
+ | `iconText` | `iconText` | Inline icon + text row |
11
+ | `statusDot` | `statusDot` | Status dot/icon + label |
12
+ | `feedList` | `container` | Bordered divided container for live feed rows (layout recipe) |
13
+ | `feedRow` | `row` | One live-feed row: status, link title, meta/issue, trailing time (layout recipe) |
14
+ | `button` | `button` | ShadCN `Button` |
15
+ | `input` | `input` | ShadCN `Input` (+ optional label / `error`) |
16
+ | `textArea` | `textarea` | Native textarea (+ optional `error`) |
17
+ | `checkbox` | `checkbox` | ShadCN `Checkbox` (+ optional `error`) |
18
+ | `switch` | `switch` | ShadCN `Switch` (+ optional `error`) |
19
+ | `select` | `select` | ShadCN `Select` (+ optional `error`) |
20
+ | `radioGroup` | `radiogroup` | ShadCN `RadioGroup` (+ optional `error`) |
21
+ | `combobox` | `combobox` | ShadCN `Combobox` (searchable select; optional `error`) |
22
+ | `date` | `date` | Calendar + Popover date picker (ISO `YYYY-MM-DD`; optional `error`) |
23
+ | `dateRange` | `dateRange` | Range picker with presets (ISO `from` / `to`) |
24
+ | `buttonGroup` | `buttonGroup` | Grouped buttons / segmented controls |
25
+ | `empty` | `empty` | Centered empty state (title, description, icon, footer slot) |
26
+ | `pagination` | `pagination` | Standalone pager (outside `dataTable`) |
27
+ | `filterBar` | `filterBar` | Ops filter row: chips + control slot |
28
+ | `filterChips` | `filterChips` | Removable filter chips only |
29
+ | `numberField` | `numberField` | Numeric input with +/- steppers |
30
+ | `phoneInput` | `phoneInput` | Country dial + local number |
31
+ | `field` | `field` | Label + description + error wrapper for one control |
32
+ | `nativeSelect` | `nativeSelect` | Styled native `<select>` |
33
+ | `navigationMenu` | `navigationmenu` (+ sub/link children) | Top nav mega-menu |
34
+ | `notice` | `notice` | Dismissible app banner |
35
+ | `eventCalendar` | `eventCalendar` | Month grid + day event list (`events` API → wire `items`) |
36
+ | `timeline` | `timeline` | Vertical or horizontal event timeline |
37
+ | `stepper` | `stepper` (+ `stepperStep`) | Inline multi-step flow with panels |
38
+ | `slider` | `slider` | ShadCN `Slider` (+ optional `error`) |
39
+ | `rating` | `rating` | Star rating (`value` / `max`; optional `error`) |
40
+ | `colorPicker` | `colorPicker` | Hex color picker (swatches + native input; optional `error`) |
41
+ | `tags` | `tags` | Multi-tag chip input (`options?`, `creatable?`; optional `error`) |
42
+ | `codeBlock` | `codeBlock` | Read-only Shiki-highlighted code (`language?`, `showCopy?`, `sensitive?`) |
43
+ | `tree` | `tree` | Nested tree (`nodes`, `selected?`, `expanded?`) |
44
+ | `editor` | `editor` | Domternal rich text (`format?: 'html' \| 'markdown'`) |
45
+ | `kanban` | `kanban` | Board (`columns` with cards; optional `lanes`; owns order + selection; card drawer; `cardMove`/`cardSelect` settle defaults) |
46
+ | `list` | `list` | Dense vertical grouped list (`groups` with items; owns order; `itemMove` settle defaults) |
47
+ | `relativeTime` | `relativeTime` | Multi-timezone clock (`timezones`; ticks when `date` omitted) |
48
+ | `qrCode` | `qrCode` | SVG QR from `value` (`size?`, `level?`) |
49
+ | `imageZoom` | `imageZoom` | Image with click-to-zoom overlay (leave `image` plain) |
50
+ | `imageCrop` | `imageCrop` | Image cropper (`src`, `aspect?`; emits data URL) |
51
+ | `gantt` | `gantt` | Project timeline (`rows` + markers + dependency arrows; owns dates; `itemMove` / `markerAdd` settle defaults) |
52
+ | `flow` | `flow` (+ `flowNode`) | Interactive diagram (`@xyflow/react`; owns edges/positions; Clay node bodies; dagre `layout()`; group/`parentId`; typed/labeled edges; custom type keys + client registries; `nodeMove`/`connect` settle defaults) |
53
+ | `ai.loader` | `aiLoader` | AI loading state (`variant`: drive/dots/orbit/pixel; optional `startedAt` elapsed) |
54
+ | `ai.thinking` | `aiThinking` | Expandable thinking traces |
55
+ | `ai.message` | `aiMessage` | Chat message (markdown, sources, actions, follow-ups, streaming) |
56
+ | `ai.chat` | `aiChat` | Tabbed thread + composer (`AiChatElement` owns messages/tab) |
57
+ | `ai.promptBar` | `aiPromptBar` | Composer with sources / commands / model / dictate stub |
58
+ | `ai.codeBlock` | `aiCodeBlock` | AI-styled code block (`filename?`, `streaming?`) |
59
+ | `ai.approval` | `aiApproval` | Human-in-the-loop approval card |
60
+ | `ai.toolChips` | `aiToolChips` | Compact tool / edit chips |
61
+ | `ai.tasks` | `aiTasks` | Nested agent task rows |
62
+ | `ai.recommendation` | `aiRecommendation` | Suggestion + confidence + alternatives |
63
+ | `ai.context` | `aiContext` | Retrieved context chunks |
64
+ | `ai.diffTable` | `aiDiffTable` | Compact proposed-edit table |
65
+ | `ai.insights` | `aiInsights` | Paged insight cards |
66
+ | `ai.selectionActions` | `aiSelectionActions` | Selection strip + actions |
67
+ | `ai.fineTune` | `aiFineTune` | Inspector-style prop editors |
68
+ | `link` | `link` | `<a>` (SPA navigation for `/…`) |
69
+ | `badge` | `badge` | ShadCN `Badge` |
70
+ | `alert` | `alert` | Bordered alert box |
71
+ | `spinner` | `spinner` | Lucide spinner |
72
+ | `skeleton` | `skeleton` | ShadCN `Skeleton` placeholder |
73
+ | `avatar` | `avatar` | ShadCN `Avatar` (+ fallback) |
74
+ | `progress` | `progress` | ShadCN `Progress` (0–100) |
75
+ | `separator` | `separator` | ShadCN `Separator` |
76
+ | `icon` | `icon` | Lucide kebab-case name (full set; same as nav / `ui.icon`) |
77
+ | `tooltip` | `tooltip` | ShadCN `Tooltip` wrapping children |
78
+ | `hoverCard` | `hovercard` | ShadCN `HoverCard` wrapping children |
79
+ | `popover` | `popover` | ShadCN `Popover` (server-owned `open`) |
80
+ | `inputOtp` | `inputotp` | ShadCN `InputOTP` |
81
+ | `toggleGroup` | `togglegroup` (+ `toggleitem`) | ShadCN `ToggleGroup` |
82
+ | `menubar` | `menubar` (+ `menubarmenu` / `menubarsubmenu` / `menubaritem` / `menubarcheckbox` / `menubarradiogroup` / `menubarradioitem` / `menubarseparator`) | ShadCN `Menubar` (submenu, checkbox, radio) |
83
+ | `carousel` | `carousel` (+ `carouselslide`) | Embla carousel |
84
+ | `command` | `command` (+ `commandgroup` / `commanditem` / `commandseparator`) | Command palette (`mode: 'dialog' \| 'inline'`; dialog owns `open`) |
85
+ | `resizable` | `resizable` (+ `resizablepanel` / `resizablehandle`) | `react-resizable-panels` |
86
+ | `scrollArea` | `scrollarea` | ShadCN `ScrollArea` (`onNearEnd` for infinite scroll) |
87
+ | `viewportEnter` | `viewportEnter` | IntersectionObserver wrapper (`onEnter`, `once?`) |
88
+ | `keybind` | `keybind` | Headless `window` `keydown` chord listener (`return null`) |
89
+ | `kbd` | `kbd` | Display-only chord glyphs (`Kbd` / `KbdGroup`; same tokens as `keybind`) |
90
+ | `markdown` | `markdown` | Client `marked` + DOMPurify |
91
+ | `html` | `html` | Trusted server HTML (`dangerouslySetInnerHTML`) |
92
+ | `image` | `image` | `<img>` |
93
+ | `iframe` | `iframe` | `<iframe>` embed |
94
+ | `upload` | `upload` | File picker / dropzone → `POST /upload` (progress/abort) → WS `upload` / `progress` / `error` / `abort` |
95
+ | `stat` | `stat` | Grid of metric cards (optional per-item `sparkline`) |
96
+ | `sparkline` | `sparkline` | KPI card with inline area/line mini chart (`headline`, `value`/`trend` shorthand) |
97
+ | `areaChart` | `areachart` | Recharts stacked area (+ legend, optional interactive periods, `headline`, `loading`, `stacked?`) |
98
+ | `barChart` | `barchart` | Recharts bar (`stacked?`, `layout?: 'vertical' \| 'horizontal'`, shared cartesian chrome) |
99
+ | `lineChart` | `linechart` | Recharts line (+ optional interactive periods, headline, loading) |
100
+ | `pieChart` | `piechart` | Recharts pie/donut (`nameKey`/`valueKey` or `series`, `innerRadius?`) |
101
+ | `radarChart` | `radarchart` | Recharts radar (`angleKey` + `series`, optional `fillOpacity`) |
102
+ | `radialChart` | `radialchart` | Recharts radial bar (`nameKey`/`valueKey` or stacked `series`, optional center text / angles) |
103
+ | `scatterChart` | `scatterchart` | Recharts scatter (`xKey` / `yKey`, optional `seriesKey`) |
104
+ | `composedChart` | `composedchart` | Recharts composed (per-series `type`, dual `yAxisId`, `referenceLine` / `referenceArea`) |
105
+ | `dataTable` | `datatable` | Search, text/facet filters, views, grouping (collapse all), selection, bulk actions, reorder (coexists with virtualization via windowed drop targets), editors (Enter/Esc + focus restore), export, multi-sort (Shift+click), local or remote (`manualPagination` + `getQuery` / `setLoading` / `withLoading`), density/zebra, column resize/pin, footer aggregates, row virtualization (≥40 body items), loading/empty, actions, detail drawer |
106
+ | `tabs` | `tabs` (+ child `tab`) | ShadCN `Tabs`; optimistic `value` |
107
+ | `accordion` | `accordion` (+ child `accordionitem`) | ShadCN `Accordion`; optimistic `value` |
108
+ | `collapsible` | `collapsible` | ShadCN `Collapsible`; optimistic open (`value`) |
109
+ | `row` | `row` | Flex row |
110
+ | `column` | `column` | Flex column |
111
+ | `container` | `container` | Max-width wrapper |
112
+ | `hero` | `hero` | Centered hero |
113
+ | `card` | `card` | ShadCN `Card` |
114
+ | `app` | `app` | Dashboard shell (sidebar + inset main); usually via `ui.run({ app })` |
115
+ | `dialog` | `dialog` | Modal overlay (server-owned `open`) |
116
+ | `dialogStack` | `dialogStack` (+ `dialogStackStep`) | Multi-step stacked modal (server-owned `open` + `index`) |
117
+ | `alertDialog` | `alertdialog` | Confirm/destructive modal (server-owned `open`) |
118
+ | `dropdownMenu` | `dropdownmenu` (+ `dropdownitem` / `dropdownseparator`) | ShadCN dropdown; item `select` events |
119
+ | `contextMenu` | `contextmenu` (+ `contextmenuitem` / `contextmenuseparator`) | ShadCN context menu; item `select` events |
120
+ | `breadcrumb` | `breadcrumb` | ShadCN breadcrumb; `href` crumbs use SPA `pushState` |
121
+ | `sheet` | `sheet` | Side panel overlay (server-owned `open`) |
122
+ | `drawer` | `drawer` | Vaul drawer overlay (server-owned `open`) |
123
+ | `refreshable` | `refreshable` | Fragment-like wrapper (`contents`) |
124
+ | *(internal)* | `root` | Page root (`min-h-screen`) |
125
+
126
+ ## Events exposed to the client
127
+
128
+ Handlers stay on the server. Serialized props include `events: string[]` so the client knows what to emit.
129
+
130
+ | Element | Typical events |
131
+ |---------|----------------|
132
+ | `button` | `click` |
133
+ | `input` / `textarea` | `input`, `change` |
134
+ | `checkbox` / `switch` | `change` (and `input` if bound) |
135
+ | `select` / `slider` / `rating` / `colorPicker` / `tags` / `radioGroup` / `combobox` / `date` / `editor` | `change` (and `input` if bound for radio/combobox/date/editor) |
136
+ | `tabs` / `accordion` / `collapsible` | `change` |
137
+ | `tree` | `select`, `expand` |
138
+ | `kanban` | `cardMove`, `cardSelect`, `cardClick` (`cardMove`/`cardSelect` settle always registered; user `on*` run after owned-model update) |
139
+ | `list` | `itemMove`, `itemClick` (`itemMove` settle always registered; user `onItemMove` runs after owned-model update) |
140
+ | `imageCrop` | `crop` |
141
+ | `scrollArea` | `nearEnd` |
142
+ | `viewportEnter` | `enter` |
143
+ | `gantt` | `itemMove`, `itemClick`, `markerAdd` (`itemMove` / `markerAdd` settle always registered; user `on*` run after owned-model update) |
144
+ | `flow` | `connect`, `nodeMove` (drag-stop), `nodesDelete`, `edgesDelete`, `selectionChange` (settle handlers always registered; user `on*` run after owned-model update) |
145
+ | `aiChat` / `aiPromptBar` | `submit`, `tabChange`, `followUp`, `action`, `sourceRemove`, `modelChange`, `command`, `dictate`, `change` |
146
+ | `aiMessage` | `followUp`, `action` |
147
+ | `aiThinking` | `toggle` |
148
+ | `aiApproval` | `approve`, `reject` |
149
+ | `aiToolChips` / `aiTasks` / `aiContext` | `chipClick`, `taskClick`, `chunkClick` |
150
+ | `aiRecommendation` | `accept`, `reject`, `alternative` |
151
+ | `aiDiffTable` / `aiInsights` / `aiSelectionActions` / `aiFineTune` | `rowClick`, `indexChange`, `prompt`, `action`, `change` |
152
+ | `upload` | `upload`, `progress`, `error`, `abort` |
153
+ | `dataTable` | `sort`, `filter`, `columnFilter`, `columnVisibility`, `columnPin`, `export`, `page`, `pageSize`, `action`, `bulkAction`, `reorder`, `selectionChange`, `cellChange`, `viewChange`, `groupToggle`, `primaryAction` |
154
+ | `dialog` / `sheet` / `drawer` / `alertDialog` | `close` (`alertDialog` also `confirm`) |
155
+ | `dialogStack` | `close`, `indexChange` (number) |
156
+ | `dropdownMenu` / `contextMenu` / `menubar` item | `select` |
157
+ | `menubar` checkbox | `checkedChange` |
158
+ | `menubar` radio group | `valueChange` |
159
+ | `popover` / `command` (dialog) | `openChange` |
160
+ | `inputOtp` / `toggleGroup` | `change` (`inputOtp` also `complete`) |
161
+ | `keybind` | `press` |
162
+
163
+ Prop names use camelCase (`onClick` → event name `click`).
164
+
165
+ ## Optimistic controls
166
+
167
+ These types keep **local optimistic state** on the client so interaction is not blocked on the WebSocket round-trip:
168
+
169
+ - `input`
170
+ - `textarea`
171
+ - `checkbox`
172
+ - `switch`
173
+ - `select`
174
+ - `radioGroup`
175
+ - `combobox`
176
+ - `date`
177
+ - `slider`
178
+ - `rating`
179
+ - `colorPicker`
180
+ - `tags`
181
+ - `tree` (`selected` / `expanded`)
182
+ - `editor`
183
+ - `kanban` (`columns` while dragging; `selectedCardId` for drawer; settle patches owned columns — do not wrap board in `ui.auto`)
184
+ - `list` (`groups` while dragging; settle patches owned groups — do not wrap list in `ui.auto`)
185
+ - `gantt` (`rows` while dragging / resizing / cross-row; markers on create; settle patches owned model — do not wrap chart in `ui.auto`)
186
+ - `flow` (node positions while dragging; edges on connect keep the client-generated id through settle; position patches do not remount RF nodes)
187
+ - `tabs`
188
+ - `accordion`
189
+ - `collapsible`
190
+ - `datatable` (search / column filter inputs)
191
+
192
+ Server patches still reconcile when `props.value` changes (e.g. `draft.text = ''` after Add).
193
+
194
+ Field `error` is **server-owned** (via initial props, `.setError`, or `ui.validate`). It is not optimistic — the client shows `props.error` after `updateProps` patches.
195
+
196
+ ## Styling
197
+
198
+ Prefer Tailwind utility classes via `.classes(...)`:
199
+
200
+ ```typescript
201
+ ui.label('Hello').classes('text-3xl font-bold text-muted-foreground');
202
+ ```
203
+
204
+ Layout `gap` uses a small numeric scale mapped to `gap-0` … `gap-8` in the client.
205
+
206
+ The client ships a light ShadCN-style theme (CSS variables in `packages/client/src/index.css`). Override tokens at runtime with `ui.run({ css: './globals.css' })`.
@@ -0,0 +1,316 @@
1
+ # Examples
2
+
3
+ Demo entry: [`apps/demo/src/main.ts`](../apps/demo/src/main.ts)
4
+ Pages: [`apps/demo/src/examples/`](../apps/demo/src/examples/) (auto-loaded via `ui.loadPages`)
5
+
6
+ After `bun run build:client && bun run demo` (or `bun run demo:cli`), open http://localhost:4000.
7
+
8
+ ## Catalog
9
+
10
+ | Route | File | Patterns shown |
11
+ |-------|------|----------------|
12
+ | `/` | `Home.ts` | `ui.hero`, `ui.icon`, `ui.link`, `ui.navigate`; catalog via `ui.navFromPages()` (same as sidebar) |
13
+ | `/examples/counter` | `Counter.ts` | `setText`, `refreshable`, button variants |
14
+ | `/examples/todo` | `Todo.ts` | `reactive`, `bindValue`, list `refreshable`, filters |
15
+ | `/examples/chat` | `Chat.ts` | `ui.storage.app` (persisted messages + ephemeral presence), async `get`/`set` |
16
+ | `/examples/ai` | `AiDemo.ts` | `ui.ai.*` gallery — chat, prompt bar, loader/thinking, agent cards, diff/insights/fine-tune (static props; no model runtime) |
17
+ | `/examples/upload` | `FileUpload.ts` | `ui.upload` button + dropzone (progress/abort/size), `ui.storage.tab` / `user`, `ui.download`, `ui.clipboard` |
18
+ | `/examples/dashboard` | `Dashboard.ts` | `stat`, `areaChart`, full-chrome `dataTable` (views, editors, detail drawer) |
19
+ | `/examples/datatable` | `DataTableDemo.ts` | Multi-sort (Shift+click / badges), footer aggregates + a11y hints, column pin, remote filter/sort + pagination; density/zebra, editors (Enter/Esc + focus restore), resize, virtualization (≥40, coexists with reorder via windowed drop targets); facets, bulk, collapse/expand, loading/empty; `ui.table` sugar |
20
+ | `/examples/composed` | `ComposedDemo.ts` | ReUI batch 1: `ui.timeline`, `ui.stepper`, `ui.dateRange`, `ui.feedList`/`ui.feedRow`, `ui.sparkline` |
21
+ | `/examples/reui-batches` | `ReuiBatchesDemo.ts` | ReUI batches 3–6: `buttonGroup`, `empty`, `pagination`, `filterBar`, form inputs, `navigationMenu`, `notice`, `stat`+sparkline, `eventCalendar`, datatable `showFilterChips` |
22
+ | `/examples/charts` | `ChartDemo.ts` | `ui.chart.*`, `ui.sparkline`, composed/scatter charts |
23
+ | `/examples/slider-demo` | `SliderDemo.ts` | Slider, checkbox, select + bindings |
24
+ | `/examples/feedback` | `FeedbackDemo.ts` | Alerts, progress, timer, `ui.theme`, `ui.runJavaScript` / `ui.scroll` |
25
+ | `/examples/form-demo` | `FormDemo.ts` | Form + validate + `ui.draft` + rating / colorPicker / tags |
26
+ | `/examples/auth` | `Auth.ts` (+ login/admin/change-password) | Signed cookie session, hashed passwords, login lockout, role-gated admin + audit log |
27
+ | `/examples/timer-content` | `TimerContent.ts` | `ui.timer`, markdown, html, image |
28
+ | `/examples/overlays` | `OverlaysDemo.ts` | Breadcrumb, dropdown/context menu, hover card, popover, dialog/sheet/drawer |
29
+ | `/examples/dialog-stack` | `DialogStackDemo.ts` | `ui.dialogStack` multi-step stacked modal |
30
+ | `/examples/controls` | `ControlsDemo.ts` | Menubar (submenu/checkbox/radio), command dialog + inline, carousel, resizable, scroll-area, `ui.state` / `ui.auto` |
31
+ | `/examples/controls-extra` | `ControlsExtra.ts` | `ui.codeBlock` (Shiki), `ui.tree` |
32
+ | `/examples/media-extra` | `MediaExtra.ts` | `ui.relativeTime`, `ui.qrCode`, `ui.imageZoom`, `ui.imageCrop` |
33
+ | `/examples/editor` | `EditorDemo.ts` | `ui.editor` HTML + Markdown side-by-side, bindValue |
34
+ | `/examples/kanban` | `KanbanDemo.ts` | `ui.kanban` swimlanes, card detail drawer, element-owned `cardMove`/`cardSelect`, reset via APIs |
35
+ | `/examples/list` | `ListDemo.ts` | `ui.list` element-owned groups, `itemMove` settle, reset via `setGroups` |
36
+ | `/examples/gantt` | `GanttDemo.ts` | `ui.gantt` owned rows/dates/markers/deps, cross-row drag, header double-click markers, `itemMove` / `markerAdd` settle |
37
+ | `/examples/flow` | `FlowDemo.ts` | Four `ui.flow` demos: ETL with in-node controls + labeled edges + dagre auto-layout, multi-handle approval branches, fan-in/out + `addNode` dynamic stages, group/`parentId` nesting (flow-owned edges/positions; no outer diagram `ui.auto`) |
38
+ | `/examples/data-clients` | `DataClientsDemo.ts` | DuckDB / Kibana / ClickHouse integration story (mock-friendly) |
39
+ | `/examples/kitchen-sink` | `KitchenSink.ts` | ShadCN catalog preview (client `KitchenSink`) |
40
+
41
+ Each page file exports optional `pageMeta` (`label`, `icon`, `order`, optional `nav: false`, optional `roles`) for `ui.navFromPages()`.
42
+
43
+ ## Pattern: sign in → account → admin (or access denied)
44
+
45
+ Try the journey as an end user (sidebar **Account**, or `/examples/auth` while logged out):
46
+
47
+ 1. **Sign in** (`/examples/auth/login`, no app shell) with **Alice** (administrator) or **Bob** (member), password `password`. Alice is prompted to **change password** once.
48
+ 2. **My account** shows name + role; Preferences save per signed-in user id; **Open admin console** is always visible on the account page.
49
+ 3. As **Bob**, sidebar hides **Admin** (`pageMeta.roles`); opening `/examples/auth/admin` shows **Access denied**. As **Alice**, Admin appears in nav; the console lists signed-in people, can **Sign everyone else out**, and shows an **audit log**.
50
+ 4. **Sign out** clears the HttpOnly auth cookie and soft-reconnects; visiting Account/Admin while signed out redirects to Sign in.
51
+ 5. Kill the WebSocket / refresh — cookie identity keeps you signed in. Idle timeout (`sessionIdleMs` on `ui.run`) signs out.
52
+
53
+ Helpers live in `_auth.ts` (skipped by `loadPages`): `@close-by/clay-auth` password hash + login limiter, cookie session via `ui.establishAuthSession`, online roster in `ui.storage.app`, plus `requireAuth` / `requireRole`.
54
+
55
+ ## Pattern: app entry + discovered pages
56
+
57
+ ```typescript
58
+ // main.ts — or shared demo-run.ts used by CLI `_run.ts`
59
+ await ui.loadPages(new URL('./examples', import.meta.url));
60
+
61
+ ui.run({
62
+ port: 4000,
63
+ title: 'Clay Demo',
64
+ authSecret: process.env.CLAY_AUTH_SECRET ?? '…',
65
+ app: {
66
+ title: 'Clay',
67
+ get nav() {
68
+ // role-aware; see apps/demo/src/demo-run.ts
69
+ return ui.navFromPages(/* { role } */);
70
+ },
71
+ },
72
+ });
73
+ ```
74
+
75
+ Or via CLI (same pages + shell; demo auth from `examples/_run.ts`):
76
+
77
+ ```bash
78
+ bun run demo:cli
79
+ # → clay apps/demo/src/examples --app -p 4000
80
+ ```
81
+
82
+ Directory mode looks for optional `_run.ts` exporting `configureRun(base)` so `demo:cli` gets the same `authSecret` / session timeouts / role-aware nav as `bun run demo`.
83
+
84
+ ```typescript
85
+ // examples/Counter.ts
86
+ export const pageMeta = { label: 'Counter', icon: 'hash', order: 10 };
87
+
88
+ ui.page('/examples/counter', () => {
89
+ // content only — shell from ui.run({ app })
90
+ });
91
+ ```
92
+
93
+ ## Pattern: counter (imperative updates)
94
+
95
+ ```typescript
96
+ ui.page('/examples/counter', () => {
97
+ let count = 0;
98
+ const countLabel = ui.label(`Count: ${count}`);
99
+
100
+ ui.button('+', {
101
+ onClick: () => {
102
+ count++;
103
+ countLabel.setText(`Count: ${count}`);
104
+ },
105
+ });
106
+ });
107
+ ```
108
+
109
+ Use `setText` / `setValue` when the node identity stays the same.
110
+
111
+ ## Pattern: todo list (refreshable + bindValue)
112
+
113
+ ```typescript
114
+ import { ui, reactive } from '@close-by/clay';
115
+
116
+ const draft = reactive({ text: '' });
117
+ const input = ui.input({ placeholder: 'What needs to be done?' });
118
+ input.bindValue(draft, 'text');
119
+
120
+ const listUi = ui.refreshable(() => {
121
+ for (const todo of todos) {
122
+ ui.label(todo.text);
123
+ }
124
+ });
125
+
126
+ ui.button('Add', {
127
+ onClick: () => {
128
+ todos = [...todos, { id: crypto.randomUUID(), text: draft.text, completed: false }];
129
+ draft.text = '';
130
+ listUi.refresh();
131
+ },
132
+ });
133
+ ```
134
+
135
+ ## Pattern: form + live summary + validate
136
+
137
+ ```typescript
138
+ import { ui, reactive, subscribe } from '@close-by/clay';
139
+
140
+ const form = reactive({ name: '', terms: false });
141
+
142
+ const nameInput = ui.input({ label: 'Name' }).bindValue(form, 'name');
143
+ const termsBox = ui.checkbox({ label: 'Accept terms' }).bindValue(form, 'terms');
144
+
145
+ ui.button('Submit', {
146
+ onClick: () => {
147
+ const ok = ui.validate([
148
+ { el: nameInput, check: () => (form.name.trim() ? null : 'Name is required') },
149
+ { el: termsBox, check: () => (form.terms ? null : 'Accept the terms') },
150
+ ]);
151
+ if (!ok) return;
152
+ nameInput.setError(null);
153
+ termsBox.setError(null);
154
+ },
155
+ });
156
+
157
+ const summary = ui.refreshable(() => {
158
+ ui.label(`Name: ${form.name || '—'}`);
159
+ ui.label(`Terms: ${form.terms}`);
160
+ });
161
+
162
+ for (const key of Object.keys(form)) {
163
+ subscribe(form, key, () => summary.refresh());
164
+ }
165
+ ```
166
+
167
+ ## Pattern: shared chat (`ui.storage.app`)
168
+
169
+ ```typescript
170
+ // main.ts (optional persistence)
171
+ import { createFilePersistence } from '@close-by/clay-persistence-file';
172
+ import { storage } from '@close-by/clay-core';
173
+ // or: ui.run({ appStorageDir: '.clay-data' })
174
+
175
+ storage.configure({
176
+ app: createFilePersistence({ dir: '.clay-data' }),
177
+ });
178
+
179
+ const messages = ui.storage.app.create<ChatMessage[]>('chatMessages', []);
180
+ const online = ui.storage.app.create<string[]>('onlineUsers', [], { persist: false });
181
+
182
+ messages.subscribe(() => {
183
+ void list.refresh();
184
+ });
185
+
186
+ ui.button('Send', {
187
+ onClick: async () => {
188
+ await messages.update((prev) => [...prev, { user, text, at: Date.now() }]);
189
+ },
190
+ });
191
+ ```
192
+
193
+ All connected sessions that subscribed will refresh when the store changes. With a persistence adapter configured, `get()` loads from the backend on every read; pass `{ persist: false }` for ephemeral keys (e.g. online presence).
194
+
195
+ ## Pattern: DataTable views
196
+
197
+ ```typescript
198
+ ui.dataTable(docs, {
199
+ keyField: 'id',
200
+ views: [
201
+ { id: 'all', label: 'All' },
202
+ { id: 'done', label: 'Done', filter: (row) => row.status === 'Done' },
203
+ ],
204
+ defaultView: 'all',
205
+ // …
206
+ });
207
+ ```
208
+
209
+ Every tab shows the table; the active view’s `filter` lenses rows. Badge counts auto-derive when `count` is omitted.
210
+
211
+ ## Pattern: DataTable grouping
212
+
213
+ ```typescript
214
+ ui.dataTable(tasks, {
215
+ keyField: 'id',
216
+ groupBy: 'status', // or (row) => row.owner
217
+ // defaultCollapsed: true,
218
+ onGroupToggle: (groupKey, collapsed) => {
219
+ console.log(groupKey, collapsed ? 'collapsed' : 'expanded');
220
+ },
221
+ // …
222
+ });
223
+ ```
224
+
225
+ Filter/sort run first; rows are then stably partitioned into contiguous groups. The client draws collapsible headers plus Collapse all / Expand all; collapse is client-owned (like selection).
226
+
227
+ ## Pattern: DataTable facet filters
228
+
229
+ ```typescript
230
+ ui.dataTable(tasks, {
231
+ keyField: 'id',
232
+ columns: [
233
+ { key: 'title', header: 'Title' },
234
+ {
235
+ key: 'status',
236
+ header: 'Status',
237
+ filter: 'facet',
238
+ facetOptions: [
239
+ { value: 'todo', label: 'Todo' },
240
+ { value: 'done', label: 'Done' },
241
+ ],
242
+ },
243
+ { key: 'owner', header: 'Owner', filter: 'facet' }, // options derived from distinct values
244
+ ],
245
+ });
246
+ ```
247
+
248
+ Facet columns filter with multi-select exact match from a header popover. Text columns use the same header filter button with a substring input popover.
249
+
250
+ ## Pattern: DataTable manual / remote pagination
251
+
252
+ When `manualPagination` is true, rows are the **current page only** — the table does **not** filter, sort, group, or slice locally. It keeps chrome state (`getQuery()`) and emits change events; your app applies that query on the server and calls `setRows` + `setTotalRows`.
253
+
254
+ **Loading / empty during refetch:** call `setLoading(true)` (or `withLoading`) **before** awaiting the fetch and keep previous rows until `setRows`. Loading hides the empty state; clearing rows first can flash empty if loading is late.
255
+
256
+ ```typescript
257
+ let page = 1;
258
+ let pageSize = 10;
259
+ let filter = '';
260
+ let columnFilters: Record<string, string> = {};
261
+ let sorts: Array<{ key: string; dir: 'asc' | 'desc' }> = [];
262
+
263
+ const remote = ui.dataTable([], {
264
+ keyField: 'id',
265
+ manualPagination: true,
266
+ totalRows: 0,
267
+ pageSize,
268
+ searchable: true,
269
+ onPageChange: async (p) => {
270
+ page = p;
271
+ await load();
272
+ },
273
+ onPageSizeChange: async (size) => {
274
+ pageSize = size;
275
+ page = 1;
276
+ await load();
277
+ },
278
+ onFilterChange: async (q) => {
279
+ filter = q;
280
+ page = 1;
281
+ await load();
282
+ },
283
+ onColumnFilterChange: async (filters) => {
284
+ columnFilters = filters;
285
+ page = 1;
286
+ await load();
287
+ },
288
+ onSortChange: async (next) => {
289
+ sorts = next;
290
+ page = 1;
291
+ await load();
292
+ },
293
+ columns: [/* … */],
294
+ });
295
+
296
+ async function load() {
297
+ await remote.withLoading(async () => {
298
+ // Or: const q = remote.getQuery();
299
+ const { rows, total } = await fetchPage({
300
+ page,
301
+ pageSize,
302
+ filter,
303
+ columnFilters,
304
+ sorts,
305
+ });
306
+ remote.setRows(rows);
307
+ remote.setTotalRows(total);
308
+ });
309
+ }
310
+ ```
311
+
312
+ Footer `aggregate` values in this mode are computed over the **provided page rows** (not the full remote set). The pager footer uses `totalRows`. For hybrid local paging with remote filter or sort only, set `manualFiltering` / `manualSorting` without `manualPagination`.
313
+
314
+ ## Styling tip
315
+
316
+ Class names are passed through to the React client. Use Tailwind utilities that exist in the client build (standard ShadCN/Tailwind set in `packages/client`).