dsh-generative-ui 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -5,7 +5,6 @@ import { join } from "node:path";
5
5
  import { createRequire } from "node:module";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import z from "@deepseek-ai/schemastery";
8
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
9
8
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
10
9
 
11
10
  // src/contract-assets.ts
@@ -16,6 +15,7 @@ var AI_STREAM_PATH = "/dsh-generative-ui/ai";
16
15
  var FS_PATH = "/dsh-generative-ui/fs";
17
16
  var EXEC_PATH = "/dsh-generative-ui/exec";
18
17
  var WEB_SEARCH_PATH = "/dsh-generative-ui/web-search";
18
+ var CARD_ERROR_PATH = "/dsh-generative-ui/card-error";
19
19
 
20
20
  // src/contract.ts
21
21
  var UI4A_DIR = ".dsh/ui4a";
@@ -56,8 +56,36 @@ function canvasIdOf(path) {
56
56
  return isCanvasId(id) ? id : null;
57
57
  }
58
58
 
59
+ // src/card-failure.ts
60
+ var CARD_FAILURE_CONTEXT = "ui4a:card-failure";
61
+ var CARD_FAILURE_CONTEXT_ORDER = 250;
62
+ var failureText = (failure) => `A ui4a card in this session is not rendering. It failed at the ${failure.phase} step:
63
+
64
+ ${failure.message}
65
+
66
+ This is current state, not a new event — it is re-read every step and disappears once the card renders. If the error names the correct usage (the available exports, for instance), fix the card and send it again. If it does not, look it up before you change anything.`;
67
+ var WAKE_TEXT = "A ui4a card you wrote is not rendering — the failure is in the runtime context. If there is no card failure there, it recovered on its own between this notice and now: say nothing about it and carry on with what you were doing.";
68
+ var WAKE_SUMMARY = "ui4a card failed to render";
69
+
70
+ class CardFailures {
71
+ bySession = new Map;
72
+ set(session, failure) {
73
+ const wasHealthy = !this.bySession.has(session);
74
+ this.bySession.set(session, failure);
75
+ return wasHealthy;
76
+ }
77
+ clear(session) {
78
+ this.bySession.delete(session);
79
+ }
80
+ text(session) {
81
+ const failure = session === undefined ? undefined : this.bySession.get(session);
82
+ return failure === undefined ? "" : failureText(failure);
83
+ }
84
+ }
85
+
59
86
  // src/skill.ts
60
87
  var CLI_URL = "https://pkg.pr.new/MindLab-Research/macaron-genui-demo/@genui/cli@main";
88
+ var RUN_CLI = `BUN_INSTALL_CACHE_DIR="$TMPDIR/bun-cache" bunx --yes genui@${CLI_URL}`;
61
89
  var SKILL_NAME = "generative-ui";
62
90
  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
