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