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/README.md +9 -2
- package/lib/client.js +791 -398
- package/lib/client.js.map +19 -17
- package/lib/index.js +534 -44
- package/lib/types/card-failure.d.ts +88 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +1 -1
- package/lib/types/client/canvas/index.d.ts +1 -1
- package/lib/types/client/runtime/GenUISurface.d.ts +65 -35
- package/lib/types/client/runtime/inline-fence.d.ts +33 -2
- package/lib/types/client/runtime/report-error.d.ts +59 -12
- package/lib/types/contract-assets.d.ts +10 -0
- package/lib/types/index.d.ts +51 -8
- package/package.json +44 -25
- package/src/card-failure.ts +106 -0
- package/src/client/canvas/CanvasPanel.tsx +10 -4
- package/src/client/canvas/index.ts +1 -1
- package/src/client/index.ts +26 -10
- package/src/client/runtime/GenUISurface.tsx +172 -48
- package/src/client/runtime/bindings.ts +3 -2
- package/src/client/runtime/compiler.ts +24 -2
- package/src/client/runtime/inline-fence.ts +195 -10
- package/src/client/runtime/register.ts +16 -4
- package/src/client/runtime/report-error.ts +89 -34
- package/src/client/runtime/state.ts +13 -1
- package/src/client/runtime/uno-config.ts +13 -0
- package/src/client/session.ts +10 -3
- package/src/contract-assets.ts +11 -0
- package/src/index.ts +123 -21
- package/src/prompt.ts +154 -10
- package/src/skill.ts +296 -22
- package/types/fs.d.ts +15 -1
package/src/skill.ts
CHANGED
|
@@ -14,6 +14,10 @@ import { CANVAS_DIR, CANVAS_SUFFIX, CAPABILITY_PREFIX, FENCE_LANG, capabilityMod
|
|
|
14
14
|
|
|
15
15
|
/** The checker, from pkg.pr.new: @genui/cli is a private workspace package and not on npm. */
|
|
16
16
|
const CLI_URL = "https://pkg.pr.new/MindLab-Research/macaron-genui-demo/@genui/cli@main";
|
|
17
|
+
// How to run `@genui/cli` straight from that URL. One constant because the two places that print a
|
|
18
|
+
// command must not drift apart, and because each runner needs something different from the others —
|
|
19
|
+
// see the paragraph under "Check it before you hand it over", where all three are spelled out.
|
|
20
|
+
const RUN_CLI = `BUN_INSTALL_CACHE_DIR="$TMPDIR/bun-cache" bunx --yes genui@${CLI_URL}`;
|
|
17
21
|
|
|
18
22
|
export const SKILL_NAME = "generative-ui";
|
|
19
23
|
|
|
@@ -67,7 +71,7 @@ export function mapNotes(typesMap: string | undefined, standaloneMap: string | u
|
|
|
67
71
|
`${check} \`build\` and \`dev\` want runnable JS, so they take a different one:`,
|
|
68
72
|
"",
|
|
69
73
|
"```",
|
|
70
|
-
|
|
74
|
+
`${RUN_CLI} build <file> -i ${standaloneMap}`,
|
|
71
75
|
"```",
|
|
72
76
|
"",
|
|
73
77
|
`That second map stubs \`${CAPABILITY_PREFIX}/*\` — the exported page has no dsh around it, so those calls log to`,
|
|
@@ -111,11 +115,89 @@ They are not two sizes of the same thing; they have different lifetimes.
|
|
|
111
115
|
|
|
112
116
|
**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
117
|
|
|
114
|
-
The tell is
|
|
118
|
+
**The tell is not "would this be useful to keep".** That question is about the content, it answers
|
|
119
|
+
yes for anything reference-shaped, and it is how a changelog, a cron explanation and a definition of
|
|
120
|
+
closures all became files. Ask instead: **did they ask for a durable thing?** A canvas is a file in
|
|
121
|
+
their workspace that they now own and have to close — creating one is an action taken on their
|
|
122
|
+
behalf, and it needs their say-so:
|
|
123
|
+
|
|
124
|
+
- They named a lasting artifact — "make me a dashboard", "a page I can share", "save this as", "a
|
|
125
|
+
tool for…", "画板", "报告" — or asked to keep or come back to something. → **canvas**
|
|
126
|
+
- They asked a question, even a large one whose answer is long and well-organised. → **inline**,
|
|
127
|
+
every time. "What changed in 2.1.251" is a question; 71 items of answer does not make it a file.
|
|
128
|
+
- The thing genuinely has more than one screen, or holds state the next turn needs. → **canvas**,
|
|
129
|
+
and say in one line that you opened it.
|
|
130
|
+
|
|
131
|
+
Measured: on \`cron-read\` — *"\`*/17 3-5 * * 2\` 这个 cron 到底几点跑?"*, a lookup with one right
|
|
132
|
+
answer — models opened a canvas **5 times in one round and 6 in the next**, and one opened a canvas
|
|
133
|
+
for *"什么是闭包?"*. Nobody asked for a file in any of them.
|
|
134
|
+
|
|
135
|
+
When it is genuinely borderline, inline is the cheaper mistake: it is one message, not a file the
|
|
136
|
+
user now owns.
|
|
115
137
|
|
|
116
138
|
Two things follow from the lifetime difference:
|
|
117
139
|
|
|
118
|
-
- An **inline** block that the user acts on
|
|
140
|
+
- 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.
|
|
141
|
+
|
|
142
|
+
**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.
|
|
143
|
+
|
|
144
|
+
So the submit handler has three statements, not one:
|
|
145
|
+
|
|
146
|
+
const [answer, setAnswer] = usePersistedState<string | null>("<this card>-answer", null)
|
|
147
|
+
…
|
|
148
|
+
onClick={() => { setAnswer(pick); sendMessage(…) }} // record, then send
|
|
149
|
+
…
|
|
150
|
+
{answer !== null && <p className="text-muted">已选择:{label(answer)}</p>} // and SHOW it
|
|
151
|
+
|
|
152
|
+
The third line is the one nobody writes — but not for the reason it first looked like. Of the
|
|
153
|
+
cards that call \`usePersistedState\`, **91 of 93 do render the value somewhere**; what they render
|
|
154
|
+
it as is \`aria-pressed\` on the button that was clicked. Reading the turns where a submit left the
|
|
155
|
+
card unchanged: **31 of 42 mark the choice with a highlight and say nothing in words**, and 19 of
|
|
156
|
+
those 42 hold it in \`useState\`, so the highlight is gone after a reload. A highlight is a fine
|
|
157
|
+
way to show which control is active while the reader is still there; it is not an answer to
|
|
158
|
+
someone coming back to this card next week, who sees one button shaded and no statement of what
|
|
159
|
+
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
|
|
160
|
+
guard the send with the recorded answer, not with anything cleverer.)
|
|
161
|
+
- **Exactly one control ends the step, and the reader must be able to find it.** This is the
|
|
162
|
+
single largest hole in what gets built: across 161 runs where the reader actually clicked
|
|
163
|
+
something, **108 of them — 67% — never once got a result back out of the card**, 468 clicks that
|
|
164
|
+
went nowhere. It is not one model's habit (every one of the ten does it) and not one case's
|
|
165
|
+
(every case does it). The shape is always the same: a card you can fiddle with forever and never
|
|
166
|
+
finish.
|
|
167
|
+
|
|
168
|
+
**The check is one grep, so run it on what you just wrote: does the source contain a
|
|
169
|
+
\`sendMessage\` call at all?** Re-measured over 171 clicking runs, 117 of them dead: **90 — 77% —
|
|
170
|
+
have no \`sendMessage\` anywhere in the card**. The reader clicks \`RESTful (JSON)\`, \`下一步 →\`,
|
|
171
|
+
\`2. 尺度缩放 / √d\` — real controls, wired to internal state and to nothing else — and the
|
|
172
|
+
conversation stops there. Not "the ending was hard to find": there was none to find.
|
|
173
|
+
|
|
174
|
+
Two endings are correct, and which one depends on whether the options need explaining:
|
|
175
|
+
|
|
176
|
+
- **The options speak for themselves** (yes/no, this file or that one) — the click IS the answer.
|
|
177
|
+
Two plain buttons, no card around them, \`sendMessage\` on click. Nothing to preview.
|
|
178
|
+
- **The options mean something you have to see to choose between** — then the click SELECTS and
|
|
179
|
+
shows, and a separate **Submit** sends. Clicking a tab must not fire the turn; a reader
|
|
180
|
+
comparing three options should be able to look at all three first.
|
|
181
|
+
|
|
182
|
+
The preview form is a selector, a result area, and one submit — that is the whole structure, and
|
|
183
|
+
the result area is where the card earns its existence:
|
|
184
|
+
|
|
185
|
+
const [pick, setPick] = useState(OPTIONS[0].id)
|
|
186
|
+
const [sent, setSent] = usePersistedState<string | null>("migration-plan-choice", null)
|
|
187
|
+
…
|
|
188
|
+
<div className="flex flex-wrap gap-2">…one button per option, aria-pressed={pick === o.id}…</div>
|
|
189
|
+
<div className="mt-3">{OPTIONS.find((o) => o.id === pick)!.preview}</div>
|
|
190
|
+
<button disabled={sent !== null} onClick={() => { setSent(pick); sendMessage(…) }}>…</button>
|
|
191
|
+
|
|
192
|
+
\`preview\` is whatever actually shows the difference: a mermaid graph of the two migration paths,
|
|
193
|
+
the formula rendered by katex, an SVG of the layout, a working miniature of the thing, a 3D view,
|
|
194
|
+
a playable board. A paragraph of text describing the option is not a preview — the reader could
|
|
195
|
+
have read that in the reply.
|
|
196
|
+
|
|
197
|
+
**And it fires once.** \`sent\` above is persisted, so a reload shows the answer that was given
|
|
198
|
+
rather than an untouched form, and the button cannot send a second turn for a question already
|
|
199
|
+
answered.
|
|
200
|
+
|
|
119
201
|
- A **canvas** stays interactive. It does not "complete"; it just sits there working.
|
|
120
202
|
- 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
203
|
|
|
@@ -130,6 +212,19 @@ Two things follow from the lifetime difference:
|
|
|
130
212
|
in a label and the user's half-typed row goes with it. Persist what they typed, not just what
|
|
131
213
|
they saved.
|
|
132
214
|
|
|
215
|
+
**An inline card is clickable before you have finished writing it, and that is where this bites
|
|
216
|
+
hardest.** The reader sees the first controls while the rest of the card is still arriving, and
|
|
217
|
+
**every chunk that adds JSX remounts every component the card defines itself** — so a choice they
|
|
218
|
+
make mid-stream is wiped by the next chunk, silently, with the control snapping back to its
|
|
219
|
+
initial state. Measured three ways on the same card, one variable each: state in a
|
|
220
|
+
card-defined child is lost, the same state in the exported component survives, and
|
|
221
|
+
\`usePersistedState\` survives in either. **Two chunks is enough** — this is not a rare race.
|
|
222
|
+
|
|
223
|
+
So anything the reader can change belongs in \`usePersistedState\`, not only the answer you
|
|
224
|
+
intend to record. The one case it cannot reach is a third-party component holding its own state:
|
|
225
|
+
\`<Disclosure defaultOpen>\` reverts to \`defaultOpen\` on every remount, and the only way to keep
|
|
226
|
+
what the reader did is to control it yourself from persisted state.
|
|
227
|
+
|
|
133
228
|
**If you write \`setRows(prev => prev.filter(r => r.id !== id))\` behind a button, keep the row.**
|
|
134
229
|
Persisting is what makes that line permanent — before it, a mistaken delete came back on reload.
|
|
135
230
|
Hold the removed row and offer it back:
|
|
@@ -181,19 +276,42 @@ Ask with an **inline** block instead: one short line saying what you need to kno
|
|
|
181
276
|
|
|
182
277
|
\`\`\`tsx
|
|
183
278
|
import { sendMessage } from "$dsh/chat"
|
|
279
|
+
import { usePersistedState } from "${capabilityModule("state")}"
|
|
184
280
|
|
|
185
281
|
export default function Pick() {
|
|
186
|
-
const [picked, setPicked] =
|
|
282
|
+
const [picked, setPicked] = usePersistedState<string | null>("ask:which-cloud-host", null)
|
|
187
283
|
const choose = (id: string) => { setPicked(id); sendMessage(id) }
|
|
284
|
+
// the key names THIS question — two asks in one conversation must not share it
|
|
188
285
|
// picked === null → the options; otherwise just the chosen one, still highlighted
|
|
189
286
|
}
|
|
190
287
|
\`\`\`
|
|
191
288
|
|
|
289
|
+
**When the two answers need no explaining, they are two buttons in a row — not two cards.** The
|
|
290
|
+
shape to match is in the question: \`Postgres or SQLite?\` / \`公制还是英制?\` / \`要我先跑测试吗?\` are
|
|
291
|
+
answered by the label alone, and a bordered tile with a description under each says the choice is
|
|
292
|
+
weightier than it is. Same \`choose\`, same key, one row:
|
|
293
|
+
|
|
294
|
+
\`\`\`tsx
|
|
295
|
+
<div className="flex flex-wrap gap-2">
|
|
296
|
+
{OPTIONS.map((o) => (
|
|
297
|
+
<button key={o.id} onClick={() => choose(o.id)} aria-pressed={picked === o.id}
|
|
298
|
+
className="rounded-md border border-line px-3 py-1.5 text-sm hover:bg-hover
|
|
299
|
+
aria-pressed:bg-accent aria-pressed:text-white aria-pressed:border-transparent
|
|
300
|
+
aria-pressed:hover:bg-accent"> {/* or the selection vanishes under the pointer */}
|
|
301
|
+
{o.label}
|
|
302
|
+
</button>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
\`\`\`
|
|
306
|
+
|
|
307
|
+
Give each option a description and you have built the card version above; the descriptions are
|
|
308
|
+
what earn the tiles. Two labels that stand on their own take the row.
|
|
309
|
+
|
|
192
310
|
Rules for that move:
|
|
193
311
|
|
|
194
312
|
- **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
313
|
- **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
|
-
- **
|
|
314
|
+
- **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.
|
|
197
315
|
|
|
198
316
|
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
317
|
|
|
@@ -214,12 +332,14 @@ This one runs *opposite* in the two places, and getting it backwards is the most
|
|
|
214
332
|
|
|
215
333
|
- **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
334
|
- **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-
|
|
218
|
-
from the token table: \`bg-
|
|
335
|
+
- **But \`bg-page\` is the page's own colour, so a wrapper painted with it is not a box.** Measured
|
|
336
|
+
from the token table: \`bg-page\` (that is the CLASS; \`--dsw-alias-bg-base\` is the variable
|
|
337
|
+
behind it, and the two vocabularies are deliberately different) is \`#fff\` on light and
|
|
338
|
+
\`#151517\` on dark — the same value the
|
|
219
339
|
transcript behind the card is painted with, on both grounds. A root \`<div>\` with
|
|
220
340
|
\`background: var(--dsw-alias-bg-base); padding: 16px; border-radius: 12px\` therefore draws
|
|
221
341
|
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
|
|
342
|
+
can find, while the \`bg-layer\` blocks inside it read as the real frame — a frame inside an
|
|
223
343
|
invisible frame. If you want the inline card to be bounded, bound it with \`bg-layer\` **plus**
|
|
224
344
|
\`border-line\` (see the both-spellings rule below). If you don't, drop the wrapper's background
|
|
225
345
|
and radius entirely rather than painting it the colour of the page.
|
|
@@ -301,9 +421,9 @@ Either way, don't restage the header. The panel already names the canvas, so a h
|
|
|
301
421
|
|
|
302
422
|
**A control you have FILLED is the opposite case, and the two get confused.** The rule above is
|
|
303
423
|
about separating a surface from the surface under it, where both tokens are deliberately faint —
|
|
304
|
-
\`border-
|
|
424
|
+
\`border-line\` is 4% black. Once an element carries a real fill (a selected segment on
|
|
305
425
|
\`state-business-primary\`, a primary button), that fill separates it completely and a leftover
|
|
306
|
-
\`border-
|
|
426
|
+
\`border-line-2\` is a grey ring around a blue block, related to nothing. Drop it — but to
|
|
307
427
|
\`transparent\`, not to \`none\`, or the selected item loses a pixel of height and the row twitches
|
|
308
428
|
as the reader clicks along it:
|
|
309
429
|
|
|
@@ -318,10 +438,48 @@ Either way, don't restage the header. The panel already names the canvas, so a h
|
|
|
318
438
|
(\`#fff\` here) as their colour and no background at all, or a white outline if the shape itself
|
|
319
439
|
has to stay readable.
|
|
320
440
|
- **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 \`
|
|
322
|
-
- **
|
|
441
|
+
- **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".
|
|
442
|
+
- **A title or a control sitting above a long list is a \`sticky\` header. Not "could be" — is.**
|
|
443
|
+
The test is mechanical, so apply it mechanically: *is there anything above the list that the
|
|
444
|
+
reader will still want once they are deep inside it?* A heading that says what they are looking
|
|
445
|
+
at, a search box, a row of filter chips, a count that changes as they filter. If yes, that strip
|
|
446
|
+
pins. Otherwise the reader scrolls into the list, decides to narrow it, and has to scroll back up
|
|
447
|
+
past everything they were reading to reach the box that narrows it.
|
|
448
|
+
**Measured across 766 generated cards: 356 have a heading or a control above a list, and 353 of
|
|
449
|
+
them let it scroll away.** Not a tendency, an absence: the shape is in every case (60 on one, 54
|
|
450
|
+
on the next, 31, 26, 23…) and every model (95 for the worst, then 56, 44, 35, 33…), and no model
|
|
451
|
+
pins it more than the rest. One of the 353, read in full: 266 lines — \`<h2>最近工作轨迹</h2>\`, a
|
|
452
|
+
search input reading \`搜项目、作者或提交内容\`, a row of per-repo filter chips, then
|
|
453
|
+
\`filtered.slice(0, limit).map(…)\` and a "load more" button. **Zero occurrences of \`sticky\`**,
|
|
454
|
+
in that card and in the second one the same turn produced. Everything needed to steer the list
|
|
455
|
+
scrolled away the moment the list was worth steering.
|
|
456
|
+
|
|
457
|
+
- **Your root sets no height and no \`overflow\`; the page is what scrolls.** You are inside a
|
|
458
|
+
column the reader is already scrolling, so a root that sizes itself and grows its own scrollbar
|
|
459
|
+
puts a second scroll inside the first. Pin with \`sticky\`, which pins against the READER's
|
|
460
|
+
scroll, and give an inner pane its own bound only when a list genuinely needs one:
|
|
461
|
+
|
|
462
|
+
\`\`\`tsx
|
|
463
|
+
<div className="isolate relative"> {/* your own stacking context */}
|
|
464
|
+
<div className="sticky top-0 z-10 bg-layer border-b border-line">…</div>
|
|
465
|
+
<div className="max-h-[30rem] overflow-y-auto">…</div> {/* the list, not the card */}
|
|
466
|
+
</div>
|
|
467
|
+
\`\`\`
|
|
323
468
|
|
|
324
|
-
|
|
469
|
+
**\`overflow\` on ANY ancestor of a \`sticky\` element switches it off, silently** — no error, no
|
|
470
|
+
warning, it simply scrolls away. Watched happen across one card's revisions: a root grew
|
|
471
|
+
\`overflow: hidden\` to contain a stacking problem, the pinned header stopped pinning, and the
|
|
472
|
+
two edits were two turns apart. Nothing between a \`sticky\` element and the page may set it.
|
|
473
|
+
|
|
474
|
+
**\`isolate\` is what keeps your \`z-index\` small.** Inside a stacking context you own, \`z-10\`
|
|
475
|
+
is above everything of yours and below everything of the app's. Without one, a number picked to
|
|
476
|
+
beat your own siblings also beats the composer the reader types into.
|
|
477
|
+
|
|
478
|
+
- **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.
|
|
479
|
+
|
|
480
|
+
- **Extra width should make the rows SHORTER, not the card wider — inline as much as in a canvas.**
|
|
481
|
+
This entry read "In a canvas" for a while and that scope was wrong: a vision panel grading
|
|
482
|
+
**inline** cards raised it in 27% of verdicts, on cards that *did* carry breakpoints. Measured across
|
|
325
483
|
one wave, height at 320 divided by height at 720: the five inline cards shrink 1.26–1.52x, and
|
|
326
484
|
the six canvases shrink **1.02–1.18x** — one is 1100px tall at 320 and still 1076px at 720. It
|
|
327
485
|
is not for want of the technique; 8 of those 9 canvases carry a container query or an intrinsic
|
|
@@ -337,7 +495,61 @@ Either way, don't restage the header. The panel already names the canvas, so a h
|
|
|
337
495
|
</div>
|
|
338
496
|
|
|
339
497
|
The reader drags a canvas panel between 320 and 720 — that drag should buy them less scrolling.
|
|
498
|
+
- **Nothing you draw may carry a width the column did not give it.** Measured by mounting 60 real
|
|
499
|
+
cards at 380px: **12 overflowed the column**, across 4 of the 26 runs sampled, and the part that
|
|
500
|
+
hangs off the edge is invisible in a screenshot — the picture is clipped at the card, so an
|
|
501
|
+
absent column reads as a design choice and nobody can name the defect. Every one of the 12 was
|
|
502
|
+
the same mistake in a different costume:
|
|
503
|
+
|
|
504
|
+
| what stuck out | how far | write instead |
|
|
505
|
+
| --- | --- | --- |
|
|
506
|
+
| \`<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 |
|
|
507
|
+
| 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 |
|
|
508
|
+
|
|
509
|
+
**A hand-rolled \`<pre>\` is two defects at once, and it is the single commonest thing in this
|
|
510
|
+
corpus.** Of 766 cards, **194 show code and 182 of them hand-roll a \`<pre>\` — 94%**; only 4 reach
|
|
511
|
+
for \`shiki\`. And they are where the overflow lives: of 107 measured overflows, **35 — a third of
|
|
512
|
+
everything — are a \`<code>\` element**, at a median of 195px past the edge against 84px for every
|
|
513
|
+
other tag combined. (Not one is a \`<pre>\`: the wrapper is fine, the \`<code>\` inside it is what
|
|
514
|
+
hangs off. The single widest overflow in the corpus is a \`<section>\` at 978px, so these are the
|
|
515
|
+
typical worst rather than the record holder.) So the fix is one import,
|
|
516
|
+
not two patches: \`shiki\` highlights it (see the library table) AND you still put the
|
|
517
|
+
\`overflow-x-auto\` on the wrapper. Unhighlighted source in a card the reader cannot scroll
|
|
518
|
+
sideways is code they can neither read nor reach the end of.
|
|
519
|
+
| \`<table className="min-w-[28rem]">\` | 84px | put the \`overflow-x-auto\` on the wrapper and drop the min-width, or let the columns wrap |
|
|
520
|
+
|
|
521
|
+
\`min-w-0\` is the answer to a flex child that will not shrink; this is its opposite — an
|
|
522
|
+
explicit intrinsic width you typed yourself, and no ancestor can undo it. **The widest offender
|
|
523
|
+
was 705px hanging off a 380px column**, which is not a card that looks slightly wrong, it is a
|
|
524
|
+
card most of which does not exist for the reader.
|
|
525
|
+
|
|
340
526
|
- **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.
|
|
527
|
+
- **Whatever \`hover:\` changes, the selected state has to claim in its hover form too.** A
|
|
528
|
+
\`hover:bg-hover\` and an \`aria-pressed:bg-accent\` on the same button generate at the SAME
|
|
529
|
+
specificity — \`:is()\` takes its argument's, so \`.class:hover\` and \`.class[aria-pressed]\` are
|
|
530
|
+
both \`(0,2,0)\` — and source order in the generated sheet puts \`hover\` last. So the selected
|
|
531
|
+
button turns back to neutral grey **while the pointer is on it**, which is exactly when the
|
|
532
|
+
reader is looking at it. **Measured across 766 generated cards: 308 real collisions in 193 cards
|
|
533
|
+
across 60 runs** — a quarter of everything written, 256 on \`bg\` and 52 on \`text\`.
|
|
534
|
+
|
|
535
|
+
Add the pressed-and-hovered pair. It is \`(0,3,0)\`, so it wins on specificity and does not care
|
|
536
|
+
where it lands in the sheet:
|
|
537
|
+
|
|
538
|
+
hover:bg-hover aria-pressed:bg-accent aria-pressed:hover:bg-accent
|
|
539
|
+
|
|
540
|
+
**Whichever attribute you marked the selection with, qualify that one** — the trick is the extra
|
|
541
|
+
variant, not the word \`aria-pressed\`. Of those 308 collisions, only 163 are on \`aria-pressed\`;
|
|
542
|
+
the rest are \`data-[state=active]\` (74), \`checked\` (43) and \`aria-selected\` (28), and each has
|
|
543
|
+
the same fix, verified against this generator:
|
|
544
|
+
|
|
545
|
+
data-[state=active]:hover:bg-accent checked:hover:bg-accent aria-selected:hover:bg-accent
|
|
546
|
+
|
|
547
|
+
**Do not reach for \`not-\`.** \`not-aria-pressed:hover:bg-hover\` and
|
|
548
|
+
\`hover:not-aria-pressed:bg-hover\` are the intuitive fix and this generator matches **neither** —
|
|
549
|
+
they produce no rule at all, so the button keeps the bug and the class list now says it was
|
|
550
|
+
handled. A ternary works too (\`picked ? "bg-accent" : "hover:bg-hover"\`) because only one branch
|
|
551
|
+
is ever present; reach for that when the two states differ in more than a couple of properties.
|
|
552
|
+
|
|
341
553
|
- **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
554
|
- **If you take the focus ring off, put something back.** \`outline-none\` on a borderless input
|
|
343
555
|
is the most common single thing in these cards that breaks keyboard use: **77 of 378 remove the
|
|
@@ -490,6 +702,18 @@ Either way, don't restage the header. The panel already names the canvas, so a h
|
|
|
490
702
|
|
|
491
703
|
\`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
704
|
|
|
705
|
+
- **Getting the attribute right and the pixels wrong is the commoner half.** A vision panel reading
|
|
706
|
+
59 cards raised this in **22% of its verdicts**, in one recurring form: \`aria-checked\` correctly
|
|
707
|
+
set, and the selected chip differing from its siblings **only by background colour**. That is one
|
|
708
|
+
channel, and it is the channel that fails first — greyscale, a dim screen, or the 8% of men with
|
|
709
|
+
a colour vision deficiency. The fix is a second channel on the same ternary, and it costs a
|
|
710
|
+
class: \`font-medium\` on the selected one, or a \`✓\` before its label, or a ring the unselected
|
|
711
|
+
ones do not carry. **Colour may be the loudest signal; it may not be the only one.**
|
|
712
|
+
(No screen for this one, deliberately: a prototype matching the template-literal ternary found
|
|
713
|
+
**7 selections across three waves and zero colour-only ones**, against 22% in the verdicts — the
|
|
714
|
+
shapes a card writes this in are too many for a regex, and a detector that narrow reports a
|
|
715
|
+
clean sweep on a defect that is everywhere.)
|
|
716
|
+
|
|
493
717
|
**Write the state and the style it produces as one token, and this whole class of bug stops
|
|
494
718
|
existing.** \`aria-checked:bg-accent\` is a single string: there is no second place for it to
|
|
495
719
|
disagree with. Measured on a card written before that was possible — the CSS said
|
|
@@ -736,7 +960,7 @@ retrieve a page body — render the snippet and link the source.
|
|
|
736
960
|
|
|
737
961
|
## Reading and writing workspace files
|
|
738
962
|
|
|
739
|
-
\`$dsh/fs\` gives a card \`readFile(path) -> string\`, \`readdir(path) -> {name, type, size}[]\`
|
|
963
|
+
\`$dsh/fs\` gives a card \`readFile(path) -> string\`, \`readBytes(path) -> Uint8Array\`, \`readdir(path) -> {name, type, size}[]\`
|
|
740
964
|
(\`type\` is \`"file"\` or \`"directory"\`, so a tree needs no probing; \`size\` is bytes, absent on
|
|
741
965
|
directories) and \`writeFile(path, content)\` over the workspace. Paths are workspace-relative and
|
|
742
966
|
\`path\` is required — there is no "current directory" argument-less form, under the
|
|
@@ -744,6 +968,29 @@ session's own access mode — the same fence the file tools run behind. So a rea
|
|
|
744
968
|
refuses the write, and the card should say so rather than looking broken: catch it and tell
|
|
745
969
|
the user the session is read-only.
|
|
746
970
|
|
|
971
|
+
**Anything that is not text goes through \`readBytes\`.** \`readFile\` decodes as UTF-8, so a png, a wav
|
|
972
|
+
or a \`.mid\` read that way comes back with every byte above 0x7f replaced by U+FFFD — corrupt, and
|
|
973
|
+
silently so. And there is **no HTTP route that serves workspace files**: \`<img src={\`/\${path}\`}>\`
|
|
974
|
+
resolves against the app, 404s, and the reader gets a page of broken icons. Measured on a real
|
|
975
|
+
canvas that found 357 images and showed none of them. The whole shape is three lines:
|
|
976
|
+
|
|
977
|
+
\`\`\`tsx
|
|
978
|
+
const [url, setUrl] = useState<string>()
|
|
979
|
+
useEffect(() => {
|
|
980
|
+
let live = true, made: string | undefined
|
|
981
|
+
void readBytes(path).then((bytes) => {
|
|
982
|
+
if (!live) return
|
|
983
|
+
made = URL.createObjectURL(new Blob([bytes]))
|
|
984
|
+
setUrl(made)
|
|
985
|
+
})
|
|
986
|
+
return () => { live = false; if (made !== undefined) URL.revokeObjectURL(made) }
|
|
987
|
+
}, [path])
|
|
988
|
+
\`\`\`
|
|
989
|
+
|
|
990
|
+
Revoking is not optional in a browser that keeps a long transcript: one object URL per image per
|
|
991
|
+
mount, never released, is a leak the reader pays for in memory. A grid of them wants an
|
|
992
|
+
\`IntersectionObserver\` too — read the bytes when the cell comes near, not all of them on mount.
|
|
993
|
+
|
|
747
994
|
Reach for it when the data **belongs to the workspace** — a file the user can also open, edit
|
|
748
995
|
and commit.
|
|
749
996
|
|
|
@@ -851,14 +1098,36 @@ A canvas is a file, so you can run a checker over it. \`@genui/cli\` validates e
|
|
|
851
1098
|
kind of TSX:
|
|
852
1099
|
|
|
853
1100
|
\`\`\`
|
|
854
|
-
|
|
1101
|
+
${RUN_CLI} check <file>${typesMap === undefined ? "" : ` -i ${typesMap}`}
|
|
855
1102
|
\`\`\`
|
|
856
1103
|
|
|
857
|
-
\`
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1104
|
+
**\`bunx\` needs the package NAME in front of the URL** — \`genui@https://…\`. Bun reads the whole
|
|
1105
|
+
argument as \`<name>@<spec>\`, so a bare URL gives it an empty name and it stops at
|
|
1106
|
+
\`unrecognised dependency format\` before fetching anything. Any name works; it is a label, not a
|
|
1107
|
+
lookup.
|
|
1108
|
+
|
|
1109
|
+
If bun is not there, in order:
|
|
1110
|
+
|
|
1111
|
+
\`\`\`
|
|
1112
|
+
pnpx --config.blockExoticSubdeps=false ${CLI_URL} check <file>
|
|
1113
|
+
npm_config_cache="$TMPDIR/npm-cache" npx --yes ${CLI_URL} check <file>
|
|
1114
|
+
\`\`\`
|
|
1115
|
+
|
|
1116
|
+
Both take the bare URL. The pnpm flag is **not** optional — the CLI pulls \`@genui/unocss\` by URL
|
|
1117
|
+
as well, and pnpm refuses URL-resolved SUBdependencies by default, so without it you get
|
|
1118
|
+
\`ERR_PNPM_EXOTIC_SUBDEP\` naming a package you never asked for. Do **not** add
|
|
1119
|
+
\`--config.cacheDir\` beside it: pnpm then loses the package's own bin and dies with
|
|
1120
|
+
\`spawn cli ENOENT\`, which reads like the package is broken and is not. It needs no cache redirect
|
|
1121
|
+
anyway — its store is the one of the three your sandbox lets you write.
|
|
1122
|
+
|
|
1123
|
+
**The other two do**, and the reason is worth knowing because it disguises itself. Sandboxed,
|
|
1124
|
+
\`touch ~/.npm/_cacache/x\` and \`touch ~/.bun/install/cache/x\` both come back
|
|
1125
|
+
\`Operation not permitted\`; the directories exist, they are simply not yours to write from in
|
|
1126
|
+
there. npm reports this as \`EPERM mkdtemp\` **and a message about root-owned files**, which sends
|
|
1127
|
+
you looking for a permissions problem in your home directory that is not there. \`$TMPDIR\` is
|
|
1128
|
+
writable, so pointing each cache at it is the whole fix.
|
|
1129
|
+
|
|
1130
|
+
\`check\` includes TypeScript diagnostics; \`lint\` is the faster syntax-only pass.
|
|
862
1131
|
|
|
863
1132
|
${maps}
|
|
864
1133
|
|
|
@@ -911,14 +1180,19 @@ Bare specifiers resolve from npm at render time — there is no install step, so
|
|
|
911
1180
|
|
|
912
1181
|
**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
1182
|
|
|
914
|
-
|
|
1183
|
+
Five that are easy not to think of, each with the one thing to get right:
|
|
915
1184
|
|
|
916
1185
|
| want | reach for | the detail |
|
|
917
1186
|
| --- | --- | --- |
|
|
918
1187
|
| 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
1188
|
| 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
1189
|
| 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 |
|
|
1190
|
+
| 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. |
|
|
1191
|
+
| 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 |
|
|
1192
|
+
| 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 |
|
|
1193
|
+
| 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 |
|
|
1194
|
+
| 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 |
|
|
1195
|
+
| 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 |
|
|
922
1196
|
|
|
923
1197
|
|
|
924
1198
|
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.
|
package/types/fs.d.ts
CHANGED
|
@@ -12,6 +12,20 @@ declare module "$dsh/fs" {
|
|
|
12
12
|
* `readFile` decodes as UTF-8 and would corrupt them silently. Capped at 8MB.
|
|
13
13
|
*/
|
|
14
14
|
export function readBytes(path: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* A refusal, told apart from a breakage.
|
|
17
|
+
*
|
|
18
|
+
* `denied` is the field to branch on: the session said no, and no retry changes that —
|
|
19
|
+
* show the reader what would have been written and let them apply it another way.
|
|
20
|
+
* Anything else (a missing directory, a full disk) is an outage and reads as one.
|
|
21
|
+
*/
|
|
22
|
+
export type FsError = Error & { denied?: boolean; code?: string };
|
|
23
|
+
/**
|
|
24
|
+
* Writes the file, under the session's own access mode.
|
|
25
|
+
*
|
|
26
|
+
* Rejects with a `FsError` whose `denied` is true when the session may not write —
|
|
27
|
+
* `code` is `FS_SANDBOX_DENIED` there. **A write the reader did not ask for is not
|
|
28
|
+
* yours to make**: put it behind a control they press.
|
|
29
|
+
*/
|
|
16
30
|
export function writeFile(path: string, content: string): Promise<void>;
|
|
17
31
|
}
|