91
  function mapNotes(typesMap, standaloneMap) {
@@ -91,7 +119,7 @@ function mapNotes(typesMap, standaloneMap) {
91
119
  `${check} \`build\` and \`dev\` want runnable JS, so they take a different one:`,
92
120
  "",
93
121
  "```",
94
- `npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} build <file> -i ${standaloneMap}`,
122
+ `${RUN_CLI} build <file> -i ${standaloneMap}`,
95
123
  "```",
96
124
  "",
97
125
  `That second map stubs \`${CAPABILITY_PREFIX}/*\` — the exported page has no dsh around it, so those calls log to`,
@@ -126,11 +154,89 @@ They are not two sizes of the same thing; they have different lifetimes.
126
154
 
127
155
  **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
156
 
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.
157
+ **The tell is not "would this be useful to keep".** That question is about the content, it answers
158
+ yes for anything reference-shaped, and it is how a changelog, a cron explanation and a definition of
159
+ closures all became files. Ask instead: **did they ask for a durable thing?** A canvas is a file in
160
+ their workspace that they now own and have to close — creating one is an action taken on their
161
+ behalf, and it needs their say-so:
162
+
163
+ - They named a lasting artifact — "make me a dashboard", "a page I can share", "save this as", "a
164
+ tool for…", "画板", "报告" — or asked to keep or come back to something. → **canvas**
165
+ - They asked a question, even a large one whose answer is long and well-organised. → **inline**,
166
+ every time. "What changed in 2.1.251" is a question; 71 items of answer does not make it a file.
167
+ - The thing genuinely has more than one screen, or holds state the next turn needs. → **canvas**,
168
+ and say in one line that you opened it.
169
+
170
+ Measured: on \`cron-read\` — *"\`*/17 3-5 * * 2\` 这个 cron 到底几点跑?"*, a lookup with one right
171
+ answer — models opened a canvas **5 times in one round and 6 in the next**, and one opened a canvas
172
+ for *"什么是闭包?"*. Nobody asked for a file in any of them.
173
+
174
+ When it is genuinely borderline, inline is the cheaper mistake: it is one message, not a file the
175
+ user now owns.
130
176
 
131
177
  Two things follow from the lifetime difference:
132
178
 
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.
179
+ - An **inline** block that the user acts on should *end that step* see the next entry for WHICH control ends it, because on a card whose options need previewing it is not the one they pick with. Whichever it is, that control does two things: send the result with \`sendMessage\` **and** record what was chosen in \`usePersistedState\`, so the card still shows it when scrolled back to weeks later.
180
+
181
+ **The second half is the one that gets dropped, and it fails in two different ways.** Measured over the 105 turns where a reader actually submitted something: **36 (34%) left the card looking exactly as it had before the click**, and another **20 (19%) showed the choice and then lost it on reload** — 53% between them, and evenly spread across every model, so it is the rule and not a habit. The first is forgetting to record at all (\`sendMessage\` treated as the finish line); the second is recording into \`useState\`, which a reload throws away. Both read to the reader as a form that did not take their answer.
182
+
183
+ So the submit handler has three statements, not one:
184
+
185
+ const [answer, setAnswer] = usePersistedState<string | null>("<this card>-answer", null)
186
+
187
+ onClick={() => { setAnswer(pick); sendMessage(…) }} // record, then send
188
+
189
+ {answer !== null && <p className="text-muted">已选择:{label(answer)}</p>} // and SHOW it
190
+
191
+ The third line is the one nobody writes — but not for the reason it first looked like. Of the
192
+ cards that call \`usePersistedState\`, **91 of 93 do render the value somewhere**; what they render
193
+ it as is \`aria-pressed\` on the button that was clicked. Reading the turns where a submit left the
194
+ card unchanged: **31 of 42 mark the choice with a highlight and say nothing in words**, and 19 of
195
+ those 42 hold it in \`useState\`, so the highlight is gone after a reload. A highlight is a fine
196
+ way to show which control is active while the reader is still there; it is not an answer to
197
+ someone coming back to this card next week, who sees one button shaded and no statement of what
198
+ was decided. Say it in words AND keep it in \`usePersistedState\`. (Re-firing on reload is the opposite mistake and does not happen — 0 of 105 — so
199
+ guard the send with the recorded answer, not with anything cleverer.)
200
+ - **Exactly one control ends the step, and the reader must be able to find it.** This is the
201
+ single largest hole in what gets built: across 161 runs where the reader actually clicked
202
+ something, **108 of them — 67% — never once got a result back out of the card**, 468 clicks that
203
+ went nowhere. It is not one model's habit (every one of the ten does it) and not one case's
204
+ (every case does it). The shape is always the same: a card you can fiddle with forever and never
205
+ finish.
206
+
207
+ **The check is one grep, so run it on what you just wrote: does the source contain a
208
+ \`sendMessage\` call at all?** Re-measured over 171 clicking runs, 117 of them dead: **90 — 77% —
209
+ have no \`sendMessage\` anywhere in the card**. The reader clicks \`RESTful (JSON)\`, \`下一步 →\`,
210
+ \`2. 尺度缩放 / √d\` — real controls, wired to internal state and to nothing else — and the
211
+ conversation stops there. Not "the ending was hard to find": there was none to find.
212
+
213
+ Two endings are correct, and which one depends on whether the options need explaining:
214
+
215
+ - **The options speak for themselves** (yes/no, this file or that one) — the click IS the answer.
216
+ Two plain buttons, no card around them, \`sendMessage\` on click. Nothing to preview.
217
+ - **The options mean something you have to see to choose between** — then the click SELECTS and
218
+ shows, and a separate **Submit** sends. Clicking a tab must not fire the turn; a reader
219
+ comparing three options should be able to look at all three first.
220
+
221
+ The preview form is a selector, a result area, and one submit — that is the whole structure, and
222
+ the result area is where the card earns its existence:
223
+
224
+ const [pick, setPick] = useState(OPTIONS[0].id)
225
+ const [sent, setSent] = usePersistedState<string | null>("migration-plan-choice", null)
226
+
227
+ <div className="flex flex-wrap gap-2">…one button per option, aria-pressed={pick === o.id}…</div>
228
+ <div className="mt-3">{OPTIONS.find((o) => o.id === pick)!.preview}</div>
229
+ <button disabled={sent !== null} onClick={() => { setSent(pick); sendMessage(…) }}>…</button>
230
+
231
+ \`preview\` is whatever actually shows the difference: a mermaid graph of the two migration paths,
232
+ the formula rendered by katex, an SVG of the layout, a working miniature of the thing, a 3D view,
233
+ a playable board. A paragraph of text describing the option is not a preview — the reader could
234
+ have read that in the reply.
235
+
236
+ **And it fires once.** \`sent\` above is persisted, so a reload shows the answer that was given
237
+ rather than an untouched form, and the button cannot send a second turn for a question already
238
+ answered.
239
+
134
240
  - A **canvas** stays interactive. It does not "complete"; it just sits there working.
135
241
  - 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
242
 
@@ -145,6 +251,19 @@ Two things follow from the lifetime difference:
145
251
  in a label and the user's half-typed row goes with it. Persist what they typed, not just what
146
252
  they saved.
147
253
 
254
+ **An inline card is clickable before you have finished writing it, and that is where this bites
255
+ hardest.** The reader sees the first controls while the rest of the card is still arriving, and
256
+ **every chunk that adds JSX remounts every component the card defines itself** — so a choice they
257
+ make mid-stream is wiped by the next chunk, silently, with the control snapping back to its
258
+ initial state. Measured three ways on the same card, one variable each: state in a
259
+ card-defined child is lost, the same state in the exported component survives, and
260
+ \`usePersistedState\` survives in either. **Two chunks is enough** — this is not a rare race.
261
+
262
+ So anything the reader can change belongs in \`usePersistedState\`, not only the answer you
263
+ intend to record. The one case it cannot reach is a third-party component holding its own state:
264
+ \`<Disclosure defaultOpen>\` reverts to \`defaultOpen\` on every remount, and the only way to keep
265
+ what the reader did is to control it yourself from persisted state.
266
+
148
267
  **If you write \`setRows(prev => prev.filter(r => r.id !== id))\` behind a button, keep the row.**
149
268
  Persisting is what makes that line permanent — before it, a mistaken delete came back on reload.
150
269
  Hold the removed row and offer it back:
@@ -196,19 +315,42 @@ Ask with an **inline** block instead: one short line saying what you need to kno
196
315
 
197
316
  \`\`\`tsx
198
317
  import { sendMessage } from "$dsh/chat"
318
+ import { usePersistedState } from "${capabilityModule("state")}"
199
319
 
200
320
  export default function Pick() {
201
- const [picked, setPicked] = useState<string | null>(null)
321
+ const [picked, setPicked] = usePersistedState<string | null>("ask:which-cloud-host", null)
202
322
  const choose = (id: string) => { setPicked(id); sendMessage(id) }
323
+ // the key names THIS question — two asks in one conversation must not share it
203
324
  // picked === null → the options; otherwise just the chosen one, still highlighted
204
325
  }
205
326
  \`\`\`
206
327
 
328
+ **When the two answers need no explaining, they are two buttons in a row — not two cards.** The
329
+ shape to match is in the question: \`Postgres or SQLite?\` / \`公制还是英制?\` / \`要我先跑测试吗?\` are
330
+ answered by the label alone, and a bordered tile with a description under each says the choice is
331
+ weightier than it is. Same \`choose\`, same key, one row:
332
+
333
+ \`\`\`tsx
334
+ <div className="flex flex-wrap gap-2">
335
+ {OPTIONS.map((o) => (
336
+ <button key={o.id} onClick={() => choose(o.id)} aria-pressed={picked === o.id}
337
+ className="rounded-md border border-line px-3 py-1.5 text-sm hover:bg-hover
338
+ aria-pressed:bg-accent aria-pressed:text-white aria-pressed:border-transparent
339
+ aria-pressed:hover:bg-accent"> {/* or the selection vanishes under the pointer */}
340
+ {o.label}
341
+ </button>
342
+ ))}
343
+ </div>
344
+ \`\`\`
345
+
346
+ Give each option a description and you have built the card version above; the descriptions are
347
+ what earn the tiles. Two labels that stand on their own take the row.
348
+
207
349
  Rules for that move:
208
350
 
209
351
  - **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
352
  - **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.
353
+ - **One ask per question, and their answer settles that question.** Take it and build: a detail they left open takes your sensible default, named in one line. A choice their answer just *opened* is a different question, and it takes this same move — they told you the language, so formal-or-casual is a question that did not exist a turn ago.
212
354
 
213
355
  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
356
 
@@ -229,12 +371,14 @@ This one runs *opposite* in the two places, and getting it backwards is the most
229
371
 
230
372
  - **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
373
  - **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
374
+ - **But \`bg-page\` is the page's own colour, so a wrapper painted with it is not a box.** Measured
375
+ from the token table: \`bg-page\` (that is the CLASS; \`--dsw-alias-bg-base\` is the variable
376
+ behind it, and the two vocabularies are deliberately different) is \`#fff\` on light and
377
+ \`#151517\` on dark — the same value the
234
378
  transcript behind the card is painted with, on both grounds. A root \`<div>\` with
235
379
  \`background: var(--dsw-alias-bg-base); padding: 16px; border-radius: 12px\` therefore draws
236
380
  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
381
+ can find, while the \`bg-layer\` blocks inside it read as the real frame — a frame inside an
238
382
  invisible frame. If you want the inline card to be bounded, bound it with \`bg-layer\` **plus**
239
383
  \`border-line\` (see the both-spellings rule below). If you don't, drop the wrapper's background
240
384
  and radius entirely rather than painting it the colour of the page.
@@ -316,9 +460,9 @@ Either way, don't restage the header. The panel already names the canvas, so a h
316
460
 
317
461
  **A control you have FILLED is the opposite case, and the two get confused.** The rule above is
318
462
  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
463
+ \`border-line\` is 4% black. Once an element carries a real fill (a selected segment on
320
464
  \`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
465
+ \`border-line-2\` is a grey ring around a blue block, related to nothing. Drop it — but to
322
466
  \`transparent\`, not to \`none\`, or the selected item loses a pixel of height and the row twitches
323
467
  as the reader clicks along it:
324
468
 
@@ -333,10 +477,48 @@ Either way, don't restage the header. The panel already names the canvas, so a h
333
477
  (\`#fff\` here) as their colour and no background at all, or a white outline if the shape itself
334
478
  has to stay readable.
335
479
  - **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.
480
+ - **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 until you do it yourself. No \`position: fixed\`, no viewport UNITS (\`vh\`/\`vw\`, at any number — the window is not your box, so \`78vh\` is wrong for the same reason \`100vh\` is), 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".
481
+ - **A title or a control sitting above a long list is a \`sticky\` header. Not "could be"is.**
482
+ The test is mechanical, so apply it mechanically: *is there anything above the list that the
483
+ reader will still want once they are deep inside it?* A heading that says what they are looking
484
+ at, a search box, a row of filter chips, a count that changes as they filter. If yes, that strip
485
+ pins. Otherwise the reader scrolls into the list, decides to narrow it, and has to scroll back up
486
+ past everything they were reading to reach the box that narrows it.
487
+ **Measured across 766 generated cards: 356 have a heading or a control above a list, and 353 of
488
+ them let it scroll away.** Not a tendency, an absence: the shape is in every case (60 on one, 54
489
+ on the next, 31, 26, 23…) and every model (95 for the worst, then 56, 44, 35, 33…), and no model
490
+ pins it more than the rest. One of the 353, read in full: 266 lines — \`<h2>最近工作轨迹</h2>\`, a
491
+ search input reading \`搜项目、作者或提交内容\`, a row of per-repo filter chips, then
492
+ \`filtered.slice(0, limit).map(…)\` and a "load more" button. **Zero occurrences of \`sticky\`**,
493
+ in that card and in the second one the same turn produced. Everything needed to steer the list
494
+ scrolled away the moment the list was worth steering.
495
+
496
+ - **Your root sets no height and no \`overflow\`; the page is what scrolls.** You are inside a
497
+ column the reader is already scrolling, so a root that sizes itself and grows its own scrollbar
498
+ puts a second scroll inside the first. Pin with \`sticky\`, which pins against the READER's
499
+ scroll, and give an inner pane its own bound only when a list genuinely needs one:
338
500
 
339
- - **In a canvas, extra width should make the rows SHORTER, not the card wider.** Measured across
501
+ \`\`\`tsx
502
+ <div className="isolate relative"> {/* your own stacking context */}
503
+ <div className="sticky top-0 z-10 bg-layer border-b border-line">…</div>
504
+ <div className="max-h-[30rem] overflow-y-auto">…</div> {/* the list, not the card */}
505
+ </div>
506
+ \`\`\`
507
+
508
+ **\`overflow\` on ANY ancestor of a \`sticky\` element switches it off, silently** — no error, no
509
+ warning, it simply scrolls away. Watched happen across one card's revisions: a root grew
510
+ \`overflow: hidden\` to contain a stacking problem, the pinned header stopped pinning, and the
511
+ two edits were two turns apart. Nothing between a \`sticky\` element and the page may set it.
512
+
513
+ **\`isolate\` is what keeps your \`z-index\` small.** Inside a stacking context you own, \`z-10\`
514
+ is above everything of yours and below everything of the app's. Without one, a number picked to
515
+ beat your own siblings also beats the composer the reader types into.
516
+
517
+ - **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 with \`@container\` and \`@[32rem]:\` variants, which is the ONE responsive tool that works here. "One comfortable column beats two cramped ones" settles what to do at 320px; it is not a licence to ship the same single column at 720. **Judged by a vision panel on 59 cards at three widths, "still one column at 720px, half the card is empty" was the single most common criticism — 91% of verdicts — and "no breakpoint of any kind in the source" was 76%.** A list of items with a name and a description is \`@[30rem]:grid-cols-2\`; a strip of stats is \`@[24rem]:grid-flow-col\`. The reader who widens the panel is asking for less scrolling, and getting a wider version of the same tall column is not an answer.
518
+
519
+ - **Extra width should make the rows SHORTER, not the card wider — inline as much as in a canvas.**
520
+ This entry read "In a canvas" for a while and that scope was wrong: a vision panel grading
521
+ **inline** cards raised it in 27% of verdicts, on cards that *did* carry breakpoints. Measured across
340
522
  one wave, height at 320 divided by height at 720: the five inline cards shrink 1.26–1.52x, and
341
523
  the six canvases shrink **1.02–1.18x** — one is 1100px tall at 320 and still 1076px at 720. It
342
524
  is not for want of the technique; 8 of those 9 canvases carry a container query or an intrinsic
@@ -352,7 +534,61 @@ Either way, don't restage the header. The panel already names the canvas, so a h
352
534
  </div>
353
535
 
354
536
  The reader drags a canvas panel between 320 and 720 — that drag should buy them less scrolling.
537
+ - **Nothing you draw may carry a width the column did not give it.** Measured by mounting 60 real
538
+ cards at 380px: **12 overflowed the column**, across 4 of the 26 runs sampled, and the part that
539
+ hangs off the edge is invisible in a screenshot — the picture is clipped at the card, so an
540
+ absent column reads as a design choice and nobody can name the defect. Every one of the 12 was
541
+ the same mistake in a different costume:
542
+
543
+ | what stuck out | how far | write instead |
544
+ | --- | --- | --- |
545
+ | \`<svg width="600" …>\` | 308–348px | \`viewBox="0 0 600 400"\` and \`className="w-full h-auto"\` — the viewBox carries the coordinates, the class carries the size |
546
+ | a \`<pre>\`/\`<code>\` of real source | 409–705px | the code keeps its long lines; the WRAPPER gets \`overflow-x-auto\`, so the card stays put and the code scrolls inside it |
547
+
548
+ **A hand-rolled \`<pre>\` is two defects at once, and it is the single commonest thing in this
549
+ corpus.** Of 766 cards, **194 show code and 182 of them hand-roll a \`<pre>\` — 94%**; only 4 reach
550
+ for \`shiki\`. And they are where the overflow lives: of 107 measured overflows, **35 — a third of
551
+ everything — are a \`<code>\` element**, at a median of 195px past the edge against 84px for every
552
+ other tag combined. (Not one is a \`<pre>\`: the wrapper is fine, the \`<code>\` inside it is what
553
+ hangs off. The single widest overflow in the corpus is a \`<section>\` at 978px, so these are the
554
+ typical worst rather than the record holder.) So the fix is one import,
555
+ not two patches: \`shiki\` highlights it (see the library table) AND you still put the
556
+ \`overflow-x-auto\` on the wrapper. Unhighlighted source in a card the reader cannot scroll
557
+ sideways is code they can neither read nor reach the end of.
558
+ | \`<table className="min-w-[28rem]">\` | 84px | put the \`overflow-x-auto\` on the wrapper and drop the min-width, or let the columns wrap |
559
+
560
+ \`min-w-0\` is the answer to a flex child that will not shrink; this is its opposite — an
561
+ explicit intrinsic width you typed yourself, and no ancestor can undo it. **The widest offender
562
+ was 705px hanging off a 380px column**, which is not a card that looks slightly wrong, it is a
563
+ card most of which does not exist for the reader.
564
+
355
565
  - **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.
566
+ - **Whatever \`hover:\` changes, the selected state has to claim in its hover form too.** A
567
+ \`hover:bg-hover\` and an \`aria-pressed:bg-accent\` on the same button generate at the SAME
568
+ specificity — \`:is()\` takes its argument's, so \`.class:hover\` and \`.class[aria-pressed]\` are
569
+ both \`(0,2,0)\` — and source order in the generated sheet puts \`hover\` last. So the selected
570
+ button turns back to neutral grey **while the pointer is on it**, which is exactly when the
571
+ reader is looking at it. **Measured across 766 generated cards: 308 real collisions in 193 cards
572
+ across 60 runs** — a quarter of everything written, 256 on \`bg\` and 52 on \`text\`.
573
+
574
+ Add the pressed-and-hovered pair. It is \`(0,3,0)\`, so it wins on specificity and does not care
575
+ where it lands in the sheet:
576
+
577
+ hover:bg-hover aria-pressed:bg-accent aria-pressed:hover:bg-accent
578
+
579
+ **Whichever attribute you marked the selection with, qualify that one** — the trick is the extra
580
+ variant, not the word \`aria-pressed\`. Of those 308 collisions, only 163 are on \`aria-pressed\`;
581
+ the rest are \`data-[state=active]\` (74), \`checked\` (43) and \`aria-selected\` (28), and each has
582
+ the same fix, verified against this generator:
583
+
584
+ data-[state=active]:hover:bg-accent checked:hover:bg-accent aria-selected:hover:bg-accent
585
+
586
+ **Do not reach for \`not-\`.** \`not-aria-pressed:hover:bg-hover\` and
587
+ \`hover:not-aria-pressed:bg-hover\` are the intuitive fix and this generator matches **neither** —
588
+ they produce no rule at all, so the button keeps the bug and the class list now says it was
589
+ handled. A ternary works too (\`picked ? "bg-accent" : "hover:bg-hover"\`) because only one branch
590
+ is ever present; reach for that when the two states differ in more than a couple of properties.
591
+
356
592
  - **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
593
  - **If you take the focus ring off, put something back.** \`outline-none\` on a borderless input
358
594
  is the most common single thing in these cards that breaks keyboard use: **77 of 378 remove the
@@ -505,6 +741,18 @@ Either way, don't restage the header. The panel already names the canvas, so a h
505
741
 
506
742
  \`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
743
 
744
+ - **Getting the attribute right and the pixels wrong is the commoner half.** A vision panel reading
745
+ 59 cards raised this in **22% of its verdicts**, in one recurring form: \`aria-checked\` correctly
746
+ set, and the selected chip differing from its siblings **only by background colour**. That is one
747
+ channel, and it is the channel that fails first — greyscale, a dim screen, or the 8% of men with
748
+ a colour vision deficiency. The fix is a second channel on the same ternary, and it costs a
749
+ class: \`font-medium\` on the selected one, or a \`✓\` before its label, or a ring the unselected
750
+ ones do not carry. **Colour may be the loudest signal; it may not be the only one.**
751
+ (No screen for this one, deliberately: a prototype matching the template-literal ternary found
752
+ **7 selections across three waves and zero colour-only ones**, against 22% in the verdicts — the
753
+ shapes a card writes this in are too many for a regex, and a detector that narrow reports a
754
+ clean sweep on a defect that is everywhere.)
755
+
508
756
  **Write the state and the style it produces as one token, and this whole class of bug stops
509
757
  existing.** \`aria-checked:bg-accent\` is a single string: there is no second place for it to
510
758
  disagree with. Measured on a card written before that was possible — the CSS said
@@ -751,7 +999,7 @@ retrieve a page body — render the snippet and link the source.
751
999
 
752
1000
  ## Reading and writing workspace files
753
1001
 
754
- \`$dsh/fs\` gives a card \`readFile(path) -> string\`, \`readdir(path) -> {name, type, size}[]\`
1002
+ \`$dsh/fs\` gives a card \`readFile(path) -> string\`, \`readBytes(path) -> Uint8Array\`, \`readdir(path) -> {name, type, size}[]\`
755
1003
  (\`type\` is \`"file"\` or \`"directory"\`, so a tree needs no probing; \`size\` is bytes, absent on
756
1004
  directories) and \`writeFile(path, content)\` over the workspace. Paths are workspace-relative and
757
1005
  \`path\` is required — there is no "current directory" argument-less form, under the
@@ -759,6 +1007,29 @@ session's own access mode — the same fence the file tools run behind. So a rea
759
1007
  refuses the write, and the card should say so rather than looking broken: catch it and tell
760
1008
  the user the session is read-only.
761
1009
 
1010
+ **Anything that is not text goes through \`readBytes\`.** \`readFile\` decodes as UTF-8, so a png, a wav
1011
+ or a \`.mid\` read that way comes back with every byte above 0x7f replaced by U+FFFD — corrupt, and
1012
+ silently so. And there is **no HTTP route that serves workspace files**: \`<img src={\`/\${path}\`}>\`
1013
+ resolves against the app, 404s, and the reader gets a page of broken icons. Measured on a real
1014
+ canvas that found 357 images and showed none of them. The whole shape is three lines:
1015
+
1016
+ \`\`\`tsx
1017
+ const [url, setUrl] = useState<string>()
1018
+ useEffect(() => {
1019
+ let live = true, made: string | undefined
1020
+ void readBytes(path).then((bytes) => {
1021
+ if (!live) return
1022
+ made = URL.createObjectURL(new Blob([bytes]))
1023
+ setUrl(made)
1024
+ })
1025
+ return () => { live = false; if (made !== undefined) URL.revokeObjectURL(made) }
1026
+ }, [path])
1027
+ \`\`\`
1028
+
1029
+ Revoking is not optional in a browser that keeps a long transcript: one object URL per image per
1030
+ mount, never released, is a leak the reader pays for in memory. A grid of them wants an
1031
+ \`IntersectionObserver\` too — read the bytes when the cell comes near, not all of them on mount.
1032
+
762
1033
  Reach for it when the data **belongs to the workspace** — a file the user can also open, edit
763
1034
  and commit.
764
1035
 
@@ -866,14 +1137,36 @@ A canvas is a file, so you can run a checker over it. \`@genui/cli\` validates e
866
1137
  kind of TSX:
867
1138
 
868
1139
  \`\`\`
869
- npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} check <file>${typesMap === undefined ? "" : ` -i ${typesMap}`}
1140
+ ${RUN_CLI} check <file>${typesMap === undefined ? "" : ` -i ${typesMap}`}
1141
+ \`\`\`
1142
+
1143
+ **\`bunx\` needs the package NAME in front of the URL** — \`genui@https://…\`. Bun reads the whole
1144
+ argument as \`<name>@<spec>\`, so a bare URL gives it an empty name and it stops at
1145
+ \`unrecognised dependency format\` before fetching anything. Any name works; it is a label, not a
1146
+ lookup.
1147
+
1148
+ If bun is not there, in order:
1149
+
1150
+ \`\`\`
1151
+ pnpx --config.blockExoticSubdeps=false ${CLI_URL} check <file>
1152
+ npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} check <file>
870
1153
  \`\`\`
871
1154
 
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.
1155
+ Both take the bare URL. The pnpm flag is **not** optional the CLI pulls \`@genui/unocss\` by URL
1156
+ as well, and pnpm refuses URL-resolved SUBdependencies by default, so without it you get
1157
+ \`ERR_PNPM_EXOTIC_SUBDEP\` naming a package you never asked for. Do **not** add
1158
+ \`--config.cacheDir\` beside it: pnpm then loses the package's own bin and dies with
1159
+ \`spawn cli ENOENT\`, which reads like the package is broken and is not. It needs no cache redirect
1160
+ anyway — its store is the one of the three your sandbox lets you write.
1161
+
1162
+ **The other two do**, and the reason is worth knowing because it disguises itself. Sandboxed,
1163
+ \`touch ~/.npm/_cacache/x\` and \`touch ~/.bun/install/cache/x\` both come back
1164
+ \`Operation not permitted\`; the directories exist, they are simply not yours to write from in
1165
+ there. npm reports this as \`EPERM mkdtemp\` **and a message about root-owned files**, which sends
1166
+ you looking for a permissions problem in your home directory that is not there. \`$TMPDIR\` is
1167
+ writable, so pointing each cache at it is the whole fix.
1168
+
1169
+ \`check\` includes TypeScript diagnostics; \`lint\` is the faster syntax-only pass.
877
1170
 
878
1171
  ${maps}
879
1172
 
@@ -926,14 +1219,19 @@ Bare specifiers resolve from npm at render time — there is no install step, so
926
1219
 
927
1220
  **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
1221
 
929
- Four that are easy not to think of, each with the one thing to get right:
1222
+ Five that are easy not to think of, each with the one thing to get right:
930
1223
 
931
1224
  | want | reach for | the detail |
932
1225
  | --- | --- | --- |
933
1226
  | 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
1227
  | 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
1228
  | 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 |
1229
+ | form controls — a switch, a select, a combobox, a modal, tabs, a disclosure | \`@headlessui/react\` | \`Field\` + \`Label\` around \`Switch\`/\`Listbox\`/\`Combobox\` — labelling comes with them. Its \`Disclosure\` and \`Tab\` are also the cheapest correct way to build the folding a dense card needs. **The container components render a \`Fragment\`, so a \`className\` on one throws and takes the whole card with it** — \`<Disclosure className=…>\` dies with *Passing props on "Fragment"!* and the reader gets that sentence instead of the card. Measured: 1 of the 4 cards in a wave that reached for this library. Write \`<Disclosure as="div" className=…>\`; same for \`Tab.Group\`, \`Listbox\`, \`Menu\`, \`RadioGroup\`. The leaves (\`Disclosure.Button\`, \`.Panel\`) are real elements and take \`className\` as they are, which is why the failure looks like the library rejecting a style it accepts everywhere else. |
1230
+ | the same, when you want arrow-key roving between tabs or menu items | \`@radix-ui/react-tabs\`, \`@radix-ui/react-accordion\`, \`@radix-ui/react-dialog\` | one package per primitive, so import only what you use. **This is the one that gives arrow-key navigation**: Radix's \`Tabs\` moves focus with ←/→ and Home/End, Headless UI's does not — a real user asked for exactly that and was right to notice it missing. Compose from \`Tabs.Root\`/\`List\`/\`Trigger\`/\`Content\`; they render unstyled, so every class is yours |
1231
+ | a formula the reader is trying to READ, not just the number it comes out as | \`katex\` | \`katex.renderToString(tex, { throwOnError: false })\` into \`dangerouslySetInnerHTML\`; it has both a default and a named \`renderToString\`, so either import works. \`throwOnError: false\` is the load-bearing half — a half-typed \`\\\\frac{a}\` renders as \`<span class="katex-error">\` instead of throwing during render and taking the card with it, and half-typed is what streaming produces. The glyph metrics come from its stylesheet: append \`<link rel="stylesheet" href="https://esm.sh/katex@0/dist/katex.min.css">\` in an effect and REMOVE it in the cleanup, the same way you would a listener |
1232
+ | a flow, a sequence, a state machine — anything whose content is *which box points at which* | \`mermaid\` | \`mermaid.initialize({ startOnLoad: false })\` once, then \`await mermaid.render(id, "graph LR; A-->B")\` which resolves \`{ svg }\` for \`dangerouslySetInnerHTML\` — it is async and returns a string, it does not mount itself. Give each render a unique id or the second one collides with the first. Reach for this before hand-placing boxes: you write the edges and it does the layout, which is the part that goes wrong by hand |
1233
+ | a diagram whose POSITIONS carry meaning — a system laid out the way the reader pictures it, a floor plan, an annotated screenshot | your own \`<svg>\`, or boxes and CSS | This is the one case where hand-drawing IS the answer, and the rule above does not contradict it: \`recharts\` renders DATA, and there is no library that knows what your boxes are or which arrow goes where. Asked to draw something, draw it — a card that answers "画出来" with a bulleted list of the parts has changed the question. Nodes as positioned boxes with \`<svg>\` lines between them, or a grid of boxes with borders for the edges, both read fine |
1234
+ | showing code, a diff, or a config file | \`shiki\` | \`await codeToHtml(src, { lang, theme })\` in an effect, then \`dangerouslySetInnerHTML\` — it is async, so render a \`<pre>\` of the raw text first and swap. A hand-rolled \`<pre>\` with no highlighting is the tell that this was skipped, and for a diff the red/green is the whole point. **\`@monaco-editor/react\` only when the reader will TYPE into it.** Measured on a real card that used it for two read-only tabs: Monaco's language service spent **22 seconds** in its worker running TypeScript analysis on code nobody was editing, \`_registerLanguages\` cost another 571ms at startup, and a tab switch took **132ms** where the same card's other buttons took 4ms. Everything the reader wanted from it — line numbers, colours, two files — \`shiki\` renders as static HTML |
937
1235
 
938
1236
 
939
1237
  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.
@@ -966,7 +1264,9 @@ export default function Answer() {
966
1264
  - **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
1265
  - The module must \`export default\` a component taking no props.
968
1266
  - **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.
1267
+ - \`import\` React and anything else you need; bare specifiers resolve from npm automatically. **Any package, not a short list** — \`@headlessui/react\` or \`@radix-ui/react-*\` for switches, tabs, modals and disclosures that already handle focus and the keyboard, \`shiki\` to syntax-highlight code or a diff (**never \`@monaco-editor/react\` for code the reader only READS** — measured, a card that did spent 22s of worker time analysing code nobody edited and took 132ms per tab switch against 4ms for its other buttons), \`recharts\` for charts, \`lucide-react\` for icons. There is no install step and no allowlist, so hand-rolling a component to avoid an import is a worse component you now own.
1268
+
1269
+ **Four widgets are never hand-written here, because hand-writing them silently drops the keyboard.** A row of tabs, a collapsible section, a dropdown or select, a modal. Written by hand they look finished and are not: measured, a real reader clicked through a hand-rolled tab strip and asked *"我以为用了的话就能有左右方向键来切换 tab 的功能呢"* — arrow-key roving, Home/End, focus returning where it came from, \`aria-selected\` following the panel. That is a day of work in a library and a \`useState\` away from wrong by hand. **\`@radix-ui/react-tabs\` is the one with arrow keys**; \`@headlessui/react\`'s \`Tab\` does not rove. Reach for the import the moment you type the state that switches between them, not after someone notices. **The hand-rolled ones do not feel like a decision** — measured across 125 cards, \`@headlessui/react\` was imported **zero times** while 40% of those cards hand-wrote a disclosure out of \`useState\` and a conditional, re-deriving the focus and keyboard behaviour \`Disclosure\` ships with. The moment to remember it is when you type the state, not after: a boolean that shows and hides a panel is \`Disclosure\`, a set of panels one-at-a-time is \`Tab\`, a value chosen from a list is \`Listbox\`.
970
1270
  - **\`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
1271
  - **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
1272
 
@@ -1003,13 +1303,13 @@ export default function Answer() {
1003
1303
  onChange={ (e) => setN(e.target.value === "" ? "" : Number(e.target.value)) } // stays empty
1004
1304
 
1005
1305
  - **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.
1306
+ - \`import { readFile, readBytes, 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. **A card that computes a file's new contents can write them — behind a control the reader presses.** Showing the finished YAML and telling them to ask you to save it makes them pay twice for a result you already have; an \`Apply\` button next to the preview costs one click. The button is the consent, so it says what it will do (\`Write cordis.patch.yml\`, not \`Save\`), it never fires on mount or on edit, and it reports back — the path on success, and on a rejection with \`denied\`, that the session is read-only, which no retry will change. **Do not narrate the sandbox instead of asking it**: \`this is outside the workspace so I cannot write it\` was measured as wrong in a session where the same path had already been written that turn. Call \`writeFile\` and let the fence answer. **A file that is not text goes through \`readBytes(path) -> Uint8Array\`, never \`readFile\`** — \`readFile\` decodes as UTF-8, so a png, a wav or a \`.mid\` comes back with every byte above 0x7f replaced by U+FFFD: corrupt, and silently so. To show a workspace image: \`const url = URL.createObjectURL(new Blob([await readBytes(path)]))\`, and \`URL.revokeObjectURL\` when it is replaced. **There is no HTTP route that serves workspace files** — \`<img src={\`/\${path}\`}>\` resolves against the app, 404s, and every thumbnail is a broken icon.
1007
1307
  - \`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
1308
  __EXEC_BULLET__
1009
1309
 
1010
1310
  - \`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
1311
  - \`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.
1312
+ - \`import { usePersistedState } from "$dsh/state"\` is \`useState\` that survives. **That is the module's only export, and the key comes first:** \`usePersistedState(key, initial)\`, lazy initialiser included, kept in \`localStorage\` under a namespaced key with the read and the write already wrapped. Both halves of that sentence are load-bearing and both were measured going wrong: a card that reached for a sibling it assumed was there (\`import { write, usePersistedState }\`) rendered **completely blank with no error at all**, because an unresolvable named import kills the whole module; and this line used to say "same signature", which is false in the one way that matters and produced \`usePersistedState(false, "saved")\`. 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
1313
  - **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
1314
  - 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
1315
  - **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.
@@ -1040,7 +1340,7 @@ __EXEC_BULLET__
1040
1340
  button that answers with everything at once. \`这个请求太模糊了\` is the argument for the fields, not against them:
1041
1341
  vague is what makes the form worth building, and a model that asks in prose has done the hard half (working out
1042
1342
  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.
1343
+ - **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, **a formula**: the user is holding something opaque and wants its behaviour, not its grammar. Those six are instances of one shape, not the shape itself — \`softmax(QK^T/√d)V\` is an expression somebody is holding exactly as a cron line is, and \`带具体数字算一遍,我自己跟着算\` is \`这个 cron 几点跑\` in another subject. Measured: three models spent eight, ten and twelve turns on that request typing matrices into code fences by hand, truncating them **three times** so the reader had to ask for the rest — a grid of numbers you are typing out is the same tell as a table you are printing. 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
1344
  - **"看看都有啥" 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
1345
  - **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
1346
  - **"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.
@@ -1054,12 +1354,31 @@ A canvas is a file rather than a fence:
1054
1354
 
1055
1355
  Use the ordinary file tools — writing the path is what creates the canvas.
1056
1356
 
1357
+ **Which one to use is the skill's call, but the default is not.** A canvas is a file in the user's
1358
+ workspace that they now own and have to close, so it is something you do TO their workspace, not a
1359
+ richer way to answer. Unless they asked for something durable — a page, a report, a tool, a board,
1360
+ somewhere to come back to — the answer to a question goes inline, however long and well-organised
1361
+ that answer turns out to be. Measured on a session that had loaded the skill: asked what changed in
1362
+ a release, the model wrote a \`.ui4a.tsx\` file for a question asked once.
1363
+
1057
1364
  ## Load the skill before you explore, not before you build
1058
1365
 
1059
1366
  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
1367
 
1061
1368
  **"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
1369
 
1370
+ **The decision is per turn, and the first turn is the worst one to make it on.** Measured across
1371
+ 121 conversations of five turns or more: 93 loaded the skill on turn 0, 7 on turn 1, exactly 1 on
1372
+ turn 2, and **none ever loaded later** — and of the 20 that never loaded, all 20 produced nothing
1373
+ across 179 turns between them. Not one recovered. The trap is that turn 0 is usually the turn where
1374
+ prose is genuinely right: the request is one line, the workspace is empty, and the honest answer is
1375
+ "paste the query". Two models got that identical opening — \`这条查询慢得离谱\` against an empty
1376
+ directory — and both answered it correctly in prose; the one that had loaded the skill anyway spent
1377
+ turn 1 on a card comparing two index designs, and the one that had not wrote that same comparison as
1378
+ a markdown code block and never reconsidered across seven further turns. **So ask again every turn.**
1379
+ A conversation is specified gradually, and the turn that finally names the table, the row count and
1380
+ the query is the turn the shape arrives — normally not the first one.
1381
+
1063
1382
  **If your last answer restated a running total, the answer was already a card.** This is the
1064
1383
  largest single shape in real use — 22% of a sampled corpus — and the one where a card almost never
1065
1384
  appears: **18 runs across three models, 0 fences, 0 canvases, and 17 of the 18 replies carried a
@@ -1103,7 +1422,7 @@ will not ask for a card they do not know they can have.
1103
1422
 
1104
1423
  **The numbered-list tell is not about architecture — it is about the list.** The paragraph above
1105
1424
  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
1425
+ a real answer to a question asking which flavour to pair with another: six numbered options, a
1107
1426
  paragraph of reasoning under each, 1,600 characters, no card — while other models built one for
1108
1427
  the same question. Six options with a note apiece is a set the reader wants to compare, and
1109
1428
  comparing is what a card does and prose does not: they read it top to bottom once and keep
@@ -1114,6 +1433,96 @@ seven times is the same list with the digits removed, and it was measured at 2,5
1114
1433
  prose in the same wave. A run of parallel items, each with its own explanation, is a card whether
1115
1434
  you number it, bold it, or bullet it.
1116
1435
 
1436
+ **A one-line gloss is still an explanation, and headings over groups make it worse, not better.**
1437
+ The rule above says "a paragraph under each" and that wording has a hole in it: \`- **read** — 读文本文件\`
1438
+ twenty times, under four \`###\` group headings, is not a paragraph apiece, so it reads as exempt.
1439
+ It is the same shape at higher density — and the grouping is you admitting the list is long enough
1440
+ to need navigating. Measured on a question asking what a runtime exposes: 17 to 23 such rows in prose on five of
1441
+ six models, four times out of four on some — the single worst-covered question of its wave. **The
1442
+ test is what the reader does with it.** A list they read once, top to bottom, and are done with is
1443
+ prose. A list they will come back to, scan for one entry, or want narrowed — a catalogue, an
1444
+ inventory, an API surface, a set of options — is a card, and the group headings you were about to
1445
+ type are its filters.
1446
+
1447
+ **If you are about to draw it, you have already agreed it is not prose.** Three shapes say this out
1448
+ loud, and each is a card that got typed into a code fence instead:
1449
+
1450
+ - **Box-drawing characters.** \`┌─┐\` \`│\` \`└─┘\` \`▼\` around labelled boxes with arrows between them.
1451
+ Measured on a question about one layer of a plugin stack: a three-tier diagram hand-drawn with 416 box characters —
1452
+ layers, the packages inside each, arrows for who calls whom. Every one of those boxes is a part
1453
+ the reader wants to open; in a fence they cannot, and the whole thing reflows into garbage on a
1454
+ narrow screen.
1455
+ - **Aligned monospace columns**, padded with spaces to line up. That is a grid you are laying out by
1456
+ hand, badly, in a medium with no layout.
1457
+
1458
+ The counter-argument is that a fence is quicker and the diagram is only illustrative. It is quicker
1459
+ for you. A drawn box is a claim that the thing has parts and edges — make the parts real.
1460
+
1461
+ **A markdown table over about four rows is a card you stopped one step short of.** It gets its own
1462
+ line because it does not feel like drawing — it is just markdown, and markdown is what prose is
1463
+ made of. But a table is a card with the interactivity taken out: you already decided the answer has
1464
+ columns and that the reader will read DOWN one of them. Give it back the sorting and the filtering
1465
+ and you have built the thing you were describing. **"I am explaining, the table is only support" is
1466
+ the thought that gets past this** — measured on a question asking what a repository's tests cover:
1467
+ prose with a 40-row two-column table under section headings, on five of six models, while the same
1468
+ models put a card up for a question asking what tools exist every single time. The difference was not the shape of
1469
+ the answer; it was that one felt like a catalogue and the other felt like teaching. A table that
1470
+ long IS the answer, whatever the paragraphs around it are doing.
1471
+
1472
+ **Every tell above is the same question, asked late.** They are worth reading because they name
1473
+ shapes you can catch yourself typing, but by then you have already chosen. Ask it first, once, of
1474
+ every answer, before the first line goes out:
1475
+
1476
+ > Is the reader going to READ this, or USE it?
1477
+
1478
+ Read means they take it in once, in order, and are done: an explanation, a recommendation, an
1479
+ answer with one part. Use means they will come back to it, look for one piece of it, compare two
1480
+ of its pieces, change an input, or act on it — and every one of those is something prose cannot do
1481
+ and a card can. **When the answer is "use", the card is the deliverable and the prose is the
1482
+ caption.** Two sentences of what you found, then the thing.
1483
+
1484
+ Three thoughts predictably get this wrong, and all three are about you rather than the reader:
1485
+ \`they asked a question, so I should answer it\` — you are, and how it is delivered is your call;
1486
+ \`this is a quick one\` — quick describes writing it, not using it; \`they did not ask for a card\` —
1487
+ they cannot ask for what they do not know is available, and in the whole measured corpus the users
1488
+ who got one never asked, while the ones who did had already been given prose first and were asking
1489
+ for it a second time.
1490
+
1491
+
1492
+ **A diff, a log, a config file — anything the reader will look THROUGH rather than read — is the
1493
+ same call.** \`最近改了啥\`, \`这个文件给我看看\`, \`报错日志是什么\`: the answer is a body of text with
1494
+ structure inside it, and prose about it plus a fenced dump is the worst of both — the fence has no
1495
+ highlighting, no folding, and no way to jump to the part they wanted. Measured: on a question
1496
+ asking what a commit changed, **0 of 24 answers built anything**, across six models, while the same
1497
+ models built cards for lists all day. A diff viewer that colours the hunks, folds the files, and
1498
+ lets them open one is a card; \`shiki\` does the colouring in about ten lines. **The reflex to catch
1499
+ is that code feels like it "is already text"** — so does a table, and the reason it wants a card is
1500
+ the same: the reader is looking for one part of it.
1501
+
1502
+ **A card the reader has to scroll to see the shape of has hidden its own structure.** Aim for the
1503
+ unopened card to sit inside roughly two thirds of the viewport — not as a CSS \`max-height\`, which
1504
+ just moves the problem into an inner scrollbar, but as the size you are budgeting for while you
1505
+ decide what starts open. Estimate it the way you would a page: a row is ~40px, a heading block
1506
+ ~80px, a paragraph of body text ~60px. Twenty rows with their descriptions showing is already past
1507
+ it before you have written the header.
1508
+
1509
+ **The moment to act on this is when you write \`.map\`, not when you finish.** Measured across two
1510
+ waves of real cards: at 320px wide, **90% render past 60vh and a fifth past two full screens**,
1511
+ median 950px — and saying "budget for two thirds of a viewport" changed that by one point, because
1512
+ by the time a card feels long it is written. So make it a rule about the code: **a \`.map\` over
1513
+ more than about eight items renders those items COLLAPSED**, one line each, with the body behind
1514
+ a \`Disclosure\`; and the filter above it starts on a real subset, never on "all". If you cannot
1515
+ decide which subset, that is the card telling you it needs a search box, not that it needs to show
1516
+ everything.
1517
+
1518
+ What that budget buys is **hierarchy, which is contrast, not just order**. A title, a set of
1519
+ counts, and a row of filters stacked as three bands of the same grey with the same rounding and
1520
+ the same text size is three rows the eye cannot rank — the reader sees a wall and starts reading
1521
+ from the top, which is the thing a card exists to avoid. Rank them: the title carries weight and
1522
+ size, ONE control is the primary (filled, the accent colour) and its siblings are quiet (text or
1523
+ outline), counts are secondary text next to what they count rather than another band of chips.
1524
+ **When every element is emphasised nothing is** — pick the one thing the reader looks for first
1525
+ and let it be the only loud thing on the row.
1117
1526
 
1118
1527
  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
1528
 
@@ -1128,10 +1537,34 @@ thirty seconds, before anything is loaded.
1128
1537
  \`Bot\`, \`BrainCircuit\`, \`Zap\` beside a heading say "an AI made this" and nothing else. An icon earns
1129
1538
  its place by naming the thing it sits next to — \`Copy\` on a copy button, \`Languages\` on a translate
1130
1539
  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.
1540
+ - **One bounded box, and lines inside it.** Inline, the card's own root is that box; inside a
1541
+ canvas the panel already drew it, so the root takes none. Everything below the root separates
1542
+ with a rule or a gap:
1543
+
1544
+ <div className="bg-layer border border-line rounded-lg p-4 divide-y divide-line">
1545
+ <section className="py-3 first:pt-0 last:pb-0">…</section>
1546
+ <section className="py-3 first:pt-0 last:pb-0">…</section>
1547
+ </div>
1548
+
1549
+ **The check is countable, so run it: walk up from any element and count the ancestors that repeat
1550
+ the box recipe — a border or a \`bg-layer*\`, plus \`rounded\`, plus a \`p-*\` — counting the one you
1551
+ are inside. Three is already a frame around a frame; four means one of them is doing nothing.**
1552
+ Prose alone has been here since the first round and \`hierarchy\` is the dimension the panel moves
1553
+ least on (+0.18 ± 0.20 across r001→r002, which is nothing) — because
1554
+ "one bounded box" reads as satisfied at every level, since each level is one box from inside
1555
+ itself. Read off the syntax tree of 755 cards: **216 (29%) stack three of these boxes**, and 41
1556
+ (5.4%) stack four or more. The deepest is five —
1557
+
1558
+ bg-layer border border-line rounded-lg p-4 → bg-page border border-line-2 rounded-md p-3
1559
+ → border border-line rounded bg-layer p-3 → bg-layer-2 border border-line rounded p-2
1560
+ → text-[10px] p-1 rounded bg-page border border-line
1561
+
1562
+ — each adding two to four pixels of padding and a one-pixel line, until the innermost box holds
1563
+ less content than frame. Grounds stack the same way and go deeper: the worst runs \`bg-layer\` →
1564
+ \`bg-page\` → \`bg-layer\` → \`bg-layer\` → \`bg-layer\` → \`bg-page\`, six deep, and the three in the
1565
+ middle are the same colour — a container that changes the ground to the ground it already had.
1566
+
1567
+ A block that feels like it needs its own border wants a heading.
1135
1568
 
1136
1569
 
1137
1570
  ## Weight
@@ -1206,6 +1639,15 @@ written \`@[30rem]:\`:
1206
1639
 
1207
1640
  <div className="grid grid-cols-1 gap-3 @[30rem]:grid-cols-2">
1208
1641
 
1642
+ **Spend the width on the ROW, not inside it.** This is the half that gets missed: 71% of measured
1643
+ cards carry a real breakpoint, and a vision panel still called out wasted width in **27% of its
1644
+ verdicts** — because the prefixes went on padding, gaps and font sizes while the list itself stayed
1645
+ one column at every width. Its words for the result: *"餐名与右侧热量标签间距过宽、视线脱节"* — a
1646
+ name on the far left and its number on the far right, 700 pixels apart, on a row that should have
1647
+ become two columns. **Before the small stuff, ask what the LIST does with the extra width**: a run
1648
+ of items with a label and a value is \`@[30rem]:grid-cols-2\`; a row of three bands is
1649
+ \`@[32rem]:grid-cols-[1fr_auto_auto]\` so the three sit on one line instead of stacking.
1650
+
1209
1651
  **Reflowing text is not a responsive layout, and it is what you ship when you write no prefix at
1210
1652
  all.** A card with no breakpoint still "works" at every width — the text simply wraps — so nothing
1211
1653
  looks broken while you write it, and the failure only shows in a screenshot. Measured on one card
@@ -1278,15 +1720,15 @@ One value that fits your longest label today is a value that stops fitting when
1278
1720
  **And a number column wants \`text-right\`, not \`text-center\`.** \`tabular-nums\` makes every digit
1279
1721
  the same width so figures stack — and centring throws that away, because \`5\` and \`25\` then sit at
1280
1722
  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.`;
1723
+ 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. **It is also the answer whenever \`$dsh/fs\` has no verb for what you need**: searching a tree (\`rg\`, \`fd\` — there is no find API and a recursive \`readdir\` walk is not one), running the project's own \`lint\` / \`check\` / tests and showing the rows, or deriving a file from another (\`sips\`, \`magick\`) instead of moving megabytes through the browser.`;
1282
1724
  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
1725
 
1284
1726
  // src/index.ts
1285
1727
  var name = "dsh-generative-ui";
1286
1728
  var inject = ["systemPrompt"];
1287
- var SETTINGS_NAMESPACE = settingsNamespace("dsh-generative-ui");
1729
+ var SETTINGS_NAMESPACE = name;
1288
1730
  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.")
1731
+ allowExec: z.boolean().default(true).description("Let generated cards run shell commands through `$dsh/exec`, under this session's own sandbox mode. Cards use it to search (`rg`, `fd`), run `lint`/`check`, and read `git`. The sandbox still applies; what does not is the per-command approval prompt, so turn this off for a session where that matters.")
1290
1732
  });
