@close-by/clay 0.2.16 → 0.2.18

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/README.md CHANGED
@@ -15,6 +15,7 @@ For coding agents: the repo-root [llms.txt](../llms.txt) is a short map of these
15
15
  | [Tailwind](./tailwind.md) | Zero-config utilities from page modules; optional tokens |
16
16
  | [Sample app](./tutorial.md) | Step-by-step tasks list, then two pages + shell |
17
17
  | [Concepts](./concepts.md) | Mental model: sessions, elements, patches; **canonical `state`/`auto`/`timer` recipe**; reactive `let` alternative |
18
+ | [Updating in place](./updating-in-place.md) | Patch instead of rebuild: `setText`, keyed `setRows`, keyed `refreshable` / `auto` lists (no flicker) |
18
19
  | [Browser APIs](./browser-apis.md) | Prefer `ui.clipboard` / hash / search / `openExternal` — never `window` in page code |
19
20
  | [URL search](./url-search.md) | `ui.urlState` + query helpers for shareable filters |
20
21
  | [Production & security](./production.md) | Auth, server exposure, rate limits, boot parity |
package/docs/api.md CHANGED
@@ -1801,6 +1801,9 @@ onClick: async () => {
1801
1801
  | `ui.openExternal(url)` | `void` | `window.open` in a new tab (`noopener`) |
1802
1802
  | `ui.theme.set(mode)` | `void` | Push `light` \| `dark` \| `system` to this client |
1803
1803
  | `ui.theme.get()` | `ThemeMode \| null` | Last value set on this session |
1804
+ | `ui.theme.palette(name)` | `void` | Switch colour palette for this client (`kiln`, `cobalt`, `ocean`, `forest`, `orchid`, `citrus`, `graphite`, `neon`, `sky`, `navy`, `indigo`, `denim`, `midnight`, or a custom one); remembered across reloads |
1805
+ | `ui.theme.getPalette()` | `ThemePalette \| null` | Last palette set, else the one the client reported on connect |
1806
+ | `ui.theme.palettes` | `readonly string[]` | Built-in palette names |
1804
1807
  | `ui.runJavaScript(code)` | `void` | Eval trusted snippet in the browser |
1805
1808
  | `ui.scroll.to(opts)` | `void` | Scroll window (`top` / `bottom` / px) |
1806
1809
  | `ui.scroll.intoView(selector, opts?)` | `void` | `Element.scrollIntoView` via selector |
@@ -1911,6 +1914,7 @@ All layout helpers accept either `(fn, props?)` or `(props, fn)`.
1911
1914
  | `ui.row` | Horizontal flex |
1912
1915
  | `ui.column` | Vertical flex |
1913
1916
  | `ui.container` | Width-constrained wrapper |
1917
+ | `ui.grid` | Responsive grid that fills the width (auto-fit columns, or fixed `cols`) |
1914
1918
  | `ui.hero` | Centered hero region |
1915
1919
  | `ui.card` | Card; callback receives the card element |
1916
1920
  | `ui.app` | SPA shell: sidebar nav + centered main content |
@@ -1965,8 +1969,44 @@ Shared layout props:
1965
1969
  | `className` | `string` | | Extra classes |
1966
1970
  | `centered` | `boolean` | | `mx-auto` (container) |
1967
1971
  | `width` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl' \| '4xl' \| '5xl' \| '6xl' \| '7xl' \| 'full'` | `'lg'` for container | Max width |
1972
+ | `align` | `'start' \| 'center' \| 'end' \| 'baseline' \| 'stretch'` | row: `center` (`stretch` when the row grows) | Cross-axis alignment |
1973
+ | `justify` | `'start' \| 'center' \| 'end' \| 'between' \| 'around'` | | Main-axis distribution |
1974
+ | `wrap` | `boolean` | row: `true` | Flex wrap |
1975
+ | `grow` | `boolean \| number` | | Take the remaining space in the parent row/column. A number sets the share (`grow: 2` gets twice `grow: 1`) |
1976
+ | `scroll` | `boolean \| 'x' \| 'y'` | | Scroll inside this element instead of growing the page. On a card, the body scrolls and the header stays |
1977
+ | `fillViewport` | `boolean` | | Height = visible page area (viewport minus the app shell header/padding). Use on the outermost page column |
1968
1978
 
1969
- Card also accepts `title?: string`.
1979
+ Card also accepts `title?: string` and `description?: string`; `grow` / `scroll` / `fillViewport` work on cards too.
1980
+
1981
+ #### `ui.grid(props, fn)`
1982
+
1983
+ | Prop | Type | Default | Description |
1984
+ |------|------|---------|-------------|
1985
+ | `minColWidth` | `string \| number` | `'16rem'` | Auto-fit as many columns as fit, each at least this wide (numbers are px). Wraps to one column on phones |
1986
+ | `cols` | `number` | | Fixed column count instead of auto-fit (one column below `sm`) |
1987
+ | `gap` | `number` | `4` | Gap scale |
1988
+
1989
+ ```typescript
1990
+ ui.grid({ minColWidth: '14rem' }, () => {
1991
+ for (const kpi of kpis) ui.card({ title: kpi.label }, () => ui.label(kpi.value));
1992
+ });
1993
+ ```
1994
+
1995
+ #### Filling the page
1996
+
1997
+ Make a page fill the window and let panels scroll instead of the page:
1998
+
1999
+ ```typescript
2000
+ ui.column({ fillViewport: true, gap: 4 }, () => {
2001
+ ui.grid({ minColWidth: '12rem' }, () => { /* KPI cards — natural height */ });
2002
+ ui.row({ grow: true, wrap: false, gap: 4 }, () => { // takes the remaining height
2003
+ ui.card({ title: 'Inbox', scroll: true, className: 'w-80 shrink-0' }, () => { /* list */ });
2004
+ ui.card({ title: 'Detail', grow: true, scroll: true }, () => { /* body */ });
2005
+ });
2006
+ });
2007
+ ```
2008
+
2009
+ Without `fillViewport`, `grow` still stretches to fill free space (the app shell content area is a flex column), but the page scrolls when content is taller than the window.
1970
2010
 
1971
2011
  ```typescript
1972
2012
  ui.container(() => {
package/docs/elements.md CHANGED
@@ -211,4 +211,6 @@ ui.label('Hello').classes('text-3xl font-bold text-muted-foreground');
211
211
 
212
212
  Layout `gap` uses a small numeric scale mapped to `gap-0` … `gap-8` in the client.
213
213
 
214
- 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' })`.
214
+ The client ships the "kiln" theme — warm neutrals, a fired-clay primary, vivid chart colours, light + dark (ShadCN-style CSS variables in `packages/client/src/index.css`, Plus Jakarta Sans bundled). Override tokens at runtime with `ui.run({ css: './globals.css' })`; Clay extras include `--clay-brand-from` / `--clay-brand-to` and the `clay-brand-gradient`, `clay-brand-text`, `clay-ambient` classes.
215
+
216
+ Twelve more palettes ship alongside kiln — `cobalt`, `ocean`, `forest`, `orchid`, `citrus`, `graphite`, `neon`, plus the blues `sky`, `navy`, `indigo`, `denim`, `midnight` (each light + dark, in `packages/client/src/palettes.css`). Pick the app default with `ui.run({ palette: 'ocean' })` or switch per session with `ui.theme.palette('neon')`. Custom palettes: define `:root[data-palette="mine"] { … }` and `:root.dark[data-palette="mine"] { … }` in your `css` file, then `ui.theme.palette('mine')`.
package/docs/examples.md CHANGED
@@ -5,11 +5,15 @@ Pages: [`apps/demo/src/examples/`](../apps/demo/src/examples/) (auto-loaded via
5
5
 
6
6
  After `bun run build:client && bun run demo` (or `bun run demo:cli`), open http://localhost:4000.
7
7
 
8
+ ## Sidebar groups
9
+
10
+ Each page sets `group` / `groupIcon` in its `pageMeta`, so the sidebar and the home page's catalog share one structure: **Start here** (themes, fill layout, counter, todo, kitchen sink) → **Reactivity** → **Data** → **Forms & controls** → **Overlays & layout** → **Apps** → **Account** → **ReUI batches**. Order within a group is `pageMeta.order`.
11
+
8
12
  ## Catalog
9
13
 
10
14
  | Route | File | Patterns shown |
11
15
  |-------|------|----------------|
12
- | `/` | `Home.ts` | `ui.hero`, `ui.icon`, `ui.link`, `ui.navigate`; catalog via `ui.navFromPages()` (same as sidebar) |
16
+ | `/` | `Home.ts` | Landing page: hero, live counter + source side by side (`ui.codeBlock`), feature grid, catalog grouped via `ui.navFromPages()` (same as sidebar) |
13
17
  | `/examples/counter` | `Counter.ts` | `setText`, `refreshable`, button variants |
14
18
  | `/examples/todo` | `Todo.ts` | `reactive`, `bindValue`, list `refreshable`, filters |
15
19
  | `/examples/chat` | `Chat.ts` | `ui.storage.app` (persisted messages + ephemeral presence), async `get`/`set` |
@@ -23,6 +27,8 @@ After `bun run build:client && bun run demo` (or `bun run demo:cli`), open http:
23
27
  | `/examples/reui-batch-7` | `ReuiBatch7Demo.ts` | ReUI batch 7: `inputGroup`, `toggle`, `descriptionList`, `staticTable`, `aspectRatio`, `itemList`, `checkboxGroup` |
24
28
  | `/examples/charts` | `ChartDemo.ts` | `ui.chart.*`, `ui.sparkline`, composed/scatter charts |
25
29
  | `/examples/slider-demo` | `SliderDemo.ts` | Slider, checkbox, select + bindings |
30
+ | `/examples/themes` | `ThemesDemo.ts` | Palette gallery: `ui.theme.palette`, light/dark, component preview |
31
+ | `/examples/layout` | `LayoutDemo.ts` | Fill layout: `fillViewport`, `grow`, `scroll`, `ui.grid` |
26
32
  | `/examples/feedback` | `FeedbackDemo.ts` | Alerts, progress, timer, `ui.theme`, `ui.runJavaScript` / `ui.scroll` |
27
33
  | `/examples/form-demo` | `FormDemo.ts` | Form + validate + `ui.draft` + rating / colorPicker / tags |
28
34
  | `/examples/auth` | `Auth.ts` (+ login/admin/change-password) | Signed cookie session, hashed passwords, login lockout, role-gated admin + audit log |
@@ -220,6 +220,28 @@ Still redact before display when possible. Prefer VPN + auth for hubs that surfa
220
220
  Nav filtering is UX only — always enforce roles in the page builder.
221
221
 
222
222
  ```ts
223
+ import { ui } from '@close-by/clay';
224
+ import { getCurrentSession } from '@close-by/clay-core';
225
+ import { resolveUserIdFromAuthCookie } from '@close-by/clay-server';
226
+ import { createAuthGuards } from '@close-by/clay-auth';
227
+
228
+ type User = { role: 'admin' | 'member' };
229
+ const userById = new Map<string, User>(); // Your app's user store, keyed by user ID.
230
+ const authSecret = process.env.CLAY_AUTH_SECRET!;
231
+ const anonymousUserId = '__clay_anonymous__';
232
+ const resolveSignedUserId = resolveUserIdFromAuthCookie(authSecret);
233
+
234
+ function getSessionUser(): User | null {
235
+ const userId = getCurrentSession()?.userId;
236
+ if (!userId || userId === anonymousUserId) return null;
237
+ return userById.get(userId) ?? null;
238
+ }
239
+
240
+ const { requireRole } = createAuthGuards({
241
+ getUser: getSessionUser,
242
+ onUnauthenticated: () => ui.navigate('/login'),
243
+ });
244
+
223
245
  // pageMeta
224
246
  export const pageMeta = {
225
247
  label: 'Admin',
@@ -230,20 +252,26 @@ export const pageMeta = {
230
252
 
231
253
  // shell
232
254
  ui.run({
233
- authSecret: process.env.CLAY_AUTH_SECRET!,
255
+ authSecret,
256
+ resolveUserId: (context) => resolveSignedUserId(context) ?? anonymousUserId,
234
257
  app: {
235
258
  title: 'Hub',
236
- nav: () => ui.navFromPages({ role: currentUser.role }),
259
+ get nav() {
260
+ const user = getSessionUser();
261
+ return ui.navFromPages(user ? { role: user.role } : {});
262
+ },
237
263
  },
238
264
  });
239
265
 
240
266
  // page
241
267
  ui.page('/admin', () => {
242
- requireRole('admin'); // @close-by/clay-auth
268
+ if (!requireRole('admin')) return;
243
269
  // …
244
270
  });
245
271
  ```
246
272
 
273
+ `get nav()` is evaluated while each page shell is built, so it filters routes for the current session. Reserve `__clay_anonymous__` outside `userById`: the resolver substitutes it for missing or invalid cookies, and `getSessionUser` treats it as signed out. Replace `userById` with your app's user store.
274
+
247
275
  See `/examples/auth` and [API — auth](./api.md).
248
276
 
249
277
  ## `ui.ai.chat` and state
@@ -18,22 +18,47 @@ Clay ships hooks for real identity beyond anonymous `userId`:
18
18
 
19
19
  ```ts
20
20
  import { ui } from '@close-by/clay';
21
- import { requireRole } from '@close-by/clay-auth';
21
+ import { getCurrentSession } from '@close-by/clay-core';
22
+ import { resolveUserIdFromAuthCookie } from '@close-by/clay-server';
23
+ import { createAuthGuards } from '@close-by/clay-auth';
24
+
25
+ type User = { role: 'admin' | 'member' };
26
+ const userById = new Map<string, User>(); // Your app's user store, keyed by user ID.
27
+ const authSecret = process.env.CLAY_AUTH_SECRET!;
28
+ const anonymousUserId = '__clay_anonymous__';
29
+ const resolveSignedUserId = resolveUserIdFromAuthCookie(authSecret);
30
+
31
+ function getSessionUser(): User | null {
32
+ const userId = getCurrentSession()?.userId;
33
+ if (!userId || userId === anonymousUserId) return null;
34
+ return userById.get(userId) ?? null;
35
+ }
36
+
37
+ const { requireRole } = createAuthGuards({
38
+ getUser: getSessionUser,
39
+ onUnauthenticated: () => ui.navigate('/login'),
40
+ });
22
41
 
23
42
  ui.run({
24
- authSecret: process.env.CLAY_AUTH_SECRET!,
43
+ authSecret,
44
+ resolveUserId: (context) => resolveSignedUserId(context) ?? anonymousUserId,
25
45
  app: {
26
46
  title: 'Hub',
27
- nav: () => ui.navFromPages({ role: currentUser.role }),
47
+ get nav() {
48
+ const user = getSessionUser();
49
+ return ui.navFromPages(user ? { role: user.role } : {});
50
+ },
28
51
  },
29
52
  });
30
53
 
31
54
  ui.page('/admin', () => {
32
- requireRole('admin');
55
+ if (!requireRole('admin')) return;
33
56
  // …
34
57
  });
35
58
  ```
36
59
 
60
+ `get nav()` is evaluated while each page shell is built, so it filters routes for the current session. Reserve `__clay_anonymous__` outside `userById`: the resolver substitutes it for missing or invalid cookies, and `getSessionUser` treats it as signed out. Replace `userById` with your app's user store.
61
+
37
62
  - `pageMeta.roles` — hide nav items via `ui.navFromPages({ role })` (UX only)
38
63
  - `requireRole` / guards in the page builder — **enforce** access before building sensitive UI
39
64
  - See [API — auth](./api.md), `/examples/auth`, and [Ops patterns — auth nav](./ops-patterns.md#auth--role-filtered-nav)
package/docs/protocol.md CHANGED
@@ -19,7 +19,7 @@ On unexpected socket close, the client reconnects with exponential backoff (500m
19
19
  { "op": "hello", "path": "/examples/counter", "userId": "optional-stable-id", "hash": "optional-hash-without-hash-sign", "browserStorage": {}, "clientStorage": {}, "tabStorage": {} }
20
20
  ```
21
21
 
22
- Client connects (or sends another `hello` after navigate on the same socket) and identifies the page. Optional `userId` (from localStorage/cookie) enables `ui.storage.user` unless overridden by `resolveUserId`. Optional `hash` (no leading `#`) hydrates `ui.getUrlHash()`. Optional `search` (no leading `?`) hydrates `ui.getUrlSearch()`. Optional `browserStorage` / `clientStorage` / `tabStorage` bags hydrate `ui.storage.browser` / `ui.storage.client` / `ui.storage.tab`.
22
+ Client connects (or sends another `hello` after navigate on the same socket) and identifies the page. Optional `userId` (from localStorage/cookie) enables `ui.storage.user` unless overridden by `resolveUserId`. Optional `hash` (no leading `#`) hydrates `ui.getUrlHash()`. Optional `search` (no leading `?`) hydrates `ui.getUrlSearch()`. Optional `browserStorage` / `clientStorage` / `tabStorage` bags hydrate `ui.storage.browser` / `ui.storage.client` / `ui.storage.tab`. Optional `palette` reports the palette the page shows, so `ui.theme.getPalette()` is correct after a reload.
23
23
 
24
24
  Sent on connect and after client-side navigation. The React client keeps matching `app` shell chrome mounted across remounts; only inset content is replaced.
25
25
 
@@ -199,6 +199,18 @@ Client runs `window.open(url, '_blank', 'noopener,noreferrer')`. Prefer `ui.open
199
199
 
200
200
  Sent by `ui.theme.set(...)`.
201
201
 
202
+ ### `palette`
203
+
204
+ ```json
205
+ { "op": "palette", "palette": "ocean" }
206
+ ```
207
+
208
+ | Field | Type | Notes |
209
+ |-------|------|-------|
210
+ | `palette` | `string` | Sets `<html data-palette>` (`kiln` / empty clears to default); persisted under `clay-palette` and restored before first paint |
211
+
212
+ Sent by `ui.theme.palette(...)`.
213
+
202
214
  ### `runJavaScript`
203
215
 
204
216
  ```json
@@ -0,0 +1,124 @@
1
+ # Updating in place (no flicker)
2
+
3
+ Clay sends every change over one WebSocket as patches. How your server code changes the element tree decides which patches go out, and therefore what the user sees.
4
+
5
+ New data should patch what is on screen, not rebuild it.
6
+
7
+ ## The rule
8
+
9
+ There are two ways to show a new value:
10
+
11
+ - **Rebuild.** You create new `ui.*` elements for the section (a new table, a new row of cards, a new list). Each new element gets a fresh id. The client receives a `setChildren` with those new ids and mounts new DOM. The old DOM is removed. Whatever lived in it is gone: scroll position, focus, what the user typed in an input, the selected row, and any enter animation runs again. The user sees a blank or jumping section.
12
+ - **Patch.** You keep the element you already have and change one prop on it, for example `label.setText(...)`. The client receives a single `updateProps` for that id, such as `{"op":"updateProps","id":"…","props":{"text":"42"}}`. The DOM node stays the same, so nothing else on the screen moves.
13
+
14
+ The flicker is caused by the remount, not by the new value. If a frame carries only the changed prop, the browser has nothing to redraw except that text.
15
+
16
+ ## Pick the right tool
17
+
18
+ | Situation | API | What goes out |
19
+ |-----------|-----|---------------|
20
+ | Change one value (a label, badge, or input) | `label.setText(…)`, `el.setValue(…)`, `el.update({ … })` | `updateProps` for that element |
21
+ | Live table data | `table.setRows(rows)` on a table created with a stable `keyField` (add `highlightChanges: true` to flash changed rows) | `updateProps` on the table; rows keyed by `keyField` keep their DOM |
22
+ | Table refetch | `table.setLoading(true)` / `table.withLoading(fn)` | `updateProps` (`loading`); previous rows stay, dimmed, instead of blanking |
23
+ | A section whose structure depends on data | `ui.refreshable(() => …)` + `.refresh()`, or `ui.auto(() => …)` | Reconciled: matched children get `updateProps` only; added, removed, or reordered children cause one `setChildren` per affected parent |
24
+ | Items in a list inside either of the above | `.key(item.id)` on each item | Items keep their identity through reorder, filter, and prepend |
25
+
26
+ Use the first row whenever the shape of the screen does not change. Reach for `refreshable` or `auto` only when children really are added, removed, or reordered.
27
+
28
+ ## How reconciliation matches
29
+
30
+ When a `refreshable` or `auto` block re-runs, Clay builds a fresh tree and matches it against the children already on screen:
31
+
32
+ 1. **Keyed first.** A new child with `.key(k)` matches the old child with the same key and the same element type. Keyed children are matched only by key.
33
+ 2. **Unkeyed by position.** Unkeyed children are matched in order, by element type. If the next old child matches the current new one, the current old child was removed and is skipped. If the current old child matches the next new one, the new child was inserted. Otherwise the pair is treated as a replacement at that position. This lookahead covers a single insert or remove; larger reshuffles should be keyed.
34
+ 3. **Matched pairs keep the old element and its id.** The fresh element's handlers and props are copied onto the old one. Only props that actually changed are sent, as one `updateProps`.
35
+ 4. **Leftovers.** Old children with no match are destroyed. New children with no match are mounted. The parent sends one `setChildren` only if its list of ids changed. A child whose type changed at a position is replaced; its siblings keep their ids.
36
+ 5. **Nested blocks keep their instance.** A nested `refreshable` or `auto` keeps its id and its subscriptions and adopts the new builder, so its own refreshes keep working.
37
+
38
+ ## Examples
39
+
40
+ ### Live orders list (`ui.auto` with keys)
41
+
42
+ ```ts
43
+ import { ui } from '@close-by/clay';
44
+
45
+ type Order = { id: string; customer: string; status: string; total: number };
46
+ const orders = ui.state({ list: [] as Order[], filter: '' });
47
+
48
+ ui.auto(() => {
49
+ const shown = orders.list.filter((o) => o.status.includes(orders.filter));
50
+ for (const o of shown) {
51
+ ui.row(() => {
52
+ ui.label(o.customer);
53
+ ui.badge(o.status, { size: 'xs' });
54
+ ui.label(`$${o.total}`);
55
+ }).key(o.id);
56
+ }
57
+ });
58
+
59
+ // From your feed handler. Replacing the array re-runs the block once.
60
+ // Each order keeps its element because its key is unchanged, so a
61
+ // status change is an updateProps on that row's badge.
62
+ orders.list = nextSnapshot;
63
+ ```
64
+
65
+ Keys must be stable. Use the order id, not the array index or a timestamp.
66
+
67
+ ### Dashboard KPI row (`setText`)
68
+
69
+ ```ts
70
+ let revenue: ReturnType<typeof ui.label>;
71
+ let openCount: ReturnType<typeof ui.label>;
72
+
73
+ ui.row(() => {
74
+ revenue = ui.label('$0').classes('text-2xl', 'font-semibold');
75
+ openCount = ui.label('0').classes('text-2xl', 'font-semibold');
76
+ });
77
+
78
+ // Called whenever new metrics arrive. Two updateProps frames, no rebuild.
79
+ function onMetrics(m: { revenue: number; open: number }) {
80
+ revenue.setText(`$${m.revenue.toLocaleString()}`);
81
+ openCount.setText(String(m.open));
82
+ }
83
+ ```
84
+
85
+ ### Live table (`setRows` + `highlightChanges` + `setLoading`)
86
+
87
+ ```ts
88
+ const table = ui.dataTable([], {
89
+ keyField: 'id',
90
+ highlightChanges: true,
91
+ searchable: true,
92
+ });
93
+
94
+ // fetchOrders() is your own data call.
95
+ async function refresh() {
96
+ await table.withLoading(async () => {
97
+ table.setRows(await fetchOrders());
98
+ });
99
+ }
100
+ ```
101
+
102
+ `withLoading` sets loading before `fn` runs and clears it afterwards, so the previous rows stay on screen under a dimmed body until `setRows` runs. Rows whose `id` did not change keep their DOM, and changed or new rows flash.
103
+
104
+ If you want columns to stay fixed across refreshes, pass `columns` explicitly. Otherwise columns are inferred again on each `setRows`.
105
+
106
+ ## Anti-patterns
107
+
108
+ - **Rebuilding a whole section when one value changes.** Wrapping a whole page in `ui.refreshable` and calling `.refresh()` every second to update a clock. Keep a reference to the clock label and call `setText`.
109
+ - **Unkeyed lists that get sorted or filtered.** Without `.key()`, position is the identity. After a sort, the element in slot 3 is reused for whatever item is now third, so focus and typed text stay in the slot instead of following the row.
110
+ - **Creating a new table instead of `setRows`.** A `ui.dataTable(...)` created inside a refreshable on every tick remounts the table and loses sort, scroll, selection, and search. Create the table once and call `setRows`.
111
+ - **Tables without `keyField`.** Rows are stamped `__rowId` by their index, so inserting or re-sorting the source shifts every row's identity. Set `keyField` to your primary key.
112
+ - **Toggling a constructor option by rebuilding.** Recreating a table with `zebra: on` inside an `auto` block. Use the setters instead: `setZebra`, `setDensity`, `setHighlightChanges`.
113
+
114
+ ## Checking it works
115
+
116
+ 1. **Devtools, DOM node.** Open the browser Elements panel and select a row, label, or input. Trigger an update (wait for the next feed tick or change the filter). The same node should still be selected and highlighted. For an input, click into it, let an update arrive, and confirm the caret stays. If the node is replaced, the block was rebuilt.
117
+ 2. **Devtools, WebSocket frames.** Open the Network panel, filter to WS, select the connection, and watch the frames as data arrives. A value change should show `updateProps` with a small payload for one id. `setChildren` should appear only when rows are added, removed, or reordered. If an unchanged list sends `setChildren` on every tick, the child ids are changing: check that `.key()` values are stable and that the block is not creating new parent elements on each run.
118
+
119
+ ## See also
120
+
121
+ - [Concepts: Updates](./concepts.md#updates-settext-vs-refreshable) for the basic `setText` vs `refreshable` split
122
+ - [API reference](./api.md) for `ui.refreshable`, `ui.auto`, `ui.dataTable`, and `Element` methods
123
+ - [Reactive `let`](./reactive-let.md) for the compile-time alternative to hand-written `ui.auto`
124
+ - [WebSocket protocol](./protocol.md) for the patch message formats
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@close-by/clay",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "NiceGUI-like ui.* facade for Clay apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@close-by/clay-compiler": "0.2.16",
41
- "@close-by/clay-core": "0.2.16",
42
- "@close-by/clay-components": "0.2.16",
43
- "@close-by/clay-server": "0.2.16",
40
+ "@close-by/clay-compiler": "0.2.18",
41
+ "@close-by/clay-core": "0.2.18",
42
+ "@close-by/clay-components": "0.2.18",
43
+ "@close-by/clay-server": "0.2.18",
44
44
  "@tailwindcss/cli": "^4.3.3",
45
45
  "tailwindcss": "^4.3.3"
46
46
  }
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  column as columnFactory,
67
67
  container as containerFactory,
68
68
  hero as heroFactory,
69
+ grid as gridFactory,
69
70
  card as cardFactory,
70
71
  dialog as dialogFactory,
71
72
  dialogStack as dialogStackFactory,
@@ -242,6 +243,8 @@ import {
242
243
  type LineChartStyleProps,
243
244
  type SparklineProps,
244
245
  type CardProps,
246
+ type GridProps,
247
+ type LayoutProps,
245
248
  type DialogProps,
246
249
  type DialogStackProps,
247
250
  type DialogStackStepOptions,
@@ -471,6 +474,7 @@ import {
471
474
  TimerHandle,
472
475
  type TimerOptions,
473
476
  type ThemeMode,
477
+ type ThemePalette,
474
478
  type DraftStorage,
475
479
  type DraftOptions,
476
480
  type UrlStateOptions,
@@ -512,6 +516,8 @@ export {
512
516
 
513
517
 
514
518
  export type {
519
+ GridProps,
520
+ LayoutProps,
515
521
  ButtonProps,
516
522
  IconButtonProps,
517
523
  ButtonVariant,
@@ -698,6 +704,7 @@ export type {
698
704
  NotifyType,
699
705
  ToastPosition,
700
706
  ThemeMode,
707
+ ThemePalette,
701
708
  PageMeta,
702
709
  NavFromPagesOptions,
703
710
  PageOptions,
@@ -1255,33 +1262,43 @@ export function sparkline(props: SparklineProps): Element {
1255
1262
  }
1256
1263
 
1257
1264
  /** Horizontal flex row (`fn` or props-first). Prefer `ui.row`. */
1258
- export function row(fn: () => void, props?: Parameters<typeof rowFactory>[1]): Element;
1259
- export function row(props: Parameters<typeof rowFactory>[0], fn: () => void): Element;
1265
+ export function row(fn: () => void, props?: LayoutProps): Element;
1266
+ export function row(props: LayoutProps, fn: () => void): Element;
1260
1267
  export function row(a: any, b?: any): Element {
1261
1268
  return rowFactory(a, b);
1262
1269
  }
1263
1270
 
1264
1271
  /** Vertical flex column (`fn` or props-first). Prefer `ui.column`. */
1265
- export function column(fn: () => void, props?: Parameters<typeof columnFactory>[1]): Element;
1266
- export function column(props: Parameters<typeof columnFactory>[0], fn: () => void): Element;
1272
+ export function column(fn: () => void, props?: LayoutProps): Element;
1273
+ export function column(props: LayoutProps, fn: () => void): Element;
1267
1274
  export function column(a: any, b?: any): Element {
1268
1275
  return columnFactory(a, b);
1269
1276
  }
1270
1277
 
1271
1278
  /** Layout container (`fn` or props-first). Prefer `ui.container`. */
1272
- export function container(fn: () => void, props?: Parameters<typeof containerFactory>[1]): Element;
1273
- export function container(props: Parameters<typeof containerFactory>[0], fn: () => void): Element;
1279
+ export function container(fn: () => void, props?: LayoutProps): Element;
1280
+ export function container(props: LayoutProps, fn: () => void): Element;
1274
1281
  export function container(a: any, b?: any): Element {
1275
1282
  return containerFactory(a, b);
1276
1283
  }
1277
1284
 
1278
1285
  /** Hero section (`fn` or props-first). Prefer `ui.hero`. */
1279
- export function hero(fn: () => void, props?: Parameters<typeof heroFactory>[1]): Element;
1280
- export function hero(props: Parameters<typeof heroFactory>[0], fn: () => void): Element;
1286
+ export function hero(fn: () => void, props?: LayoutProps): Element;
1287
+ export function hero(props: LayoutProps, fn: () => void): Element;
1281
1288
  export function hero(a: any, b?: any): Element {
1282
1289
  return heroFactory(a, b);
1283
1290
  }
1284
1291
 
1292
+ /**
1293
+ * Responsive grid that fills the width (`fn` or props-first). Prefer `ui.grid`.
1294
+ * `minColWidth` auto-fits columns (default 16rem); `cols` fixes the count. See {@link GridProps}.
1295
+ */
1296
+ export function grid(fn: () => void, props?: GridProps): Element;
1297
+ export function grid(props: GridProps, fn: () => void): Element;
1298
+ export function grid(a: any, b?: any): Element {
1299
+ return gridFactory(a, b);
1300
+ }
1301
+
1285
1302
  /** Card with optional header/footer (`fn` or props-first). Prefer `ui.card`. See {@link CardProps}. */
1286
1303
  export function card(fn: (card: Element) => void, props?: CardProps): Element;
1287
1304
  export function card(props: CardProps, fn: (card: Element) => void): Element;
@@ -1925,6 +1942,7 @@ export const ui = {
1925
1942
  row,
1926
1943
  column,
1927
1944
  container,
1945
+ grid,
1928
1946
  hero,
1929
1947
  card,
1930
1948
  app,