@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.
- package/README.md +1 -1
- package/docs/README.md +80 -0
- package/docs/ai.md +65 -0
- package/docs/api.md +2136 -0
- package/docs/architecture.md +103 -0
- package/docs/boot.md +103 -0
- package/docs/browser-apis.md +65 -0
- package/docs/clickhouse.md +59 -0
- package/docs/concepts.md +269 -0
- package/docs/duckdb.md +68 -0
- package/docs/elements.md +194 -0
- package/docs/examples.md +314 -0
- package/docs/getting-started.md +278 -0
- package/docs/jsx-investigation.md +167 -0
- package/docs/kibana.md +138 -0
- package/docs/llms.txt +44 -0
- package/docs/ops-patterns.md +176 -0
- package/docs/production.md +71 -0
- package/docs/protocol.md +238 -0
- package/docs/reactive-let.md +217 -0
- package/docs/tailwind.md +73 -0
- package/docs/tutorial.md +234 -0
- package/package.json +13 -6
- package/src/docs-dir.ts +44 -0
- package/src/index.ts +27 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Package responsibilities
|
|
4
|
+
|
|
5
|
+
| Package | Role |
|
|
6
|
+
|---------|------|
|
|
7
|
+
| `@close-by/clay` | App-facing `ui` object: factories, `page`, `loadPages`, `navFromPages`, `run` |
|
|
8
|
+
| `@close-by/clay-cli` | `clay` binary: run a `.ts` file or page directory with sane defaults |
|
|
9
|
+
| `@close-by/clay-components` | Thin `Element` factories (no HTML strings) |
|
|
10
|
+
| `@close-by/clay-core` | Element tree, session, page wrapper, reactive, protocol, `storage` (`tab` / `user` / `app`) + PersistenceAdapter |
|
|
11
|
+
| `@close-by/clay-persistence-file` | File-backed `PersistenceAdapter` for `storage.configure` |
|
|
12
|
+
| `@close-by/clay-server` | Bun.serve: static SPA assets + `/ws` upgrade, `/auth/session`, session timeouts |
|
|
13
|
+
| `@close-by/clay-auth` | Optional helpers: password hash, login limiter, `requireAuth` / `requireRole`, `auditRecord` |
|
|
14
|
+
| `@close-by/clay-client` | Vite/React app: WS session hook + element → ShadCN (Sonner, BoundDataTable, …). **Private** — build output is copied into `@close-by/clay-cli` via `bun run build:client` / `pack:publishable`. |
|
|
15
|
+
|
|
16
|
+
`@close-by/clay-compiler` (old Datastar `let` transform) was removed; compile-time reactive `let` remains a Later backlog item.
|
|
17
|
+
|
|
18
|
+
## Runtime data flow
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
clay <file|dir> ──► import / loadPages
|
|
22
|
+
│
|
|
23
|
+
ui.run({ app? }) ──► setPageWrapper(app shell)
|
|
24
|
+
│
|
|
25
|
+
▼
|
|
26
|
+
ClayServer ──HTTP──► SPA shell (index.html + assets)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or library entry:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
ui.loadPages(dir) ──► ui.page registrations + pageMeta
|
|
33
|
+
│
|
|
34
|
+
ui.run({ app }) ──► setPageWrapper(app shell)
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
ClayServer ──HTTP──► SPA shell (index.html + assets)
|
|
38
|
+
│
|
|
39
|
+
└──WS /ws──► ClientSession
|
|
40
|
+
│
|
|
41
|
+
├─ mount: wrapper? → builder() → Element tree → JSON
|
|
42
|
+
├─ event: element.handleEvent → handlers
|
|
43
|
+
└─ patch: updateProps / setChildren / …
|
|
44
|
+
│
|
|
45
|
+
▼
|
|
46
|
+
React useClaySession + ElementRenderer
|
|
47
|
+
(notify → Sonner toast)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Ownership boundaries
|
|
51
|
+
|
|
52
|
+
- **Server** owns truth for structure and business state.
|
|
53
|
+
- **Client** owns presentation and optimistic form UX.
|
|
54
|
+
- **Handlers never run in the browser**; only event names and values cross the wire.
|
|
55
|
+
|
|
56
|
+
## Session lifecycle
|
|
57
|
+
|
|
58
|
+
1. Upgrade to WebSocket; `data.session = null`
|
|
59
|
+
2. On first `hello`, `new ClientSession(path, send)` → `mount()`
|
|
60
|
+
3. Page path change → destroy previous session, mount new path (same socket; client sends another `hello`)
|
|
61
|
+
4. Socket close → `session.destroy()`
|
|
62
|
+
|
|
63
|
+
On the client, a durable WebSocket + sticky React key for `type: 'app'` keeps sidebar/header mounted across navigate when chrome identity matches; only the inset tree remounts.
|
|
64
|
+
|
|
65
|
+
Per-tab isolation: local `let` / `reactive` state inside a page builder is not shared across tabs. Use `ui.storage.app` when you need a process-wide store. Optional `PersistenceAdapter` (via `storage.configure({ app })` or `ui.run({ appStorageDir })`) persists keys by default; `{ persist: false }` keeps a key in memory only. Use `createFilePersistence` from `@close-by/clay-persistence-file` for disk-backed JSON, or bring your own adapter. Persisted `get()` always reloads from the adapter.
|
|
66
|
+
|
|
67
|
+
## Extending the system
|
|
68
|
+
|
|
69
|
+
### New element type
|
|
70
|
+
|
|
71
|
+
1. Add a factory in `packages/components/src/index.ts` (`new Element('myType', props)`).
|
|
72
|
+
2. Re-export via `packages/ui/src/index.ts` on the `ui` object.
|
|
73
|
+
3. Add a `case 'myType':` in `packages/client/src/ElementRenderer.tsx`.
|
|
74
|
+
4. Rebuild the client (`bun run build:client`).
|
|
75
|
+
|
|
76
|
+
### Custom server
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { ClayServer } from '@close-by/clay-server';
|
|
80
|
+
import { setPageWrapper } from '@close-by/clay-core';
|
|
81
|
+
import { app } from '@close-by/clay-components';
|
|
82
|
+
import './pages';
|
|
83
|
+
|
|
84
|
+
setPageWrapper((pageFn) =>
|
|
85
|
+
app({ title: 'App', nav: [{ label: 'Home', href: '/' }] }, pageFn),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const server = new ClayServer({ port: 4000, title: 'App' });
|
|
89
|
+
server.start();
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Or use `ui.run({ app, … })` which sets the page wrapper and starts the server.
|
|
93
|
+
|
|
94
|
+
## Retired stack
|
|
95
|
+
|
|
96
|
+
The previous Datastar + DaisyUI + SSE signal-sync architecture is removed:
|
|
97
|
+
|
|
98
|
+
- No `/clay/stream` or `/clay/events`
|
|
99
|
+
- No DaisyUI CDN page template
|
|
100
|
+
- No signal cache / PatchBus as the primary update path
|
|
101
|
+
- Compiler preload (`bunfig.toml`) removed
|
|
102
|
+
|
|
103
|
+
Historical notes may remain under `iterations/`.
|
package/docs/boot.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Boot: CLI vs library
|
|
2
|
+
|
|
3
|
+
Clay has two entry styles. Prefer **one production path** (library mode) and treat the CLI as a thin dev wrapper so port, CSS, and `clientDir` do not drift.
|
|
4
|
+
|
|
5
|
+
## Recommended production path — library mode
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// index.ts
|
|
9
|
+
import { ui, resolveClayClientDir } from '@close-by/clay';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
await ui.loadPages(new URL('./pages', import.meta.url));
|
|
13
|
+
|
|
14
|
+
ui.run({
|
|
15
|
+
port: Number(process.env.PORT) || 3000,
|
|
16
|
+
title: 'My App',
|
|
17
|
+
clientDir: resolveClayClientDir(),
|
|
18
|
+
css: join(import.meta.dir, 'src/globals.generated.css'),
|
|
19
|
+
app: {
|
|
20
|
+
title: 'My App',
|
|
21
|
+
nav: ui.navFromPages(),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bun ./index.ts
|
|
28
|
+
# Docker / ship: CMD ["bun", "./index.ts"]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### `clientDir`
|
|
32
|
+
|
|
33
|
+
| Approach | Notes |
|
|
34
|
+
|----------|--------|
|
|
35
|
+
| **Omit `clientDir`** | `ui.run` defaults via `resolveClayClientDir()` — prefers `@close-by/clay-cli/client-dist` when that package is installed |
|
|
36
|
+
| **`resolveClayClientDir()`** | Explicit; same resolution — use this instead of `join(..., 'node_modules/@close-by/clay-cli/client-dist')` |
|
|
37
|
+
| **Custom path** | Only if you ship your own built client |
|
|
38
|
+
|
|
39
|
+
`resolveClayClientDir` is exported from `@close-by/clay`. The CLI also exposes `resolveBundledClientDir` from `@close-by/clay-cli/helpers` for advanced tooling.
|
|
40
|
+
|
|
41
|
+
## Dev path — `clay` CLI
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bunx clay ./pages --app --title "My App" --reload
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Optional `pages/_run.ts` merges into the same `RunConfig` the CLI would pass to `ui.run`:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import type { RunConfig } from '@close-by/clay';
|
|
51
|
+
import { resolve } from 'path';
|
|
52
|
+
|
|
53
|
+
export function configureRun(base: RunConfig): RunConfig {
|
|
54
|
+
return {
|
|
55
|
+
...base,
|
|
56
|
+
port: 4200,
|
|
57
|
+
css: resolve(import.meta.dir, '../src/globals.generated.css'),
|
|
58
|
+
// clientDir already set by the CLI to shipped client-dist
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Keep `_run.ts` as the single place for port/CSS/auth so CLI and library mode stay aligned — library `index.ts` should mirror those values (or import a shared `createRunConfig()` helper).
|
|
64
|
+
|
|
65
|
+
### Reload stubs
|
|
66
|
+
|
|
67
|
+
`--reload` writes stubs under **`.clay-reload/`** in the project cwd (not inside `pages/`). Add to `.gitignore`:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
.clay-reload
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Stubs are dev-only transient files (`_clay-reload-<pid>.ts`). They must not land in `pages/` or page discovery will pick them up.
|
|
74
|
+
|
|
75
|
+
### CI and `tsc`
|
|
76
|
+
|
|
77
|
+
`bunx clay --reload` is for local dev only — do **not** pass `--reload` in CI or production starts.
|
|
78
|
+
|
|
79
|
+
If you run **`tsc --noEmit`** (or IDE typecheck) on the app repo, exclude reload artifacts explicitly — **`tsc` does not read `.gitignore`**:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"exclude": [".clay-reload", "**/_clay-reload-*.ts"]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Without that exclude, a leftover stub from a crashed dev session can produce spurious TS errors until you delete `.clay-reload/`. Clay apps that only typecheck `pages/` and `src/` rarely need more; monorepos should exclude `.clay-reload` at the app root `tsconfig.json`.
|
|
88
|
+
|
|
89
|
+
Legacy `_clay-reload-*.ts` files under `pages/` (pre-0.2.5) should be deleted; modern Clay writes outside the tree.
|
|
90
|
+
|
|
91
|
+
## Reactive-let
|
|
92
|
+
|
|
93
|
+
The CLI leaves the compile-time `let` transform **off** unless you pass `--reactive-let`. Library mode never registers the Bun loader unless you call `registerReactiveLetPlugin()` yourself. Prefer Phase 1 `ui.state` / `ui.auto` for real apps — see [reactive-let](./reactive-let.md).
|
|
94
|
+
|
|
95
|
+
## Checklist (avoid drift)
|
|
96
|
+
|
|
97
|
+
| Setting | CLI | Library |
|
|
98
|
+
|---------|-----|---------|
|
|
99
|
+
| Port | `-p` / `_run.ts` | `ui.run({ port })` |
|
|
100
|
+
| CSS | `_run.ts` → `css` | `ui.run({ css })` |
|
|
101
|
+
| Client assets | CLI sets `clientDir` | `resolveClayClientDir()` or omit |
|
|
102
|
+
| Reactive-let | off by default | off unless you register the plugin |
|
|
103
|
+
| Nav / shell | `--app` | `app: { nav: ui.navFromPages() }` |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Browser APIs in Clay pages
|
|
2
|
+
|
|
3
|
+
Page builders and event handlers run on the **server** (Bun), not in the browser. DOM globals such as `window`, `document`, `navigator`, and `location` are missing or wrong there — using them is a common source of silent no-ops (e.g. URL hash deep-links that never update).
|
|
4
|
+
|
|
5
|
+
## Do
|
|
6
|
+
|
|
7
|
+
| Need | Use |
|
|
8
|
+
|------|-----|
|
|
9
|
+
| Copy text | `ui.clipboard(text)` |
|
|
10
|
+
| Read / set URL hash | `ui.getUrlHash()` / `ui.setUrlHash(hash)` |
|
|
11
|
+
| Open a URL in a new tab | `ui.openExternal(url)` |
|
|
12
|
+
| In-app route change | `ui.navigate(path)` |
|
|
13
|
+
| Scroll | `ui.scroll.to` / `ui.scroll.intoView` |
|
|
14
|
+
| Escape hatch | `ui.runJavaScript(code)` (trusted snippets only) |
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
ui.button('Copy id', {
|
|
18
|
+
onClick: () => {
|
|
19
|
+
ui.clipboard(orderId);
|
|
20
|
+
ui.notify('Copied', 'success');
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const focus = ui.getUrlHash(); // from last hello / setUrlHash
|
|
25
|
+
ui.setUrlHash(traceId);
|
|
26
|
+
ui.openExternal('https://signoz.example/trace/' + traceId);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`getUrlHash` returns the hash **without** a leading `#`. It is hydrated from the client on each `hello` (connect, reconnect, SPA navigate) and updated when you call `setUrlHash`.
|
|
30
|
+
|
|
31
|
+
## Don’t
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// Wrong — server has no clipboard / location
|
|
35
|
+
await navigator.clipboard.writeText(value);
|
|
36
|
+
window.location.hash = traceId;
|
|
37
|
+
window.open(url, '_blank');
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Even with `typeof window !== 'undefined'` guards, page code still runs on the server: those branches never run, so the UI looks fine while deep-links and copy do nothing.
|
|
41
|
+
|
|
42
|
+
## TypeScript: keep DOM globals out of page folders
|
|
43
|
+
|
|
44
|
+
Clay page modules run on Bun, not in the browser. Extend **`@close-by/clay/page-tsconfig`** for folders that hold page builders so `window`, `navigator`, and friends are compile-time errors:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"extends": "@close-by/clay/page-tsconfig",
|
|
49
|
+
"include": ["pages/**/*.ts", "src/examples/**/*.ts"]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That tsconfig omits the DOM `lib` — only `ESNext` + `bun-types`.
|
|
54
|
+
|
|
55
|
+
## Dev warnings at load time
|
|
56
|
+
|
|
57
|
+
When the CLI or `ui.loadPages` imports a page module, Clay logs **`[clay-page]`** warnings for forbidden DOM globals (line numbers + suggested helpers). Disable with `CLAY_NO_PAGE_CHECKS=1`.
|
|
58
|
+
|
|
59
|
+
With **`clay --reactive-let`**, transformed files that import known-fragile CJS packages (e.g. `@clickhouse/client`) also emit **`[clay-reactive-let]`** warnings — the Bun loader can break CJS named-export interop.
|
|
60
|
+
|
|
61
|
+
## Related
|
|
62
|
+
|
|
63
|
+
- Helpers table: [API — dialogs and feedback](./api.md#dialogs-and-feedback)
|
|
64
|
+
- Protocol: `clipboard`, `setUrlHash`, `openExternal` in [WebSocket protocol](./protocol.md)
|
|
65
|
+
- Phase 1 reactivity (preferred over DOM hacks): [reactive-let](./reactive-let.md)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# ClickHouse (`@close-by/clay-clickhouse`)
|
|
2
|
+
|
|
3
|
+
Multi-connection ClickHouse wrapper with the same shape as [`@close-by/clay-duckdb`](./duckdb.md): named clients, `exec` / `query` / `queryOne`, and insert/update/delete helpers. Also includes `stream` for large SELECTs.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { ClickHouse } from '@close-by/clay-clickhouse';
|
|
7
|
+
|
|
8
|
+
const ch = new ClickHouse();
|
|
9
|
+
|
|
10
|
+
await ch.connect('analytics', {
|
|
11
|
+
url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123',
|
|
12
|
+
username: process.env.CLICKHOUSE_USER ?? 'default',
|
|
13
|
+
password: process.env.CLICKHOUSE_PASSWORD ?? '',
|
|
14
|
+
database: 'analytics',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const db = ch.db('analytics');
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Method | Description |
|
|
21
|
+
|--------|-------------|
|
|
22
|
+
| `connect(name, config)` | Open HTTP client and register under `name` |
|
|
23
|
+
| `db(name)` | Get a named `Connection` |
|
|
24
|
+
| `has(name)` / `list()` | Registry helpers |
|
|
25
|
+
| `close(name)` / `closeAll()` | Disconnect and drop from registry |
|
|
26
|
+
|
|
27
|
+
## Query / exec
|
|
28
|
+
|
|
29
|
+
ClickHouse query parameters use `{name:Type}` in SQL:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
const rows = await db.query<{ id: number; name: string }>(
|
|
33
|
+
`SELECT id, name FROM users WHERE day = {day:String}`,
|
|
34
|
+
{ day: '2026-07-01' },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const one = await db.queryOne('SELECT count() AS c FROM users');
|
|
38
|
+
await db.exec('CREATE TABLE IF NOT EXISTS scratch (id UInt64) ENGINE = Memory');
|
|
39
|
+
|
|
40
|
+
for await (const row of db.stream('SELECT * FROM events')) {
|
|
41
|
+
// large result sets
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## CRUD
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
await db.insert('users', { id: 1, name: 'Ada' });
|
|
49
|
+
await db.update('users', { name: 'Ada Lovelace' }, { id: 1 });
|
|
50
|
+
await db.delete('users', { id: 1 });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`update` / `delete` require a non-empty `where` and emit ClickHouse mutations (`ALTER TABLE … UPDATE/DELETE`).
|
|
54
|
+
|
|
55
|
+
Use `db.raw` for the underlying `@clickhouse/client` when needed.
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
await ch.closeAll();
|
|
59
|
+
```
|
package/docs/concepts.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# Concepts
|
|
2
|
+
|
|
3
|
+
## Mental model
|
|
4
|
+
|
|
5
|
+
Clay is **server-owned UI**:
|
|
6
|
+
|
|
7
|
+
1. Your page callback runs on the server and builds an **element tree**.
|
|
8
|
+
2. Each browser tab gets a **`ClientSession`** over WebSocket.
|
|
9
|
+
3. The React client mounts that tree and renders ShadCN components.
|
|
10
|
+
4. User events go to the server as `{ op: "event", id, type, value? }`.
|
|
11
|
+
5. Server handlers mutate elements; Clay sends **patches** back.
|
|
12
|
+
|
|
13
|
+
You do not write React for app screens. You write `ui.button`, `ui.input`, `ui.refreshable`, etc.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
┌─────────────┐ hello(path) ┌──────────────────┐
|
|
17
|
+
│ React client│ ─────────────► │ ClayServer / WS │
|
|
18
|
+
│ (ShadCN) │ ◄── mount ──── │ ClientSession │
|
|
19
|
+
│ │ ◄── patch ──── │ element tree │
|
|
20
|
+
│ │ ── event ────► │ page handlers │
|
|
21
|
+
└─────────────┘ └──────────────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Pages and sessions
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
ui.page('/examples/todo', () => {
|
|
28
|
+
// runs once per WebSocket session when the client connects to this path
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Register routes with `ui.page(path, fn)` (or `page` from `@close-by/clay-core`).
|
|
33
|
+
- On `hello`, the server creates a session, runs the page builder, and sends `mount`.
|
|
34
|
+
- State in local variables (`let count = 0`) is **per session** (per tab), not shared.
|
|
35
|
+
- Use `ui.storage.app` for process-wide shared data (e.g. chat messages). Configure a `PersistenceAdapter` via `storage.configure({ app })` or `ui.run({ appStorageDir })` to persist stores (default on when configured; opt out with `{ persist: false }`). `await state.get()` reloads from the adapter when persisted.
|
|
36
|
+
|
|
37
|
+
## Elements
|
|
38
|
+
|
|
39
|
+
Every `ui.*` factory returns an `Element`:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
const label = ui.label('Hello');
|
|
43
|
+
label.classes('text-xl font-bold');
|
|
44
|
+
label.setText('Hello again');
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Elements:
|
|
48
|
+
|
|
49
|
+
- Have an `id`, `type`, `props`, and `children`
|
|
50
|
+
- Attach to the current parent (context stack inside `ui.row(() => { … })`)
|
|
51
|
+
- Serialize to JSON for the client (handlers stay on the server; only event *names* are sent)
|
|
52
|
+
|
|
53
|
+
## Updates: `setText` vs `refreshable`
|
|
54
|
+
|
|
55
|
+
### Prop updates
|
|
56
|
+
|
|
57
|
+
For changing text/value on an existing node:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
label.setText(`Count: ${count}`);
|
|
61
|
+
input.setValue('');
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
These enqueue `updateProps` patches.
|
|
65
|
+
|
|
66
|
+
### Structural updates
|
|
67
|
+
|
|
68
|
+
When children appear/disappear or lists change, wrap the region in `ui.refreshable`:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
const list = ui.refreshable(() => {
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
ui.label(item);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// later
|
|
78
|
+
items.push('new');
|
|
79
|
+
list.refresh(); // rebuilds children and sends setChildren
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`refresh()` clears the refreshable’s children, re-runs the builder, and patches the client.
|
|
83
|
+
|
|
84
|
+
## Canonical recipe: `state` + `auto` (+ `timer`)
|
|
85
|
+
|
|
86
|
+
For async loads, filters, and live panels, prefer Phase 1 APIs over `refreshable` + manual `.refresh()`:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
const live = ui.state({
|
|
90
|
+
units: [] as Unit[],
|
|
91
|
+
loading: true,
|
|
92
|
+
error: null as string | null,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function load() {
|
|
96
|
+
live.loading = true;
|
|
97
|
+
try {
|
|
98
|
+
live.units = await fetchUnits();
|
|
99
|
+
live.error = null;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
live.error = e instanceof Error ? e.message : String(e);
|
|
102
|
+
} finally {
|
|
103
|
+
live.loading = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ui.auto(() => {
|
|
108
|
+
if (live.loading) ui.spinner();
|
|
109
|
+
else if (live.error) ui.alert(live.error, { variant: 'destructive' });
|
|
110
|
+
else {
|
|
111
|
+
for (const u of live.units) {
|
|
112
|
+
ui.button(u.id, { onClick: () => { /* … */ } });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
void load();
|
|
118
|
+
ui.timer(30, () => void load()); // poll; mutates `live` → auto rebuilds
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Rules**
|
|
122
|
+
|
|
123
|
+
- Keep mutable state in `ui.state` **outside** `ui.auto` (locals inside the builder reset each rebuild).
|
|
124
|
+
- One `auto` can wrap many widgets that share the same reads; split `auto` regions when list vs detail should update independently.
|
|
125
|
+
- Prefer `ui.label(() => …)` / `bindValue` when only text/value changes.
|
|
126
|
+
|
|
127
|
+
### When to use what
|
|
128
|
+
|
|
129
|
+
| Need | Use |
|
|
130
|
+
|------|-----|
|
|
131
|
+
| Data that drives the tree (lists, loading, selection) | `ui.state` + `ui.auto` |
|
|
132
|
+
| Polling / intervals | `ui.timer` mutating state |
|
|
133
|
+
| One-shot structural rebuild you trigger yourself | `ui.refreshable` + `.refresh()` |
|
|
134
|
+
| Single label/input sync | `ui.label(() => …)` / `bindValue` / `bindText` |
|
|
135
|
+
| NiceGUI-style `let` sugar (demos, counters) | `// @clay-reactive` + `clay --reactive-let` — see [reactive-let](./reactive-let.md#happy-path-let--clay-reactive) |
|
|
136
|
+
| Production async / dense ops consoles | `ui.state` + `ui.auto` (Phase 1) |
|
|
137
|
+
|
|
138
|
+
### Alternative: reactive `let` (Phase 2)
|
|
139
|
+
|
|
140
|
+
For teaching and small demos you can write plain `let` and opt in with a file pragma:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// @clay-reactive
|
|
144
|
+
import { ui } from '@close-by/clay';
|
|
145
|
+
|
|
146
|
+
export default function () {
|
|
147
|
+
let count = 0;
|
|
148
|
+
ui.label(`Count: ${count}`);
|
|
149
|
+
ui.button('+', { onClick: () => { count++; } });
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Run with `clay page.ts --reactive-let`. The compiler lifts `count` to `ui.state` and wires labels automatically. Full rules and limits: [reactive-let.md](./reactive-let.md).
|
|
154
|
+
|
|
155
|
+
## Bindings and `reactive`
|
|
156
|
+
|
|
157
|
+
Two-way bind form controls to a reactive object:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { ui, reactive } from '@close-by/clay';
|
|
161
|
+
|
|
162
|
+
const draft = reactive({ text: '' });
|
|
163
|
+
const input = ui.input({ placeholder: '…' });
|
|
164
|
+
input.bindValue(draft, 'text');
|
|
165
|
+
|
|
166
|
+
// read
|
|
167
|
+
const value = draft.text;
|
|
168
|
+
|
|
169
|
+
// write (also updates the input via patch)
|
|
170
|
+
draft.text = '';
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- `reactive(obj)` — Proxy that notifies subscribers on property change
|
|
174
|
+
- `ui.state(obj)` — alias for `reactive` (prefer in app code)
|
|
175
|
+
- `ui.auto(fn)` — rebuild a region when tracked `state`/`reactive` reads change; stable trees patch via `updateProps` (see [`reactive-let.md`](./reactive-let.md))
|
|
176
|
+
- `ui.label(() => string)` / `.bindText(fn)` — computed label text without wrapping `auto`
|
|
177
|
+
- `bindValue(obj, key)` — syncs element value ↔ `obj[key]` on `input`/`change`
|
|
178
|
+
- `bindTextFrom(obj, key)` — one-way sync into label text
|
|
179
|
+
- `subscribe(obj, key, fn)` — run a callback when a key changes (e.g. refresh a summary panel)
|
|
180
|
+
|
|
181
|
+
## Events
|
|
182
|
+
|
|
183
|
+
Declare handlers with camelCase props (`onClick`, `onChange`, `onInput`) or `.on(event, handler)`:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
ui.button('Save', {
|
|
187
|
+
onClick: async () => {
|
|
188
|
+
await save();
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
ui.input({
|
|
193
|
+
onInput: (value) => console.log(value),
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The client keeps local optimistic state for inputs, textareas, checkboxes, switches, selects, radio groups, comboboxes, dates, sliders, tabs, accordions, collapsibles, and DataTable filter inputs so typing/toggling feels instant while the server confirms.
|
|
198
|
+
|
|
199
|
+
## Layout context
|
|
200
|
+
|
|
201
|
+
Layout helpers push a parent onto a stack:
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
ui.column(() => {
|
|
205
|
+
ui.label('Title');
|
|
206
|
+
ui.row(() => {
|
|
207
|
+
ui.button('A');
|
|
208
|
+
ui.button('B');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Children created inside the callback become children of that layout element.
|
|
214
|
+
|
|
215
|
+
### App shell (`ui.run({ app })`)
|
|
216
|
+
|
|
217
|
+
Configure chrome once at startup; page modules only register content. `ui.loadPages(dir)` imports every page file under a directory; optional `pageMeta` feeds `ui.navFromPages()` for the primary sidebar.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
await ui.loadPages(new URL('./pages', import.meta.url));
|
|
221
|
+
|
|
222
|
+
ui.run({
|
|
223
|
+
app: {
|
|
224
|
+
title: 'My App',
|
|
225
|
+
nav: ui.navFromPages(),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// pages/Charts.ts
|
|
232
|
+
export const pageMeta = { label: 'Charts', icon: 'chart-area', order: 90 };
|
|
233
|
+
|
|
234
|
+
ui.page('/examples/charts', () => {
|
|
235
|
+
ui.label('Charts'); // inherits global shell
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Opt out per route with `ui.page(path, fn, { shell: false })`. Keep explicit `ui.app(props, fn)` for advanced cases.
|
|
240
|
+
|
|
241
|
+
The client keeps this chrome **sticky** across SPA navigate: the WebSocket stays open, a new `hello` remounts the server session, and React reuses the `app` shell (sidebar / header) when the chrome structure matches. Only the inset page content remounts; nav `active` flags update from the new tree.
|
|
242
|
+
|
|
243
|
+
## Notifications and navigation
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
ui.notify('Saved!', 'success');
|
|
247
|
+
ui.notify('Look here', {
|
|
248
|
+
type: 'warning',
|
|
249
|
+
duration: 0,
|
|
250
|
+
position: 'top-right',
|
|
251
|
+
description: 'Sticky toast via Sonner',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// still available:
|
|
255
|
+
import { notify, navigate } from '@close-by/clay-core';
|
|
256
|
+
navigate('/examples/todo');
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`ui.notify` / `notify` map to [Sonner](https://ui.shadcn.com/docs/components/sonner) on the client (`toast.success` / `info` / `warning` / `error`). `duration: 0` is sticky; optional `description` is Sonner’s secondary line. `navigate` tells the client to change path and send a new `hello` on the same WebSocket (server remounts the page; client keeps matching `app` chrome mounted).
|
|
260
|
+
|
|
261
|
+
## Imperative overlays
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
const sure = await ui.confirm('Continue?');
|
|
265
|
+
const name = await ui.prompt('Your name');
|
|
266
|
+
const pick = await ui.choose('Pick one', ['A', 'B']);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
These open ephemeral dialogs, return Promises, and only work inside an active session (e.g. `onClick` / `onAction`). They are not synchronous — a WebSocket round-trip is required.
|
package/docs/duckdb.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# DuckDB (`@close-by/clay-duckdb`)
|
|
2
|
+
|
|
3
|
+
Multi-database DuckDB wrapper: named instances, `ATTACH` for DuckDB/SQLite/Postgres/MySQL, and insert/update/delete helpers.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun add @close-by/clay-duckdb # or depend on workspace:* inside this monorepo
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Connect multiple databases
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { DuckDB } from '@close-by/clay-duckdb';
|
|
13
|
+
|
|
14
|
+
const duck = new DuckDB();
|
|
15
|
+
|
|
16
|
+
await duck.connect('app', './data/app.duckdb');
|
|
17
|
+
await duck.connect('scratch', ':memory:');
|
|
18
|
+
|
|
19
|
+
const app = duck.db('app');
|
|
20
|
+
await app.exec(`CREATE TABLE IF NOT EXISTS users (
|
|
21
|
+
id INTEGER PRIMARY KEY,
|
|
22
|
+
name VARCHAR,
|
|
23
|
+
email VARCHAR
|
|
24
|
+
)`);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Method | Description |
|
|
28
|
+
|--------|-------------|
|
|
29
|
+
| `connect(name, path?)` | Open/create a DuckDB file (default `:memory:`) |
|
|
30
|
+
| `db(name)` | Get a named `Connection` |
|
|
31
|
+
| `has(name)` / `list()` | Registry helpers |
|
|
32
|
+
| `close(name)` / `closeAll()` | Disconnect and drop from registry |
|
|
33
|
+
|
|
34
|
+
## CRUD
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
await app.insert('users', { id: 1, name: 'Ada', email: 'a@x.com' });
|
|
38
|
+
await app.update('users', { name: 'Ada Lovelace' }, { id: 1 });
|
|
39
|
+
await app.delete('users', { id: 1 });
|
|
40
|
+
|
|
41
|
+
const rows = await app.query<{ id: number; name: string }>(
|
|
42
|
+
'SELECT * FROM users WHERE id = $id',
|
|
43
|
+
{ id: 1 },
|
|
44
|
+
);
|
|
45
|
+
const one = await app.queryOne('SELECT * FROM users WHERE id = $id', { id: 1 });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`update` and `delete` require a non-empty `where` object (safety guard).
|
|
49
|
+
|
|
50
|
+
Use `app.raw` for the underlying `@duckdb/node-api` connection when you need appenders or streaming.
|
|
51
|
+
|
|
52
|
+
## ATTACH external databases
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
await duck.attach('app', {
|
|
56
|
+
alias: 'legacy',
|
|
57
|
+
type: 'sqlite', // 'duckdb' | 'sqlite' | 'postgres' | 'mysql'
|
|
58
|
+
path: './legacy.sqlite', // file path or DSN for postgres/mysql
|
|
59
|
+
readOnly: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await app.query('SELECT * FROM legacy.users');
|
|
63
|
+
await app.insert('legacy.users', { id: 2, name: 'Grace' });
|
|
64
|
+
|
|
65
|
+
await duck.detach('app', 'legacy');
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Extensions (`sqlite`, `postgres`, `mysql`) are `INSTALL`/`LOAD`ed once per process when first needed. Prefer `type: 'duckdb'` for sibling DuckDB files (no extension).
|