@close-by/clay 0.2.6 → 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.
@@ -0,0 +1,217 @@
1
+ # Compile-time reactive `let`
2
+
3
+ Status: **Phase 1 shipped** (`ui.state` + `ui.auto`). **Phase 2 shipped** (`@close-by/clay-compiler` transform + optional `clay --reactive-let`; implicit regions, dep-isolated `auto`, compile-time `bindText` for labels, rest/nested destructuring, loop-scoped keyed state, shadow renames).
4
+
5
+ **Recommended for new demos and toy pages:** write plain `let` with `// @clay-reactive` and `clay --reactive-let` — see [Happy path](#happy-path-let--clay-reactive) below.
6
+
7
+ **Production / dense apps:** Prefer Phase 1 (`ui.state` / `ui.auto`) when you need explicit control, async load patterns, or want zero compiler surface. Phase 2 remains **opt-in** (CLI plugin off unless `--reactive-let`; transform only with a pragma or `"use reactive";`). Silent page-wide rewrites caused blank mounts and CJS interop pain in production hubs — see [Known failure modes](#known-failure-modes).
8
+
9
+ ## Goal
10
+
11
+ NiceGUI-style automatic UI updates when local state changes, without manual `setText` / `refresh` on every write.
12
+
13
+ ## Phase 1 (shipped)
14
+
15
+ Runtime helpers — no compiler:
16
+
17
+ ```ts
18
+ const s = ui.state({ count: 0 });
19
+ ui.auto(() => {
20
+ ui.button('Inc', { onClick: () => { s.count++; } });
21
+ ui.label(`Count: ${s.count}`); // patches text when s.count changes (stable tree)
22
+ });
23
+
24
+ // Or bind a single label without wrapping `auto`:
25
+ ui.label(() => `Count: ${s.count}`);
26
+ ```
27
+
28
+ | API | Behavior |
29
+ |-----|----------|
30
+ | `ui.state(initial)` | Alias for `reactive()` — proxy that notifies on property writes |
31
+ | `ui.auto(fn)` | `RefreshableElement` that tracks `state`/`reactive` **reads** during `fn`, then rebuilds on those writes. When the child tree **shape** (types + counts) is unchanged and contains no nested `refreshable`/`auto`, updates go via `updateProps` (`setText`/`setValue`-style) instead of `setChildren` remount |
32
+ | `ui.label(() => string)` | Creates a label and `.bindText(compute)` — re-runs compute when tracked reads change |
33
+ | `Element.bindText(fn)` | Same tracking as `auto`, but only patches `text` |
34
+ | `reactive` / `subscribe` / `bindValue` / `bindTextFrom` | Unchanged |
35
+
36
+ **Rules**
37
+
38
+ - Keep mutable state **outside** `ui.auto` (or it resets each rebuild).
39
+ - Only properties **read** during the last builder run are tracked.
40
+ - Prefer `ui.label(() => …)` / `bindText` / `bindTextFrom` / `bindValue` when a full block rebuild is unnecessary.
41
+ - Structural changes inside `auto` (added/removed children, type changes, nested `auto`) still remount via `setChildren`.
42
+
43
+ Tests: `packages/core/src/auto.test.ts`, `packages/core/src/element.test.ts`.
44
+
45
+ ## Happy path: `let` + `@clay-reactive`
46
+
47
+ For counter-style pages and demos, you can skip manual `ui.auto` / `label.setText`:
48
+
49
+ ```ts
50
+ // counter-let.ts — run: clay counter-let.ts --reactive-let
51
+ // @clay-reactive
52
+ import { ui } from '@close-by/clay';
53
+
54
+ export default function () {
55
+ let count = 0;
56
+ ui.label(`Count: ${count}`);
57
+ ui.row({ gap: 2 }, () => {
58
+ ui.button('-', { onClick: () => { count--; } });
59
+ ui.button('+', { onClick: () => { count++; } });
60
+ });
61
+ }
62
+ ```
63
+
64
+ Clay lifts `count` into `ui.state`, turns the label into `bindText`, and leaves buttons as handler-only writes. No `refresh()` calls.
65
+
66
+ | When to use `let` + pragma | When to stay on Phase 1 |
67
+ |-----------------------------|-------------------------|
68
+ | Counters, toggles, filters on demo pages | Async fetch before first paint, heavy CJS imports |
69
+ | Orders-shaped proof pages (`ReactiveLetOrdersLet.ts`) | Per-row mutable state without stable row keys |
70
+ | Teaching / prototyping NiceGUI-style UX | You want zero Bun loader / compiler in the path |
71
+
72
+ Phase 1 remains the **escape hatch** — copy patterns from [`ReactiveLetOrders.ts`](../apps/demo/src/examples/ReactiveLetOrders.ts) when Phase 2 limits bite.
73
+
74
+ ## Phase 2 (shipped, opt-in)
75
+
76
+ Package `@close-by/clay-compiler` rewrites a **documented subset** of `let` into Phase 1 APIs.
77
+
78
+ ### Opt-in (required)
79
+
80
+ Both of:
81
+
82
+ 1. **CLI / loader:** pass `--reactive-let` to `clay` (or call `registerReactiveLetPlugin()` yourself). Off by default.
83
+ 2. **Source:** one of:
84
+ - File pragma: `// @clay-reactive` (near the top of the file) — applies to **`ui.page` / `page` callbacks only**
85
+ - Block directive as the first statement: `"use reactive";` — required for nested helpers / `ui.column(() => …)` lets
86
+
87
+ `ui.page(...)` alone does **not** opt in. Module-level helpers (`function mergeUnits() { … }`) and nested `function` / arrow bodies do **not** inherit the file pragma — use `"use reactive";` inside the page callback or a nested site when needed.
88
+
89
+ ### Who wraps `ui.auto`?
90
+
91
+ Compile-time region analysis **does not** see into nested function bodies. Authors must still wrap rebuild regions when UI is built from helpers or widget callbacks:
92
+
93
+ | Pattern | Who wraps `ui.auto`? |
94
+ |---------|----------------------|
95
+ | Inline UI in the reactive site body that reads lets | Transform (or `bindText` on labels) |
96
+ | Named `renderX()` builders | **Author** — `ui.auto(() => { renderFeed(); })` |
97
+ | UI inside widget callbacks (`row` / `column` / `dialog` / …) that need rebuild | **Author**, **or** read lets at the site top level so the transform can see them |
98
+
99
+ Bare top-level `renderFeed()` calls that read lifted lets are **auto-wrapped** in `ui.auto(() => { renderFeed(); })` by default (`autoWrapBuilders: true`). The same applies to bare builder calls **inside Clay widget callbacks** (`ui.row`, `ui.column`, `ui.resizable` / `r.panel`, …) — including **composer** helpers that only call other state-reading builders, and calls **with arguments** (`renderFeed('cls')`). Event handlers (`onClick`, `onChange`, …) are excluded. Pass `{ autoWrapBuilders: false }` to the transform to **warn** instead.
100
+
101
+ Inline UI in widget callbacks (`ui.label(expr)`, `ui.icon(…, { className: expr })`, …) gets **bindText** or nested **`ui.auto`** via region analysis — no manual wrap when the transform can see the reads.
102
+
103
+ ### Decision table (complex pages)
104
+
105
+ | You wrote… | Clay does… | You must… |
106
+ |------------|------------|-----------|
107
+ | `let x = Date.now()` / `defaultRangeFrom()` / `new Map()` | Lifts into `ui.state({ x: … })` one-shot | Nothing |
108
+ | `renderList()` at page top level (bare call) | Wraps in `ui.auto` | Nothing |
109
+ | `renderList()` inside `ui.row(() => …)` (bare call) | Wraps in `ui.auto` inside the widget callback | Nothing |
110
+ | Inline `ui.label` / `ui.icon` reads in widget callback | `bindText` or nested `ui.auto` | Nothing (when reads are visible to the transform) |
111
+ | `units.filter(u => { … })` reading lifted state | Plain JS (no `ui.auto` inside filter) | Nothing |
112
+ | `onClick` / `onChange` / `ui.timer` handler | Not a build region | Normal JS |
113
+ | `const live = ui.state({…})` (no pragma) | Unchanged | Explicit `ui.auto` (Phase 1) |
114
+ | Local `const detail = …` shadows lifted `let detail` | Renames in emit + compile warning | Prefer distinct names |
115
+
116
+ ### What transforms
117
+
118
+ Simple `let` / `const` declarations (identifier or object/array destructuring, including **nested** and **rest** patterns) with a simple initializer **anywhere** in an eligible function body, including nested blocks (`if` / bare blocks / `try` / `switch`) and **inside loops** (loop-scoped bindings use keyed `ui.state` maps), but **not** inside nested function scopes (nested functions are separate sites only with their own `"use reactive";`):
119
+
120
+ ```ts
121
+ // @clay-reactive
122
+ import { ui } from '@close-by/clay';
123
+
124
+ ui.page('/', () => {
125
+ ui.label('hi');
126
+ let count = 0;
127
+ ui.label(`Count: ${count}`);
128
+ ui.button('+', { onClick: () => { count++; } });
129
+ });
130
+ ```
131
+
132
+ Becomes roughly:
133
+
134
+ ```ts
135
+ ui.page('/', () => {
136
+ const __clay_s0 = ui.state({ count: 0 });
137
+ ui.label('hi'); // inert — no build-time read of count
138
+ ui.label(() => `Count: ${__clay_s0.count}`); // compile-time bindText
139
+ ui.button('+', { onClick: () => { __clay_s0.count++; } }); // handler-only write
140
+ });
141
+ ```
142
+
143
+ **Implicit regions**
144
+
145
+ | Pattern | Result |
146
+ |---------|--------|
147
+ | `ui.label(expr)` that reads tracked state | `ui.label(() => expr)` — runtime `bindText`, no `auto` |
148
+ | Other build-time reads (e.g. `ui.badge({ text: String(n) })`, `ui.badge(String(n))`, `ui.button(String(n))`, `ui.iconText(String(n), { icon })`) | compile-time bindText when the text expr reads state |
149
+ | Other build-time reads without bindText sugar | `ui.auto` around that statement |
150
+ | Contiguous autos with **disjoint** deps | Separate `auto` regions |
151
+ | Contiguous autos with **overlapping** deps | One shared `auto` |
152
+ | Shell / layout / handler-only writes | Stay outside any `auto` |
153
+ | Nested `ui.column(() => …)` reading outer state | Inner region / bindText inside the callback |
154
+
155
+ **Simple initializers:** literals; `undefined`; unary `±!~`; binary / conditional / template expressions of simples; property / element access of simples; identifiers (outer bindings); shallow array/object literals; **call / `new`** with simple callees and args; nullish coalescing (`??`) for destructuring defaults. **Destructuring:** simple and **nested** patterns (`let { a: { b } }`); **rest** (`let { x, ...rest }`) lifts named fields and keeps `const { ...rest }`. **Loop-scoped** `let` inside `for` / `for-of` bodies becomes keyed state (`__clay_l0_open[key]`) so each iteration keeps its own mutable slot. **Not** transformed: `await`, sibling-let refs (`let a = 1; let b = a + 1` aborts the site), spread args in initializers.
156
+
157
+ **Shadowing:** a later local `const detail = …` that reuses a lifted name is **renamed** in emit by default (`renameShadowedLocals: true`) and emits a compile warning with the generated name. Pass `{ renameShadowedLocals: false }` to warn without renaming.
158
+
159
+ Type positions are never rewritten — a lifted `let loading` may share a name with `type DetailState { loading: boolean }` without breaking emit.
160
+
161
+ ### CLI
162
+
163
+ ```bash
164
+ clay ./pages --app --reload # no reactive-let plugin
165
+ clay hello.ts --reactive-let # enable Bun loader; still need pragma / "use reactive"
166
+ clay hello.ts --no-reactive-let # explicit off (same as default)
167
+ ```
168
+
169
+ Programmatic:
170
+
171
+ ```ts
172
+ import { registerReactiveLetPlugin } from '@close-by/clay-compiler/plugin';
173
+ import { transformReactiveLet } from '@close-by/clay-compiler';
174
+
175
+ registerReactiveLetPlugin();
176
+ ```
177
+
178
+ ### Known failure modes
179
+
180
+ When the transform or Bun loader was on by default (pre–opt-in), dense apps hit:
181
+
182
+ | Symptom | Likely cause |
183
+ |---------|----------------|
184
+ | Blank UI / mount errors | Simple `let`s inside `ui.page` rewritten into a mega-`auto` that fights async load / structural patterns |
185
+ | `@clickhouse/client` (and similar CJS) named-export breakage | Bun `onLoad` plugin in the path; prefer `--no-reactive-let` (default) or Clay’s HTTP helpers until interop is proven. Dev: `[clay-reactive-let]` warns when a rewritten file imports a known-fragile package. |
186
+
187
+ Mitigation today: leave the plugin off; use Phase 1 APIs. If you enable `--reactive-let`, only mark intentional toy/demo files with the pragma.
188
+
189
+ ### Limits
190
+
191
+ - No `var`. Deeply nested patterns with rest at inner levels may abort the site.
192
+ - Sibling initializers abort the site (`let b = a + 1` when both would lift).
193
+ - Duplicate binding names in nested blocks abort the transform for that function.
194
+ - Loop-scoped state keys prefer `String(row.id)` for `for (const row of …)` loops, with loop-index fallback when `.id` is missing.
195
+ - Compile-time bindText covers `ui.label` / `label`, `ui.badge` / `badge` (positional + `{ text }`), `ui.button` / `button`, and `ui.iconText`.
196
+ - Running the demo via plain `bun apps/demo/...` does **not** load the plugin unless you register it; use `clay --reactive-let` or `--preload`.
197
+ - `const` is lifted like `let` (assignments become state writes). Prefer `let` in typed source if you reassign — `const` reassignment is a TS error before the transform runs.
198
+
199
+ **Runtime:** nested `auto` / `refreshable` trees reuse in place when structure matches (`canReuseElementTree` in `@close-by/clay-core`) — inner regions keep their ids when an outer `auto` refreshes without touching inner deps.
200
+
201
+ Tests: `packages/compiler/src/transform.test.ts`, `packages/core/src/auto.test.ts`.
202
+
203
+ ## Proof (Orders-shaped)
204
+
205
+ | Page | Syntax | Run |
206
+ |------|--------|-----|
207
+ | [`ReactiveLetOrders.ts`](../apps/demo/src/examples/ReactiveLetOrders.ts) | Phase 1 `state` / `auto` | `bun run cli` |
208
+ | [`ReactiveLetOrdersLet.ts`](../apps/demo/src/examples/ReactiveLetOrdersLet.ts) | `let` + `// @clay-reactive` | `bun run cli:reactive-let` |
209
+
210
+ ```bash
211
+ cd apps/demo && bun run cli # Orders (state/auto) — always interactive
212
+ cd apps/demo && bun run cli:reactive-let # both pages; let page needs this
213
+ ```
214
+
215
+ Compiler coverage: `packages/compiler/src/orders-proof.test.ts` transforms the let demo and asserts separate list/detail regions (no mega-`auto`).
216
+
217
+ Without `--reactive-let`, `let` assignments are silent no-ops (no error).
@@ -0,0 +1,73 @@
1
+ # Tailwind CSS for Clay apps
2
+
3
+ Clay auto-builds Tailwind utilities from your page modules. Use `.classes(...)` / `className` freely — no app Tailwind config, no separate CSS build step.
4
+
5
+ ## Zero-config (default)
6
+
7
+ **CLI** (`clay ./pages` or `clay ./pages --reload`):
8
+
9
+ - On by default; scans the entry directory
10
+ - With `--reload`, rebuilds utilities on file changes
11
+ - Opt out: `--no-tailwind`
12
+
13
+ **Library mode** (`ui.run`):
14
+
15
+ ```typescript
16
+ ui.run({
17
+ // default when `pages/`, `src/`, or `app/` exists under cwd
18
+ // tailwind: true,
19
+ // or: tailwind: { content: ['./pages'], watch: true },
20
+ });
21
+ ```
22
+
23
+ Generated CSS lands in `.clay/tailwind.css` and is injected via `css`. Add `.clay/` to `.gitignore`.
24
+
25
+ `@close-by/clay` depends on `tailwindcss` and `@tailwindcss/cli` — you do not need to install them yourself.
26
+
27
+ ## What you get
28
+
29
+ | Need | How |
30
+ |------|-----|
31
+ | Utilities in pages (`text-[10px]`, `w-[32rem]`, …) | Automatic scan + build |
32
+ | Theme tokens (`--primary`, `--background`, …) | Optional extra `css` file (merged after utilities) |
33
+ | Disable | `--no-tailwind` / `ui.run({ tailwind: false })` / `CLAY_NO_TAILWIND=1` |
34
+
35
+ Clay’s shipped client already includes a Tailwind + shadcn **baseline**. Auto Tailwind adds **app-scanned** utilities on top (theme + utilities only — not a second full preflight).
36
+
37
+ ## Token overrides
38
+
39
+ ```css
40
+ /* src/tokens.css */
41
+ :root {
42
+ --primary: oklch(0.45 0.12 150);
43
+ }
44
+ ```
45
+
46
+ ```typescript
47
+ ui.run({
48
+ css: './src/tokens.css', // merged with auto Tailwind output
49
+ });
50
+ ```
51
+
52
+ ## Custom scan roots
53
+
54
+ ```typescript
55
+ ui.run({
56
+ tailwind: {
57
+ content: ['./pages', './lib/ui-helpers'],
58
+ watch: true,
59
+ appendCss: '/* optional extra CSS in the Tailwind input */',
60
+ },
61
+ });
62
+ ```
63
+
64
+ ## Manual pipeline (optional)
65
+
66
+ If you prefer your own Tailwind input/`@source` setup, turn auto off and inject a prebuilt file:
67
+
68
+ ```bash
69
+ bunx clay ./pages --no-tailwind
70
+ # and ui.run({ tailwind: false, css: './src/globals.generated.css' })
71
+ ```
72
+
73
+ See [`examples/tailwind-app/`](../examples/tailwind-app/) for a minimal zero-config app.
@@ -0,0 +1,234 @@
1
+ # Sample app: tasks
2
+
3
+ A short walkthrough with the `clay` CLI: a tasks list, then two pages and a sidebar. Needs [Bun](https://bun.sh/) 1.1+.
4
+
5
+ You will use a default export (the CLI registers `/` and starts the server), `bindValue` for the input, and `ui.refreshable` when the list changes.
6
+
7
+ **Alternative:** for a counter-style app without manual `.refresh()`, see [reactive `let`](./reactive-let.md#happy-path-let--clay-reactive) (`// @clay-reactive` + `clay --reactive-let`). This tutorial teaches Phase 1 explicitly so the update model stays visible.
8
+
9
+ ## 1. Create a project
10
+
11
+ ```bash
12
+ mkdir clay-tasks && cd clay-tasks
13
+ bun init -y
14
+ bun add @close-by/clay-cli @close-by/clay
15
+ ```
16
+
17
+ In this Clay checkout, skip `bun add` and use `bun run clay …` after `bun run build:client`.
18
+
19
+ ## 2. A page the CLI can run
20
+
21
+ Create `tasks.ts`. Export a **default function** — `clay` mounts it at `/`.
22
+
23
+ ```typescript
24
+ // tasks.ts
25
+ import { ui } from '@close-by/clay';
26
+
27
+ export default function () {
28
+ ui.label('Tasks').classes('text-2xl font-semibold');
29
+ ui.label('Add items in the next step.').classes('text-sm text-muted-foreground');
30
+ }
31
+ ```
32
+
33
+ ```bash
34
+ bunx clay tasks.ts --reload --title Tasks
35
+ ```
36
+
37
+ The browser should open on http://localhost:3000. Leave `--reload` on so later saves restart the server.
38
+
39
+ ## 3. Capture input
40
+
41
+ `reactive` + `bindValue` keep the field in sync. `let` in the page builder is **per browser tab**.
42
+
43
+ ```typescript
44
+ import { ui, reactive } from '@close-by/clay';
45
+
46
+ export default function () {
47
+ const draft = reactive({ text: '' });
48
+
49
+ ui.label('Tasks').classes('text-2xl font-semibold');
50
+
51
+ ui.row(() => {
52
+ ui.input({ placeholder: 'What needs doing?' }).classes('flex-1').bindValue(draft, 'text');
53
+ ui.button('Add', {
54
+ onClick: () => {
55
+ const text = draft.text.trim();
56
+ if (!text) return;
57
+ ui.notify(`Would add: ${text}`, 'success');
58
+ draft.text = '';
59
+ },
60
+ });
61
+ }, { gap: 2 });
62
+ }
63
+ ```
64
+
65
+ Save, wait for the reload, type a line, click **Add**. You should get a toast.
66
+
67
+ ## 4. Render the list
68
+
69
+ `setText` / `setValue` update an existing node. When rows appear or disappear, wrap that region in `ui.refreshable` and call `.refresh()`.
70
+
71
+ ```typescript
72
+ import { ui, reactive } from '@close-by/clay';
73
+
74
+ type Task = { id: string; text: string; done: boolean };
75
+
76
+ export default function () {
77
+ const tasks: Task[] = [];
78
+ const draft = reactive({ text: '' });
79
+ let list: ReturnType<typeof ui.refreshable>;
80
+
81
+ ui.label('Tasks').classes('text-2xl font-semibold');
82
+
83
+ ui.row(() => {
84
+ ui.input({ placeholder: 'What needs doing?' }).classes('flex-1').bindValue(draft, 'text');
85
+ ui.button('Add', {
86
+ onClick: () => {
87
+ const text = draft.text.trim();
88
+ if (!text) return;
89
+ tasks.push({ id: String(Date.now()), text, done: false });
90
+ draft.text = '';
91
+ list.refresh();
92
+ },
93
+ });
94
+ }, { gap: 2 });
95
+
96
+ list = ui.refreshable(() => {
97
+ if (tasks.length === 0) {
98
+ ui.label('Nothing yet.').classes('text-sm text-muted-foreground');
99
+ return;
100
+ }
101
+ ui.column(() => {
102
+ for (const task of tasks) {
103
+ ui.label(task.text);
104
+ }
105
+ }, { gap: 2 });
106
+ });
107
+ }
108
+ ```
109
+
110
+ Add a couple of items. The list should grow without a full page reload.
111
+
112
+ ## 5. Toggle and delete
113
+
114
+ Replace the `ui.label(task.text)` loop body with a checkbox and a delete button:
115
+
116
+ ```typescript
117
+ ui.row(() => {
118
+ ui.checkbox({
119
+ checked: task.done,
120
+ label: task.text,
121
+ onChange: (checked) => {
122
+ task.done = Boolean(checked);
123
+ list.refresh();
124
+ },
125
+ }).classes(task.done ? 'flex-1 line-through opacity-60' : 'flex-1');
126
+ ui.button('Delete', {
127
+ variant: 'ghost',
128
+ size: 'sm',
129
+ onClick: () => {
130
+ const i = tasks.indexOf(task);
131
+ if (i >= 0) tasks.splice(i, 1);
132
+ list.refresh();
133
+ },
134
+ });
135
+ }, { gap: 2 }).classes('items-center');
136
+ ```
137
+
138
+ Handlers run on the **server**. Clay patches the client.
139
+
140
+ ## 6. Two pages and a shell
141
+
142
+ Directory mode does **not** pick up default exports. Each file calls `ui.page` and may export `pageMeta` for the sidebar.
143
+
144
+ ```typescript
145
+ // pages/tasks.ts
146
+ import { ui, reactive } from '@close-by/clay';
147
+
148
+ export const pageMeta = { label: 'Tasks', icon: 'list-todo', order: 10 };
149
+
150
+ type Task = { id: string; text: string; done: boolean };
151
+
152
+ ui.page('/', () => {
153
+ const tasks: Task[] = [];
154
+ const draft = reactive({ text: '' });
155
+ let list: ReturnType<typeof ui.refreshable>;
156
+
157
+ ui.label('Tasks').classes('text-2xl font-semibold');
158
+
159
+ ui.row(() => {
160
+ ui.input({ placeholder: 'What needs doing?' }).classes('flex-1').bindValue(draft, 'text');
161
+ ui.button('Add', {
162
+ onClick: () => {
163
+ const text = draft.text.trim();
164
+ if (!text) return;
165
+ tasks.push({ id: String(Date.now()), text, done: false });
166
+ draft.text = '';
167
+ list.refresh();
168
+ },
169
+ });
170
+ }, { gap: 2 });
171
+
172
+ list = ui.refreshable(() => {
173
+ if (tasks.length === 0) {
174
+ ui.label('Nothing yet.').classes('text-sm text-muted-foreground');
175
+ return;
176
+ }
177
+ ui.column(() => {
178
+ for (const task of tasks) {
179
+ ui.row(() => {
180
+ ui.checkbox({
181
+ checked: task.done,
182
+ label: task.text,
183
+ onChange: (checked) => {
184
+ task.done = Boolean(checked);
185
+ list.refresh();
186
+ },
187
+ }).classes(task.done ? 'flex-1 line-through opacity-60' : 'flex-1');
188
+ ui.button('Delete', {
189
+ variant: 'ghost',
190
+ size: 'sm',
191
+ onClick: () => {
192
+ const i = tasks.indexOf(task);
193
+ if (i >= 0) tasks.splice(i, 1);
194
+ list.refresh();
195
+ },
196
+ });
197
+ }, { gap: 2 }).classes('items-center');
198
+ }
199
+ }, { gap: 2 });
200
+ });
201
+ });
202
+ ```
203
+
204
+ ```typescript
205
+ // pages/about.ts
206
+ import { ui } from '@close-by/clay';
207
+
208
+ export const pageMeta = { label: 'About', icon: 'info', order: 20 };
209
+
210
+ ui.page('/about', () => {
211
+ ui.label('About').classes('text-2xl font-semibold');
212
+ ui.label('This page is a TypeScript module. The CLI discovered it and put it in the nav.');
213
+ });
214
+ ```
215
+
216
+ ```bash
217
+ bunx clay ./pages --app --title Tasks --reload
218
+ ```
219
+
220
+ `--app` wraps every page in a dashboard shell. Nav comes from `pageMeta` (`label`, `icon`, `order`).
221
+
222
+ ## What you used
223
+
224
+ | Piece | Role |
225
+ |-------|------|
226
+ | `clay` CLI | Run a file or a page directory; ships the React client |
227
+ | Default export | Single-file `/` (file mode only) |
228
+ | `ui.page` + `pageMeta` | Routes and sidebar (directory mode) |
229
+ | `reactive` / `bindValue` | Input without manual `setValue` |
230
+ | `ui.refreshable` | Rebuild a list when items change (simple panels) |
231
+ | `ui.state` / `ui.auto` / `ui.timer` | Preferred for async / live / multi-region UIs — see [Concepts](./concepts.md#canonical-recipe-state--auto--timer) |
232
+ | `let` in the builder | Per-tab state (not shared across users) |
233
+
234
+ Next: [Concepts](./concepts.md), [API reference](./api.md), or the [demo catalog](./examples.md).
package/package.json CHANGED
@@ -1,22 +1,29 @@
1
1
  {
2
2
  "name": "@close-by/clay",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "NiceGUI-like ui.* facade for Clay apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./page-tsconfig": "./page-tsconfig.json"
10
+ "./page-tsconfig": "./page-tsconfig.json",
11
+ "./docs-dir": "./src/docs-dir.ts",
12
+ "./docs/*": "./docs/*"
11
13
  },
12
14
  "files": [
13
15
  "src",
16
+ "docs",
14
17
  "page-tsconfig.json",
15
18
  "README.md",
16
19
  "LICENSE",
17
20
  "!src/**/*.test.ts",
18
21
  "!src/**/__fixtures__"
19
22
  ],
23
+ "scripts": {
24
+ "sync-docs": "bun ../../scripts/sync-docs.ts",
25
+ "prepack": "bun run sync-docs"
26
+ },
20
27
  "repository": {
21
28
  "type": "git",
22
29
  "url": "git+https://github.com/closeby-corp/clay.git",
@@ -27,10 +34,10 @@
27
34
  "access": "public"
28
35
  },
29
36
  "dependencies": {
30
- "@close-by/clay-compiler": "0.2.6",
31
- "@close-by/clay-core": "0.2.6",
32
- "@close-by/clay-components": "0.2.6",
33
- "@close-by/clay-server": "0.2.6",
37
+ "@close-by/clay-compiler": "0.2.8",
38
+ "@close-by/clay-core": "0.2.8",
39
+ "@close-by/clay-components": "0.2.8",
40
+ "@close-by/clay-server": "0.2.8",
34
41
  "@tailwindcss/cli": "^4.3.3",
35
42
  "tailwindcss": "^4.3.3"
36
43
  }
@@ -0,0 +1,44 @@
1
+ import { access } from 'fs/promises';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { createRequire } from 'module';
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ /**
9
+ * Resolve shipped markdown docs for the installed Clay version.
10
+ *
11
+ * 1. `packages/ui/docs` when developing in the monorepo (after sync-docs)
12
+ * 2. `@close-by/clay/docs` when installed from npm
13
+ */
14
+ export async function resolveClayDocsDir(fromDir?: string): Promise<string> {
15
+ const base = fromDir ?? dirname(fileURLToPath(import.meta.url));
16
+ const local = join(base, '../docs');
17
+ if (await hasDocIndex(local)) return local;
18
+
19
+ try {
20
+ const pkgJson = require.resolve('@close-by/clay/package.json');
21
+ const shipped = join(dirname(pkgJson), 'docs');
22
+ if (await hasDocIndex(shipped)) return shipped;
23
+ } catch {
24
+ // not installed as dependency
25
+ }
26
+
27
+ throw new Error(
28
+ 'Clay docs not found. Install @close-by/clay from npm or run bun scripts/sync-docs.ts in the monorepo.',
29
+ );
30
+ }
31
+
32
+ export function resolveClayDocPath(docsDir: string, name: string): string {
33
+ const file = name.endsWith('.md') ? name : `${name}.md`;
34
+ return join(docsDir, file);
35
+ }
36
+
37
+ async function hasDocIndex(dir: string): Promise<boolean> {
38
+ try {
39
+ await access(join(dir, 'getting-started.md'));
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  icon as iconFactory,
28
28
  iconText as iconTextFactory,
29
29
  statusDot as statusDotFactory,
30
+ feedRow as feedRowFactory,
31
+ feedList as feedListFactory,
30
32
  stat as statFactory,
31
33
  dataTable as dataTableFactory,
32
34
  areaChart as areaChartFactory,
@@ -129,6 +131,9 @@ import {
129
131
  type IconSlotProps,
130
132
  type StatusDotProps,
131
133
  type StatusDotColor,
134
+ type FeedRowProps,
135
+ type FeedRowStatus,
136
+ type FeedListProps,
132
137
  type StatItem,
133
138
  type DataTableProps,
134
139
  type DataTableAction,
@@ -841,6 +846,24 @@ export function statusDot(props?: StatusDotProps): Element {
841
846
  return statusDotFactory(props);
842
847
  }
843
848
 
849
+ /** Bordered container for live feed rows. See {@link FeedListProps}. */
850
+ export function feedList(props: FeedListProps, fn: () => void): Element;
851
+ export function feedList(fn: () => void): Element;
852
+ export function feedList(
853
+ propsOrFn: FeedListProps | (() => void),
854
+ fn?: () => void,
855
+ ): Element {
856
+ if (typeof propsOrFn === 'function') {
857
+ return feedListFactory(propsOrFn);
858
+ }
859
+ return feedListFactory(propsOrFn, fn!);
860
+ }
861
+
862
+ /** One live-feed row (status, link title, meta/issue, trailing time). See {@link FeedRowProps}. */
863
+ export function feedRow(props: FeedRowProps, footer?: () => void): Element {
864
+ return feedRowFactory(props, footer);
865
+ }
866
+
844
867
  /** Render markdown to HTML. Prefer `ui.markdown`. See {@link MarkdownProps}. */
845
868
  export function markdown(text?: string, props?: MarkdownProps): Element {
846
869
  return markdownFactory(text, props);
@@ -1594,6 +1617,10 @@ export const ui = {
1594
1617
  progress,
1595
1618
  separator,
1596
1619
  icon,
1620
+ iconText,
1621
+ statusDot,
1622
+ feedRow,
1623
+ feedList,
1597
1624
  markdown,
1598
1625
  html,
1599
1626
  image,