dsh-generative-ui 0.0.0 → 0.0.2

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/cordis.patch.yml +6 -0
  4. package/lib/client.js +18568 -0
  5. package/lib/client.js.map +62 -0
  6. package/lib/index.js +1597 -0
  7. package/lib/types/client/canvas/CanvasLauncher.d.ts +6 -0
  8. package/lib/types/client/canvas/CanvasPanel.d.ts +88 -0
  9. package/lib/types/client/canvas/collect.d.ts +45 -0
  10. package/lib/types/client/canvas/index.d.ts +43 -0
  11. package/lib/types/client/canvas/mount.d.ts +30 -0
  12. package/lib/types/client/canvas/panel-css.d.ts +1 -0
  13. package/lib/types/client/canvas/read.d.ts +12 -0
  14. package/lib/types/client/canvas/subpages.d.ts +20 -0
  15. package/lib/types/client/canvas/useDismissable.d.ts +15 -0
  16. package/lib/types/client/index.d.ts +20 -0
  17. package/lib/types/client/runtime/GenUISurface.d.ts +159 -0
  18. package/lib/types/client/runtime/bindings.d.ts +143 -0
  19. package/lib/types/client/runtime/compiler.d.ts +35 -0
  20. package/lib/types/client/runtime/inline-fence.d.ts +23 -0
  21. package/lib/types/client/runtime/observe.d.ts +30 -0
  22. package/lib/types/client/runtime/register.d.ts +2 -0
  23. package/lib/types/client/runtime/registry.d.ts +7 -0
  24. package/lib/types/client/runtime/report-error.d.ts +17 -0
  25. package/lib/types/client/runtime/segments.d.ts +18 -0
  26. package/lib/types/client/runtime/state.d.ts +18 -0
  27. package/lib/types/client/runtime/uno-config.d.ts +16 -0
  28. package/lib/types/client/runtime/uno.d.ts +50 -0
  29. package/lib/types/client/session.d.ts +26 -0
  30. package/lib/types/contract-assets.d.ts +41 -0
  31. package/lib/types/contract.d.ts +56 -0
  32. package/lib/types/index.d.ts +255 -0
  33. package/lib/types/prompt.d.ts +13 -0
  34. package/lib/types/skill.d.ts +27 -0
  35. package/package.json +135 -9
  36. package/src/client/canvas/CanvasLauncher.tsx +52 -0
  37. package/src/client/canvas/CanvasPanel.tsx +238 -0
  38. package/src/client/canvas/collect.ts +188 -0
  39. package/src/client/canvas/index.ts +255 -0
  40. package/src/client/canvas/mount.ts +91 -0
  41. package/src/client/canvas/panel-css.ts +2 -0
  42. package/src/client/canvas/panel.css +242 -0
  43. package/src/client/canvas/read.ts +55 -0
  44. package/src/client/canvas/subpages.ts +109 -0
  45. package/src/client/canvas/useDismissable.ts +37 -0
  46. package/src/client/index.ts +217 -0
  47. package/src/client/runtime/GenUISurface.tsx +359 -0
  48. package/src/client/runtime/bindings.ts +292 -0
  49. package/src/client/runtime/compiler.ts +80 -0
  50. package/src/client/runtime/inline-fence.ts +222 -0
  51. package/src/client/runtime/observe.ts +65 -0
  52. package/src/client/runtime/register.ts +57 -0
  53. package/src/client/runtime/registry.ts +65 -0
  54. package/src/client/runtime/report-error.ts +79 -0
  55. package/src/client/runtime/segments.ts +116 -0
  56. package/src/client/runtime/state.ts +47 -0
  57. package/src/client/runtime/uno-config.ts +71 -0
  58. package/src/client/runtime/uno.ts +124 -0
  59. package/src/client/session.ts +46 -0
  60. package/src/contract-assets.ts +46 -0
  61. package/src/contract.ts +111 -0
  62. package/src/index.ts +583 -0
  63. package/src/prompt.ts +377 -0
  64. package/src/skill.ts +931 -0
  65. package/types/README.md +34 -0
  66. package/types/ai.d.ts +14 -0
  67. package/types/chat.d.ts +14 -0
  68. package/types/check.ts +39 -0
  69. package/types/exec.d.ts +17 -0
  70. package/types/fs.d.ts +17 -0
  71. package/types/importmap.json +10 -0
  72. package/types/standalone/ai.js +7 -0
  73. package/types/standalone/chat.js +6 -0
  74. package/types/standalone/exec.js +7 -0
  75. package/types/standalone/fs.js +18 -0
  76. package/types/standalone/importmap.json +10 -0
  77. package/types/standalone/state.js +24 -0
  78. package/types/standalone/web.js +7 -0
  79. package/types/state.d.ts +25 -0
  80. package/types/web.d.ts +31 -0
  81. package/index.js +0 -1
