@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.
@@ -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)
@@ -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)