@close-by/clay 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +0 -3
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Ops console patterns
|
|
2
|
+
|
|
3
|
+
Dense internal tools (live feeds, master–detail, logs/traces) push Clay past card dashboards. Prefer Phase 1 reactivity ([Concepts](./concepts.md#canonical-recipe-state--auto--timer)) and the helpers below.
|
|
4
|
+
|
|
5
|
+
## Status chips
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
ui.badge('ok', { size: 'xs', color: 'emerald' });
|
|
9
|
+
ui.badge('warn', { size: 'xs', color: 'amber', variant: 'outline' });
|
|
10
|
+
ui.badge('error', { size: 'xs', color: 'red' });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Prop | Notes |
|
|
14
|
+
|------|--------|
|
|
15
|
+
| `size: 'xs'` | Compact ops chip (`h-5` / `text-[10px]`) |
|
|
16
|
+
| `color` | Named (`green`, `red`, `amber`, …) or CSS (`#22c55e`); overrides `variant` |
|
|
17
|
+
| `variant` | `default` / `secondary` / `destructive` / `outline` when `color` unset |
|
|
18
|
+
|
|
19
|
+
## Copy & external links
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
ui.copyButton(traceId); // icon-only
|
|
23
|
+
ui.copyButton(traceId, { label: 'Copy', size: 'sm' });
|
|
24
|
+
|
|
25
|
+
ui.externalLink('Open in SigNoz', signozUrl);
|
|
26
|
+
// or
|
|
27
|
+
ui.link('Docs', 'https://example.com', { external: true });
|
|
28
|
+
ui.openExternal(signozUrl); // no visible control — protocol helper
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Never use `navigator.clipboard` / `window.open` in page code — see [Browser APIs](./browser-apis.md).
|
|
32
|
+
|
|
33
|
+
## Master–detail with `ui.resizable`
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const live = ui.state({ selectedId: null as string | null, rows: [] as Row[] });
|
|
37
|
+
|
|
38
|
+
ui.auto(() => {
|
|
39
|
+
ui.resizable(
|
|
40
|
+
{ orientation: 'horizontal', className: 'h-[calc(100vh-8rem)] w-full min-h-[24rem]' },
|
|
41
|
+
(r) => {
|
|
42
|
+
r.panel({ defaultSize: 40, minSize: 28 }, () => {
|
|
43
|
+
ui.auto(() => {
|
|
44
|
+
for (const row of live.rows) {
|
|
45
|
+
ui.button(row.id, {
|
|
46
|
+
variant: row.id === live.selectedId ? 'secondary' : 'ghost',
|
|
47
|
+
className: 'w-full justify-start',
|
|
48
|
+
onClick: () => {
|
|
49
|
+
live.selectedId = row.id;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
r.panel({ defaultSize: 60, minSize: 36 }, () => {
|
|
56
|
+
ui.auto(() => {
|
|
57
|
+
const row = live.rows.find((x) => x.id === live.selectedId);
|
|
58
|
+
if (!row) {
|
|
59
|
+
ui.label('Select a row').classes('text-sm text-muted-foreground');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// detail…
|
|
63
|
+
ui.label(row.id);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Keep **separate** `ui.auto` regions for list vs detail so selection updates do not remount the whole shell. Default panel sizes (`defaultSize` / `minSize`) are percentages.
|
|
72
|
+
|
|
73
|
+
## Live feed rows (not `ui.list`)
|
|
74
|
+
|
|
75
|
+
`ui.list` is a **grouped drag-and-drop** list — wrong tool for a live ops feed. Use **`ui.feedList`** + **`ui.feedRow`** (app still owns polling, merge, and filters):
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
ui.auto(() => {
|
|
79
|
+
ui.feedList(() => {
|
|
80
|
+
for (const unit of live.units) {
|
|
81
|
+
ui.feedRow(
|
|
82
|
+
{
|
|
83
|
+
selected: live.selectedId === unit.id,
|
|
84
|
+
status: { color: unit.ok ? 'emerald' : 'red' },
|
|
85
|
+
title: unit.id,
|
|
86
|
+
meta: unit.summary,
|
|
87
|
+
issue: unit.error ?? undefined,
|
|
88
|
+
marker: unit.isNew ? 'new' : undefined,
|
|
89
|
+
trailing: () => formatRelative(unit.at, live.clock),
|
|
90
|
+
onClick: () => {
|
|
91
|
+
live.selectedId = unit.id;
|
|
92
|
+
},
|
|
93
|
+
onTrailingClick: () => toggleAbsoluteTime(unit.id),
|
|
94
|
+
},
|
|
95
|
+
() => {
|
|
96
|
+
for (const chip of unit.chips) {
|
|
97
|
+
ui.badge(chip.label, { size: 'xs', color: chip.color });
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Primitive | Role |
|
|
107
|
+
|-----------|------|
|
|
108
|
+
| `ui.feedList` | Bordered `divide-y` container — default ops feed chrome |
|
|
109
|
+
| `ui.feedRow` | One row: status dot, link title, meta / issue / hint, trailing time |
|
|
110
|
+
| Footer callback | Optional chips or extra content when there is no `issue` line |
|
|
111
|
+
|
|
112
|
+
Manual `ui.row` composition still works when you need a custom layout.
|
|
113
|
+
|
|
114
|
+
## Logs / traces — sensitive bodies
|
|
115
|
+
|
|
116
|
+
Structured logs often contain API keys or partner tokens. Scrubbing is the **app’s** job; Clay can still reduce shoulder-surfing:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
ui.codeBlock({
|
|
120
|
+
code: rawLogLine,
|
|
121
|
+
language: 'json',
|
|
122
|
+
sensitive: true, // blurred until Reveal; copy disabled until then
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Still redact before display when possible. Prefer VPN + auth for hubs that surface production logs.
|
|
127
|
+
|
|
128
|
+
## Auth + role-filtered nav
|
|
129
|
+
|
|
130
|
+
Nav filtering is UX only — always enforce roles in the page builder.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// pageMeta
|
|
134
|
+
export const pageMeta = {
|
|
135
|
+
label: 'Admin',
|
|
136
|
+
icon: 'settings',
|
|
137
|
+
order: 90,
|
|
138
|
+
roles: ['admin'],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// shell
|
|
142
|
+
ui.run({
|
|
143
|
+
authSecret: process.env.CLAY_AUTH_SECRET!,
|
|
144
|
+
app: {
|
|
145
|
+
title: 'Hub',
|
|
146
|
+
nav: () => ui.navFromPages({ role: currentUser.role }),
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// page
|
|
151
|
+
ui.page('/admin', () => {
|
|
152
|
+
requireRole('admin'); // @close-by/clay-auth
|
|
153
|
+
// …
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
See `/examples/auth` and [API — auth](./api.md).
|
|
158
|
+
|
|
159
|
+
## `ui.ai.chat` and state
|
|
160
|
+
|
|
161
|
+
`AiChatElement` owns messages via `setMessages` / imperative updates. That is intentional — the chat widget is a stateful client control, not a pure `auto` tree.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const session = ui.state({ conversationId: '…' });
|
|
165
|
+
var messages: AiChatMessage[] = [/* welcome */];
|
|
166
|
+
|
|
167
|
+
const chat = ui.ai.chat({
|
|
168
|
+
messages,
|
|
169
|
+
onSubmit: async (text) => {
|
|
170
|
+
// update messages, then:
|
|
171
|
+
chat.setMessages(messages);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Use `ui.state` for surrounding controls (conversation id, busy flags). Prefer documenting this exception over forcing chat through `ui.auto` (would remount the composer). Full API: [AI UI](./ai.md).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Production & security
|
|
2
|
+
|
|
3
|
+
Clay apps are **server-driven**: page builders and most event handlers run in your Bun process. Every button click, input change, and timer tick can hit server code. Treat Clay like a web API plus UI, not a static site.
|
|
4
|
+
|
|
5
|
+
## Server exposure
|
|
6
|
+
|
|
7
|
+
| Fact | Implication |
|
|
8
|
+
|------|-------------|
|
|
9
|
+
| WebSocket + HTTP to one Bun process | Scale horizontally only with shared session/storage adapters; rate-limit at the edge or in middleware |
|
|
10
|
+
| Page code runs with full server privileges | Never expose unauthenticated Clay hubs to the public internet without hardening |
|
|
11
|
+
| No built-in multi-tenant isolation | One process serves all connected clients; use auth + per-user data in handlers |
|
|
12
|
+
|
|
13
|
+
**Defaults that fit internal ops tools:** VPN or private network, single-team auth, read-heavy dashboards.
|
|
14
|
+
|
|
15
|
+
## Authentication
|
|
16
|
+
|
|
17
|
+
Clay ships hooks for real identity beyond anonymous `userId`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { ui } from '@close-by/clay';
|
|
21
|
+
import { requireRole } from '@close-by/clay-auth';
|
|
22
|
+
|
|
23
|
+
ui.run({
|
|
24
|
+
authSecret: process.env.CLAY_AUTH_SECRET!,
|
|
25
|
+
app: {
|
|
26
|
+
title: 'Hub',
|
|
27
|
+
nav: () => ui.navFromPages({ role: currentUser.role }),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
ui.page('/admin', () => {
|
|
32
|
+
requireRole('admin');
|
|
33
|
+
// …
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `pageMeta.roles` — hide nav items via `ui.navFromPages({ role })` (UX only)
|
|
38
|
+
- `requireRole` / guards in the page builder — **enforce** access before building sensitive UI
|
|
39
|
+
- See [API — auth](./api.md), `/examples/auth`, and [Ops patterns — auth nav](./ops-patterns.md#auth--role-filtered-nav)
|
|
40
|
+
|
|
41
|
+
Nav filtering is not authorization. Always guard pages that must not leak data.
|
|
42
|
+
|
|
43
|
+
## Rate limits & abuse
|
|
44
|
+
|
|
45
|
+
Clay does not ship a global rate limiter. For handlers that trigger expensive work (ClickHouse queries, re-dispatch, file export):
|
|
46
|
+
|
|
47
|
+
- Keep queries bounded (limits, time windows)
|
|
48
|
+
- Use app-level throttling/debounce on hot actions
|
|
49
|
+
- Put the hub behind a reverse proxy with rate limits if exposed beyond VPN
|
|
50
|
+
|
|
51
|
+
## Sensitive data in UI
|
|
52
|
+
|
|
53
|
+
Logs, traces, and config payloads may contain secrets. Clay provides `ui.codeBlock({ sensitive: true })` to reduce shoulder-surfing; **scrubbing before display is still the app's job**. See [Ops patterns — sensitive bodies](./ops-patterns.md#logs--traces--sensitive-bodies).
|
|
54
|
+
|
|
55
|
+
## Boot parity (CLI vs library)
|
|
56
|
+
|
|
57
|
+
Production should use **library mode** (`ui.run`) with the same port, CSS, and flags as dev CLI. Checklist: [Boot: CLI vs library](./boot.md).
|
|
58
|
+
|
|
59
|
+
| Setting | CLI | Library |
|
|
60
|
+
|---------|-----|---------|
|
|
61
|
+
| Port | `-p` / `_run.ts` | `ui.run({ port })` |
|
|
62
|
+
| CSS | `_run.ts` → `css` | `ui.run({ css })` |
|
|
63
|
+
| Client assets | bundled `clientDir` | `resolveClayClientDir()` |
|
|
64
|
+
| Reactive-let | `--reactive-let` (opt-in) | off unless you register the plugin |
|
|
65
|
+
| Reload stubs | `.clay-reload/` (gitignored) | N/A in prod |
|
|
66
|
+
|
|
67
|
+
## Related
|
|
68
|
+
|
|
69
|
+
- [Browser APIs](./browser-apis.md) — never `window` / `navigator` in page code
|
|
70
|
+
- [Concepts — canonical recipe](./concepts.md#canonical-recipe-state--auto--timer)
|
|
71
|
+
- [Reactive let — decision table](./reactive-let.md#decision-table-complex-pages)
|
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# WebSocket protocol
|
|
2
|
+
|
|
3
|
+
All fields are **camelCase**. Transport: single WebSocket at `/ws`.
|
|
4
|
+
|
|
5
|
+
## Connection flow
|
|
6
|
+
|
|
7
|
+
1. Browser loads SPA HTML + `/assets/index.js` + `/assets/index.css`
|
|
8
|
+
2. Client opens `ws://host/ws` (or `wss://`)
|
|
9
|
+
3. Client sends `{ op: "hello", path: "/examples/todo" }`
|
|
10
|
+
4. Server creates `ClientSession`, runs the page builder, replies `{ op: "mount", sessionId, tree }`
|
|
11
|
+
5. Interaction continues with `event` ↔ `patch` (plus optional `notify` / `navigate` / `theme`)
|
|
12
|
+
|
|
13
|
+
On unexpected socket close, the client reconnects with exponential backoff (500ms → 10s) and shows a corner **Clay** status chip (`reloading` / brief `ready`). A sticky “Disconnected — reconnecting…” toast appears only if the outage lasts longer than ~4s. Matching `app` chrome stays mounted across remounts.
|
|
14
|
+
## Client → server
|
|
15
|
+
|
|
16
|
+
### `hello`
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{ "op": "hello", "path": "/examples/counter", "userId": "optional-stable-id", "hash": "optional-hash-without-hash-sign", "browserStorage": {}, "clientStorage": {}, "tabStorage": {} }
|
|
20
|
+
```
|
|
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 `browserStorage` / `clientStorage` / `tabStorage` bags hydrate `ui.storage.browser` / `ui.storage.client` / `ui.storage.tab`.
|
|
23
|
+
|
|
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
|
+
|
|
26
|
+
### `event`
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{ "op": "event", "id": "input_abc", "type": "input", "value": "hello" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Field | Type | Description |
|
|
33
|
+
|-------|------|-------------|
|
|
34
|
+
| `id` | `string` | Element id |
|
|
35
|
+
| `type` | `string` | Event name (`click`, `input`, `change`, …) |
|
|
36
|
+
| `value` | `unknown` | Optional payload (string, number, boolean, …) |
|
|
37
|
+
|
|
38
|
+
## Server → client
|
|
39
|
+
|
|
40
|
+
### `mount`
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"op": "mount",
|
|
45
|
+
"sessionId": "session_…",
|
|
46
|
+
"tree": {
|
|
47
|
+
"id": "root_…",
|
|
48
|
+
"type": "root",
|
|
49
|
+
"props": {},
|
|
50
|
+
"children": [ /* ElementNode[] */ ]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `patch`
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"op": "patch",
|
|
60
|
+
"patches": [
|
|
61
|
+
{ "op": "updateProps", "id": "label_1", "props": { "text": "Count: 1" } },
|
|
62
|
+
{ "op": "setChildren", "id": "refreshable_1", "children": [ /* … */ ] }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Patch ops
|
|
68
|
+
|
|
69
|
+
| `op` | Fields | Meaning |
|
|
70
|
+
|------|--------|---------|
|
|
71
|
+
| `replace` | `id`, `node` | Replace entire subtree at `id` |
|
|
72
|
+
| `updateProps` | `id`, `props` | Shallow-merge props |
|
|
73
|
+
| `setChildren` | `id`, `children` | Replace children array |
|
|
74
|
+
| `remove` | `id` | Remove node from parent |
|
|
75
|
+
|
|
76
|
+
### `navigate`
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{ "op": "navigate", "path": "/examples/todo" }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Client updates history (`pushState` + `popstate`) and sends a new `hello` on the same WebSocket. Matching `app` chrome stays mounted on the client; inset content remounts.
|
|
83
|
+
|
|
84
|
+
### `reconnect`
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{ "op": "reconnect" }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Client closes the socket and reconnects (picks up updated HttpOnly cookies on the next upgrade). Used after auth cookie changes when a full soft-reconnect is requested without going through `authSession`.
|
|
91
|
+
|
|
92
|
+
### `authSession`
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "op": "authSession", "action": "establish", "token": "signed…", "path": "/account" }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{ "op": "authSession", "action": "clear", "path": "/login" }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Client `POST`s / `DELETE`s `/auth/session` (credentials included), optionally updates the SPA path, then soft-reconnects so the next `hello` sees the cookie. Requires `ui.run({ authSecret })`.
|
|
103
|
+
|
|
104
|
+
### `notify`
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"op": "notify",
|
|
109
|
+
"id": "toast_1",
|
|
110
|
+
"message": "Saved!",
|
|
111
|
+
"type": "success",
|
|
112
|
+
"duration": 2500,
|
|
113
|
+
"position": "bottom-right",
|
|
114
|
+
"description": "Optional secondary line"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
| Field | Type | Default | Notes |
|
|
119
|
+
|-------|------|---------|-------|
|
|
120
|
+
| `id` | `string` | required (server) | Passed to Sonner as toast id |
|
|
121
|
+
| `message` | `string` | | Toast title |
|
|
122
|
+
| `type` | `info\|success\|warning\|error` | `info` | Maps to `toast.info` / `success` / `warning` / `error` |
|
|
123
|
+
| `duration` | `number` | `2500` | ms; `0` → Sonner `Infinity` (sticky) |
|
|
124
|
+
| `position` | `top-left\|top-right\|bottom-left\|bottom-right` | `bottom-right` | Sonner position |
|
|
125
|
+
| `description` | `string` | | Optional Sonner description |
|
|
126
|
+
|
|
127
|
+
Client renders via ShadCN `<Toaster />` + imperative `toast` from `sonner`.
|
|
128
|
+
|
|
129
|
+
### `dismissNotify`
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{ "op": "dismissNotify", "id": "toast_1" }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Calls `toast.dismiss(id)` on the client.
|
|
136
|
+
|
|
137
|
+
### `download`
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"op": "download",
|
|
142
|
+
"filename": "data.csv",
|
|
143
|
+
"mime": "text/csv;charset=utf-8",
|
|
144
|
+
"content": "Title,Status\nAlpha,done"
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Client triggers a file download via a temporary Blob URL.
|
|
149
|
+
|
|
150
|
+
### `clipboard`
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{ "op": "clipboard", "content": "Title\tStatus\nAlpha\tdone" }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Client writes `content` with `navigator.clipboard.writeText` (shows an error toast on failure). Prefer `ui.clipboard` from page code.
|
|
157
|
+
|
|
158
|
+
### `setUrlHash`
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{ "op": "setUrlHash", "hash": "trace-abc" }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Client sets `location.hash` via `history.replaceState` (`hash` has no leading `#`; empty string clears). Hydrated the other way on `hello.hash`. Prefer `ui.setUrlHash` / `ui.getUrlHash`.
|
|
165
|
+
|
|
166
|
+
### `openExternal`
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{ "op": "openExternal", "url": "https://example.com" }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Client runs `window.open(url, '_blank', 'noopener,noreferrer')`. Prefer `ui.openExternal`.
|
|
173
|
+
|
|
174
|
+
### `theme`
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{ "op": "theme", "theme": "dark" }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
| Field | Type | Notes |
|
|
181
|
+
|-------|------|-------|
|
|
182
|
+
| `theme` | `light\|dark\|system` | Applied via next-themes; persisted under `clay-theme` |
|
|
183
|
+
|
|
184
|
+
Sent by `ui.theme.set(...)`.
|
|
185
|
+
|
|
186
|
+
### `runJavaScript`
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
{ "op": "runJavaScript", "code": "console.log('hi')" }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Client evaluates the snippet (trusted server-authored code).
|
|
193
|
+
|
|
194
|
+
### `scroll`
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{ "op": "scroll", "target": "window", "top": "bottom", "behavior": "smooth" }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{ "op": "scroll", "target": "selector", "selector": "#footer", "block": "start" }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `clientStorage`
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{ "op": "clientStorage", "scope": "browser", "action": "set", "key": "theme", "value": "dark" }
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Updates the client Web Storage bag used by `ui.storage.browser` (`localStorage`), `ui.storage.client` (`sessionStorage`), or `ui.storage.tab` (`sessionStorage`, key `clay-tab-storage`). Write-through keeps the bag warm for the next `hello`.
|
|
211
|
+
|
|
212
|
+
### `error`
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{ "op": "error", "message": "No page registered for /missing" }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Element JSON
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
type ElementNode = {
|
|
222
|
+
id: string;
|
|
223
|
+
type: string;
|
|
224
|
+
props: Record<string, unknown>;
|
|
225
|
+
children: ElementNode[];
|
|
226
|
+
};
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
- No functions on the wire
|
|
230
|
+
- Interactive nodes include `props.events: string[]`
|
|
231
|
+
- Values for forms live in `props.value` (checkboxes use boolean `value`)
|
|
232
|
+
|
|
233
|
+
## TypeScript types
|
|
234
|
+
|
|
235
|
+
Shared definitions live in:
|
|
236
|
+
|
|
237
|
+
- `packages/core/src/protocol.ts`
|
|
238
|
+
- `packages/client/src/protocol.ts` (mirror for the browser bundle)
|