1291
1733
  var wasmFile = (importMetaUrl) => createRequire(importMetaUrl).resolve("@esm.sh/tsx/pkg/tsx_bg.wasm");
1292
1734
  var resolvedMap = (relative, importMetaUrl) => {
@@ -1320,8 +1762,8 @@ async function serveCanvas(liveWorkspaces, req, res) {
1320
1762
  if (!liveWorkspaces().has(cwd))
1321
1763
  return void res.writeHead(403).end();
1322
1764
  if (id === null) {
1323
- const ids = await readdir(join(cwd, CANVAS_DIR)).then((names) => names.flatMap((name2) => {
1324
- const found = canvasIdOf(`${CANVAS_DIR}/${name2}`);
1765
+ const ids = await readdir(join(cwd, CANVAS_DIR)).then((names) => names.flatMap((name) => {
1766
+ const found = canvasIdOf(`${CANVAS_DIR}/${name}`);
1325
1767
  return found === null ? [] : [found];
1326
1768
  }), () => []);
1327
1769
  res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
@@ -1370,7 +1812,7 @@ async function serveFs(ctx, liveWorkspaces, req, res) {
1370
1812
  if (req.method === "GET") {
1371
1813
  if (url.searchParams.get("list") !== null) {
1372
1814
  const entries = await ctx.fs.listDir(target);
1373
- return json(200, { entries: entries.map(({ name: name2, type, size }) => ({ name: name2, type, size })) });
1815
+ return json(200, { entries: entries.map(({ name, type, size }) => ({ name, type, size })) });
1374
1816
  }
1375
1817
  if (url.searchParams.get("bytes") !== null) {
1376
1818
  const bytes = await ctx.fs.readBytes(target, undefined, MAX_BINARY);
@@ -1543,16 +1985,30 @@ function apply(ctx, config = Config({})) {
1543
1985
  configured = { allowExec, fiber: ctx.plugin({ name: "dsh-generative-ui:configured", apply: (scoped) => applyWith(scoped, allowExec) }) };
1544
1986
  };
1545
1987
  rebuild();
1546
- installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
1547
- setSource: (source) => {
1548
- current = source;
1549
- },
1550
- onChange: rebuild
1988
+ ctx.inject(["settings"], (sctx) => {
1989
+ sctx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
1990
+ setSource: (source) => {
1991
+ current = source;
1992
+ },
1993
+ onChange: rebuild
1994
+ });
1551
1995
  });
1552
1996
  ctx.effect(() => () => void configured?.fiber.dispose(), "dsh-generative-ui: settings scope");
1553
1997
  }
1554
1998
  function applyWith(ctx, allowExec) {
1555
1999
  ctx.effect(() => ctx.systemPrompt.section({ name: PROMPT_SECTION_NAME, order: PROMPT_SECTION_ORDER, text: inlinePrompt(allowExec) }), "dsh-generative-ui: inline prompt");
2000
+ const failures = new CardFailures;
2001
+ ctx.effect(() => ctx.systemPrompt.context({ name: CARD_FAILURE_CONTEXT, order: CARD_FAILURE_CONTEXT_ORDER, text: (assembly) => failures.text(assembly.agent?.id) }), "dsh-generative-ui: card failure context");
2002
+ let wake = null;
2003
+ ctx.inject(["agents"], (withAgents) => {
2004
+ wake = (session) => {
2005
+ const agent = withAgents.agents.get(session);
2006
+ agent?.followup(createUserMessage({ content: [{ type: "text", text: WAKE_TEXT }], source: { kind: "plugin", plugin: "dsh-generative-ui", form: "notice", summary: WAKE_SUMMARY } }));
2007
+ };
2008
+ return () => {
2009
+ wake = null;
2010
+ };
2011
+ });
1556
2012
  ctx.inject(["webServer", "sessions"], (scoped) => {
1557
2013
  const file = wasmFile(import.meta.url);
1558
2014
  const liveWorkspaces = () => {
@@ -1561,6 +2017,7 @@ function applyWith(ctx, allowExec) {
1561
2017
  };
1562
2018
  scoped.effect(() => scoped.webServer.register({ kind: "prefix", path: ASSET_PREFIX, handler: (req, res) => serveAsset(req, res, file) }), "dsh-generative-ui: tsx wasm");
1563
2019
  scoped.effect(() => scoped.webServer.register({ kind: "exact", path: CANVAS_READ_PATH, handler: (req, res) => serveCanvas(liveWorkspaces, req, res) }), "dsh-generative-ui: canvas reads");
2020
+ scoped.effect(() => scoped.webServer.register({ kind: "exact", path: CARD_ERROR_PATH, handler: (req, res) => serveCardError(failures, () => wake, req, res) }), "dsh-generative-ui: card failures");
1564
2021
  scoped.inject(["fs", "sandboxPolicy"], (withFs) => {
1565
2022
  withFs.effect(() => withFs.webServer.register({ kind: "exact", path: FS_PATH, handler: (req, res) => serveFs(withFs, liveWorkspaces, req, res) }), "dsh-generative-ui: workspace files");
1566
2023
  });
@@ -1576,9 +2033,41 @@ function applyWith(ctx, allowExec) {
1576
2033
  });
1577
2034
  });
1578
2035
  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");
2036
+ scoped.effect(() => scoped.skills.register({
2037
+ name: SKILL_NAME,
2038
+ description: SKILL_DESCRIPTION,
2039
+ content: skillBody(typesImportMap(import.meta.url), standaloneImportMap(import.meta.url), allowExec),
2040
+ source: "runtime",
2041
+ invocation: { modelInvocable: true, userInvocable: false }
2042
+ }), "dsh-generative-ui: skill");
1580
2043
  });
1581
2044
  }
2045
+ async function serveCardError(failures, wake, req, res) {
2046
+ if (req.method !== "POST")
2047
+ return void res.writeHead(405).end();
2048
+ const url = new URL(req.url ?? "", "http://localhost");
2049
+ const session = url.searchParams.get("session");
2050
+ if (session === null || session === "")
2051
+ return void res.writeHead(400).end();
2052
+ let body = "";
2053
+ for await (const chunk of req) {
2054
+ body += chunk;
2055
+ if (body.length > MAX_BODY)
2056
+ return void res.writeHead(413).end();
2057
+ }
2058
+ let report;
2059
+ try {
2060
+ report = JSON.parse(body);
2061
+ } catch {
2062
+ return void res.writeHead(400).end();
2063
+ }
2064
+ if (report.message === undefined || report.message === "") {
2065
+ failures.clear(session);
2066
+ } else if (failures.set(session, { message: report.message, phase: report.phase ?? "compile" })) {
2067
+ wake()?.(session);
2068
+ }
2069
+ res.writeHead(204).end();
2070
+ }
1582
2071
  export {
1583
2072
  ASSET_PREFIX,
1584
2073
  Config,
@@ -1591,6 +2080,7 @@ export {
1591
2080
  serveAi,
1592
2081
  serveAsset,
1593
2082
  serveCanvas,
2083
+ serveCardError,
1594
2084
  serveExec,
1595
2085
  serveFs,
1596
2086
  serveWebSearch