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/src/skill.ts ADDED
@@ -0,0 +1,931 @@
1
+ /**
2
+ * The taste, loaded on demand.
3
+ *
4
+ * `dsh-base` mounts `dsh-skill` + `dsh-tool-skill` by default, so a runtime registration here
5
+ * shows up in the model's `<available_skills>` catalog and its body is fetched only when the
6
+ * model calls `skill({ name })`. That is the whole reason this file exists separately from
7
+ * prompt.ts: judgement about layout and framing is long, and paying for it on every request —
8
+ * including the ones that are pure prose — is what the skill mechanism exists to avoid.
9
+ *
10
+ * The catalog carries `name` and `description` **only** — not `whenToUse`, not the body — so the
11
+ * description is the entire routing signal and has to name the trigger, not summarise the content.
12
+ */
13
+ import { CANVAS_DIR, CANVAS_SUFFIX, CAPABILITY_PREFIX, FENCE_LANG, capabilityModule } from "./contract.ts";
14
+
15
+ /** The checker, from pkg.pr.new: @genui/cli is a private workspace package and not on npm. */
16
+ const CLI_URL = "https://pkg.pr.new/MindLab-Research/macaron-genui-demo/@genui/cli@main";
17
+
18
+ export const SKILL_NAME = "generative-ui";
19
+
20
+ export const 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.`;
21
+
22
+ /**
23
+ * The skill body.
24
+ *
25
+ * A function of the import-map path because that path is only known at runtime — the plugin
26
+ * lives wherever the profile installed it, and the model runs the checker from the workspace.
27
+ * Without the map, `check` reports `Cannot find module "$dsh/chat"` on every card that uses
28
+ * one, and a false error is worse than no check: the model goes and "fixes" it.
29
+ */
30
+ /**
31
+ * The paragraph about which import map serves which command.
32
+ *
33
+ * Built here rather than inline: nesting one template interpolation inside another inside the
34
+ * body is how this file broke twice, and the two maps have genuinely different lifetimes —
35
+ * the type one may exist while the stub one does not.
36
+ */
37
+ /** Exported for `test/skill.test.ts`: three states, and this file has broken on them twice. */
38
+ export function mapNotes(typesMap: string | undefined, standaloneMap: string | undefined): string {
39
+ if (typesMap === undefined) return "";
40
+ const check = [
41
+ // A card is created BY its path. Checking a draft somewhere else therefore produces a card
42
+ // nothing will ever mount — measured once in wave 8: a complete 150-line routine written to
43
+ // `rutina.tsx` in the workspace root, because the model planned "write a temp file, then run
44
+ // the checker" and the checker step never finished. It reads as the model declining to build.
45
+ `**Check the canvas file itself, at \`${CANVAS_DIR}/<id>${CANVAS_SUFFIX}\`.** Writing that path is what creates the`,
46
+ "canvas, so there is no draft stage to check first: a `.tsx` anywhere else is a file the user",
47
+ "will never see, however correct it is. Write it where it belongs, then check it there and fix",
48
+ "it in place — the panel streams as you write and re-renders as you edit.",
49
+ "",
50
+ `The \`-i\` is not optional when the card imports \`${CAPABILITY_PREFIX}/*\`: without it every one of those lines`,
51
+ "is reported as `Cannot find module`, and there is nothing to fix — they resolve at render time.",
52
+ "",
53
+ "**It silences that error rather than typing the calls.** Measured: a map pointing at a file",
54
+ "that does not exist reports `OK` just the same, so `$dsh/*` ends up `any` and a wrong",
55
+ "argument or a misspelt result field passes the check. Everything else in the card is really",
56
+ "type-checked; the capability calls are on you.",
57
+ "",
58
+ "",
59
+ "One more diagnostic never to skim past: *referenced directly or indirectly in its own initializer*. It means",
60
+ "a `const` shadows something of the same name and now refers to itself — `const rows = useMemo(() => rows(x), [x])`",
61
+ "beside a top-level `function rows`. That throws on the first render and the card is blank, and it arrives",
62
+ "surrounded by ordinary `implicitly has an 'any' type` lines that are safe to ignore. Rename the local.",
63
+ "That map holds type declarations, so it serves `check` and `lint`.",
64
+ ].join("\n");
65
+ if (standaloneMap === undefined) return `${check} \`build\` and \`dev\` want runnable JS and will fail on it.`;
66
+ return [
67
+ `${check} \`build\` and \`dev\` want runnable JS, so they take a different one:`,
68
+ "",
69
+ "```",
70
+ `npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} build <file> -i ${standaloneMap}`,
71
+ "```",
72
+ "",
73
+ `That second map stubs \`${CAPABILITY_PREFIX}/*\` — the exported page has no dsh around it, so those calls log to`,
74
+ "the console and return empty instead of working. The layout, the styling and everything that",
75
+ "does not touch the harness are real; anything that does is inert. Useful for showing someone a",
76
+ "snapshot, not for testing the interactive parts.",
77
+ ].join("\n");
78
+ }
79
+
80
+ /**
81
+ * The skill, for the capabilities this host exposes.
82
+ *
83
+ * With commands off the whole `## Running a command` section is cut rather than softened: it is
84
+ * ~90 lines that all assume `bash()` exists, and half a section describing a capability the host
85
+ * does not have is worse than none — the model reads the surviving half as permission.
86
+ */
87
+ export const skillBody = (typesMap: string | undefined, standaloneMap: string | undefined, allowExec = false): string =>
88
+ ((maps) =>
89
+ `# Building a generative UI
90
+
91
+ ## Is this a UI at all
92
+
93
+ 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.
94
+
95
+ 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.
96
+
97
+ Two specific traps:
98
+
99
+ - **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.
100
+ - **Do not decorate an answer.** A metric with an icon and a border is still just a number. Ship the number.
101
+
102
+ **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.
103
+
104
+ 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.
105
+
106
+ ## Inline or canvas
107
+
108
+ They are not two sizes of the same thing; they have different lifetimes.
109
+
110
+ **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.
111
+
112
+ **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.
113
+
114
+ 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.
115
+
116
+ Two things follow from the lifetime difference:
117
+
118
+ - 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.
119
+ - A **canvas** stays interactive. It does not "complete"; it just sits there working.
120
+ - 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:
121
+
122
+ \`\`\`tsx
123
+ import { usePersistedState } from "${capabilityModule("state")}"
124
+ const [entries, setEntries] = usePersistedState<Entry[]>("expense-ledger", [])
125
+ \`\`\`
126
+
127
+ **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.
128
+ **And a reload is not the common case — your own next edit is.** Every revision replaces the
129
+ whole file, so the canvas remounts and anything held only in \`useState\` is gone; change one word
130
+ in a label and the user's half-typed row goes with it. Persist what they typed, not just what
131
+ they saved.
132
+
133
+ **If you write \`setRows(prev => prev.filter(r => r.id !== id))\` behind a button, keep the row.**
134
+ Persisting is what makes that line permanent — before it, a mistaken delete came back on reload.
135
+ Hold the removed row and offer it back:
136
+
137
+ \`\`\`tsx
138
+ const [undo, setUndo] = React.useState<Row | null>(null)
139
+ const remove = (id: string) => {
140
+ setUndo(rows.find((r) => r.id === id) ?? null)
141
+ setRows((prev) => prev.filter((r) => r.id !== id))
142
+ }
143
+ { undo && <button onClick={() => { setRows((p) => [...p, undo]); setUndo(null) }}>Undo delete</button> }
144
+ \`\`\`
145
+
146
+ A confirm step does the same job — but not the browser's own \`confirm()\`, which is a modal
147
+ from another era sitting on top of a panel that has its own visual language, and which offers
148
+ no way back once it is answered.
149
+
150
+ **The reason this is missed is not that undo is hard to write — it is that the line does not
151
+ look like a delete.** Measured across 36 cards that destroy something: 10 shipped no way back,
152
+ and every one of them had written one of these without recognising it:
153
+
154
+ - \`setRows(prev => prev.filter(r => r.id !== id))\` — 6 of the 10
155
+ - \`delete obj[key]\` on a persisted map — the other 4, and the one that reads least like a
156
+ delete because nothing named \`remove\` appears anywhere near it
157
+ - \`rows.splice(i, 1)\`
158
+ - \`setRows([])\` behind "clear", "reset", "start over", or a new day — but only when the rows
159
+ are the user's; clearing a queue you generated is not a delete
160
+ - setting a quantity or a count to 0 where the row disappears at 0
161
+ - replacing a whole persisted object — \`setPlan(freshPlan)\` drops whatever the user edited
162
+
163
+ Anything the user cannot type back in under five seconds needs a way back.
164
+
165
+ **A running clock is state too**, and the least obvious kind: a stopwatch or a timer mid-count
166
+ reads 0 again after one edit. Measured — the interval itself is cleaned up correctly, nothing
167
+ stacks up, but the elapsed value is gone. Store the *start timestamp* rather than the elapsed
168
+ count, so the display is derived and survives a remount by arithmetic.
169
+
170
+ **Reaching for \`localStorage\` by hand is where this goes wrong.** A full quota, or storage
171
+ disabled entirely, and \`setItem\` raises — from inside an effect, where it reaches the error
172
+ boundary and takes the whole card down over a saved preference. Persistence went from 1 corpus
173
+ card to 20 fresh ones once this section asked for it, and **10 of those 29 writes were bare**.
174
+ \`usePersistedState\` has the \`try\` on both sides; use it and the question does not arise.
175
+
176
+ ## Ask with an interface when the request is underspecified
177
+
178
+ "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.
179
+
180
+ 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:
181
+
182
+ \`\`\`tsx
183
+ import { sendMessage } from "$dsh/chat"
184
+
185
+ export default function Pick() {
186
+ const [picked, setPicked] = useState<string | null>(null)
187
+ const choose = (id: string) => { setPicked(id); sendMessage(id) }
188
+ // picked === null → the options; otherwise just the chosen one, still highlighted
189
+ }
190
+ \`\`\`
191
+
192
+ Rules for that move:
193
+
194
+ - **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.
195
+ - **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.
196
+ - **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.
197
+
198
+ 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.
199
+
200
+ ## Say something before it and something after
201
+
202
+ 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.
203
+
204
+ - **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.
205
+ - **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).
206
+
207
+ Both short. Two or three sentences total. Don't narrate tooling ("now I'll write the file") — say what the user gets.
208
+
209
+ **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.
210
+
211
+ ## Framing
212
+
213
+ This one runs *opposite* in the two places, and getting it backwards is the most visible mistake:
214
+
215
+ - **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.
216
+ - **Inline is the card.** It sits between paragraphs, so one bounded box is what tells the reader where it starts and stops.
217
+ - **But \`bg-base\` is the page's own colour, so a wrapper painted with it is not a box.** Measured
218
+ from the token table: \`bg-base\` is \`#fff\` on light and \`#151517\` on dark — the same value the
219
+ transcript behind the card is painted with, on both grounds. A root \`<div>\` with
220
+ \`background: var(--dsw-alias-bg-base); padding: 16px; border-radius: 12px\` therefore draws
221
+ nothing a reader can see: what is left is an invisible 16px inset and a rounded corner nobody
222
+ can find, while the \`bg-layer-1\` blocks inside it read as the real frame — a frame inside an
223
+ invisible frame. If you want the inline card to be bounded, bound it with \`bg-layer\` **plus**
224
+ \`border-line\` (see the both-spellings rule below). If you don't, drop the wrapper's background
225
+ and radius entirely rather than painting it the colour of the page.
226
+
227
+ 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.
228
+
229
+ ## Layout
230
+
231
+ - **The space between blocks is the root's job, and it is one class on the element that holds
232
+ them.** A card is two to four stacked blocks, and what separates them is a \`gap\` on their
233
+ parent — not a margin on each child, which collapses and doubles unpredictably:
234
+
235
+ <div className="grid gap-4">
236
+
237
+ Measured on a card written before this syntax: the root's layout was \`.r { display: grid; gap:
238
+ 12px }\` in a \`<style>\` block, the class landed on an \`<input>\` twenty lines away, and the two
239
+ blocks below ended up flush — no border between them, no space, reading as one block with a
240
+ stray heading in the middle. Nothing failed; the gap simply never applied. A class written on
241
+ the element it governs cannot come apart from it, which is most of why the styling here is
242
+ classes. Inside a block the same \`gap\` separates its rows; a \`mb-4\` on one child while its
243
+ siblings rely on the gap is what produces one odd space and eleven equal ones.
244
+
245
+ - **A collapse whose rows all start open is decoration, and a filter that starts at "everything"
246
+ has not filtered.** Measured on two generated cards, two models, two weeks apart, both with the
247
+ mechanism written correctly: a symptom card with one-panel-at-a-time \`aria-expanded\` shipped all
248
+ six panels open at 3369px, and a 41-question study canvas — which also built a topic filter, a
249
+ to-learn/mastered toggle AND a search box — rendered every question expanded with the filter on
250
+ "All", repeating its two buttons 82 times down **12000px**. The model knew the list needed
251
+ narrowing in both cases; what it did not do was choose the initial state. If the list is longer
252
+ than a screen, the first render shows labels and the filter starts somewhere narrower than
253
+ everything.
254
+
255
+ - **A list of options collapses the prose, not the facts — and folding the wrong half is the
256
+ common way to end up with a card nobody can scan.** Measured on a real card recommending six
257
+ ways to manage a symptom: each entry kept three lines of description permanently on screen and
258
+ hid one line — \`Onset: 15 min\` — behind a "Show details" link, repeated six times. The
259
+ mechanism was right (one panel open at a time, \`aria-expanded\` on every trigger); the choice
260
+ of what went inside it was backwards, and the card came out 3369px tall at every width. What
261
+ earns a permanent line is what the reader compares the options **by** — the name, the one
262
+ number that distinguishes it. The paragraph explaining why it works is what folds. A list of
263
+ more than about four options where every entry carries a paragraph is not a list any more, and
264
+ the fix is not a smaller font.
265
+
266
+ - **A comparison table is read down a column, so its text cells are left-aligned and only its
267
+ numbers are right-aligned.** Measured on a real card comparing two cell types over 12 rows:
268
+ every cell was centred, so at 440px eight of the twelve rows wrapped to two lines and each
269
+ line started at a different x — there is no straight edge for the eye to run down, and the
270
+ two columns being compared no longer line up with each other row by row. Centring looks tidy
271
+ in a mock where every cell is one short word and falls apart the moment one cell is a phrase.
272
+ Numbers are the exception in both directions: right-align them and add
273
+ \`font-variant-numeric: tabular-nums\`, so the digits stack. Header cells take the alignment of
274
+ the column beneath them, not their own.
275
+
276
+ **An unknown is not a zero.** A row the reader has not reported yet shows \`—\` and contributes
277
+ nothing to the total. \`0\` is a measurement: it says the value was taken and came out zero, and it
278
+ drags every average and running total down silently. Measured on one wave, one turn, one
279
+ context: one card rendered the not-yet-eaten dinner as \`Cena · pendiente —\` and another
280
+ rendered the same row as \`kcal 0 / Prot 0 / Carb 0\`. Same question, so this is a coin flip
281
+ rather than a blind spot — which is what makes it worth one line. The em dash takes
282
+ \`text-muted\`, and if a total is shown beside incomplete rows, say what it is a total OF.
283
+
284
+ - **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.
285
+
286
+ **And a field you type into is not a surface — it is a hole in one.** \`bg-page\` is the colour
287
+ of the ground everything else sits on, so an \`<input>\` painted with it is the same white as the
288
+ card in light theme and reads as a faint outline. Measured on a card generated after the rule
289
+ above landed: nine inputs, all \`bg-page border-line\`, on a card that used \`bg-layer-2\`
290
+ correctly exactly once elsewhere — the model knows the token and still reaches for the ground
291
+ colour. An input takes \`bg-layer-2\` (a step further from the ground than its container, not
292
+ back towards it) with \`border-line-2\`, and the placeholder takes \`text-muted\`.
293
+
294
+ **A thing you can tap needs more than the divider colour.** The rule above is about separating a
295
+ block from the surface below it, and \`border-line\` — 4% black — is right for that. It is not
296
+ enough for a control sitting on a surface that already has the same background: measured on a
297
+ real card, four tappable option boxes drawn with \`border-line\` on a \`bg-layer\` parent read
298
+ clearly on dark and were nearly invisible on light, where every layer is \`#fff\` and 4% black is
299
+ the only thing left. A tappable thing takes \`bg-layer-2\` or \`border-line-2\`, and the hairline
300
+ stays for dividers.
301
+
302
+ **A control you have FILLED is the opposite case, and the two get confused.** The rule above is
303
+ about separating a surface from the surface under it, where both tokens are deliberately faint —
304
+ \`border-l1\` is 4% black. Once an element carries a real fill (a selected segment on
305
+ \`state-business-primary\`, a primary button), that fill separates it completely and a leftover
306
+ \`border-l2\` is a grey ring around a blue block, related to nothing. Drop it — but to
307
+ \`transparent\`, not to \`none\`, or the selected item loses a pixel of height and the row twitches
308
+ as the reader clicks along it:
309
+
310
+ border: selected ? "1px solid transparent" : "1px solid var(--dsw-alias-border-l2)"
311
+
312
+ **And once a row is filled, everything inside it has to move off that fill too.** Measured on a
313
+ real card: a step row filled with \`state-business-primary\` when ticked, and the checkbox inside
314
+ it took \`background: state-business-primary\` for its own checked state — the same token, so the
315
+ box vanished into the row and left a white tick floating on blue with nothing around it. The
316
+ same happens to a chip, a count, an icon tile: any child that had a background of its own is now
317
+ sitting on a background that matches it. On a filled row the children want the fill's foreground
318
+ (\`#fff\` here) as their colour and no background at all, or a white outline if the shape itself
319
+ has to stay readable.
320
+ - **Keep nesting shallow.** A bordered box inside a bordered box is almost always wrong; a divider line does the job.
321
+ - **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".
322
+ - **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.
323
+
324
+ - **In a canvas, extra width should make the rows SHORTER, not the card wider.** Measured across
325
+ one wave, height at 320 divided by height at 720: the five inline cards shrink 1.26–1.52x, and
326
+ the six canvases shrink **1.02–1.18x** — one is 1100px tall at 320 and still 1076px at 720. It
327
+ is not for want of the technique; 8 of those 9 canvases carry a container query or an intrinsic
328
+ grid. They spend it *inside* a row — a stat strip, a chip group — and never on the row itself.
329
+ The shape that costs the most is a three-band row: a name, a right-aligned number, then a
330
+ control on its own full-width line, so at 720 the name and its number sit 1100px apart with a
331
+ rail between them. At that width the three fit on ONE line:
332
+
333
+ <div className="grid gap-2 @[32rem]:grid-cols-[1fr_12rem_auto] @[32rem]:items-center">
334
+ <span className="min-w-0 truncate">{name}</span>
335
+ <input type="range" … />
336
+ <span className="tabular-nums text-right">{value}</span>
337
+ </div>
338
+
339
+ The reader drags a canvas panel between 320 and 720 — that drag should buy them less scrolling.
340
+ - **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.
341
+ - **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.
342
+ - **If you take the focus ring off, put something back.** \`outline-none\` on a borderless input
343
+ is the most common single thing in these cards that breaks keyboard use: **77 of 378 remove the
344
+ ring and 0 replace it**, so tabbing through the card moves an invisible cursor. The
345
+ browser's default ring is ugly next to a custom input, which is why it goes — the fix is a
346
+ ring you like, not no ring:
347
+
348
+ <input className="outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent" />
349
+
350
+ \`:focus-visible\`, not \`:focus\` — it shows the ring for the keyboard and not for the mouse,
351
+ which is the reason the ring was annoying in the first place.
352
+ - **The rules below share one cause, and knowing it is worth more than the list.** A card gets
353
+ written as a *picture* of an interface — the slider looks right, the number reads right, the
354
+ ring is visual noise so it goes. Every one of them is correct through a mouse and an eye, and
355
+ broken through a keyboard or a screen reader. Measured: the two most common pairs of defects
356
+ in 378 cards are a stripped focus ring beside an unlabelled slider (8 cards) and an unlabelled
357
+ slider beside an unguarded number field (6) — the same card, treating its controls as decoration
358
+ three times over. When you add a control, ask what it announces and what happens on Tab.
359
+ - **A control the keyboard cannot reach is not a control.** Two shapes, both measured across 378
360
+ real cards and neither mentioned here before: **17 cards put \`onClick\` on a \`<div>\`**, which
361
+ takes no focus and answers no Enter or Space, and **31 buttons whose only content is an icon
362
+ carry no \`aria-label\`**, so a screen reader announces "button" and nothing else. Both are one
363
+ word to fix and invisible to you, since a mouse works either way:
364
+
365
+ <button aria-label="复制" onClick={copy}><Copy size={14} /></button>
366
+
367
+ If it does something when clicked, it is a \`<button type="button">\`. A \`div\` with an
368
+ \`onClick\` is a div.
369
+
370
+ **A clickable row is the case that survives this rule** — 13 of the 17 are a list row, a table
371
+ cell, or a card, where wrapping each one in a \`<button>\` feels wrong. It is not: a \`<button>\`
372
+ with \`display: block; width: 100%; text-align: left\` looks exactly like the row and is
373
+ reachable. **\`textAlign: "left"\` is the part that gets dropped, and it is needed whatever the
374
+ display is.** A row laid out as \`display: flex\` (to push a trailing action right with
375
+ \`space-between\`) still inherits the button's centred text, so a short bold title sits visibly
376
+ off-centre above the longer line beneath it while everything else looks left-aligned — the two
377
+ cards where I hit this both had \`flexDirection: "column"\` on the text block, which declares the
378
+ axis and does nothing about the alignment. If the row genuinely cannot be one — a virtualised list measuring its own height —
379
+ then \`role="button" tabIndex={0}\` and an \`onKeyDown\` for Enter and Space, all three, because
380
+ any one alone leaves it half-reachable.
381
+
382
+ **A slider is the same problem with no visible text to fall back on.** 61 range inputs across
383
+ the corpus carry no label of any kind, and unlike a text field there is no placeholder and
384
+ nothing inside the control to read — a screen reader announces "slider, 40" and stops.
385
+
386
+ Almost every one of them HAS a visible name: **38 of 54 put it in a \`<span>\` directly above
387
+ the control**, which looks labelled and announces as nothing. A \`<span>\` is not a label, and
388
+ neither is the number beside it — both are separate elements, connected to nothing:
389
+
390
+ <input type="range" aria-label="音量" min={0} max={100} value={v} onChange={…} />
391
+
392
+ **And a bare \`<input type="range">\` is the loudest thing on the card.** The browser paints its
393
+ own track in the OS accent — a thick, fully saturated blue that ignores your theme, is identical
394
+ on light and dark, and outshouts the number beside it. **43 of the 52 corpus cards with a slider
395
+ ship it untouched**, including all three reference cards. \`accent-color\` does not fix it:
396
+ measured side by side, it swaps one blue band for another. The track and the thumb are
397
+ pseudo-elements, which utilities reach through a bracketed selector on the input itself:
398
+
399
+ <input type="range" className="flex-1 min-w-0 appearance-none bg-transparent
400
+ [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full
401
+ [&::-webkit-slider-runnable-track]:bg-line-2
402
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:-mt-1.5
403
+ [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5
404
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-label" />
405
+
406
+ The thumb takes \`bg-label\`, which contrasts the TRACK and therefore inverts with the theme.
407
+ Note what this spelling removes: the previous version of this rule taught the same overrides in
408
+ a \`<style>\` block, and a card wrote \`className="r"\` on the input against a \`.r
409
+ input[type=range]\` selector — asking for an input *inside* the input. Not one declaration
410
+ matched, the OS-blue track shipped, and the dead override block sat in the source looking
411
+ correct. A bracketed selector is attached to the element it styles and cannot miss it.
412
+
413
+ Then decide what the control means, because the three shapes are not interchangeable and you can
414
+ tell them apart from what the number is:
415
+
416
+ - **Picking a value** (speed, font size, a threshold) — plain track, thumb marks *where you are*.
417
+ Filling the left half would claim the value accumulates, and 120ms is not an amount of anything.
418
+ - **An adjustable amount** (budget, volume, progress you can scrub) — fill the left of the track,
419
+ because its length IS the quantity. The fill moves with the value, so this is one of the few
420
+ places a \`style\` object is right: put the gradient there and leave the rest in classes.
421
+
422
+ style={ { background: \`linear-gradient(to right, var(--dsw-alias-state-business-primary) \${pct}%, var(--dsw-alias-border-l2) \${pct}%)\` } }
423
+ - **An amount they cannot change** — fill only, and then it is not a slider at all. Two nested
424
+ \`<div>\`s render identically and announce honestly; a \`readOnly\` range still says "slider" to a
425
+ screen reader and invites a drag that does nothing.
426
+
427
+ - **And when the content arrives on its own, say so where it lands.** A card that fetches shows a
428
+ spinner becoming a list; someone using a screen reader gets nothing — focus has not moved, and
429
+ the new content is silent below it. **0 of 64 corpus cards that fetch anything announce their
430
+ results**, the one defect a fresh batch still gets wrong too. One attribute on the container
431
+ the results land in:
432
+
433
+ <div aria-live="polite">{loading ? <Spinner /> : <List items={rows} />}</div>
434
+
435
+ On the container, not the spinner — the element has to be in the DOM BEFORE the content changes
436
+ for the change to be announced at all.
437
+
438
+ **This is the one rule whose effect you cannot see.** A missing focus ring is visible the moment
439
+ you tab; an unlabelled icon reads wrong the moment you look. A card with no live region looks
440
+ exactly like one that has it, in every state, so the only way it gets written is on purpose.
441
+ Measured: **8 of 23** cards that fetch anything announce the result, against 88-94% for every
442
+ other rule in this section.
443
+
444
+ **And when it fails, say so where the results would have been.** \`} catch {}\` around a
445
+ \`streamText\` or a \`bash\`, then \`setLoading(false)\`: the spinner stops, the card is empty, and
446
+ nothing tells the reader whether it failed or simply found nothing. **15 of 378 corpus cards do
447
+ this, 14 of them calling the model** — where a request failing is the likeliest thing worth
448
+ explaining. Rendering \`stderr\` counts; so does letting it throw to the surface's error
449
+ boundary. An empty \`catch\` around the call itself does not.
450
+
451
+ A \`<label>\` BESIDE the control names nothing. \`<label>音量</label><input type="range" …/>\` is
452
+ the shape two corpus cards took, and it is worse than no label: it reads as done. A label only
453
+ associates when it wraps the control or carries \`htmlFor\` matching its \`id\`:
454
+
455
+ <label>音量 <input type="range" value={v} onChange={…} /></label> // wrapping, so it names it
456
+
457
+ **A \`<select>\` has the same problem for the same reason** — its options are its value, not its
458
+ name, so an unlabelled one announces "combo box, 每天" and the reader never learns what it
459
+ selects. Six corpus cards, and the same two fixes. The screen catches these; nothing said so
460
+ until now, which is why they are still here after the slider rule landed.
461
+ - **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:
462
+
463
+ \`\`\`tsx
464
+ <div role="radiogroup" aria-label="选择场次">
465
+ {SESSIONS.map((s) => (
466
+ <button key={s.id} role="radio" aria-checked={s.id === picked} onClick={() => pick(s.id)}
467
+ className={s.id === picked ? "picked" : ""}>{s.label}</button>
468
+ ))}
469
+ </div>
470
+ \`\`\`
471
+
472
+ **The tell is the ternary you are about to write.** Measured across 378 cards: 95 of the 114 that
473
+ get this wrong express the selection as \`background: picked === x ? … : …\` — one shape, whatever
474
+ the array is called (\`PRESETS\`, \`options\`, \`ranges\`, \`STYLES\`, \`MODES\` all appear). If you are
475
+ writing a conditional \`background\` inside a \`.map\` over choices, the attribute belongs on the
476
+ same element, and it is the same condition you already typed. The className spelling needs it just
477
+ as much — moving the ternary into a string changes nothing about what is announced:
478
+
479
+ \`\`\`tsx
480
+ <button className={\`btn\${picked === x ? " active" : ""}\`} aria-pressed={picked === x}>
481
+ \`\`\`
482
+
483
+ **A disabled control should say why, in its own label.** Two wave-2 cards gate the same form.
484
+ One writes a greyed-out \`Calcular mi plan\` and leaves the reader to guess which field is
485
+ missing; the other swaps the label to **"Completa tus datos para continuar"**. Same disabled
486
+ state, no extra element, and the button explains itself. When a precondition disables a control,
487
+ put the precondition in the label.
488
+
489
+ **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.
490
+
491
+ \`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.
492
+
493
+ **Write the state and the style it produces as one token, and this whole class of bug stops
494
+ existing.** \`aria-checked:bg-accent\` is a single string: there is no second place for it to
495
+ disagree with. Measured on a card written before that was possible — the CSS said
496
+ \`.sev-btn[aria-pressed="true"]\`, the JSX twenty lines below wrote \`aria-checked={o.id ===
497
+ severity}\`, both correct on their own, and they simply never met. All three buttons rendered
498
+ identically at every width while the card carried a full selected-state block it never used. It
499
+ compiled, it rendered, no checker fired, and only a screenshot showed it. The same card's
500
+ \`<style>\` also opened with \`.r { display: grid; gap: 12px }\` and put \`className="r"\` on an
501
+ \`<input type=range>\`: the slider became a grid, every slider override addressed an input inside
502
+ an input, and the root never got its \`gap\`, so the blocks below sat flush. One misplaced class,
503
+ three symptoms, none of them where the class was.
504
+
505
+ So: a state variant (\`aria-checked:\`, \`data-[open=true]:\`, \`hover:\`, \`focus-visible:\`) rather
506
+ than a selector that has to go and find the element.
507
+
508
+ 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.
509
+ - **Every visual change is continuous.** No jump cuts: enter from where the element is, and let exits finish.
510
+ - **A card that animates needs the \`motion-reduce:\` variant on whatever moves.** Measured across
511
+ 378 real cards: 131 animate and **7** honour the preference. It is not a preference about taste
512
+ — people turn it on for vestibular disorders and migraine, and a looping demo is exactly what it
513
+ is for. It is one more token beside the transition you already wrote:
514
+
515
+ <div className="transition-transform duration-150 motion-reduce:transition-none" />
516
+
517
+ For a keyframe animation the pair is \`animate-… motion-reduce:animate-none\`. The old spelling
518
+ of this rule needed a \`<style>\` block for the media query, which is why 59 of those 131 cards
519
+ could not follow it at all: they styled inline, and a media query has nowhere to live in a
520
+ style object. A variant has nowhere it cannot live.
521
+
522
+ Where the motion IS the explanation — a packet crossing a diagram, a sort swapping two bars —
523
+ shorten it rather than removing it (\`animation-duration: .01s\`), so the card still steps.
524
+
525
+ ## Sound
526
+
527
+ Every fact here was measured in a real browser, not recalled — the failure modes are silent
528
+ ones, so guessing costs a card that looks fine and makes no noise.
529
+
530
+ **A context built before any click is born suspended, and starting an oscillator on it throws
531
+ nothing.** It schedules against a clock that never advances: no error, no sound. Worse,
532
+ \`await ctx.resume()\` on a document nobody has ever clicked **never settles** — it does not
533
+ reject, so a \`try/catch\` buys nothing and an \`await\` in front of your setup deadlocks the card
534
+ at first render.
535
+
536
+ **But one click unlocks the whole page, not just that handler.** Chromium's gate is
537
+ "has this document ever been activated", so after a single press anywhere in the card, a
538
+ 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
539
+ metronome or a sequencer possible: only the *first* press has to be a real gesture. Build the
540
+ context lazily inside that first click, or build it eagerly and gate every sound behind a
541
+ "someone has pressed something" flag.
542
+
543
+ **Drawing sound needs no gesture at all.** \`decodeAudioData\` works on a suspended context, and
544
+ \`OfflineAudioContext\` renders with no interaction whatever. So a card can \`readBytes\` a wav,
545
+ decode it and paint its waveform the moment it opens; only *hearing* it is gated. An
546
+ \`AnalyserNode\` resolves to \`sampleRate / fftSize\`, so the default \`fftSize = 2048\` gives 1024 bins
547
+ at 21.5Hz and halving it gives 512 bins at **43Hz** — fine for a picture, never enough for a
548
+ tuner (use autocorrelation on the time-domain data for pitch).
549
+
550
+ A bare \`OscillatorNode\` sine reads as a test tone. Layer two or three partials and shape a
551
+ \`GainNode\` envelope and it reads as an instrument instead. Close the context on unmount, or
552
+ every reload leaves another one behind.
553
+
554
+ ## Declare every hook before the JSX
555
+
556
+ An inline card is recompiled on every streamed frame and the renderer keeps its state only
557
+ while the **hook signature** is unchanged; add a hook and the tree remounts, so a chart drawn
558
+ so far starts again from nothing.
559
+
560
+ This is normally invisible, and measuring a real card shows why: across 53 streamed frames the
561
+ hook count changed three times — **all three inside the first 21%, before the \`return\` existed at
562
+ all.** Remounting an empty card costs nothing, and for the remaining 79% the signature held
563
+ steady while the chart filled in.
564
+
565
+ That free ride depends on writing them in the ordinary order: **all \`useState\` / \`useMemo\` /
566
+ \`useEffect\` at the top of the component, none of them conditional, and none added after the
567
+ markup is on screen.** A hook introduced late — or one behind an \`if\` that flips — lands the
568
+ remount in the middle of a visible card, and the reader watches it blank and rebuild.
569
+
570
+ ## Anything that keeps running
571
+
572
+ A game loop, an AutoPlay demo, a metronome, a clock, a progress animation — anything on
573
+ \`requestAnimationFrame\`, \`setInterval\` or a \`MediaStream\` — **must be returned from its
574
+ effect's cleanup.** Measured: after the card is unmounted, a loop with a \`cancelAnimationFrame\`
575
+ cleanup stops dead, and one without keeps ticking for as long as the tab is open.
576
+
577
+ This matters here more than in an ordinary app, because **a card is replaced every time the
578
+ user asks for a change.** Ten revisions of a Snake card leaves ten loops running, each still
579
+ painting into a canvas nobody can see, and the symptom is not a broken card — it is the whole
580
+ conversation getting slower for reasons that look like someone else's fault.
581
+
582
+ \`\`\`tsx
583
+ useEffect(() => {
584
+ let id = requestAnimationFrame(function tick() { step(); id = requestAnimationFrame(tick) })
585
+ return () => cancelAnimationFrame(id)
586
+ }, [])
587
+ \`\`\`
588
+
589
+ The same goes for \`setInterval\` (\`clearInterval\`), listeners on \`window\` or \`document\`
590
+ (\`removeEventListener\`), and an \`AudioContext\` (\`close()\`). If AutoPlay is meant to be shown to
591
+ someone, give it a visible pause as well — a demo you cannot stop is a demo you cannot talk over.
592
+
593
+ **A handler the reader can start twice needs the same discipline, and an effect's cleanup does
594
+ not cover it.** Clicking "生成" while the last stream is still arriving runs both loops at once:
595
+ they interleave their \`setState\` calls, and whichever started FIRST usually finishes last, so
596
+ the answer the reader is looking at gets overwritten by the one they replaced. Measured across
597
+ 378 cards: 23 do this, and the majority of them await \`bash\`, which has no time bound at all.
598
+
599
+ Bump a ref on entry and let a superseded run return:
600
+
601
+ \`\`\`tsx
602
+ const runId = useRef(0)
603
+ const generate = async (topic: string) => {
604
+ const id = ++runId.current
605
+ for await (const chunk of streamText({ prompt: topic })) {
606
+ if (id !== runId.current) return // a newer click owns the state now
607
+ setLines(chunk)
608
+ }
609
+ }
610
+ \`\`\`
611
+
612
+ Inside a \`useEffect\` the same job is done by \`let cancelled = false\` and a cleanup that sets it —
613
+ use whichever the surrounding code already uses.
614
+
615
+ ## Running a command
616
+
617
+ \`bash(command)\` from \`$dsh/exec\` runs one command in the workspace and resolves
618
+ with \`{stdout, stderr, exitCode, truncated, timedOut}\`. It runs under the session's own sandbox
619
+ mode, so it opens nothing your own bash tool has not already opened.
620
+
621
+ **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:
622
+
623
+ \`\`\`tsx
624
+ const [loading, setLoading] = useState(true)
625
+ const load = async (p: string) => { try { setRows(await readdir(p)) } finally { setLoading(false) } }
626
+ useEffect(() => { void load(path) }, [path]) // ← the line that is missing when a card hangs
627
+ \`\`\`
628
+
629
+ **A card that re-runs a command needs \`signal\`.** Polling on a timer, or running one per
630
+ keystroke, stacks a second command on top of a slow first — and the panel then paints whichever
631
+ finishes last, which is not necessarily the newest. Pass an \`AbortController\`'s signal and abort
632
+ the previous run: it kills the command itself, not just your wait.
633
+
634
+ Measured across 378 real cards: 11 poll or re-run a command and **0** pass a signal, while the
635
+ rule immediately below — check \`exitCode\` — is followed by 18 of 19. The difference is that one
636
+ of them names a field you can see and the other describes a shape. So, the shape:
637
+
638
+ \`\`\`tsx
639
+ useEffect(() => {
640
+ const ctrl = new AbortController();
641
+ const tick = async () => {
642
+ // A canvas nobody is looking at should not be shelling out every two seconds.
643
+ if (document.hidden) return;
644
+ try {
645
+ const { stdout, exitCode } = await bash("git status --porcelain", { signal: ctrl.signal });
646
+ setStatus({ stdout, exitCode });
647
+ } catch (error) {
648
+ // The abort is the expected path here, not a failure: every re-run causes one.
649
+ if ((error as Error).name === "AbortError") return;
650
+ throw error;
651
+ }
652
+ };
653
+ void tick();
654
+ const timer = setInterval(tick, 2000);
655
+ return () => { ctrl.abort(); clearInterval(timer) };
656
+ }, []);
657
+ \`\`\`
658
+
659
+ **A non-zero exit resolves.** Check \`exitCode\` and show what the command said —
660
+ \`git status\` failing outside a repo is a thing the card should display, not an
661
+ exception to swallow. Only a failure to run at all rejects.
662
+
663
+ This is the shortest path to anything the filesystem alone cannot answer: history
664
+ (\`git log\`), state (\`git status\`, \`git diff --stat\`),
665
+ search at speed (\`rg -n pattern\`), sizes (\`du -sh *\`). A whole test suite usually will
666
+ not fit in 15 seconds — one file's tests might, and \`timedOut\` is the honest thing to show when
667
+ it does not. Prefer one
668
+ command over many \`readFile\` calls: a card that walks a tree with twenty round trips is slower and
669
+ more code than one \`ls -R\`.
670
+
671
+ **A card's commands are invisible in a way yours are not.** When you run a command, it is in
672
+ the transcript before it runs, attributed, and the user can see it. When a card runs one, it is
673
+ inside code they did not read, behind a button whose label they trust, and it can fire on mount
674
+ with no click at all. The sandbox is the same; their ability to notice is not. So:
675
+
676
+ - **Nothing destructive, ever** — no \`rm\`, no \`git clean\`, no \`git reset --hard\`, no
677
+ \`checkout\` that discards, no \`kill\`, no package installs. A card observes; when something
678
+ should change, hand it to the user through \`sendMessage\` and let them agree to it in the open.
679
+ - **Show what you ran.** A card that shells out should say so — the command in small type near
680
+ the result, or under a disclosure. It costs one line and turns "permitted" into "seen".
681
+
682
+ **This is about commands, not about \`writeFile\`.** A command can do something there is no
683
+ way back from; a file write leaves a diff, sits in version control, and a read-only session
684
+ refuses it. So a card that edits a config, fills in a missing key, renames in bulk or saves a
685
+ draft should **write the file** — with the change visible before it lands and a button that
686
+ commits it. Turning that into a question ("which values do you want to change?") gives back the
687
+ one thing the card was for. Reserve \`sendMessage\` for what the card genuinely cannot do:
688
+ running the destructive command, or a change big enough that the user wants you to think about
689
+ it first.
690
+
691
+ Two limits worth designing around. Commands are killed after **15 seconds**, so nothing that
692
+ watches, serves, or waits. And the card is on the user's page — a command runs while they
693
+ look at a spinner, so keep it to one round trip per interaction rather than one per row.
694
+
695
+ **A timeout is not an empty result, and the two arrive as the same value.** A killed command
696
+ resolves — 200, \`stdout: ""\`, \`timedOut: true\` — so \`bash()\` does not throw and a card that
697
+ renders \`stdout\` shows the reader **"no matches"** for a search that never finished. Check
698
+ \`timedOut\` before you report emptiness. Measured on a real card: a workspace search that
699
+ reported no matches for \`*.ts\` under a directory holding 5,327 of them.
700
+
701
+ **And in \`find\`, exclude by pruning, not by filtering.** \`-not -path '*/node_modules/*'\` is a
702
+ predicate: \`find\` still descends into every excluded directory and stats every file inside
703
+ before discarding it. \`-prune\` stops the walk. Same tree, same 5,327 results, measured:
704
+
705
+ find . -type f -not -path '*/node_modules/*' … # 55-65s -> killed at 15s, 0 rows
706
+ find . \\( -name node_modules -o -name .git \\) -prune -o -type f … -print # 6.3s, 5327 rows
707
+
708
+ The filtering spelling is the one that reads more naturally and it is the one that times out.
709
+
710
+ ## Searching the web
711
+
712
+ \`search(query, options?)\` from \`$dsh/web\` resolves with \`{content?, sources, truncated}\`. Each
713
+ source is \`{url, title?, snippet?, publishedAt?}\` and **only \`url\` is guaranteed** — a card that
714
+ renders \`source.title\` unguarded shows blank rows against some providers. \`content\` is a
715
+ generated answer that some providers return and others do not, so it is a bonus, never the plan.
716
+
717
+ **Search only. There is no \`fetch\`**, and that is deliberate: the local fetch backend can reach
718
+ private-network addresses, so this deployment turns it off for its own tools too. A card cannot
719
+ retrieve a page body — render the snippet and link the source.
720
+
721
+ - **Show the sources, always.** This is the one capability whose output a reader cannot check any
722
+ other way: they can redo a calculation and they can re-read a file, but they cannot see where a
723
+ claim came from unless the card links it. A fact from the web with no link beside it is the card
724
+ asking to be trusted about the one thing it has no standing on.
725
+ - **Reach for it when the answer depends on something you cannot know** — a current price, a
726
+ release date, whether a package still exports a name. Not for what you already know: a search
727
+ for the formula for BMI spends a round trip to be told what you would have written anyway.
728
+ - **One search per interaction, not one per keystroke.** It is a network round trip through a
729
+ provider, so debounce a search-as-you-type box and pass the \`signal\` so an abandoned query is
730
+ actually cancelled. An aborted call rejects with an \`AbortError\`, which is not a failure:
731
+ \`if (e.name === "AbortError") return\`.
732
+ - **Say when it found nothing.** \`sources\` coming back empty is a result the reader needs — an
733
+ empty list with no message reads as the card being broken. Same rule as every other fetch: the
734
+ three states are loading, empty, and failed, and they must look different.
735
+
736
+
737
+ ## Reading and writing workspace files
738
+
739
+ \`$dsh/fs\` gives a card \`readFile(path) -> string\`, \`readdir(path) -> {name, type, size}[]\`
740
+ (\`type\` is \`"file"\` or \`"directory"\`, so a tree needs no probing; \`size\` is bytes, absent on
741
+ directories) and \`writeFile(path, content)\` over the workspace. Paths are workspace-relative and
742
+ \`path\` is required — there is no "current directory" argument-less form, under the
743
+ session's own access mode — the same fence the file tools run behind. So a read-only session
744
+ refuses the write, and the card should say so rather than looking broken: catch it and tell
745
+ the user the session is read-only.
746
+
747
+ Reach for it when the data **belongs to the workspace** — a file the user can also open, edit
748
+ and commit.
749
+
750
+ **You reading the file is not the card reading the file.** You have your own tools, so it is
751
+ easy to open the README, summarise it, and paste the summary in as a string — and the result
752
+ is a photograph: right the moment you took it, silently stale from the next edit on. If the
753
+ card is about workspace content, the card calls \`readFile\`. Reserve your own reading for
754
+ deciding *what to build*, not for supplying what it displays.
755
+
756
+ **Read on demand, not all at once.** A list of twenty files does not want twenty
757
+ \`readFile\` calls before it can draw — it wants to draw immediately from \`readdir\` (which
758
+ already carries the type and the size), and to fetch a body only when the reader asks for one.
759
+ Hovering a row, clicking to expand it, selecting it in a two-pane layout: all of these are one
760
+ read at the moment of interest, cached after. That is what makes a card feel instant on a big
761
+ tree, and it is also the difference between a browser and a table — a table answers what you
762
+ guessed the reader wanted, a browser answers what they actually reach for.
763
+
764
+ A useful default: draw from the cheap call, fetch on \`onMouseEnter\` (with a short delay so a
765
+ sweep across the list does not fire twenty reads) or on click, keep what you fetched in a
766
+ \`Map\`, and show a quiet placeholder in the gap. Never read a file the reader has not looked
767
+ at yet.
768
+
769
+ Keep \`localStorage\` for a canvas's own private state (which tab was open, the draft they were
770
+ typing); writing that to disk just litters the repo.
771
+
772
+ ## Generating content inside the card
773
+
774
+ \`streamText\` from \`$dsh/ai\` is for content whose **answer space is open**, and the trap is
775
+ that knowing the subject feels like the same thing as the data being fixed. It is not:
776
+
777
+ > "I know Tokyo, so the attractions are fixed knowledge — I don't need \`streamText\` here."
778
+
779
+ That sentence is from a real generation, and it produced five hardcoded itineraries. The
780
+ error is not the knowledge claim; it is that *three-day Tokyo itineraries* is not a set of
781
+ five. Writing them out samples the space and presents the sample as the whole. Ask **could I
782
+ enumerate every answer**, not *do I know this topic*:
783
+
784
+ | | Closed — no model call | Open — \`streamText\` |
785
+ | --- | --- | --- |
786
+ | Converter | 100°C is one number | |
787
+ | Timer | one formula | |
788
+ | Itinerary | | any city, any length, any interest |
789
+ | Recipe | | whatever they have in the fridge |
790
+ | Names | | for a thing you have not been told about |
791
+
792
+ A closed answer has one right value per input. An open one has as many as the user has ideas,
793
+ and hardcoding it produces a card that demos beautifully and dead-ends the moment they want
794
+ something you did not think of. Yours is the interface; the content is theirs.
795
+
796
+ It inherits the app's model, so there is no key to ask for and no setup.
797
+
798
+ **A second call must cancel the first.** Regenerating as the user types, or offering a Stop
799
+ button, means two generations in flight and the reader sees whichever finishes last — not the
800
+ newest. Pass an \`AbortController\`'s signal in the options and abort the previous one; that
801
+ stops the generation itself, not just your reading of it.
802
+
803
+ Measured across 378 real cards: 24 stream from the model and **1** passes a signal. So here it
804
+ is as code, since the rule beside it — parse the buffer as it grows — is followed by 22 of the
805
+ same 24, and the only difference between them is that one shows the lines:
806
+
807
+ \`\`\`tsx
808
+ const running = useRef<AbortController | null>(null);
809
+ const regenerate = async () => {
810
+ running.current?.abort(); // whatever is in flight is now stale
811
+ const ctrl = (running.current = new AbortController());
812
+ try {
813
+ for await (const chunk of streamText({ prompt, signal: ctrl.signal })) { /* … */ }
814
+ } catch (error) {
815
+ // The one rejection that is not a failure. Showing it puts "AbortError" on screen
816
+ // every time the user types another character.
817
+ if ((error as Error).name === "AbortError") return;
818
+ throw error;
819
+ }
820
+ };
821
+ useEffect(() => () => running.current?.abort(), []); // and on unmount
822
+ \`\`\`
823
+
824
+ Ask for JSON and parse the buffer as it grows, so items land one at a time rather than all
825
+ at once at the end:
826
+
827
+ \`\`\`tsx
828
+ import { streamText } from "$dsh/ai"
829
+ import { parse, Allow } from "partial-json"
830
+
831
+ let buffer = ""
832
+ for await (const chunk of streamText({ prompt: \`…Return JSON: {"items":[{"title":"","note":""}]}\` })) {
833
+ buffer += chunk
834
+ try { setData(parse(buffer, Allow.ALL)) } catch {} // half-written JSON throws; skip that frame
835
+ }
836
+ \`\`\`
837
+
838
+ **Every field is optional until the stream ends.** \`partial-json\` hands you the object as it
839
+ grows, so an item can arrive with a title and nothing else — and one \`item.difficulty.includes(…)\`
840
+ on that frame throws inside render, which unmounts the whole card mid-generation. Read every
841
+ streamed field defensively (\`item.steps ?? []\`, \`item.difficulty === "简单" ? … : …\`) and never
842
+ call a method on one without a fallback. This is the failure mode of this API, not an edge case.
843
+
844
+ One user turn per call — there is no conversation here. Anything the card knows from earlier
845
+ goes into the prompt it builds. And skip it entirely when the data is genuinely fixed: a
846
+ converter, a timer, a colour picker have nothing to generate.
847
+
848
+ ## Check it before you hand it over
849
+
850
+ A canvas is a file, so you can run a checker over it. \`@genui/cli\` validates exactly this
851
+ kind of TSX:
852
+
853
+ \`\`\`
854
+ npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} check <file>${typesMap === undefined ? "" : ` -i ${typesMap}`}
855
+ \`\`\`
856
+
857
+ \`npx\`, not \`bunx\` — bun cannot parse a scoped package name inside that URL. The
858
+ \`npm_config_cache\` prefix is not optional: your commands run sandboxed and npm's default cache
859
+ under \`~/.npm\` is not writable there, so a bare \`npx\` dies with \`EPERM mkdtemp\` and a message
860
+ about root-owned files that has nothing to do with the real cause. \`check\` includes
861
+ TypeScript diagnostics; \`lint\` is the faster syntax-only pass.
862
+
863
+ ${maps}
864
+
865
+ Either way, the way to see your work actually run is to write the canvas and look at the panel.
866
+
867
+ **Two mistakes it reports that do not blow up**, both found in real cards written here, and both
868
+ the kind you never notice because the thing still works:
869
+
870
+ - **Two utilities that set the same property.** \`className="grid … flex"\` does not merge and does
871
+ not error — which of them wins is decided by the order the rules were generated in, not by the
872
+ order you wrote them, so it can differ between a streaming frame and the settled card. The
873
+ older form of this was a duplicate key in a style object (\`{ display: "block", …, display:
874
+ "flex" }\`, last one wins, first silently dropped); the class form is harder to see because the
875
+ two words sit inside one string. Read the whole class list before adding a layout word to it.
876
+ - **Writing a ref during render.** \`statusRef.current = status\` in the component body reads as a
877
+ cheap way to keep a loop's view of state fresh, and React is explicit that it is not one; do it in
878
+ an effect. A long-running AutoPlay is exactly where this bites, because the loop outlives the
879
+ render that set it.
880
+
881
+ It is worth the round trip because it catches the mistakes that cost the most here — the ones
882
+ that otherwise reach the user as a blank card with nothing in the console. Each of these was
883
+ run through it and the message is quoted as it actually comes back:
884
+
885
+ - \`<META[key].icon />\` — JSX allows the member form \`<a.b />\` but not a subscript.
886
+ "JSX element type '<the object>' does not have any construct or call signatures".
887
+ - \`import { Pie } from "recharts"\` beside \`export default function Pie()\` — "Import
888
+ declaration conflicts with local declaration". Nothing fails at build time; at runtime the
889
+ component recurses into itself until React throws #185.
890
+ - \`<Fragment>\` used without importing it — "Cannot find name 'Fragment'". A \`ReferenceError\`
891
+ at render, so the card mounts and shows nothing.
892
+ - A glob or a regex quantifier written as JSX text — \`<code>src/*.{ts,tsx}</code>\` reports
893
+ "Cannot find name 'ts'", which is precisely what it will throw when the reader opens it.
894
+
895
+ What it does **not** catch is worth knowing too, so you do not read a clean run as a working
896
+ card: a hook called at module scope, and a hardcoded \`#fff\` background, both pass. Those are
897
+ yours to get right.
898
+
899
+ Skip it for a small inline block you can read in one screen. Run it on anything long, and on
900
+ anything you are about to leave in the workspace as a canvas.
901
+
902
+ **Read the report, do not obey it.** Run over 378 real cards it reported something on 136 of
903
+ them, and 97 of those were \`implicitly has an 'any' type\` on a lambda parameter — a card that
904
+ runs perfectly. Annotating every parameter to quiet it costs lines and buys nothing. The lines
905
+ worth acting on name a *mechanism* that is wrong (a conflicting declaration, a duplicate key, a
906
+ name that does not exist, a comma operator), not a type that could be narrower.
907
+
908
+ ## Imports
909
+
910
+ 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.
911
+
912
+ **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.
913
+
914
+ Four that are easy not to think of, each with the one thing to get right:
915
+
916
+ | want | reach for | the detail |
917
+ | --- | --- | --- |
918
+ | 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}\` |
919
+ | 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 |
920
+ | 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 |
921
+ | form controls | \`@headlessui/react\` | \`Field\` + \`Label\` around \`Switch\`/\`Listbox\`/\`Combobox\` — labelling comes with them |
922
+
923
+
924
+ 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.
925
+
926
+ 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\`.
927
+
928
+ One lookup costs a few seconds; a wrong name costs a blank card, a confused user, and a repair round-trip.`)(mapNotes(typesMap, standaloneMap))
929
+ // Cut the section whole, from its heading to the next one. Anchored on the headings rather
930
+ // than on line numbers so editing the prose in between cannot silently change what is cut.
931
+ .replace(allowExec ? "" : /\n## Running a command\n[\s\S]*?(?=\n## )/, "");