package/lib/index.js ADDED
@@ -0,0 +1,1597 @@
1
+ // src/index.ts
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import { fileURLToPath } from "node:url";
7
+ import z from "@deepseek-ai/schemastery";
8
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
9
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
10
+
11
+ // src/contract-assets.ts
12
+ var ASSET_PREFIX = "/dsh-generative-ui/assets";
13
+ var WASM_PATH = `${ASSET_PREFIX}/tsx_bg.wasm`;
14
+ var CANVAS_READ_PATH = "/dsh-generative-ui/canvas";
15
+ var AI_STREAM_PATH = "/dsh-generative-ui/ai";
16
+ var FS_PATH = "/dsh-generative-ui/fs";
17
+ var EXEC_PATH = "/dsh-generative-ui/exec";
18
+ var WEB_SEARCH_PATH = "/dsh-generative-ui/web-search";
19
+
20
+ // src/contract.ts
21
+ var UI4A_DIR = ".dsh/ui4a";
22
+ var CANVAS_DIR = `${UI4A_DIR}/canvases`;
23
+ var CANVAS_SUFFIX = ".ui4a.tsx";
24
+ var FENCE_LANG = "ui4a/tsx";
25
+ var CAPABILITY_PREFIX = "$dsh";
26
+ var capabilityModule = (group) => `${CAPABILITY_PREFIX}/${group}`;
27
+ var CANVAS_ID = /^[^/\\.\s]+$/;
28
+ var isCanvasId = (id) => CANVAS_ID.test(id);
29
+ var canvasPath = (id) => `${CANVAS_DIR}/${id}${CANVAS_SUFFIX}`;
30
+ var canvasChildDir = (id) => `${CANVAS_DIR}/${id}`;
31
+ function canvasChildPath(id, specifier, from) {
32
+ if (!isCanvasId(id))
33
+ return null;
34
+ const segments = specifier.replace(/^\.\//, "").split("/");
35
+ if (segments.length === 0 || !segments.every(isCanvasId))
36
+ return null;
37
+ const root = canvasChildDir(id);
38
+ const within = from === undefined ? null : from.replace(/\\/g, "/").split(`${root}/`)[1];
39
+ if (within === undefined || within === null) {
40
+ if (segments.length < 2 || segments[0] !== id)
41
+ return null;
42
+ return `${root}/${segments.slice(1).join("/")}`;
43
+ }
44
+ const dir = within.split("/").slice(0, -1);
45
+ return [root, ...dir, ...segments].join("/");
46
+ }
47
+ var normalize = (path) => {
48
+ const at = path.replace(/\\/g, "/").lastIndexOf(`${CANVAS_DIR}/`);
49
+ return at === -1 ? path.replace(/^\.?\//, "") : path.slice(at);
50
+ };
51
+ function canvasIdOf(path) {
52
+ const relative = normalize(path);
53
+ if (!relative.startsWith(`${CANVAS_DIR}/`) || !relative.endsWith(CANVAS_SUFFIX))
54
+ return null;
55
+ const id = relative.slice(CANVAS_DIR.length + 1, -CANVAS_SUFFIX.length);
56
+ return isCanvasId(id) ? id : null;
57
+ }
58
+
59
+ // src/skill.ts
60
+ var CLI_URL = "https://pkg.pr.new/MindLab-Research/macaron-genui-demo/@genui/cli@main";
61
+ var SKILL_NAME = "generative-ui";
62
+ var SKILL_DESCRIPTION = `How to decide between an inline ${FENCE_LANG} block, a canvas file, and plain prose — and how to lay one out so it reads. Load it **before you decide**, not after — including when your first instinct is that prose is enough. Most of the questions that should have been an interface do not ask for one.`;
63
+ function mapNotes(typesMap, standaloneMap) {
64
+ if (typesMap === undefined)
65
+ return "";
66
+ const check = [
67
+ `**Check the canvas file itself, at \`${CANVAS_DIR}/<id>${CANVAS_SUFFIX}\`.** Writing that path is what creates the`,
68
+ "canvas, so there is no draft stage to check first: a `.tsx` anywhere else is a file the user",
69
+ "will never see, however correct it is. Write it where it belongs, then check it there and fix",
70
+ "it in place — the panel streams as you write and re-renders as you edit.",
71
+ "",
72
+ `The \`-i\` is not optional when the card imports \`${CAPABILITY_PREFIX}/*\`: without it every one of those lines`,
73
+ "is reported as `Cannot find module`, and there is nothing to fix — they resolve at render time.",
74
+ "",
75
+ "**It silences that error rather than typing the calls.** Measured: a map pointing at a file",
76
+ "that does not exist reports `OK` just the same, so `$dsh/*` ends up `any` and a wrong",
77
+ "argument or a misspelt result field passes the check. Everything else in the card is really",
78
+ "type-checked; the capability calls are on you.",
79
+ "",
80
+ "",
81
+ "One more diagnostic never to skim past: *referenced directly or indirectly in its own initializer*. It means",
82
+ "a `const` shadows something of the same name and now refers to itself — `const rows = useMemo(() => rows(x), [x])`",
83
+ "beside a top-level `function rows`. That throws on the first render and the card is blank, and it arrives",
84
+ "surrounded by ordinary `implicitly has an 'any' type` lines that are safe to ignore. Rename the local.",
85
+ "That map holds type declarations, so it serves `check` and `lint`."
86
+ ].join(`
87
+ `);
88
+ if (standaloneMap === undefined)
89
+ return `${check} \`build\` and \`dev\` want runnable JS and will fail on it.`;
90
+ return [
91
+ `${check} \`build\` and \`dev\` want runnable JS, so they take a different one:`,
92
+ "",
93
+ "```",
94
+ `npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} build <file> -i ${standaloneMap}`,
95
+ "```",
96
+ "",
97
+ `That second map stubs \`${CAPABILITY_PREFIX}/*\` — the exported page has no dsh around it, so those calls log to`,
98
+ "the console and return empty instead of working. The layout, the styling and everything that",
99
+ "does not touch the harness are real; anything that does is inert. Useful for showing someone a",
100
+ "snapshot, not for testing the interactive parts."
101
+ ].join(`
102
+ `);
103
+ }
104
+ var skillBody = (typesMap, standaloneMap, allowExec = false) => ((maps) => `# Building a generative UI
105
+
106
+ ## Is this a UI at all
107
+
108
+ An interface earns its place when the answer has a shape prose has to flatten: numbers to compare, a control to move, options to pick between, something that changes as the user pokes at it.
109
+
110
+ It does not earn its place when the answer is a sentence. A definition, a yes/no, a recommendation with a reason — wrapping those in a card adds a box and a heading around text that was already fine, and costs the reader a second to work out there is nothing to click. When you find yourself building a component whose whole body is one paragraph, write the paragraph.
111
+
112
+ Two specific traps:
113
+
114
+ - **Do not restate the reply as a card.** If the interface only repeats what the prose next to it already said, one of them is redundant, and it is the card.
115
+ - **Do not decorate an answer.** A metric with an icon and a border is still just a number. Ship the number.
116
+
117
+ **And a long answer is not automatically prose.** The trap above is a card whose body is one paragraph; the opposite trap is a wall of markdown that was a list of things to *do*. A recipe, a workout, a packing list, a set of steps — the reader works through those one item at a time, loses their place, and comes back to them. Ticking an item off is the whole interaction, and markdown cannot offer it. If you are about to write \`- \` more than about six times and the items are actions rather than facts, that is the block, not prose.
118
+
119
+ Conversely: "visualise this", "show me a chart", "make it interactive", "let me try it" are unambiguous requests for the block. Build it directly — don't reach for \`run_code\` or an image; the fence renders in the browser.
120
+
121
+ ## Inline or canvas
122
+
123
+ They are not two sizes of the same thing; they have different lifetimes.
124
+
125
+ **Inline** is *one step of the conversation*. It lives in the message where it was said, it is read once, and it scrolls away. Use it when the UI is tied to what you are saying right now: the comparison you just described, the option set you need answered, a small live calculation.
126
+
127
+ **Canvas** (\`${CANVAS_DIR}/<id>${CANVAS_SUFFIX}\`) is *a place the user comes back to*. It stays in the panel across turns, keeps state, and can hold several views. Use it when the thing has substance — a tool, a dashboard, an editor, anything with more than one screen or worth reopening tomorrow.
128
+
129
+ The tell is the question "would the user want this again in ten turns?" Yes → canvas. No → inline. When it is genuinely borderline, inline is the cheaper mistake: it is one message, not a file the user now owns.
130
+
131
+ Two things follow from the lifetime difference:
132
+
133
+ - An **inline** block that the user acts on — picks an option, submits a choice — should *end that step*: send the result with \`sendMessage\` **and** record what was chosen, so the card still shows it when scrolled back to weeks later. Both halves matter: skip the send and the click goes nowhere, skip the record and the card resets to untouched. A form that looks untouched after submitting reads as broken.
134
+ - A **canvas** stays interactive. It does not "complete"; it just sits there working.
135
+ - A **canvas outlives the reply that made it**, so data the user puts into it — entries, notes, cards — must survive a reload on its own. Reach for \`usePersistedState\` from \`${capabilityModule("state")}\` — \`useState\`'s signature including a lazy initialiser, with the value kept in \`localStorage\` under a namespaced key, and the read and write already wrapped:
136
+
137
+ \`\`\`tsx
138
+ import { usePersistedState } from "${capabilityModule("state")}"
139
+ const [entries, setEntries] = usePersistedState<Entry[]>("expense-ledger", [])
140
+ \`\`\`
141
+
142
+ **Name the key after this canvas, not after the data.** \`"ledger"\`, \`"todos"\`, \`"settings"\` are what every card reaches for, and two cards sharing a key share the rows. Plain \`useState\` is a bug you cannot see while building: the ledger looks right until the tab reloads and every row is gone.
143
+ **And a reload is not the common case — your own next edit is.** Every revision replaces the
144
+ whole file, so the canvas remounts and anything held only in \`useState\` is gone; change one word
145
+ in a label and the user's half-typed row goes with it. Persist what they typed, not just what
146
+ they saved.
147
+
148
+ **If you write \`setRows(prev => prev.filter(r => r.id !== id))\` behind a button, keep the row.**
149
+ Persisting is what makes that line permanent — before it, a mistaken delete came back on reload.
150
+ Hold the removed row and offer it back:
151
+
152
+ \`\`\`tsx
153
+ const [undo, setUndo] = React.useState<Row | null>(null)
154
+ const remove = (id: string) => {
155
+ setUndo(rows.find((r) => r.id === id) ?? null)
156
+ setRows((prev) => prev.filter((r) => r.id !== id))
157
+ }
158
+ { undo && <button onClick={() => { setRows((p) => [...p, undo]); setUndo(null) }}>Undo delete</button> }
159
+ \`\`\`
160
+
161
+ A confirm step does the same job — but not the browser's own \`confirm()\`, which is a modal
162
+ from another era sitting on top of a panel that has its own visual language, and which offers
163
+ no way back once it is answered.
164
+
165
+ **The reason this is missed is not that undo is hard to write — it is that the line does not
166
+ look like a delete.** Measured across 36 cards that destroy something: 10 shipped no way back,
167
+ and every one of them had written one of these without recognising it:
168
+
169
+ - \`setRows(prev => prev.filter(r => r.id !== id))\` — 6 of the 10
170
+ - \`delete obj[key]\` on a persisted map — the other 4, and the one that reads least like a
171
+ delete because nothing named \`remove\` appears anywhere near it
172
+ - \`rows.splice(i, 1)\`
173
+ - \`setRows([])\` behind "clear", "reset", "start over", or a new day — but only when the rows
174
+ are the user's; clearing a queue you generated is not a delete
175
+ - setting a quantity or a count to 0 where the row disappears at 0
176
+ - replacing a whole persisted object — \`setPlan(freshPlan)\` drops whatever the user edited
177
+
178
+ Anything the user cannot type back in under five seconds needs a way back.
179
+
180
+ **A running clock is state too**, and the least obvious kind: a stopwatch or a timer mid-count
181
+ reads 0 again after one edit. Measured — the interval itself is cleaned up correctly, nothing
182
+ stacks up, but the elapsed value is gone. Store the *start timestamp* rather than the elapsed
183
+ count, so the display is derived and survives a remount by arithmetic.
184
+
185
+ **Reaching for \`localStorage\` by hand is where this goes wrong.** A full quota, or storage
186
+ disabled entirely, and \`setItem\` raises — from inside an effect, where it reaches the error
187
+ boundary and takes the whole card down over a saved preference. Persistence went from 1 corpus
188
+ card to 20 fresh ones once this section asked for it, and **10 of those 29 writes were bare**.
189
+ \`usePersistedState\` has the \`try\` on both sides; use it and the question does not arise.
190
+
191
+ ## Ask with an interface when the request is underspecified
192
+
193
+ "Build me a tool", "show me the data" — several plausible readings, no default. Guessing wastes a build; asking in prose makes the user type the answer back.
194
+
195
+ Ask with an **inline** block instead: one short line saying what you need to know, then 2–4 concrete options as clickable cards, each wired to \`sendMessage\` so a click *is* the reply:
196
+
197
+ \`\`\`tsx
198
+ import { sendMessage } from "$dsh/chat"
199
+
200
+ export default function Pick() {
201
+ const [picked, setPicked] = useState<string | null>(null)
202
+ const choose = (id: string) => { setPicked(id); sendMessage(id) }
203
+ // picked === null → the options; otherwise just the chosen one, still highlighted
204
+ }
205
+ \`\`\`
206
+
207
+ Rules for that move:
208
+
209
+ - **Do it before you explore.** Listing the workspace tells you what is there, never what the user wants. Stalling in tool calls is not a step.
210
+ - **Real options, not a form.** Each card is a thing you could go build right now. "Something else" belongs at the end as a plain text field, not as one of the cards.
211
+ - **Ask once.** Take the answer and build. A second round reads as stalling — if a detail is still open, pick the sensible default and say so in one line.
212
+
213
+ Don't ask when the request already names the thing, when there is one obvious reading, or when building it is faster than asking about it. Plain conversational questions get plain answers.
214
+
215
+ ## Say something before it and something after
216
+
217
+ A reply that is nothing but an interface reads like a document that is nothing but a code block — it arrives with no warning and the reader has to work out what they are looking at.
218
+
219
+ - **Before** — one line, *before* you open the fence or write the file, saying what you are about to build. It streams out while the code is still compiling, so for several seconds it is the only thing the reader has.
220
+ - **After** — one or two lines: what it does, plus the one thing worth pointing out (a control that isn't obvious, an assumption you made, what to say to change it).
221
+
222
+ Both short. Two or three sentences total. Don't narrate tooling ("now I'll write the file") — say what the user gets.
223
+
224
+ **Write the card in the language they wrote to you in — every label, every button, every helper line.** This is not a preference, it is whether they can use it: a Spanish speaker handed a card labelled 日常休闲 / 户外运动 got no answer at all. It is easy to miss because the card is a separate act of writing from the reply, and the reply is usually right; measured, a card for \`Suggest an outfit that matches the occasion and weather\` came back entirely in Chinese. The corpus is **en 39% / es 31% / fr 12% / it 9% / pt 5%, and Chinese 0.2%** — so Chinese is the wrong default in almost every turn, and if you find yourself typing a CJK label, check what language the question was in.
225
+
226
+ ## Framing
227
+
228
+ This one runs *opposite* in the two places, and getting it backwards is the most visible mistake:
229
+
230
+ - **Canvas fills its panel.** It already has a frame and a title bar around it. So take the whole space — \`height: 100%\`, your own padding, backgrounds bleeding to the edges — and do **not** wrap yourself in one more rounded, bordered, tinted box. A card inside the panel is a frame inside a frame.
231
+ - **Inline is the card.** It sits between paragraphs, so one bounded box is what tells the reader where it starts and stops.
232
+ - **But \`bg-base\` is the page's own colour, so a wrapper painted with it is not a box.** Measured
233
+ from the token table: \`bg-base\` is \`#fff\` on light and \`#151517\` on dark — the same value the
234
+ transcript behind the card is painted with, on both grounds. A root \`<div>\` with
235
+ \`background: var(--dsw-alias-bg-base); padding: 16px; border-radius: 12px\` therefore draws
236
+ nothing a reader can see: what is left is an invisible 16px inset and a rounded corner nobody
237
+ can find, while the \`bg-layer-1\` blocks inside it read as the real frame — a frame inside an
238
+ invisible frame. If you want the inline card to be bounded, bound it with \`bg-layer\` **plus**
239
+ \`border-line\` (see the both-spellings rule below). If you don't, drop the wrapper's background
240
+ and radius entirely rather than painting it the colour of the page.
241
+
242
+ Either way, don't restage the header. The panel already names the canvas, so a heading repeating that name is the second copy of it — measured, **22 of the 24 canvases that carried a heading had written their own filename back out**: \`liste-courses\` headed "Liste de courses", \`waist-routine\` headed "Rutina de Cintura", \`bone-routine\` headed "Rutina para fortalecer los huesos". Translating the id into the user's language does not make it a different line. The two that got it right show what the slot is actually for: one headed a **section** (\`Ingredienti\`), the other **spoke to the reader** (\`Hasna, ya toca el almuerzo\`). If a heading is not naming a part of the page or saying something to the person reading it, delete it; a small-caps kicker above the heading plus a subtitle under it is three lines of chrome before anything happens. **And on Chinese text an uppercase kicker is decoration that does not even render**: measured, 15 of the 19 kickers in 378 real cards set \`textTransform: "uppercase"\` over CJK, where it does nothing at all — the letter-spacing survives and the transform is a no-op, so what is left is a small grey line the layout did not need. One heading at most, often none. A chip in the top right has to be something the user actually tracks, not decoration to balance the layout.
243
+
244
+ ## Layout
245
+
246
+ - **The space between blocks is the root's job, and it is one class on the element that holds
247
+ them.** A card is two to four stacked blocks, and what separates them is a \`gap\` on their
248
+ parent — not a margin on each child, which collapses and doubles unpredictably:
249
+
250
+ <div className="grid gap-4">
251
+
252
+ Measured on a card written before this syntax: the root's layout was \`.r { display: grid; gap:
253
+ 12px }\` in a \`<style>\` block, the class landed on an \`<input>\` twenty lines away, and the two
254
+ blocks below ended up flush — no border between them, no space, reading as one block with a
255
+ stray heading in the middle. Nothing failed; the gap simply never applied. A class written on
256
+ the element it governs cannot come apart from it, which is most of why the styling here is
257
+ classes. Inside a block the same \`gap\` separates its rows; a \`mb-4\` on one child while its
258
+ siblings rely on the gap is what produces one odd space and eleven equal ones.
259
+
260
+ - **A collapse whose rows all start open is decoration, and a filter that starts at "everything"
261
+ has not filtered.** Measured on two generated cards, two models, two weeks apart, both with the
262
+ mechanism written correctly: a symptom card with one-panel-at-a-time \`aria-expanded\` shipped all
263
+ six panels open at 3369px, and a 41-question study canvas — which also built a topic filter, a
264
+ to-learn/mastered toggle AND a search box — rendered every question expanded with the filter on
265
+ "All", repeating its two buttons 82 times down **12000px**. The model knew the list needed
266
+ narrowing in both cases; what it did not do was choose the initial state. If the list is longer
267
+ than a screen, the first render shows labels and the filter starts somewhere narrower than
268
+ everything.
269
+
270
+ - **A list of options collapses the prose, not the facts — and folding the wrong half is the
271
+ common way to end up with a card nobody can scan.** Measured on a real card recommending six
272
+ ways to manage a symptom: each entry kept three lines of description permanently on screen and
273
+ hid one line — \`Onset: 15 min\` — behind a "Show details" link, repeated six times. The
274
+ mechanism was right (one panel open at a time, \`aria-expanded\` on every trigger); the choice
275
+ of what went inside it was backwards, and the card came out 3369px tall at every width. What
276
+ earns a permanent line is what the reader compares the options **by** — the name, the one
277
+ number that distinguishes it. The paragraph explaining why it works is what folds. A list of
278
+ more than about four options where every entry carries a paragraph is not a list any more, and
279
+ the fix is not a smaller font.
280
+
281
+ - **A comparison table is read down a column, so its text cells are left-aligned and only its
282
+ numbers are right-aligned.** Measured on a real card comparing two cell types over 12 rows:
283
+ every cell was centred, so at 440px eight of the twelve rows wrapped to two lines and each
284
+ line started at a different x — there is no straight edge for the eye to run down, and the
285
+ two columns being compared no longer line up with each other row by row. Centring looks tidy
286
+ in a mock where every cell is one short word and falls apart the moment one cell is a phrase.
287
+ Numbers are the exception in both directions: right-align them and add
288
+ \`font-variant-numeric: tabular-nums\`, so the digits stack. Header cells take the alignment of
289
+ the column beneath them, not their own.
290
+
291
+ **An unknown is not a zero.** A row the reader has not reported yet shows \`—\` and contributes
292
+ nothing to the total. \`0\` is a measurement: it says the value was taken and came out zero, and it
293
+ drags every average and running total down silently. Measured on one wave, one turn, one
294
+ context: one card rendered the not-yet-eaten dinner as \`Cena · pendiente —\` and another
295
+ rendered the same row as \`kcal 0 / Prot 0 / Carb 0\`. Same question, so this is a coin flip
296
+ rather than a blind spot — which is what makes it worth one line. The em dash takes
297
+ \`text-muted\`, and if a total is shown beside incomplete rows, say what it is a total OF.
298
+
299
+ - **Write both the border and the background, and let the theme decide which one shows.** Measured on this app's own tokens, not assumed: light paints \`bg-page\`, \`bg-layer-1\` and \`bg-layer-2\` all \`#fff\`, so a block with only a background is **invisible** there and the border is the sole thing separating it; dark gives the layers real values (\`#151517\` / \`#232324\` / \`#2c2c2e\`) and carries it on the background alone. Rendered side by side, background-only vanishes on light and border-only is indistinguishable from both-together on dark — so both is the one spelling that works on both grounds, and it is **not** the "border and background are redundant" anti-pattern you know from elsewhere. That anti-pattern assumes a background you can see. Floating surfaces (modals, dropdowns) keep both regardless — they have to occlude.
300
+
301
+ **And a field you type into is not a surface — it is a hole in one.** \`bg-page\` is the colour
302
+ of the ground everything else sits on, so an \`<input>\` painted with it is the same white as the
303
+ card in light theme and reads as a faint outline. Measured on a card generated after the rule
304
+ above landed: nine inputs, all \`bg-page border-line\`, on a card that used \`bg-layer-2\`
305
+ correctly exactly once elsewhere — the model knows the token and still reaches for the ground
306
+ colour. An input takes \`bg-layer-2\` (a step further from the ground than its container, not
307
+ back towards it) with \`border-line-2\`, and the placeholder takes \`text-muted\`.
308
+
309
+ **A thing you can tap needs more than the divider colour.** The rule above is about separating a
310
+ block from the surface below it, and \`border-line\` — 4% black — is right for that. It is not
311
+ enough for a control sitting on a surface that already has the same background: measured on a
312
+ real card, four tappable option boxes drawn with \`border-line\` on a \`bg-layer\` parent read
313
+ clearly on dark and were nearly invisible on light, where every layer is \`#fff\` and 4% black is
314
+ the only thing left. A tappable thing takes \`bg-layer-2\` or \`border-line-2\`, and the hairline
315
+ stays for dividers.
316
+
317
+ **A control you have FILLED is the opposite case, and the two get confused.** The rule above is
318
+ about separating a surface from the surface under it, where both tokens are deliberately faint —
319
+ \`border-l1\` is 4% black. Once an element carries a real fill (a selected segment on
320
+ \`state-business-primary\`, a primary button), that fill separates it completely and a leftover
321
+ \`border-l2\` is a grey ring around a blue block, related to nothing. Drop it — but to
322
+ \`transparent\`, not to \`none\`, or the selected item loses a pixel of height and the row twitches
323
+ as the reader clicks along it:
324
+
325
+ border: selected ? "1px solid transparent" : "1px solid var(--dsw-alias-border-l2)"
326
+
327
+ **And once a row is filled, everything inside it has to move off that fill too.** Measured on a
328
+ real card: a step row filled with \`state-business-primary\` when ticked, and the checkbox inside
329
+ it took \`background: state-business-primary\` for its own checked state — the same token, so the
330
+ box vanished into the row and left a white tick floating on blue with nothing around it. The
331
+ same happens to a chip, a count, an icon tile: any child that had a background of its own is now
332
+ sitting on a background that matches it. On a filled row the children want the fill's foreground
333
+ (\`#fff\` here) as their colour and no background at all, or a white outline if the shape itself
334
+ has to stay readable.
335
+ - **Keep nesting shallow.** A bordered box inside a bordered box is almost always wrong; a divider line does the job.
336
+ - **You are a component on someone else's page.** Your root is a normal node inside the chat column or the panel — nothing isolates you. No \`position: fixed\`, no \`100vw\`/\`100vh\`, no portals into \`document.body\`, no global listeners you don't remove. Overlays go in a \`relative\` wrapper you own with \`absolute inset-0\`. Effect libraries default to the wrong thing here and have to be pointed at your own element — \`canvas-confetti\` attaches a fullscreen canvas to \`document.body\` unless you pass one, so \`confetti.create(ref.current, { resize: true, useWorker: true })\` with that \`<canvas>\` absolutely positioned inside your container. Same for anything that says "mounts to body" or "fullscreen".
337
+ - **The width is not the viewport's.** The same component lands in a narrow chat column *and* in a wide panel, so a media query tells you nothing useful — measure your own container, or design something that reads at any width. Content grids especially: one comfortable column beats two cramped ones.
338
+
339
+ - **In a canvas, extra width should make the rows SHORTER, not the card wider.** Measured across
340
+ one wave, height at 320 divided by height at 720: the five inline cards shrink 1.26–1.52x, and
341
+ the six canvases shrink **1.02–1.18x** — one is 1100px tall at 320 and still 1076px at 720. It
342
+ is not for want of the technique; 8 of those 9 canvases carry a container query or an intrinsic
343
+ grid. They spend it *inside* a row — a stat strip, a chip group — and never on the row itself.
344
+ The shape that costs the most is a three-band row: a name, a right-aligned number, then a
345
+ control on its own full-width line, so at 720 the name and its number sit 1100px apart with a
346
+ rail between them. At that width the three fit on ONE line:
347
+
348
+ <div className="grid gap-2 @[32rem]:grid-cols-[1fr_12rem_auto] @[32rem]:items-center">
349
+ <span className="min-w-0 truncate">{name}</span>
350
+ <input type="range" … />
351
+ <span className="tabular-nums text-right">{value}</span>
352
+ </div>
353
+
354
+ The reader drags a canvas panel between 320 and 720 — that drag should buy them less scrolling.
355
+ - **Layout breaks late, controls break early.** A row of buttons can reflow at a small width; a grid of content cards cannot, because each column has to stay wide enough to read.
356
+ - **Icons must name the thing beside them.** \`Sparkles\`, \`WandSparkles\`, \`Wand2\`, \`Stars\`, \`Bot\`, \`BrainCircuit\`, \`Zap\` as decoration say "an AI made this" and nothing else — \`Copy\` on a copy button, \`Languages\` on a translate tab, and nothing on a heading that reads fine without one. Prefer no icon to a decorative one.
357
+ - **If you take the focus ring off, put something back.** \`outline-none\` on a borderless input
358
+ is the most common single thing in these cards that breaks keyboard use: **77 of 378 remove the
359
+ ring and 0 replace it**, so tabbing through the card moves an invisible cursor. The
360
+ browser's default ring is ugly next to a custom input, which is why it goes — the fix is a
361
+ ring you like, not no ring:
362
+
363
+ <input className="outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" />
364
+
365
+ \`:focus-visible\`, not \`:focus\` — it shows the ring for the keyboard and not for the mouse,
366
+ which is the reason the ring was annoying in the first place.
367
+ - **The rules below share one cause, and knowing it is worth more than the list.** A card gets
368
+ written as a *picture* of an interface — the slider looks right, the number reads right, the
369
+ ring is visual noise so it goes. Every one of them is correct through a mouse and an eye, and
370
+ broken through a keyboard or a screen reader. Measured: the two most common pairs of defects
371
+ in 378 cards are a stripped focus ring beside an unlabelled slider (8 cards) and an unlabelled
372
+ slider beside an unguarded number field (6) — the same card, treating its controls as decoration
373
+ three times over. When you add a control, ask what it announces and what happens on Tab.
374
+ - **A control the keyboard cannot reach is not a control.** Two shapes, both measured across 378
375
+ real cards and neither mentioned here before: **17 cards put \`onClick\` on a \`<div>\`**, which
376
+ takes no focus and answers no Enter or Space, and **31 buttons whose only content is an icon
377
+ carry no \`aria-label\`**, so a screen reader announces "button" and nothing else. Both are one
378
+ word to fix and invisible to you, since a mouse works either way:
379
+
380
+ <button aria-label="复制" onClick={copy}><Copy size={14} /></button>
381
+
382
+ If it does something when clicked, it is a \`<button type="button">\`. A \`div\` with an
383
+ \`onClick\` is a div.
384
+
385
+ **A clickable row is the case that survives this rule** — 13 of the 17 are a list row, a table
386
+ cell, or a card, where wrapping each one in a \`<button>\` feels wrong. It is not: a \`<button>\`
387
+ with \`display: block; width: 100%; text-align: left\` looks exactly like the row and is
388
+ reachable. **\`textAlign: "left"\` is the part that gets dropped, and it is needed whatever the
389
+ display is.** A row laid out as \`display: flex\` (to push a trailing action right with
390
+ \`space-between\`) still inherits the button's centred text, so a short bold title sits visibly
391
+ off-centre above the longer line beneath it while everything else looks left-aligned — the two
392
+ cards where I hit this both had \`flexDirection: "column"\` on the text block, which declares the
393
+ axis and does nothing about the alignment. If the row genuinely cannot be one — a virtualised list measuring its own height —
394
+ then \`role="button" tabIndex={0}\` and an \`onKeyDown\` for Enter and Space, all three, because
395
+ any one alone leaves it half-reachable.
396
+
397
+ **A slider is the same problem with no visible text to fall back on.** 61 range inputs across
398
+ the corpus carry no label of any kind, and unlike a text field there is no placeholder and
399
+ nothing inside the control to read — a screen reader announces "slider, 40" and stops.
400
+
401
+ Almost every one of them HAS a visible name: **38 of 54 put it in a \`<span>\` directly above
402
+ the control**, which looks labelled and announces as nothing. A \`<span>\` is not a label, and
403
+ neither is the number beside it — both are separate elements, connected to nothing:
404
+
405
+ <input type="range" aria-label="音量" min={0} max={100} value={v} onChange={…} />
406
+
407
+ **And a bare \`<input type="range">\` is the loudest thing on the card.** The browser paints its
408
+ own track in the OS accent — a thick, fully saturated blue that ignores your theme, is identical
409
+ on light and dark, and outshouts the number beside it. **43 of the 52 corpus cards with a slider
410
+ ship it untouched**, including all three reference cards. \`accent-color\` does not fix it:
411
+ measured side by side, it swaps one blue band for another. The track and the thumb are
412
+ pseudo-elements, which utilities reach through a bracketed selector on the input itself:
413
+
414
+ <input type="range" className="flex-1 min-w-0 appearance-none bg-transparent
415
+ [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full
416
+ [&::-webkit-slider-runnable-track]:bg-line-2
417
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1.5
418
+ [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5
419
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-label" />
420
+
421
+ The thumb takes \`bg-label\`, which contrasts the TRACK and therefore inverts with the theme.
422
+ Note what this spelling removes: the previous version of this rule taught the same overrides in
423
+ a \`<style>\` block, and a card wrote \`className="r"\` on the input against a \`.r
424
+ input[type=range]\` selector — asking for an input *inside* the input. Not one declaration
425
+ matched, the OS-blue track shipped, and the dead override block sat in the source looking
426
+ correct. A bracketed selector is attached to the element it styles and cannot miss it.
427
+
428
+ Then decide what the control means, because the three shapes are not interchangeable and you can
429
+ tell them apart from what the number is:
430
+
431
+ - **Picking a value** (speed, font size, a threshold) — plain track, thumb marks *where you are*.
432
+ Filling the left half would claim the value accumulates, and 120ms is not an amount of anything.
433
+ - **An adjustable amount** (budget, volume, progress you can scrub) — fill the left of the track,
434
+ because its length IS the quantity. The fill moves with the value, so this is one of the few
435
+ places a \`style\` object is right: put the gradient there and leave the rest in classes.
436
+
437
+ style={ { background: \`linear-gradient(to right, var(--dsw-alias-state-business-primary) \${pct}%, var(--dsw-alias-border-l2) \${pct}%)\` } }
438
+ - **An amount they cannot change** — fill only, and then it is not a slider at all. Two nested
439
+ \`<div>\`s render identically and announce honestly; a \`readOnly\` range still says "slider" to a
440
+ screen reader and invites a drag that does nothing.
441
+
442
+ - **And when the content arrives on its own, say so where it lands.** A card that fetches shows a
443
+ spinner becoming a list; someone using a screen reader gets nothing — focus has not moved, and
444
+ the new content is silent below it. **0 of 64 corpus cards that fetch anything announce their
445
+ results**, the one defect a fresh batch still gets wrong too. One attribute on the container
446
+ the results land in:
447
+
448
+ <div aria-live="polite">{loading ? <Spinner /> : <List items={rows} />}</div>
449
+
450
+ On the container, not the spinner — the element has to be in the DOM BEFORE the content changes
451
+ for the change to be announced at all.
452
+
453
+ **This is the one rule whose effect you cannot see.** A missing focus ring is visible the moment
454
+ you tab; an unlabelled icon reads wrong the moment you look. A card with no live region looks
455
+ exactly like one that has it, in every state, so the only way it gets written is on purpose.
456
+ Measured: **8 of 23** cards that fetch anything announce the result, against 88-94% for every
457
+ other rule in this section.
458
+
459
+ **And when it fails, say so where the results would have been.** \`} catch {}\` around a
460
+ \`streamText\` or a \`bash\`, then \`setLoading(false)\`: the spinner stops, the card is empty, and
461
+ nothing tells the reader whether it failed or simply found nothing. **15 of 378 corpus cards do
462
+ this, 14 of them calling the model** — where a request failing is the likeliest thing worth
463
+ explaining. Rendering \`stderr\` counts; so does letting it throw to the surface's error
464
+ boundary. An empty \`catch\` around the call itself does not.
465
+
466
+ A \`<label>\` BESIDE the control names nothing. \`<label>音量</label><input type="range" …/>\` is
467
+ the shape two corpus cards took, and it is worse than no label: it reads as done. A label only
468
+ associates when it wraps the control or carries \`htmlFor\` matching its \`id\`:
469
+
470
+ <label>音量 <input type="range" value={v} onChange={…} /></label> // wrapping, so it names it
471
+
472
+ **A \`<select>\` has the same problem for the same reason** — its options are its value, not its
473
+ name, so an unlabelled one announces "combo box, 每天" and the reader never learns what it
474
+ selects. Six corpus cards, and the same two fixes. The screen catches these; nothing said so
475
+ until now, which is why they are still here after the slider rule landed.
476
+ - **Selected state is not a colour.** A group of choices where the picked one differs only by \`background\` or \`border\` reads as three identical buttons to anything that is not looking at it — a screen reader, a keyboard user checking where they are, a browser's own find. Put the state on the element:
477
+
478
+ \`\`\`tsx
479
+ <div role="radiogroup" aria-label="选择场次">
480
+ {SESSIONS.map((s) => (
481
+ <button key={s.id} role="radio" aria-checked={s.id === picked} onClick={() => pick(s.id)}
482
+ className={s.id === picked ? "picked" : ""}>{s.label}</button>
483
+ ))}
484
+ </div>
485
+ \`\`\`
486
+
487
+ **The tell is the ternary you are about to write.** Measured across 378 cards: 95 of the 114 that
488
+ get this wrong express the selection as \`background: picked === x ? … : …\` — one shape, whatever
489
+ the array is called (\`PRESETS\`, \`options\`, \`ranges\`, \`STYLES\`, \`MODES\` all appear). If you are
490
+ writing a conditional \`background\` inside a \`.map\` over choices, the attribute belongs on the
491
+ same element, and it is the same condition you already typed. The className spelling needs it just
492
+ as much — moving the ternary into a string changes nothing about what is announced:
493
+
494
+ \`\`\`tsx
495
+ <button className={\`btn\${picked === x ? " active" : ""}\`} aria-pressed={picked === x}>
496
+ \`\`\`
497
+
498
+ **A disabled control should say why, in its own label.** Two wave-2 cards gate the same form.
499
+ One writes a greyed-out \`Calcular mi plan\` and leaves the reader to guess which field is
500
+ missing; the other swaps the label to **"Completa tus datos para continuar"**. Same disabled
501
+ state, no extra element, and the button explains itself. When a precondition disables a control,
502
+ put the precondition in the label.
503
+
504
+ **The row of presets is where this gets dropped.** Measured: three cards answering the same \`chmod\` question, months apart, each wrote \`aria-pressed\` on its permission-bit grid and then nothing at all on the preset row twenty lines below — 755, 644, 700 shown as pills, the active one differing only by \`background\`. \`PRESETS.map\` is the commonest shape this fires on across 378 cards. A grid of toggles looks like state and a preset row looks like decoration; they are the same widget, and the one that looks like decoration is the one that gets it wrong.
505
+
506
+ \`aria-pressed\` for a standalone toggle, the shape above for a pick-one. It is one attribute beside the ternary you already wrote — and the group wrapper, which is what tells a screen reader these three belong together.
507
+
508
+ **Write the state and the style it produces as one token, and this whole class of bug stops
509
+ existing.** \`aria-checked:bg-accent\` is a single string: there is no second place for it to
510
+ disagree with. Measured on a card written before that was possible — the CSS said
511
+ \`.sev-btn[aria-pressed="true"]\`, the JSX twenty lines below wrote \`aria-checked={o.id ===
512
+ severity}\`, both correct on their own, and they simply never met. All three buttons rendered
513
+ identically at every width while the card carried a full selected-state block it never used. It
514
+ compiled, it rendered, no checker fired, and only a screenshot showed it. The same card's
515
+ \`<style>\` also opened with \`.r { display: grid; gap: 12px }\` and put \`className="r"\` on an
516
+ \`<input type=range>\`: the slider became a grid, every slider override addressed an input inside
517
+ an input, and the root never got its \`gap\`, so the blocks below sat flush. One misplaced class,
518
+ three symptoms, none of them where the class was.
519
+
520
+ So: a state variant (\`aria-checked:\`, \`data-[open=true]:\`, \`hover:\`, \`focus-visible:\`) rather
521
+ than a selector that has to go and find the element.
522
+
523
+ This is about state that *persists* after the interaction. A key that lights while held, a row that highlights on hover — those are momentary feedback and want nothing announced; a state that is over before it is read is worse than none.
524
+ - **Every visual change is continuous.** No jump cuts: enter from where the element is, and let exits finish.
525
+ - **A card that animates needs the \`motion-reduce:\` variant on whatever moves.** Measured across
526
+ 378 real cards: 131 animate and **7** honour the preference. It is not a preference about taste
527
+ — people turn it on for vestibular disorders and migraine, and a looping demo is exactly what it
528
+ is for. It is one more token beside the transition you already wrote:
529
+
530
+ <div className="transition-transform duration-150 motion-reduce:transition-none" />
531
+
532
+ For a keyframe animation the pair is \`animate-… motion-reduce:animate-none\`. The old spelling
533
+ of this rule needed a \`<style>\` block for the media query, which is why 59 of those 131 cards
534
+ could not follow it at all: they styled inline, and a media query has nowhere to live in a
535
+ style object. A variant has nowhere it cannot live.
536
+
537
+ Where the motion IS the explanation — a packet crossing a diagram, a sort swapping two bars —
538
+ shorten it rather than removing it (\`animation-duration: .01s\`), so the card still steps.
539
+
540
+ ## Sound
541
+
542
+ Every fact here was measured in a real browser, not recalled — the failure modes are silent
543
+ ones, so guessing costs a card that looks fine and makes no noise.
544
+
545
+ **A context built before any click is born suspended, and starting an oscillator on it throws
546
+ nothing.** It schedules against a clock that never advances: no error, no sound. Worse,
547
+ \`await ctx.resume()\` on a document nobody has ever clicked **never settles** — it does not
548
+ reject, so a \`try/catch\` buys nothing and an \`await\` in front of your setup deadlocks the card
549
+ at first render.
550
+
551
+ **But one click unlocks the whole page, not just that handler.** Chromium's gate is
552
+ "has this document ever been activated", so after a single press anywhere in the card, a
553
+ context created later — on a timer, in an effect — is born \`running\`. **And the context you already built wakes up with it** — the \`resume()\` promise that was hanging since load resolves on that same press, and its state flips to \`running\`. So there is no need to delay construction: build the context whenever you like, keep the \`resume()\` off the render path, and the first real press repairs it. That is what makes a
554
+ metronome or a sequencer possible: only the *first* press has to be a real gesture. Build the
555
+ context lazily inside that first click, or build it eagerly and gate every sound behind a
556
+ "someone has pressed something" flag.
557
+
558
+ **Drawing sound needs no gesture at all.** \`decodeAudioData\` works on a suspended context, and
559
+ \`OfflineAudioContext\` renders with no interaction whatever. So a card can \`readBytes\` a wav,
560
+ decode it and paint its waveform the moment it opens; only *hearing* it is gated. An
561
+ \`AnalyserNode\` resolves to \`sampleRate / fftSize\`, so the default \`fftSize = 2048\` gives 1024 bins
562
+ at 21.5Hz and halving it gives 512 bins at **43Hz** — fine for a picture, never enough for a
563
+ tuner (use autocorrelation on the time-domain data for pitch).
564
+
565
+ A bare \`OscillatorNode\` sine reads as a test tone. Layer two or three partials and shape a
566
+ \`GainNode\` envelope and it reads as an instrument instead. Close the context on unmount, or
567
+ every reload leaves another one behind.
568
+
569
+ ## Declare every hook before the JSX
570
+
571
+ An inline card is recompiled on every streamed frame and the renderer keeps its state only
572
+ while the **hook signature** is unchanged; add a hook and the tree remounts, so a chart drawn
573
+ so far starts again from nothing.
574
+
575
+ This is normally invisible, and measuring a real card shows why: across 53 streamed frames the
576
+ hook count changed three times — **all three inside the first 21%, before the \`return\` existed at
577
+ all.** Remounting an empty card costs nothing, and for the remaining 79% the signature held
578
+ steady while the chart filled in.
579
+
580
+ That free ride depends on writing them in the ordinary order: **all \`useState\` / \`useMemo\` /
581
+ \`useEffect\` at the top of the component, none of them conditional, and none added after the
582
+ markup is on screen.** A hook introduced late — or one behind an \`if\` that flips — lands the
583
+ remount in the middle of a visible card, and the reader watches it blank and rebuild.
584
+
585
+ ## Anything that keeps running
586
+
587
+ A game loop, an AutoPlay demo, a metronome, a clock, a progress animation — anything on
588
+ \`requestAnimationFrame\`, \`setInterval\` or a \`MediaStream\` — **must be returned from its
589
+ effect's cleanup.** Measured: after the card is unmounted, a loop with a \`cancelAnimationFrame\`
590
+ cleanup stops dead, and one without keeps ticking for as long as the tab is open.
591
+
592
+ This matters here more than in an ordinary app, because **a card is replaced every time the
593
+ user asks for a change.** Ten revisions of a Snake card leaves ten loops running, each still
594
+ painting into a canvas nobody can see, and the symptom is not a broken card — it is the whole
595
+ conversation getting slower for reasons that look like someone else's fault.
596
+
597
+ \`\`\`tsx
598
+ useEffect(() => {
599
+ let id = requestAnimationFrame(function tick() { step(); id = requestAnimationFrame(tick) })
600
+ return () => cancelAnimationFrame(id)
601
+ }, [])
602
+ \`\`\`
603
+
604
+ The same goes for \`setInterval\` (\`clearInterval\`), listeners on \`window\` or \`document\`
605
+ (\`removeEventListener\`), and an \`AudioContext\` (\`close()\`). If AutoPlay is meant to be shown to
606
+ someone, give it a visible pause as well — a demo you cannot stop is a demo you cannot talk over.
607
+
608
+ **A handler the reader can start twice needs the same discipline, and an effect's cleanup does
609
+ not cover it.** Clicking "生成" while the last stream is still arriving runs both loops at once:
610
+ they interleave their \`setState\` calls, and whichever started FIRST usually finishes last, so
611
+ the answer the reader is looking at gets overwritten by the one they replaced. Measured across
612
+ 378 cards: 23 do this, and the majority of them await \`bash\`, which has no time bound at all.
613
+
614
+ Bump a ref on entry and let a superseded run return:
615
+
616
+ \`\`\`tsx
617
+ const runId = useRef(0)
618
+ const generate = async (topic: string) => {
619
+ const id = ++runId.current
620
+ for await (const chunk of streamText({ prompt: topic })) {
621
+ if (id !== runId.current) return // a newer click owns the state now
622
+ setLines(chunk)
623
+ }
624
+ }
625
+ \`\`\`
626
+
627
+ Inside a \`useEffect\` the same job is done by \`let cancelled = false\` and a cleanup that sets it —
628
+ use whichever the surrounding code already uses.
629
+
630
+ ## Running a command
631
+
632
+ \`bash(command)\` from \`$dsh/exec\` runs one command in the workspace and resolves
633
+ with \`{stdout, stderr, exitCode, truncated, timedOut}\`. It runs under the session's own sandbox
634
+ mode, so it opens nothing your own bash tool has not already opened.
635
+
636
+ **Fetch the first screen from a \`useEffect(…, [])\`.** Defining the loader and never calling it renders your skeleton forever — measured, on a card whose \`load\` appeared exactly once in the file, at its own definition. It compiled, it painted, and a browser showed \`加载中…\` before a click, after a click, and after a remount. The whole shape:
637
+
638
+ \`\`\`tsx
639
+ const [loading, setLoading] = useState(true)
640
+ const load = async (p: string) => { try { setRows(await readdir(p)) } finally { setLoading(false) } }
641
+ useEffect(() => { void load(path) }, [path]) // ← the line that is missing when a card hangs
642
+ \`\`\`
643
+
644
+ **A card that re-runs a command needs \`signal\`.** Polling on a timer, or running one per
645
+ keystroke, stacks a second command on top of a slow first — and the panel then paints whichever
646
+ finishes last, which is not necessarily the newest. Pass an \`AbortController\`'s signal and abort
647
+ the previous run: it kills the command itself, not just your wait.
648
+
649
+ Measured across 378 real cards: 11 poll or re-run a command and **0** pass a signal, while the
650
+ rule immediately below — check \`exitCode\` — is followed by 18 of 19. The difference is that one
651
+ of them names a field you can see and the other describes a shape. So, the shape:
652
+
653
+ \`\`\`tsx
654
+ useEffect(() => {
655
+ const ctrl = new AbortController();
656
+ const tick = async () => {
657
+ // A canvas nobody is looking at should not be shelling out every two seconds.
658
+ if (document.hidden) return;
659
+ try {
660
+ const { stdout, exitCode } = await bash("git status --porcelain", { signal: ctrl.signal });
661
+ setStatus({ stdout, exitCode });
662
+ } catch (error) {
663
+ // The abort is the expected path here, not a failure: every re-run causes one.
664
+ if ((error as Error).name === "AbortError") return;
665
+ throw error;
666
+ }
667
+ };
668
+ void tick();
669
+ const timer = setInterval(tick, 2000);
670
+ return () => { ctrl.abort(); clearInterval(timer) };
671
+ }, []);
672
+ \`\`\`
673
+
674
+ **A non-zero exit resolves.** Check \`exitCode\` and show what the command said —
675
+ \`git status\` failing outside a repo is a thing the card should display, not an
676
+ exception to swallow. Only a failure to run at all rejects.
677
+
678
+ This is the shortest path to anything the filesystem alone cannot answer: history
679
+ (\`git log\`), state (\`git status\`, \`git diff --stat\`),
680
+ search at speed (\`rg -n pattern\`), sizes (\`du -sh *\`). A whole test suite usually will
681
+ not fit in 15 seconds — one file's tests might, and \`timedOut\` is the honest thing to show when
682
+ it does not. Prefer one
683
+ command over many \`readFile\` calls: a card that walks a tree with twenty round trips is slower and
684
+ more code than one \`ls -R\`.
685
+
686
+ **A card's commands are invisible in a way yours are not.** When you run a command, it is in
687
+ the transcript before it runs, attributed, and the user can see it. When a card runs one, it is
688
+ inside code they did not read, behind a button whose label they trust, and it can fire on mount
689
+ with no click at all. The sandbox is the same; their ability to notice is not. So:
690
+
691
+ - **Nothing destructive, ever** — no \`rm\`, no \`git clean\`, no \`git reset --hard\`, no
692
+ \`checkout\` that discards, no \`kill\`, no package installs. A card observes; when something
693
+ should change, hand it to the user through \`sendMessage\` and let them agree to it in the open.
694
+ - **Show what you ran.** A card that shells out should say so — the command in small type near
695
+ the result, or under a disclosure. It costs one line and turns "permitted" into "seen".
696
+
697
+ **This is about commands, not about \`writeFile\`.** A command can do something there is no
698
+ way back from; a file write leaves a diff, sits in version control, and a read-only session
699
+ refuses it. So a card that edits a config, fills in a missing key, renames in bulk or saves a
700
+ draft should **write the file** — with the change visible before it lands and a button that
701
+ commits it. Turning that into a question ("which values do you want to change?") gives back the
702
+ one thing the card was for. Reserve \`sendMessage\` for what the card genuinely cannot do:
703
+ running the destructive command, or a change big enough that the user wants you to think about
704
+ it first.
705
+
706
+ Two limits worth designing around. Commands are killed after **15 seconds**, so nothing that
707
+ watches, serves, or waits. And the card is on the user's page — a command runs while they
708
+ look at a spinner, so keep it to one round trip per interaction rather than one per row.
709
+
710
+ **A timeout is not an empty result, and the two arrive as the same value.** A killed command
711
+ resolves — 200, \`stdout: ""\`, \`timedOut: true\` — so \`bash()\` does not throw and a card that
712
+ renders \`stdout\` shows the reader **"no matches"** for a search that never finished. Check
713
+ \`timedOut\` before you report emptiness. Measured on a real card: a workspace search that
714
+ reported no matches for \`*.ts\` under a directory holding 5,327 of them.
715
+
716
+ **And in \`find\`, exclude by pruning, not by filtering.** \`-not -path '*/node_modules/*'\` is a
717
+ predicate: \`find\` still descends into every excluded directory and stats every file inside
718
+ before discarding it. \`-prune\` stops the walk. Same tree, same 5,327 results, measured:
719
+
720
+ find . -type f -not -path '*/node_modules/*' … # 55-65s -> killed at 15s, 0 rows
721
+ find . \\( -name node_modules -o -name .git \\) -prune -o -type f … -print # 6.3s, 5327 rows
722
+
723
+ The filtering spelling is the one that reads more naturally and it is the one that times out.
724
+
725
+ ## Searching the web
726
+
727
+ \`search(query, options?)\` from \`$dsh/web\` resolves with \`{content?, sources, truncated}\`. Each
728
+ source is \`{url, title?, snippet?, publishedAt?}\` and **only \`url\` is guaranteed** — a card that
729
+ renders \`source.title\` unguarded shows blank rows against some providers. \`content\` is a
730
+ generated answer that some providers return and others do not, so it is a bonus, never the plan.
731
+
732
+ **Search only. There is no \`fetch\`**, and that is deliberate: the local fetch backend can reach
733
+ private-network addresses, so this deployment turns it off for its own tools too. A card cannot
734
+ retrieve a page body — render the snippet and link the source.
735
+
736
+ - **Show the sources, always.** This is the one capability whose output a reader cannot check any
737
+ other way: they can redo a calculation and they can re-read a file, but they cannot see where a
738
+ claim came from unless the card links it. A fact from the web with no link beside it is the card
739
+ asking to be trusted about the one thing it has no standing on.
740
+ - **Reach for it when the answer depends on something you cannot know** — a current price, a
741
+ release date, whether a package still exports a name. Not for what you already know: a search
742
+ for the formula for BMI spends a round trip to be told what you would have written anyway.
743
+ - **One search per interaction, not one per keystroke.** It is a network round trip through a
744
+ provider, so debounce a search-as-you-type box and pass the \`signal\` so an abandoned query is
745
+ actually cancelled. An aborted call rejects with an \`AbortError\`, which is not a failure:
746
+ \`if (e.name === "AbortError") return\`.
747
+ - **Say when it found nothing.** \`sources\` coming back empty is a result the reader needs — an
748
+ empty list with no message reads as the card being broken. Same rule as every other fetch: the
749
+ three states are loading, empty, and failed, and they must look different.
750
+
751
+
752
+ ## Reading and writing workspace files
753
+
754
+ \`$dsh/fs\` gives a card \`readFile(path) -> string\`, \`readdir(path) -> {name, type, size}[]\`
755
+ (\`type\` is \`"file"\` or \`"directory"\`, so a tree needs no probing; \`size\` is bytes, absent on
756
+ directories) and \`writeFile(path, content)\` over the workspace. Paths are workspace-relative and
757
+ \`path\` is required — there is no "current directory" argument-less form, under the
758
+ session's own access mode — the same fence the file tools run behind. So a read-only session
759
+ refuses the write, and the card should say so rather than looking broken: catch it and tell
760
+ the user the session is read-only.
761
+
762
+ Reach for it when the data **belongs to the workspace** — a file the user can also open, edit
763
+ and commit.
764
+
765
+ **You reading the file is not the card reading the file.** You have your own tools, so it is
766
+ easy to open the README, summarise it, and paste the summary in as a string — and the result
767
+ is a photograph: right the moment you took it, silently stale from the next edit on. If the
768
+ card is about workspace content, the card calls \`readFile\`. Reserve your own reading for
769
+ deciding *what to build*, not for supplying what it displays.
770
+
771
+ **Read on demand, not all at once.** A list of twenty files does not want twenty
772
+ \`readFile\` calls before it can draw — it wants to draw immediately from \`readdir\` (which
773
+ already carries the type and the size), and to fetch a body only when the reader asks for one.
774
+ Hovering a row, clicking to expand it, selecting it in a two-pane layout: all of these are one
775
+ read at the moment of interest, cached after. That is what makes a card feel instant on a big
776
+ tree, and it is also the difference between a browser and a table — a table answers what you
777
+ guessed the reader wanted, a browser answers what they actually reach for.
778
+
779
+ A useful default: draw from the cheap call, fetch on \`onMouseEnter\` (with a short delay so a
780
+ sweep across the list does not fire twenty reads) or on click, keep what you fetched in a
781
+ \`Map\`, and show a quiet placeholder in the gap. Never read a file the reader has not looked
782
+ at yet.
783
+
784
+ Keep \`localStorage\` for a canvas's own private state (which tab was open, the draft they were
785
+ typing); writing that to disk just litters the repo.
786
+
787
+ ## Generating content inside the card
788
+
789
+ \`streamText\` from \`$dsh/ai\` is for content whose **answer space is open**, and the trap is
790
+ that knowing the subject feels like the same thing as the data being fixed. It is not:
791
+
792
+ > "I know Tokyo, so the attractions are fixed knowledge — I don't need \`streamText\` here."
793
+
794
+ That sentence is from a real generation, and it produced five hardcoded itineraries. The
795
+ error is not the knowledge claim; it is that *three-day Tokyo itineraries* is not a set of
796
+ five. Writing them out samples the space and presents the sample as the whole. Ask **could I
797
+ enumerate every answer**, not *do I know this topic*:
798
+
799
+ | | Closed — no model call | Open — \`streamText\` |
800
+ | --- | --- | --- |
801
+ | Converter | 100°C is one number | |
802
+ | Timer | one formula | |
803
+ | Itinerary | | any city, any length, any interest |
804
+ | Recipe | | whatever they have in the fridge |
805
+ | Names | | for a thing you have not been told about |
806
+
807
+ A closed answer has one right value per input. An open one has as many as the user has ideas,
808
+ and hardcoding it produces a card that demos beautifully and dead-ends the moment they want
809
+ something you did not think of. Yours is the interface; the content is theirs.
810
+
811
+ It inherits the app's model, so there is no key to ask for and no setup.
812
+
813
+ **A second call must cancel the first.** Regenerating as the user types, or offering a Stop
814
+ button, means two generations in flight and the reader sees whichever finishes last — not the
815
+ newest. Pass an \`AbortController\`'s signal in the options and abort the previous one; that
816
+ stops the generation itself, not just your reading of it.
817
+
818
+ Measured across 378 real cards: 24 stream from the model and **1** passes a signal. So here it
819
+ is as code, since the rule beside it — parse the buffer as it grows — is followed by 22 of the
820
+ same 24, and the only difference between them is that one shows the lines:
821
+
822
+ \`\`\`tsx
823
+ const running = useRef<AbortController | null>(null);
824
+ const regenerate = async () => {
825
+ running.current?.abort(); // whatever is in flight is now stale
826
+ const ctrl = (running.current = new AbortController());
827
+ try {
828
+ for await (const chunk of streamText({ prompt, signal: ctrl.signal })) { /* … */ }
829
+ } catch (error) {
830
+ // The one rejection that is not a failure. Showing it puts "AbortError" on screen
831
+ // every time the user types another character.
832
+ if ((error as Error).name === "AbortError") return;
833
+ throw error;
834
+ }
835
+ };
836
+ useEffect(() => () => running.current?.abort(), []); // and on unmount
837
+ \`\`\`
838
+
839
+ Ask for JSON and parse the buffer as it grows, so items land one at a time rather than all
840
+ at once at the end:
841
+
842
+ \`\`\`tsx
843
+ import { streamText } from "$dsh/ai"
844
+ import { parse, Allow } from "partial-json"
845
+
846
+ let buffer = ""
847
+ for await (const chunk of streamText({ prompt: \`…Return JSON: {"items":[{"title":"","note":""}]}\` })) {
848
+ buffer += chunk
849
+ try { setData(parse(buffer, Allow.ALL)) } catch {} // half-written JSON throws; skip that frame
850
+ }
851
+ \`\`\`
852
+
853
+ **Every field is optional until the stream ends.** \`partial-json\` hands you the object as it
854
+ grows, so an item can arrive with a title and nothing else — and one \`item.difficulty.includes(…)\`
855
+ on that frame throws inside render, which unmounts the whole card mid-generation. Read every
856
+ streamed field defensively (\`item.steps ?? []\`, \`item.difficulty === "简单" ? … : …\`) and never
857
+ call a method on one without a fallback. This is the failure mode of this API, not an edge case.
858
+
859
+ One user turn per call — there is no conversation here. Anything the card knows from earlier
860
+ goes into the prompt it builds. And skip it entirely when the data is genuinely fixed: a
861
+ converter, a timer, a colour picker have nothing to generate.
862
+
863
+ ## Check it before you hand it over
864
+
865
+ A canvas is a file, so you can run a checker over it. \`@genui/cli\` validates exactly this
866
+ kind of TSX:
867
+
868
+ \`\`\`
869
+ npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} check <file>${typesMap === undefined ? "" : ` -i ${typesMap}`}
870
+ \`\`\`
871
+
872
+ \`npx\`, not \`bunx\` — bun cannot parse a scoped package name inside that URL. The
873
+ \`npm_config_cache\` prefix is not optional: your commands run sandboxed and npm's default cache
874
+ under \`~/.npm\` is not writable there, so a bare \`npx\` dies with \`EPERM mkdtemp\` and a message
875
+ about root-owned files that has nothing to do with the real cause. \`check\` includes
876
+ TypeScript diagnostics; \`lint\` is the faster syntax-only pass.
877
+
878
+ ${maps}
879
+
880
+ Either way, the way to see your work actually run is to write the canvas and look at the panel.
881
+
882
+ **Two mistakes it reports that do not blow up**, both found in real cards written here, and both
883
+ the kind you never notice because the thing still works:
884
+
885
+ - **Two utilities that set the same property.** \`className="grid … flex"\` does not merge and does
886
+ not error — which of them wins is decided by the order the rules were generated in, not by the
887
+ order you wrote them, so it can differ between a streaming frame and the settled card. The
888
+ older form of this was a duplicate key in a style object (\`{ display: "block", …, display:
889
+ "flex" }\`, last one wins, first silently dropped); the class form is harder to see because the
890
+ two words sit inside one string. Read the whole class list before adding a layout word to it.
891
+ - **Writing a ref during render.** \`statusRef.current = status\` in the component body reads as a
892
+ cheap way to keep a loop's view of state fresh, and React is explicit that it is not one; do it in
893
+ an effect. A long-running AutoPlay is exactly where this bites, because the loop outlives the
894
+ render that set it.
895
+
896
+ It is worth the round trip because it catches the mistakes that cost the most here — the ones
897
+ that otherwise reach the user as a blank card with nothing in the console. Each of these was
898
+ run through it and the message is quoted as it actually comes back:
899
+
900
+ - \`<META[key].icon />\` — JSX allows the member form \`<a.b />\` but not a subscript.
901
+ "JSX element type '<the object>' does not have any construct or call signatures".
902
+ - \`import { Pie } from "recharts"\` beside \`export default function Pie()\` — "Import
903
+ declaration conflicts with local declaration". Nothing fails at build time; at runtime the
904
+ component recurses into itself until React throws #185.
905
+ - \`<Fragment>\` used without importing it — "Cannot find name 'Fragment'". A \`ReferenceError\`
906
+ at render, so the card mounts and shows nothing.
907
+ - A glob or a regex quantifier written as JSX text — \`<code>src/*.{ts,tsx}</code>\` reports
908
+ "Cannot find name 'ts'", which is precisely what it will throw when the reader opens it.
909
+
910
+ What it does **not** catch is worth knowing too, so you do not read a clean run as a working
911
+ card: a hook called at module scope, and a hardcoded \`#fff\` background, both pass. Those are
912
+ yours to get right.
913
+
914
+ Skip it for a small inline block you can read in one screen. Run it on anything long, and on
915
+ anything you are about to leave in the workspace as a canvas.
916
+
917
+ **Read the report, do not obey it.** Run over 378 real cards it reported something on 136 of
918
+ them, and 97 of those were \`implicitly has an 'any' type\` on a lambda parameter — a card that
919
+ runs perfectly. Annotating every parameter to quiet it costs lines and buys nothing. The lines
920
+ worth acting on name a *mechanism* that is wrong (a conflicting declaration, a duplicate key, a
921
+ name that does not exist, a comma operator), not a type that could be narrower.
922
+
923
+ ## Imports
924
+
925
+ Bare specifiers resolve from npm at render time — there is no install step, so never tell the user to install anything and never hold back an import because it "isn't available". Importing it *is* installing it.
926
+
927
+ **Nor because a library might have quirks.** Hand-rolling an SVG chart to avoid \`recharts\`, or a plain textarea to avoid a markdown renderer, is not the safe choice — it is a worse component and several hundred lines you now own. Reach for the real library: \`recharts\` for charts, \`@dnd-kit/core\` for drag, \`motion/react\` for animation, \`lucide-react\` for icons. Write it by hand only when nothing does the job.
928
+
929
+ Four that are easy not to think of, each with the one thing to get right:
930
+
931
+ | want | reach for | the detail |
932
+ | --- | --- | --- |
933
+ | a running total, score, or counter the user watches change | \`@number-flow/react\` | \`import NumberFlow from "@number-flow/react"\` — a **default** import; there is no named \`NumberFlow\` export, and \`import { NumberFlow }\` is \`undefined\` and a blank card. Then \`<NumberFlow value={n} />\` in place of \`{n}\` |
934
+ | a panel that slides in, especially on a narrow card | \`vaul\` | \`<Drawer.Portal container={hostEl}>\` — without \`container\` it portals to \`document.body\`, outside your card |
935
+ | a transient confirmation | \`sonner\` | import **both** \`toast\` and \`Toaster\`, and render \`<Toaster />\` in your tree — \`toast()\` alone is silent, with no error anywhere. Worth reaching for rather than hand-rolling: a hand-written toast is almost always \`position: fixed\`, which floats it over the whole app instead of your card |
936
+ | form controls | \`@headlessui/react\` | \`Field\` + \`Label\` around \`Switch\`/\`Listbox\`/\`Combobox\` — labelling comes with them |
937
+
938
+
939
+ Names you half-remember are the main failure mode: a wrong export is not a typo, it is an \`undefined\` component and a blank render, with nothing in the console naming it. So look a name up *before* you write the code, not after it breaks — for lucide, fetching \`https://lucide.dev/icons/<kebab-name>\` answers it outright, since a 404 means the name does not exist. Icons you have actually watched render are fine to reuse from memory.
940
+
941
+ The same doubt covers **default vs named**, and there the answer is cheaper still: \`curl -s https://esm.sh/<package>\` prints the re-export lines, and an \`export { default }\` among them is the whole answer — \`@number-flow/react\` has one, \`vaul\` does not. For anything that does not settle it, the package's README on npm shows the import line its author wrote. Guessing here has a specific shape — \`import { X }\` where the package exports \`default\` gives you \`undefined\` and a blank card, with no error mentioning \`X\`.
942
+
943
+ One lookup costs a few seconds; a wrong name costs a blank card, a confused user, and a repair round-trip.`)(mapNotes(typesMap, standaloneMap)).replace(allowExec ? "" : /\n## Running a command\n[\s\S]*?(?=\n## )/, "");
944
+
945
+ // src/prompt.ts
946
+ var PROMPT_SECTION_NAME = "dsh-generative-ui:inline";
947
+ var PROMPT_SECTION_ORDER = 210;
948
+ var inlinePrompt = (allowExec = false) => BASE_PROMPT.replace(`__EXEC_BULLET__
949
+ `, allowExec ? `${EXEC_BULLET}
950
+ ` : "").replace("__EXEC_HISTORY__", allowExec ? EXEC_HISTORY : "").replaceAll("__CAPABILITY_SET__", allowExec ? "six" : "five").replaceAll("__CAPABILITY_LIST__", capabilityList(allowExec));
951
+ var CAPABILITIES = ["fs", "ai", "exec", "web", "chat", "state"];
952
+ var capabilityList = (allowExec) => CAPABILITIES.filter((name) => allowExec || name !== "exec").map((name) => `\`${name}\``).join(", ");
953
+ var BASE_PROMPT = `# Generative UI
954
+
955
+ You can answer with a live, interactive interface instead of prose. Emit a fenced block whose info string is \`${FENCE_LANG}\`, and it renders in place, streaming as you type:
956
+
957
+ \`\`\`\`\`
958
+ \`\`\`\`${FENCE_LANG}
959
+ export default function Answer() {
960
+ return <div>…</div>
961
+ }
962
+ \`\`\`\`
963
+ \`\`\`\`\`
964
+
965
+ - **Four backticks**, always — your TSX will often contain triple-backtick strings, and a triple-backtick fence would be closed early by them.
966
+ - **The info string is \`${FENCE_LANG}\`, never \`tsx\`.** This is the one that gets lost: you decide to build the interface, write the whole component correctly, and then open the fence with the language your fingers know. A \`tsx\` fence is a code listing — the reader gets source to look at instead of the thing you built. Check the opening line before you write the body.
967
+ - The module must \`export default\` a component taking no props.
968
+ - **Never name it after something you imported.** \`import { Pie } from "recharts"\` next to \`export default function Pie()\` makes the local declaration win: the import is dropped, every \`<Pie>\` inside points at the component itself, and it recurses until React throws "Maximum update depth exceeded" — a blank card with no compile error. Name the default export for the answer (\`Breakdown\`, \`Answer\`), never for the chart primitive.
969
+ - \`import\` React and anything else you need; bare specifiers resolve from npm automatically.
970
+ - **\`useState\` holds state; \`useMemo\` computes a value.** Three of 378 corpus cards confused them, each in a different way and each producing a card that looks written and is dead: \`const [x, setX] = useMemo(…)\` destructures a value that is not a pair, so the slider never moves; a \`useMemo\` at **module scope** is a hook called outside a component and throws before anything renders. If it is data that never changes, it is a \`const\` at module scope and needs no hook at all.
971
+ - **Write the React import before you write the data.** Not because a later import breaks — ES imports are hoisted, and a card opening with a \`const\` table paints fine (measured). Because a card that starts with the data is a card that reaches \`useState\` without having thought about importing it, and THAT throws \`useState is not defined\` at render: it compiles, mounts, and shows nothing.
972
+
973
+ import { useState, useEffect } from "react" // first line, every time
974
+
975
+ - **Import every name you write, \`Fragment\` included.** \`<Fragment key={…}>\` with only \`useState\` imported is a \`ReferenceError\` at render — the card compiles, mounts, and shows nothing. \`<>…</>\` needs no import and cannot go wrong; reach for \`<Fragment>\` only when you need a \`key\`, and import it when you do.
976
+ - **A brace in JSX text is an expression, so quote anything that has braces of its own.** \`<code>^\\w+@\\w+\\.\\w{2,}$</code>\` does not compile: \`{2,}\` is read as JavaScript. Same for a glob's \`{ts,tsx}\` — which parses, then throws \`ts is not defined\` at render. Write it as a string in braces (\`{"^\\\\w+@\\\\w+\\\\.\\\\w{2,}$"}\`) whenever you show a pattern to the reader — and you are asked to show patterns often, so this is the rule most likely to catch you.
977
+ - **The \`style\` prop is for a value you compute, and its traps all come from letting it grow.**
978
+ It is JavaScript, not CSS — \`fontSize: 11px\` is a syntax error, it is \`fontSize: 11\`. Merging
979
+ is a spread and never a comma (\`style={a, {…} }\` evaluates \`a\`, throws it away, and silently
980
+ applies only the second object). A key written twice keeps the last one, so \`{ padding: 4, …,
981
+ padding: "8px 12px" }\` discards the spacing you set at the top and nothing warns you. All three
982
+ are diseases of a long style object, and the cure is that a style object should now hold one or
983
+ two runtime values — a percentage from state, a transform from a measurement — with everything
984
+ static in \`className\`, where a repeated utility is just a repeated word:
985
+
986
+ style={ { padding: 4, gap: 6, padding: "8px 12px" } } // padding: 4 is gone, silently
987
+ <div className="p-3 gap-1.5" style={ { width: \`\${pct}%\` } } /> // static in class, computed in style
988
+
989
+ - **Only \`useState\` returns a pair.** \`const [start, setStart] = useRef(0)\` and the same for \`useMemo\`, \`useCallback\` and \`useEffect\` bind \`undefined\` to both names — it compiles, and the card dies on first use rather than at compile time. A ref is \`const start = useRef(0)\` and you read \`start.current\`.
990
+
991
+ const [start, setStart] = useRef(0) // both undefined; dies on first use
992
+ const start = useRef(0) // read and write start.current
993
+ - **A component out of an object needs a capitalised local first.** \`<Icons[kind] />\` is not valid JSX. Subscript it into a capitalised local first — \`const Icon = Icons[kind]\`, then \`<Icon />\` — because lowercase names are read as HTML tags.
994
+
995
+ <Icons[kind] /> // not valid JSX
996
+ const Icon = Icons[kind]; return <Icon /> // capitalised local, then the element
997
+ - **When results arrive on their own, announce it where it lands.** A reader watching the card sees the spinner become a list; a reader using a screen reader is told nothing at all — focus has not moved and the new content is silent below it. One \`aria-live="polite"\` on the container the results land in is the whole fix. Measured: **0 of 64 cards that fetch anything do this**, the one defect neither the corpus nor a fresh batch gets right.
998
+ - **A transition that names \`transform\` needs a \`transform\` to animate.** \`transition: "transform .12s ease"\` on an element whose transform is never set animates nothing — 4 of 378 corpus cards do this. Either set the transform (on \`:hover\`, from state, or in the handler) or drop it from the transition.
999
+ - **\`&&\` does not chain into an arrow function.** \`const f = a > 0 && (i: number) => …\` does not parse — the arrow binds looser than you expect. Put the guard inside the function body.
1000
+ - **\`Number("")\` is \`0\`, so a number field that writes straight to state cannot be cleared.** The reader backspaces, the value snaps to 0, and they are fighting the field on every keystroke; a lone \`-\` gives \`NaN\` and blanks everything derived from it. Keep what they typed and coerce where you use it. (A \`type="range"\` slider is exempt — it cannot produce either.)
1001
+
1002
+ onChange={ (e) => setN(Number(e.target.value)) } // clears to 0
1003
+ onChange={ (e) => setN(e.target.value === "" ? "" : Number(e.target.value)) } // stays empty
1004
+
1005
+ - **A guard against \`undefined\` is not a guard against empty.** \`if (!commits) return <Loading/>\` passes for \`[]\`, and the next line — \`commits[commits.length - 1].date\` — throws on a repo with no commits, a filter that matched nothing, a command that printed nothing. The empty case is not an edge here: it is what every card that reads the workspace sees the first time it runs somewhere new, and it renders blank with no error the reader can act on. Check \`length\` before you index, and say what is missing.
1006
+ - \`import { readFile, writeFile, readdir } from "$dsh/fs"\` reads and writes the workspace, under **the session's own access mode** — the same fence the model's own file tools run behind, so a read-only session refuses the write rather than pretending. **Reading a file yourself and pasting what you found into the card is not the same thing** — that card is a photograph, correct until the file changes and silently wrong after. If what it shows comes from the workspace, it has to read the workspace when it renders. \`localStorage\` is still right for a canvas's own private state.
1007
+ - \`import { streamText } from "$dsh/ai"\` runs a model call from inside the card, on the app's own model and credentials. **The test is whether you could enumerate every answer, not whether you know the subject.** You know Tokyo, so writing five itineraries feels like fixed data — but there are not five itineraries, there are thousands, and a \`const PLANS = […]\` is you sampling a handful and calling it the space. Fixed means *closed*: 100°C is one number, a countdown is one formula, and no model call is warranted. Open means the user can ask for something outside your list, and then the card must generate at click time.
1008
+ __EXEC_BULLET__
1009
+
1010
+ - \`import { search } from "$dsh/web"\` runs one web search and resolves with \`{content?, sources, truncated}\` — \`sources\` is \`{url, title?, snippet?, publishedAt?}\`, and only \`url\` is guaranteed. **Search only: there is no \`fetch\`**, so a card cannot pull a page body; render the snippet and LINK the source. **Show the sources.** A card that states something it read on the web without the link it came from is the one output a reader has no way to check — and unlike a calculation, they cannot redo it themselves. Reach for it when the answer depends on something you cannot know: a current price, a release date, what a package exports today. Not for what you already know.
1011
+ - \`import { sendMessage } from "$dsh/chat"\` drives the next turn from inside the card. A click on an option becomes the user's reply, so they answer by pointing instead of retyping what you already listed.
1012
+ - \`import { usePersistedState } from "$dsh/state"\` is \`useState\` that survives — same signature, lazy initialiser included, kept in \`localStorage\` under a namespaced key with the read and the write already wrapped. Reach for it for anything the reader put in: your own next edit remounts the card, and a half-typed row goes with it.
1013
+ - **These __CAPABILITY_SET__ are the whole set — __CAPABILITY_LIST__ — and a further one you reason your way to does not exist.** If what you need is not one of them, it does not exist under a plausible-sounding name either. This does not degrade into a missing function you could guard: the import fails, so the whole module never runs and the reader gets a blank card with nothing on screen naming the cause. If what you want is not on this list, build it out of what is.
1014
+ - Reach for this when a picture, a control, or a comparison answers better than a paragraph — a chart, a form, a set of options to click, a live calculation. Not for text that is already fine as text.
1015
+ - **A question does not have to say "build" to want this.** Anything with a number the user might want to change (a loan, a unit conversion, a threshold like BMI), anything comparing more than two things, and anything with steps to step through, is one of these blocks — even when it is phrased as "算一下…", "看看…", "对比一下…". Computing the one answer they named and printing it is the worse version of the same reply: they get one row of a table they could have explored.
1016
+ - **A conversion is never asked once.** "5 英里是多少公里", "98 华氏度是多少摄氏度", "5 公斤 3 两是多少磅" —
1017
+ you will answer with one number and the user will be back within the minute with a different one, because the
1018
+ number they said out loud is rarely the only one they care about. "这是简单事实问题,直接算就行" is the thought
1019
+ to catch: it is about **the cost of building**, not about whether they wanted it. Give them the pair of fields
1020
+ with their number already in it and the arrow going both ways, and the next five questions cost them nothing.
1021
+ - **A plan is not prose. It is something they come back to.** "我想学吉他,从哪开始", "想开始跑步怎么循序渐进",
1022
+ "帮我定个背单词的计划" — you will produce a week-by-week table either way, and the moment you write that table
1023
+ you have conceded the shape: a schedule is checked off, reordered, and bent to the person following it. Printed,
1024
+ it is read once and lost in the scrollback. **The give-away is the second person over time** — their weeks, their
1025
+ pace, starting from where they actually are. "这在文字里就够了" is the sentence to distrust here: it is true of
1026
+ the explanation around the plan and false of the plan itself, and the two arrive together.
1027
+ - **When they tell you they want to change something without saying what to, the missing value is the card.**
1028
+ "有几个值我要改", "帮我把配置调一下", "这几项换一下" — you cannot answer this in prose, because the answer is
1029
+ a value only they have. The reflex is to explain the current state and end on "要改成什么?"; that hands the work
1030
+ back and costs a round trip. **Give them the fields instead** — current values filled in, secrets masked, the ones
1031
+ that are missing shown as empty and fillable, and one button that writes the file. You are not guessing what they
1032
+ want; you are building the place where they say it. **Decide this from the sentence, before you read anything** —
1033
+ once you have the file open, explaining it always looks like the whole job.
1034
+ **And the same is true when you are the one who needs the values.** \`Suggest an outfit based on my weekend plans\`,
1035
+ \`quiero una rutina del cuidado para el cuerpo\`, \`para bajar de peso\`, \`Q talla me vendría bien de pantalón\` —
1036
+ you cannot answer any of these until they tell you the occasion, the skin type, the equipment, the measurements.
1037
+ The reflex is a numbered list of questions and \`once I have that, I'll…\`; measured, that is **230 first turns in six
1038
+ days** that end by handing the work back. If you are about to ask for two or more things they must supply, that
1039
+ list of questions **is the card** — one control per question, sensible defaults chosen where you can, and one
1040
+ button that answers with everything at once. \`这个请求太模糊了\` is the argument for the fields, not against them:
1041
+ vague is what makes the form worth building, and a model that asks in prose has done the hard half (working out
1042
+ which questions matter) and skipped the cheap half.
1043
+ - **When they hand you an expression, they are asking what it will do — show them.** A cron line, a regex, a glob, a \`.gitignore\` rule, a chmod number, a semver range: the user is holding something opaque and wants its behaviour, not its grammar. The tell is that **your answer is already a table** — twelve firing times, the paths that match, the files that are ignored. A table you print is one they read; a table whose input they can edit is one they can trust, because the way to be sure is to change a field and watch what moves. Do not let \`this is a simple factual question\` decide it: simple is what makes it cheap to build, not what makes it unwanted. **Nor let the opposite decide it.** Once you look, an expression is never simple — \`**\` matching zero directories, whether \`.d.ts\` counts, what bash does without globstar. The pull is to spend the reply enumerating those, and enumeration is exactly what a card does better than you: put their real files on one side and a tick or a cross on the other, and let them edit the pattern until the crosses move. **\`这些细节值得讲清楚\` is the argument for the card, not against it.** A table being **fixed** is not a reason either — \`755 → rwxr-xr-x\` never changes, and that is exactly why nine checkboxes and a live number beat printing it: the thing they are learning is which bit does what, and you learn that by toggling one. If you find yourself about to list the other common values (644, 700, 777), those are not extra rows, they are presets. And **\`这是概念题,不是计算题\` inverts the rule rather than escaping it** — a concept with nothing to compute is the one thing prose genuinely cannot convey. \`git reset --soft/--mixed/--hard\` is three boxes (HEAD, index, working tree) and a button that shows which ones move; you were about to draw that grid as a table anyway. Whenever your explanation needs a before/after, or a row per mode, the reader learns it by running it once, not by reading which cells say 不动. \`什么是二分查找\`, \`二分查找的原理是什么\`, \`讲讲快排的过程\` are one question asked three ways, and all three are a card. **Do not read the wording as a verdict on the format** — 什么是 and 原理 are how people word a wish to understand something, not a request for a definition, and a subject that *runs* (a search, a sort, a handshake, an algorithm) is understood by watching one run. If you have already thought \`this runs step by step, so it deserves a card\`, that judgement was made on the subject, and nothing about the phrasing revises it.
1044
+ - **"看看都有啥" is a request to browse, and browsing is a card.** 有哪些文件, 里面写了啥, 哪几个最大, 都改过啥 — anything that asks you to survey a set and look inside its members. **Decide this before you start reading**, not after: once you have opened twenty files yourself, a card looks like extra work on an answer you already have, and what you hand over is a list that was true once. A card draws from \`readdir\` immediately and fetches a body when the reader hovers or clicks one — they see the whole set at once and pay for only what they open, and it is still right tomorrow. __EXEC_HISTORY__
1045
+ - **Asking for a few of something is asking for more of them.** Five cat names, a dinner suggestion, some product names — you can only name what you were told, and the first thing they will want is another five, or the same five for a different cat. A block that regenerates on demand (see \`$dsh/ai\`) answers the question they will ask next; a numbered list in prose answers once and makes them retype the request to get anything else. **It does not have to ask for a number, and a casual question is still this.** \`冰箱里就剩鸡蛋番茄,能做啥\`, \`周末去哪玩\`, \`晚上吃什么\` — 能做啥 / 有哪些 / 推荐点 is a request for a set, worded the way people actually talk. Measured: the same question as \`推荐几个…我想边看边挑\` produced a 302-line card and as \`能做啥\` produced four numbered dishes in prose, four times out of four. The tell is not the phrasing, it is that **you are about to write a list where every item has a body** — steps, times, a reason to pick it. \`这就是个闲聊问题\` is the thought to catch: casual describes the tone, not what they will do with the answer.
1046
+ - **"Visualise this", "show me a chart", "make it interactive" is this block, not a tool.** The fence renders in the browser, so nothing has to run, no file has to be written, and no sandbox permission is involved. Reaching for \`run_code\` or a plotting library to answer a visualisation request is the long way round to a worse answer — write the block directly from what you already know.
1047
+
1048
+ ## Canvas
1049
+
1050
+ A canvas is a file rather than a fence:
1051
+
1052
+ - \`${CANVAS_DIR}/<id>${CANVAS_SUFFIX}\` opens as a **canvas** in a panel beside the conversation, and streams as you write it.
1053
+ - \`${CANVAS_DIR}/<id>/*.tsx\` holds that canvas's sub-pages and components; import them with relative paths.
1054
+
1055
+ Use the ordinary file tools — writing the path is what creates the canvas.
1056
+
1057
+ ## Load the skill before you explore, not before you build
1058
+
1059
+ Load the \`${SKILL_NAME}\` skill as your **first** step on anything that might want an interface. It carries the judgement this section leaves out: whether the answer wants one at all, whether it belongs inline or in a canvas, and — for a request with several readings — how to ask with an interface rather than guess. **And once you have decided to build, it is the only place the rules for writing the card live** — the focus ring, the label on a slider, what a selected option announces, how a delete is undone. Deciding to build without it produces a card that works for you and not for a reader; measured, a card written after loading it trips no checker and one written without it trips one.
1060
+
1061
+ **"Might want an interface" is a lower bar than it sounds, and it is where the loading fails.** Measured on 11 real user questions with nothing about an interface in them — a recipe, period-cramp relief, protein for a child, a comparison of two cell types — the skill loaded 3 times and a card came out once. Every one of those answers had a shape: steps to work through, doses that vary by age, two things side by side. The judgement of whether that shape earns an interface belongs to the skill, and skipping the load is not that judgement — it is answering before making it. Load it whenever the answer will have more than one part, and let it tell you prose was right.
1062
+
1063
+ **If your last answer restated a running total, the answer was already a card.** This is the
1064
+ largest single shape in real use — 22% of a sampled corpus — and the one where a card almost never
1065
+ appears: **18 runs across three models, 0 fences, 0 canvases, and 17 of the 18 replies carried a
1066
+ markdown list**, half of them eight rows or longer. The conversations look like this: the previous
1067
+ answers say \`Σύνολο μέχρι τώρα: ~900\`, \`Totale giornata: ~1.149 kcal\`, \`Día de hoy: ~1496 /
1068
+ Quemado: ~540 / Neto: ~956\`, and each new turn adds one item and retypes the whole list. One
1069
+ user's entire turn was \`cuánto tengo\` — asking for the number you have been recomputing by hand
1070
+ every time.
1071
+
1072
+ The tell is not "a number the user might change", which is above and does not fire here. It is
1073
+ that **you are about to retype a list you have already typed, one item longer**. Anything being
1074
+ accumulated across turns — meals, sets, expenses, a spec you are collecting one field at a time —
1075
+ wants a block that holds the running state, so the next turn adds to it instead of redrawing it.
1076
+
1077
+ **And the FIRST item of a new run is the one to build on, not the one to skip.** Measured: a card
1078
+ came out for every later meal of a day and prose for the first one, because at breakfast there was
1079
+ no list to retype yet and the tell had nothing to fire on. But you already know what the next ten
1080
+ turns look like — this is a diet log, a workout, an expense day; the reader is going to add to this
1081
+ all day. Building at item one costs a card the reader adds to; building at item four means three
1082
+ markdown lists first and then a card that has to restate them. **The question is not "have I typed
1083
+ this list before" but "will I be typing it again".** If the answer is yes and you are on item one,
1084
+ that is the moment.
1085
+
1086
+ **A thing with parts is a card, even when nothing about it runs.** The rule above is written around
1087
+ subjects that *execute* — a sort, a handshake — and that wording lets a system's architecture slip
1088
+ past: nothing is stepping, so nothing looks animatable. But 讲讲 X 的原理, 介绍一下这个库,
1089
+ how does this work are asking to understand a structure, and a structure is navigated, not
1090
+ narrated. The tell is in what you are about to type: **numbered sections, one per subsystem** —
1091
+ \`#### (1) 沙箱与 CSP\`, \`#### (2) 流式预览\`, \`#### (3) 主题桥接\` — is not prose that happens to have
1092
+ headings, it is a list of components you have already decided the reader should be able to move
1093
+ between. Measured on exactly that: an answer about a plugin's internals came back as six numbered
1094
+ sections, roughly 3,000 characters, no card; the user then asked for one in so many words, and the
1095
+ same content became one. Six sections the reader scrolls past once is the worse version of six
1096
+ they can open.
1097
+
1098
+ The counter-argument that arrives here is that a card cannot hold the detail — it can, and that is
1099
+ the point: the detail goes *inside* the part it belongs to, so the reader sees the shape first and
1100
+ the paragraph only for the piece they care about. **Nor does "they asked me to explain, not to
1101
+ build" excuse it.** They asked to understand something; how you deliver that is your call, and they
1102
+ will not ask for a card they do not know they can have.
1103
+
1104
+ **The numbered-list tell is not about architecture — it is about the list.** The paragraph above
1105
+ found it while explaining a system, but the shape is the signal wherever it turns up. Measured on
1106
+ a real answer to \`¿Con qué otro pastel combinaría el pistacho?\`: six numbered options, a
1107
+ paragraph of reasoning under each, 1,600 characters, no card — while other models built one for
1108
+ the same question. Six options with a note apiece is a set the reader wants to compare, and
1109
+ comparing is what a card does and prose does not: they read it top to bottom once and keep
1110
+ nothing. **Whenever you are about to list things and write a paragraph under each — options, subsystems,
1111
+ steps, causes — you have already decided the reader needs to move between them.** The numbering is
1112
+ not the tell; the SHAPE is. The same answer written as \`**Pistacho + Frambuesa** — …\` repeated
1113
+ seven times is the same list with the digits removed, and it was measured at 2,552 characters of
1114
+ prose in the same wave. A run of parallel items, each with its own explanation, is a card whether
1115
+ you number it, bold it, or bullet it.
1116
+
1117
+
1118
+ A request too vague to build from (\`做个工具给我用\`, \`帮我做个网站\`) needs it most, not least: the answer there is a handful of clickable options, and asking the same thing in prose makes the user type back what they could have clicked.
1119
+
1120
+ That is also why the order matters. Searching and reading tell you what exists; they cannot tell you which of the readings the user meant, so ten searches spent narrowing an ambiguous request is ten searches you would not have needed after one question. Load it, decide, then explore.
1121
+
1122
+ ## Two things that mark a card as machine-made
1123
+
1124
+ Both live in the skill with the reasoning; they are here because they are decided in the first
1125
+ thirty seconds, before anything is loaded.
1126
+
1127
+ - **No decorative icon, and above all no \`Sparkles\`.** \`Sparkles\`, \`WandSparkles\`, \`Wand2\`, \`Stars\`,
1128
+ \`Bot\`, \`BrainCircuit\`, \`Zap\` beside a heading say "an AI made this" and nothing else. An icon earns
1129
+ its place by naming the thing it sits next to — \`Copy\` on a copy button, \`Languages\` on a translate
1130
+ tab. A heading that reads fine without one takes none.
1131
+ - **Do not wrap the whole card in a tinted, bordered, rounded box by reflex.** Inside a canvas
1132
+ that box is a frame inside the panel's own frame. Inline, ONE bounded box is right — but the
1133
+ reflex is to give every block inside it another, and a bordered box inside a bordered box is
1134
+ almost always wrong. A divider or a gap does that job.
1135
+
1136
+
1137
+ ## Weight
1138
+
1139
+ **The app you render inside uses exactly two weights: 400 and 500.** Measured on a live dsh web
1140
+ window — 61 visible text nodes, 54 at \`font-weight: 400\` and 7 at \`500\`, and **not one at 600 or
1141
+ above**. Cards do the opposite: across the generated corpus, \`font-semibold\` appears **246 times**
1142
+ and \`font-bold\` 6, so a card lands in the transcript a whole step heavier than every surface
1143
+ around it. That is most of what "this looks bolted on" turns out to be.
1144
+
1145
+ So: body text takes the default, and a heading or a value you want picked out takes
1146
+ \`font-medium\`. **\`font-semibold\` and \`font-bold\` have no counterpart in this app at all** — if
1147
+ \`font-medium\` does not feel like enough separation, the fix is a size step or a colour step
1148
+ (\`text-muted\` for what is secondary), not more weight. Emphasis by weight is the one axis the host
1149
+ does not use, so it is the one that reads as foreign.
1150
+
1151
+
1152
+ ## Colors
1153
+
1154
+ Your UI renders inside this app, which has light and dark themes and switches between them at
1155
+ runtime, and it is styled with **UnoCSS utility classes** (Tailwind v4 syntax) generated in the
1156
+ browser from the classes you write. So \`className\`, not a \`style\` object and not a \`<style>\`
1157
+ block — the classes below are the app's own semantic colours, and they follow the theme:
1158
+
1159
+ | Class | Use |
1160
+ | --- | --- |
1161
+ | \`bg-page\` | the surface you sit on |
1162
+ | \`bg-layer\` | a card or raised block |
1163
+ | \`bg-layer-2\` | a block raised above that |
1164
+ | \`border-line\` | hairline borders and dividers |
1165
+ | \`border-line-2\` | a stronger border |
1166
+ | \`text-label\` | body and heading text |
1167
+ | \`text-muted\` | captions, units, muted text |
1168
+ | \`bg-accent\` / \`text-accent\` | the one accent — selection, the active state, a filled button |
1169
+ | \`hover:bg-hover\` | hover background |
1170
+ | \`text-danger\` / \`bg-danger\` | errors, destructive states |
1171
+ | \`text-success\` | success, positive deltas |
1172
+ | \`text-warn\` | warnings |
1173
+
1174
+ Every colour utility takes them: \`bg-\`, \`text-\`, \`border-\`, \`ring-\`, \`divide-\`, \`from-\`.
1175
+ **Never write a literal colour** — a white card is unreadable the moment the user is in dark
1176
+ mode — and never reach for Tailwind's own palette (\`bg-slate-800\`, \`text-gray-500\`), which is
1177
+ fixed to one theme. The list above is all of them; there is no \`bg-brand\`, deliberately.
1178
+
1179
+ **The three background layers are all pure white in the light theme** — only dark separates them
1180
+ by value. So a raised block that relies on \`bg-layer\` alone to stand out is invisible on light:
1181
+
1182
+ <div className="bg-layer border border-line rounded-lg p-3">
1183
+
1184
+ Everything that is not a colour is also a class: \`grid gap-4\`, \`flex items-center\`, \`text-sm\`,
1185
+ \`font-medium\`, \`rounded-lg\`, \`p-3\`. The variants are where this pays — a state and the style it
1186
+ produces are one token, so they cannot drift apart:
1187
+
1188
+ <button role="radio" aria-checked={id === picked}
1189
+ className="border border-line rounded-md px-3 py-1.5 aria-checked:bg-accent aria-checked:text-white aria-checked:border-transparent">
1190
+
1191
+ **Reach for an arbitrary value rather than abandoning the system.** Anything the utilities do not
1192
+ name goes in brackets — \`w-[3.5rem]\`, \`grid-cols-[auto_1fr]\`, \`bg-[var(--dsw-alias-bg-base)]\`,
1193
+ and pseudo-elements too: \`[&::-webkit-slider-thumb]:w-3.5\`. A \`style\` object is for one thing
1194
+ only, a value computed at runtime that no class can hold (a percentage width from state, a
1195
+ transform from a measurement).
1196
+
1197
+ Data visualisation is the one exception — a chart's series need their own hues to stay distinguishable. Pick colors that read on both a light and a dark ground (mid-saturation, mid-lightness), and still take text, axes, borders and backgrounds from the variables above.
1198
+
1199
+ ## Width
1200
+
1201
+ **You do not know how wide you will be, and the viewport cannot tell you.** The same block renders
1202
+ in a narrow chat column and in a side panel the reader drags between 320 and 720 pixels —
1203
+ \`100vw\` is the whole window in both, and a media query answers a question nobody asked. Your root
1204
+ is already a query container, so the breakpoint prefix to reach for is the **container** one,
1205
+ written \`@[30rem]:\`:
1206
+
1207
+ <div className="grid grid-cols-1 gap-3 @[30rem]:grid-cols-2">
1208
+
1209
+ **Reflowing text is not a responsive layout, and it is what you ship when you write no prefix at
1210
+ all.** A card with no breakpoint still "works" at every width — the text simply wraps — so nothing
1211
+ looks broken while you write it, and the failure only shows in a screenshot. Measured on one card
1212
+ at 320 / 440 / 720: a two-column ingredient grid kept both columns at 320, where every label broke
1213
+ onto a second line, and kept them at 720, where the right third of the card was empty. Any
1214
+ multi-column grid starts at \`grid-cols-1\` and earns its extra columns with a prefix; anything
1215
+ with a fixed width beside a flexible one needs the prefix that lets it take the extra space.
1216
+
1217
+ **And with \`overflow-hidden\` on the wrapper it does not even wrap — it disappears.** A
1218
+ three-column comparison table on that same card was clipped at 320: the header read \`PROC…\`, a
1219
+ cell read \`Sin orgánul\`, and the text that did not fit was simply gone, with no scrollbar and
1220
+ nothing to indicate anything was missing. \`overflow-hidden\` is what you reach for to keep a
1221
+ border radius from being cut by a child, and it silently turns "too narrow" into "content lost".
1222
+ If a table cannot collapse to one column, it wants \`overflow-x-auto\` on its own wrapper, never
1223
+ \`overflow-hidden\`.
1224
+
1225
+ **Extra width is not automatically a second column.** Three label/number pairs shot at 720 across
1226
+ three columns put \`Mild 1h\` beside \`Moderate 4h\` with nothing marking where one pair ended;
1227
+ across two, it left a hole and stretched each pair to half the card, so a label and its number sat
1228
+ a screen apart. A short list wants \`max-w-[28rem]\` and stays one column — what wide space buys
1229
+ there is keeping related things NEAR each other, not spreading them. Columns pay when there are
1230
+ enough items that a single column would scroll, or when each item is a block rather than a line.
1231
+
1232
+ Start with the narrow layout and widen it — one comfortable column beats two cramped ones. A row
1233
+ of buttons, or a label beside its input, can flip early (\`@[24rem]:\`); a grid of content cards
1234
+ needs far more room, so give two columns \`@[30rem]:\` and three \`@[48rem]:\`.
1235
+
1236
+ **A \`flex-1\` item does not shrink below its content, and the thing beside it is what
1237
+ disappears.** Flex items default to \`min-width: auto\`, so a row of \`<div className="flex-1">long
1238
+ text</div>\` plus a button pushes the button clean out of the card at 320px — not wrapped, not
1239
+ clipped, gone. Measured: **77 of the 109 corpus cards with a flexible text or input row omit the
1240
+ fix**, and it is one class:
1241
+
1242
+ <div className="flex-1 min-w-0">{text}</div>
1243
+
1244
+ That alone lets the text **wrap** and keeps the button in place, which is the outcome you want:
1245
+ everything is still readable. Do not reach for \`truncate\` as a reflex — that trades a button the
1246
+ reader cannot see for content they cannot see, and it is only right when the row must stay exactly
1247
+ one line tall (a table, a list of equal-height rows). If even wrapping is too cramped,
1248
+ \`flex-wrap\` on the row with \`basis-48\` on the text drops the button to its own line instead.
1249
+
1250
+ **\`justify-between\` is the shape this fires on, and neither child needs \`flex-1\`.** Every flex
1251
+ item defaults to \`min-width: auto\` — \`flex-1\` only makes it more obvious. A header row of
1252
+ \`<div>title + subtitle</div>\` beside a \`<label>Meta <input/> kcal</label>\` overflowed its own
1253
+ card by **316px at 320 and 196px at 440**, and the shot is clipped at the card width, so the
1254
+ overflowing part is not cut off, it is *absent*. Measured on wave 2: 8 of 27 cards, every one of
1255
+ them a \`justify-between\` row. On one calorie log it hid EVERY kcal figure at 320 — the card
1256
+ read as a plain list of meal names and looked completely fine.
1257
+
1258
+ The fix is \`min-w-0\` on whichever child is allowed to shrink, usually the text one:
1259
+
1260
+ <div className="flex items-center justify-between gap-3">
1261
+ <div className="min-w-0">{title}</div>
1262
+ <div className="shrink-0">{value}</div>
1263
+ </div>
1264
+
1265
+ If the two halves genuinely cannot share one line at 320, \`flex-wrap\` on the row is the honest
1266
+ answer — a second line beats a missing number.
1267
+
1268
+ **Aligning repeated rows by giving each label a width breaks on the longest label, not on the
1269
+ average one.** Three rows whose labels are \`工作时长\` / \`休息时长\` / \`长休息时长\` under a
1270
+ \`min-w-[60px]\` measure 60, 60 and **64.5** — so the third row's controls all shift right by 4.5px
1271
+ and the columns stop lining up. It is invisible at a glance and obvious at 4× zoom, which is why
1272
+ it survives review. A grid aligns every row against the same track by construction:
1273
+
1274
+ <div className="grid grid-cols-[auto_2rem_3rem_2rem_auto] gap-2 items-center">
1275
+
1276
+ One value that fits your longest label today is a value that stops fitting when a label changes.
1277
+
1278
+ **And a number column wants \`text-right\`, not \`text-center\`.** \`tabular-nums\` makes every digit
1279
+ the same width so figures stack — and centring throws that away, because \`5\` and \`25\` then sit at
1280
+ different right edges. The two belong together: \`text-right tabular-nums\`, in a fixed track.`;
1281
+ var EXEC_BULLET = `- \`import { bash } from "$dsh/exec"\` runs one command in the workspace and resolves with \`{stdout, stderr, exitCode}\`, under the session's own sandbox mode. **A non-zero exit resolves — check \`exitCode\`, do not catch it.** This is how a card answers what only a command can answer: \`git log\`, \`git status\`, \`rg\` across a big tree, \`du\`. **Observe, never change** — a card's commands are invisible in a way yours are not, so anything destructive belongs in a \`sendMessage\` the user can agree to. Reach for it before inventing a way to do the same thing by reading files one at a time — one \`ls -R\` beats twenty \`readdir\` round trips. Commands are killed after 15 seconds, so nothing that watches or serves.`;
1282
+ var EXEC_HISTORY = `**A history is a set too.** 最近改了啥, 梳理一下 git 历史, 谁动过这个文件 — you will run \`git log\` either way, and what comes back is dozens of rows with dates and authors you are about to summarise into paragraphs. Summarising throws away the rows. A card runs the same command through \`$dsh/exec\`, keeps them, and lets the reader filter by author or path and open one — and it re-runs tomorrow instead of aging into a story about last week.`;
1283
+
1284
+ // src/index.ts
1285
+ var name = "dsh-generative-ui";
1286
+ var inject = ["systemPrompt"];
1287
+ var SETTINGS_NAMESPACE = settingsNamespace("dsh-generative-ui");
1288
+ var Config = z.object({
1289
+ allowExec: z.boolean().default(false).description("Let generated cards run shell commands through `$dsh/exec`. A card is model-written code running in your browser; leave this off unless you want that.")
1290
+ });
1291
+ var wasmFile = (importMetaUrl) => createRequire(importMetaUrl).resolve("@esm.sh/tsx/pkg/tsx_bg.wasm");
1292
+ var resolvedMap = (relative, importMetaUrl) => {
1293
+ let path;
1294
+ try {
1295
+ path = fileURLToPath(new URL(relative, importMetaUrl));
1296
+ } catch {
1297
+ return;
1298
+ }
1299
+ return existsSync(path) ? path : undefined;
1300
+ };
1301
+ var typesImportMap = (importMetaUrl) => resolvedMap("../types/importmap.json", importMetaUrl);
1302
+ var standaloneImportMap = (importMetaUrl) => resolvedMap("../types/standalone/importmap.json", importMetaUrl);
1303
+ async function serveAsset(req, res, file) {
1304
+ if (req.method !== "GET" && req.method !== "HEAD")
1305
+ return void res.writeHead(405).end();
1306
+ const pathname = new URL(req.url ?? "/", "http://x").pathname;
1307
+ if (pathname !== WASM_PATH)
1308
+ return void res.writeHead(404).end();
1309
+ res.writeHead(200, { "content-type": "application/wasm", "cache-control": "public, max-age=31536000, immutable" });
1310
+ res.end(await readFile(file));
1311
+ }
1312
+ async function serveCanvas(liveWorkspaces, req, res) {
1313
+ if (req.method !== "GET")
1314
+ return void res.writeHead(405).end();
1315
+ const url = new URL(req.url ?? "/", "http://x");
1316
+ const cwd = url.searchParams.get("cwd");
1317
+ const id = url.searchParams.get("id");
1318
+ if (cwd === null || id !== null && !isCanvasId(id))
1319
+ return void res.writeHead(400).end();
1320
+ if (!liveWorkspaces().has(cwd))
1321
+ return void res.writeHead(403).end();
1322
+ if (id === null) {
1323
+ const ids = await readdir(join(cwd, CANVAS_DIR)).then((names) => names.flatMap((name2) => {
1324
+ const found = canvasIdOf(`${CANVAS_DIR}/${name2}`);
1325
+ return found === null ? [] : [found];
1326
+ }), () => []);
1327
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1328
+ return void res.end(JSON.stringify(ids));
1329
+ }
1330
+ const child = url.searchParams.get("child");
1331
+ if (child !== null) {
1332
+ const path = canvasChildPath(id, child, url.searchParams.get("from") ?? undefined);
1333
+ if (path === null)
1334
+ return void res.writeHead(400).end();
1335
+ for (const suffix of [".tsx", ".ts", "/index.tsx", "/index.ts", ""]) {
1336
+ try {
1337
+ const body = await readFile(join(cwd, path + suffix), "utf8");
1338
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-ui4a-filename": path + suffix });
1339
+ return void res.end(body);
1340
+ } catch {}
1341
+ }
1342
+ return void res.writeHead(404).end();
1343
+ }
1344
+ try {
1345
+ const code = await readFile(join(cwd, canvasPath(id)), "utf8");
1346
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
1347
+ res.end(code);
1348
+ } catch {
1349
+ res.writeHead(404).end();
1350
+ }
1351
+ }
1352
+ async function serveFs(ctx, liveWorkspaces, req, res) {
1353
+ const url = new URL(req.url ?? "/", "http://x");
1354
+ const cwd = url.searchParams.get("cwd");
1355
+ const path = url.searchParams.get("path");
1356
+ if (cwd === null || path === null || path === "")
1357
+ return void res.writeHead(400).end();
1358
+ if (!liveWorkspaces().has(cwd))
1359
+ return void res.writeHead(403).end();
1360
+ const json = (status, body) => {
1361
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1362
+ res.end(JSON.stringify(body));
1363
+ };
1364
+ const failed = (error) => {
1365
+ const code = error?.code;
1366
+ json(code === "FS_SANDBOX_DENIED" ? 403 : 404, { error: code ?? (error instanceof Error ? error.message : String(error)) });
1367
+ };
1368
+ try {
1369
+ const target = await ctx.fs.resolve(path, { cwd });
1370
+ if (req.method === "GET") {
1371
+ if (url.searchParams.get("list") !== null) {
1372
+ const entries = await ctx.fs.listDir(target);
1373
+ return json(200, { entries: entries.map(({ name: name2, type, size }) => ({ name: name2, type, size })) });
1374
+ }
1375
+ if (url.searchParams.get("bytes") !== null) {
1376
+ const bytes = await ctx.fs.readBytes(target, undefined, MAX_BINARY);
1377
+ return json(200, { base64: Buffer.from(bytes).toString("base64"), byteLength: bytes.byteLength });
1378
+ }
1379
+ return json(200, { content: await ctx.fs.readText(target) });
1380
+ }
1381
+ if (req.method !== "POST")
1382
+ return void res.writeHead(405).end();
1383
+ let body = "";
1384
+ for await (const chunk of req) {
1385
+ body += chunk;
1386
+ if (body.length > MAX_BODY)
1387
+ return void res.writeHead(413).end();
1388
+ }
1389
+ let content;
1390
+ try {
1391
+ ({ content } = JSON.parse(body));
1392
+ } catch {
1393
+ return void res.writeHead(400).end();
1394
+ }
1395
+ if (typeof content !== "string")
1396
+ return void res.writeHead(400).end();
1397
+ const sessionId = url.searchParams.get("session");
1398
+ const session = sessionId === null ? undefined : ctx.sessions.list().find((entry) => entry.id === sessionId);
1399
+ if (session === undefined)
1400
+ return void res.writeHead(400).end();
1401
+ await ctx.fs.writeText(target, content, undefined, undefined, ctx.sandboxPolicy.resolve({ session }));
1402
+ return json(200, { written: target.displayPath });
1403
+ } catch (error) {
1404
+ return failed(error);
1405
+ }
1406
+ }
1407
+ var EXEC_TIMEOUT_MS = 15000;
1408
+ async function serveExec(ctx, liveWorkspaces, req, res) {
1409
+ const url = new URL(req.url ?? "/", "http://x");
1410
+ const cwd = url.searchParams.get("cwd");
1411
+ if (cwd === null || !liveWorkspaces().has(cwd))
1412
+ return void res.writeHead(cwd === null ? 400 : 403).end();
1413
+ if (req.method !== "POST")
1414
+ return void res.writeHead(405).end();
1415
+ const sessionId = url.searchParams.get("session");
1416
+ const session = sessionId === null ? undefined : ctx.sessions.list().find((entry) => entry.id === sessionId);
1417
+ if (session === undefined)
1418
+ return void res.writeHead(400).end();
1419
+ let body = "";
1420
+ for await (const chunk of req) {
1421
+ body += chunk;
1422
+ if (body.length > MAX_BODY)
1423
+ return void res.writeHead(413).end();
1424
+ }
1425
+ let command;
1426
+ try {
1427
+ ({ command } = JSON.parse(body));
1428
+ } catch {
1429
+ return void res.writeHead(400).end();
1430
+ }
1431
+ if (typeof command !== "string" || command === "")
1432
+ return void res.writeHead(400).end();
1433
+ const json = (status, value) => {
1434
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1435
+ res.end(JSON.stringify(value));
1436
+ };
1437
+ try {
1438
+ const controller = new AbortController;
1439
+ req.on("close", () => controller.abort());
1440
+ const spec = ctx.shell.resolve({ command, workdir: cwd, timeoutMs: EXEC_TIMEOUT_MS, sandboxPolicy: ctx.sandboxPolicy.resolve({ session }), signal: controller.signal });
1441
+ const result = await ctx.shell.run(spec);
1442
+ return json(200, {
1443
+ stdout: result.stdout.text,
1444
+ stderr: result.stderr.text,
1445
+ exitCode: result.exitCode,
1446
+ truncated: { stdout: result.stdout.truncated, stderr: result.stderr.truncated },
1447
+ timedOut: result.timedOut === true
1448
+ });
1449
+ } catch (error) {
1450
+ return json(500, { error: error instanceof Error ? error.message : String(error) });
1451
+ }
1452
+ }
1453
+ var SEARCH_MAX_RESULTS = 8;
1454
+ async function serveWebSearch(ctx, liveWorkspaces, req, res) {
1455
+ const url = new URL(req.url ?? "/", "http://x");
1456
+ const cwd = url.searchParams.get("cwd");
1457
+ if (cwd === null || !liveWorkspaces().has(cwd))
1458
+ return void res.writeHead(cwd === null ? 400 : 403).end();
1459
+ if (req.method !== "POST")
1460
+ return void res.writeHead(405).end();
1461
+ let body = "";
1462
+ for await (const chunk of req) {
1463
+ body += chunk;
1464
+ if (body.length > MAX_BODY)
1465
+ return void res.writeHead(413).end();
1466
+ }
1467
+ let query;
1468
+ let maxResults;
1469
+ try {
1470
+ ({ query, maxResults } = JSON.parse(body));
1471
+ } catch {
1472
+ return void res.writeHead(400).end();
1473
+ }
1474
+ if (typeof query !== "string" || query.trim() === "")
1475
+ return void res.writeHead(400).end();
1476
+ const json = (status, value) => {
1477
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1478
+ res.end(JSON.stringify(value));
1479
+ };
1480
+ try {
1481
+ const controller = new AbortController;
1482
+ req.on("close", () => controller.abort());
1483
+ const result = await ctx.web.search({ query, maxResults: maxResults ?? SEARCH_MAX_RESULTS }, controller.signal);
1484
+ return json(200, { content: result.content, sources: result.sources, truncated: result.truncated });
1485
+ } catch (error) {
1486
+ return json(500, { error: error instanceof Error ? error.message : String(error) });
1487
+ }
1488
+ }
1489
+ var MAX_BODY = 64 * 1024;
1490
+ var MAX_BINARY = 8 * 1024 * 1024;
1491
+ async function serveAi(ctx, liveWorkspaces, req, res) {
1492
+ if (req.method !== "POST")
1493
+ return void res.writeHead(405).end();
1494
+ const url = new URL(req.url ?? "/", "http://x");
1495
+ const cwd = url.searchParams.get("cwd");
1496
+ if (cwd === null)
1497
+ return void res.writeHead(400).end();
1498
+ if (!liveWorkspaces().has(cwd))
1499
+ return void res.writeHead(403).end();
1500
+ let body = "";
1501
+ for await (const chunk of req) {
1502
+ body += chunk;
1503
+ if (body.length > MAX_BODY)
1504
+ return void res.writeHead(413).end();
1505
+ }
1506
+ let request;
1507
+ try {
1508
+ request = JSON.parse(body);
1509
+ } catch {
1510
+ return void res.writeHead(400).end();
1511
+ }
1512
+ if (request.prompt === undefined || request.prompt === "")
1513
+ return void res.writeHead(400).end();
1514
+ const messages = [createUserMessage({ content: [{ type: "text", text: request.prompt }], source: { kind: "plugin", plugin: "dsh-generative-ui" } })];
1515
+ const selection = ctx.agentDefaultModel.currentSelection();
1516
+ const controller = new AbortController;
1517
+ req.on("close", () => controller.abort());
1518
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" });
1519
+ try {
1520
+ for await (const chunk of ctx.llm.stream({ ...selection, messages, system: request.system, signal: controller.signal })) {
1521
+ if (chunk.type === "text-delta" && chunk.text !== undefined)
1522
+ res.write(chunk.text);
1523
+ else if (chunk.type === "finish" && chunk.reason !== undefined && chunk.reason.kind !== "stop")
1524
+ res.write(`
1525
+
1526
+ [${chunk.reason.kind}${chunk.reason.failure?.message === undefined ? "" : `: ${chunk.reason.failure.message}`}]`);
1527
+ }
1528
+ } catch (error) {
1529
+ res.write(`
1530
+
1531
+ [error: ${error instanceof Error ? error.message : String(error)}]`);
1532
+ }
1533
+ res.end();
1534
+ }
1535
+ function apply(ctx, config = Config({})) {
1536
+ let current = () => config;
1537
+ let configured = null;
1538
+ const rebuild = () => {
1539
+ const allowExec = current().allowExec === true;
1540
+ if (configured?.allowExec === allowExec)
1541
+ return;
1542
+ configured?.fiber.dispose();
1543
+ configured = { allowExec, fiber: ctx.plugin({ name: "dsh-generative-ui:configured", apply: (scoped) => applyWith(scoped, allowExec) }) };
1544
+ };
1545
+ rebuild();
1546
+ installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
1547
+ setSource: (source) => {
1548
+ current = source;
1549
+ },
1550
+ onChange: rebuild
1551
+ });
1552
+ ctx.effect(() => () => void configured?.fiber.dispose(), "dsh-generative-ui: settings scope");
1553
+ }
1554
+ function applyWith(ctx, allowExec) {
1555
+ ctx.effect(() => ctx.systemPrompt.section({ name: PROMPT_SECTION_NAME, order: PROMPT_SECTION_ORDER, text: inlinePrompt(allowExec) }), "dsh-generative-ui: inline prompt");
1556
+ ctx.inject(["webServer", "sessions"], (scoped) => {
1557
+ const file = wasmFile(import.meta.url);
1558
+ const liveWorkspaces = () => {
1559
+ const sessions = scoped.sessions.list();
1560
+ return new Set(sessions.flatMap((session) => session.header.cwd === undefined ? [] : [session.header.cwd]));
1561
+ };
1562
+ scoped.effect(() => scoped.webServer.register({ kind: "prefix", path: ASSET_PREFIX, handler: (req, res) => serveAsset(req, res, file) }), "dsh-generative-ui: tsx wasm");
1563
+ scoped.effect(() => scoped.webServer.register({ kind: "exact", path: CANVAS_READ_PATH, handler: (req, res) => serveCanvas(liveWorkspaces, req, res) }), "dsh-generative-ui: canvas reads");
1564
+ scoped.inject(["fs", "sandboxPolicy"], (withFs) => {
1565
+ withFs.effect(() => withFs.webServer.register({ kind: "exact", path: FS_PATH, handler: (req, res) => serveFs(withFs, liveWorkspaces, req, res) }), "dsh-generative-ui: workspace files");
1566
+ });
1567
+ if (allowExec)
1568
+ scoped.inject(["shell", "sandboxPolicy"], (withShell) => {
1569
+ withShell.effect(() => withShell.webServer.register({ kind: "exact", path: EXEC_PATH, handler: (req, res) => serveExec(withShell, liveWorkspaces, req, res) }), "dsh-generative-ui: commands");
1570
+ });
1571
+ scoped.inject(["web"], (withWeb) => {
1572
+ withWeb.effect(() => withWeb.webServer.register({ kind: "exact", path: WEB_SEARCH_PATH, handler: (req, res) => serveWebSearch(withWeb, liveWorkspaces, req, res) }), "dsh-generative-ui: web search");
1573
+ });
1574
+ scoped.inject(["llm", "agentDefaultModel"], (withLlm) => {
1575
+ withLlm.effect(() => withLlm.webServer.register({ kind: "exact", path: AI_STREAM_PATH, handler: (req, res) => serveAi(withLlm, liveWorkspaces, req, res) }), "dsh-generative-ui: model stream");
1576
+ });
1577
+ });
1578
+ ctx.inject(["skills"], (scoped) => {
1579
+ scoped.effect(() => scoped.skills.register({ name: SKILL_NAME, description: SKILL_DESCRIPTION, content: skillBody(typesImportMap(import.meta.url), standaloneImportMap(import.meta.url)), source: "runtime", invocation: { modelInvocable: true, userInvocable: false } }), "dsh-generative-ui: skill");
1580
+ });
1581
+ }
1582
+ export {
1583
+ ASSET_PREFIX,
1584
+ Config,
1585
+ SETTINGS_NAMESPACE,
1586
+ WASM_PATH,
1587
+ apply,
1588
+ inject,
1589
+ name,
1590
+ resolvedMap,
1591
+ serveAi,
1592
+ serveAsset,
1593
+ serveCanvas,
1594
+ serveExec,
1595
+ serveFs,
1596
+ serveWebSearch
1597
+ };