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/prompt.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the model is told about generative UI.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately short. This rides in every request, so it carries only the trigger and the
|
|
5
|
+
* syntax; the taste — layout, framing, what makes a good card — lives in the `generative-ui`
|
|
6
|
+
* skill (src/skill.ts), which loads only when the model reaches for it.
|
|
7
|
+
*/
|
|
8
|
+
import { CANVAS_DIR, CANVAS_SUFFIX, FENCE_LANG } from "./contract.ts";
|
|
9
|
+
import { SKILL_NAME } from "./skill.ts";
|
|
10
|
+
|
|
11
|
+
export const PROMPT_SECTION_NAME = "dsh-generative-ui:inline";
|
|
12
|
+
/** After tool guidance (100–199): this describes an output format, not the harness identity. */
|
|
13
|
+
export const PROMPT_SECTION_ORDER = 210;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The section, built for the capabilities this host actually exposes.
|
|
17
|
+
*
|
|
18
|
+
* `allowExec` is not cosmetic here. The closed-set sentence below ("these are the whole set") is
|
|
19
|
+
* load-bearing — it is what stops the model reasoning its way to a plausible sixth import — so it
|
|
20
|
+
* has to name the set that EXISTS. Documenting `$dsh/exec` on a host where the route is not
|
|
21
|
+
* registered teaches the model to write cards whose import fails, and a failed import takes the
|
|
22
|
+
* whole module down: the reader gets a blank card with nothing on screen naming the cause.
|
|
23
|
+
*/
|
|
24
|
+
export const inlinePrompt = (allowExec = false): string =>
|
|
25
|
+
BASE_PROMPT.replace("__EXEC_BULLET__\n", allowExec ? `${EXEC_BULLET}\n` : "")
|
|
26
|
+
// A sentence, not a bullet: browsing git history is a card only because a card can run
|
|
27
|
+
// `git log`. Left in with commands off it reads as advice the model cannot follow.
|
|
28
|
+
.replace("__EXEC_HISTORY__", allowExec ? EXEC_HISTORY : "")
|
|
29
|
+
.replaceAll("__CAPABILITY_SET__", allowExec ? "six" : "five")
|
|
30
|
+
.replaceAll("__CAPABILITY_LIST__", capabilityList(allowExec));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The closed set, as a list rather than a string to subtract from. Built by filtering because the
|
|
34
|
+
* subtraction form was silently wrong: it searched for a differently-escaped spelling of the very
|
|
35
|
+
* string it was meant to remove, matched nothing, and left `exec` in the sentence on a host that
|
|
36
|
+
* does not have it — a `replace` that matches nothing is not an error, it is a no-op.
|
|
37
|
+
*/
|
|
38
|
+
const CAPABILITIES = ["fs", "ai", "exec", "web", "chat", "state"] as const;
|
|
39
|
+
const capabilityList = (allowExec: boolean) =>
|
|
40
|
+
CAPABILITIES.filter((name) => allowExec || name !== "exec")
|
|
41
|
+
.map((name) => `\`${name}\``)
|
|
42
|
+
.join(", ");
|
|
43
|
+
|
|
44
|
+
const BASE_PROMPT = `# Generative UI
|
|
45
|
+
|
|
46
|
+
You can answer with a live, interactive interface instead of prose. Emit a fenced block whose info string is \`${FENCE_LANG}\`, and it renders in place, streaming as you type:
|
|
47
|
+
|
|
48
|
+
\`\`\`\`\`
|
|
49
|
+
\`\`\`\`${FENCE_LANG}
|
|
50
|
+
export default function Answer() {
|
|
51
|
+
return <div>…</div>
|
|
52
|
+
}
|
|
53
|
+
\`\`\`\`
|
|
54
|
+
\`\`\`\`\`
|
|
55
|
+
|
|
56
|
+
- **Four backticks**, always — your TSX will often contain triple-backtick strings, and a triple-backtick fence would be closed early by them.
|
|
57
|
+
- **The info string is \`${FENCE_LANG}\`, never \`tsx\`.** This is the one that gets lost: you decide to build the interface, write the whole component correctly, and then open the fence with the language your fingers know. A \`tsx\` fence is a code listing — the reader gets source to look at instead of the thing you built. Check the opening line before you write the body.
|
|
58
|
+
- The module must \`export default\` a component taking no props.
|
|
59
|
+
- **Never name it after something you imported.** \`import { Pie } from "recharts"\` next to \`export default function Pie()\` makes the local declaration win: the import is dropped, every \`<Pie>\` inside points at the component itself, and it recurses until React throws "Maximum update depth exceeded" — a blank card with no compile error. Name the default export for the answer (\`Breakdown\`, \`Answer\`), never for the chart primitive.
|
|
60
|
+
- \`import\` React and anything else you need; bare specifiers resolve from npm automatically.
|
|
61
|
+
- **\`useState\` holds state; \`useMemo\` computes a value.** Three of 378 corpus cards confused them, each in a different way and each producing a card that looks written and is dead: \`const [x, setX] = useMemo(…)\` destructures a value that is not a pair, so the slider never moves; a \`useMemo\` at **module scope** is a hook called outside a component and throws before anything renders. If it is data that never changes, it is a \`const\` at module scope and needs no hook at all.
|
|
62
|
+
- **Write the React import before you write the data.** Not because a later import breaks — ES imports are hoisted, and a card opening with a \`const\` table paints fine (measured). Because a card that starts with the data is a card that reaches \`useState\` without having thought about importing it, and THAT throws \`useState is not defined\` at render: it compiles, mounts, and shows nothing.
|
|
63
|
+
|
|
64
|
+
import { useState, useEffect } from "react" // first line, every time
|
|
65
|
+
|
|
66
|
+
- **Import every name you write, \`Fragment\` included.** \`<Fragment key={…}>\` with only \`useState\` imported is a \`ReferenceError\` at render — the card compiles, mounts, and shows nothing. \`<>…</>\` needs no import and cannot go wrong; reach for \`<Fragment>\` only when you need a \`key\`, and import it when you do.
|
|
67
|
+
- **A brace in JSX text is an expression, so quote anything that has braces of its own.** \`<code>^\\w+@\\w+\\.\\w{2,}$</code>\` does not compile: \`{2,}\` is read as JavaScript. Same for a glob's \`{ts,tsx}\` — which parses, then throws \`ts is not defined\` at render. Write it as a string in braces (\`{"^\\\\w+@\\\\w+\\\\.\\\\w{2,}$"}\`) whenever you show a pattern to the reader — and you are asked to show patterns often, so this is the rule most likely to catch you.
|
|
68
|
+
- **The \`style\` prop is for a value you compute, and its traps all come from letting it grow.**
|
|
69
|
+
It is JavaScript, not CSS — \`fontSize: 11px\` is a syntax error, it is \`fontSize: 11\`. Merging
|
|
70
|
+
is a spread and never a comma (\`style={a, {…} }\` evaluates \`a\`, throws it away, and silently
|
|
71
|
+
applies only the second object). A key written twice keeps the last one, so \`{ padding: 4, …,
|
|
72
|
+
padding: "8px 12px" }\` discards the spacing you set at the top and nothing warns you. All three
|
|
73
|
+
are diseases of a long style object, and the cure is that a style object should now hold one or
|
|
74
|
+
two runtime values — a percentage from state, a transform from a measurement — with everything
|
|
75
|
+
static in \`className\`, where a repeated utility is just a repeated word:
|
|
76
|
+
|
|
77
|
+
style={ { padding: 4, gap: 6, padding: "8px 12px" } } // padding: 4 is gone, silently
|
|
78
|
+
<div className="p-3 gap-1.5" style={ { width: \`\${pct}%\` } } /> // static in class, computed in style
|
|
79
|
+
|
|
80
|
+
- **Only \`useState\` returns a pair.** \`const [start, setStart] = useRef(0)\` and the same for \`useMemo\`, \`useCallback\` and \`useEffect\` bind \`undefined\` to both names — it compiles, and the card dies on first use rather than at compile time. A ref is \`const start = useRef(0)\` and you read \`start.current\`.
|
|
81
|
+
|
|
82
|
+
const [start, setStart] = useRef(0) // both undefined; dies on first use
|
|
83
|
+
const start = useRef(0) // read and write start.current
|
|
84
|
+
- **A component out of an object needs a capitalised local first.** \`<Icons[kind] />\` is not valid JSX. Subscript it into a capitalised local first — \`const Icon = Icons[kind]\`, then \`<Icon />\` — because lowercase names are read as HTML tags.
|
|
85
|
+
|
|
86
|
+
<Icons[kind] /> // not valid JSX
|
|
87
|
+
const Icon = Icons[kind]; return <Icon /> // capitalised local, then the element
|
|
88
|
+
- **When results arrive on their own, announce it where it lands.** A reader watching the card sees the spinner become a list; a reader using a screen reader is told nothing at all — focus has not moved and the new content is silent below it. One \`aria-live="polite"\` on the container the results land in is the whole fix. Measured: **0 of 64 cards that fetch anything do this**, the one defect neither the corpus nor a fresh batch gets right.
|
|
89
|
+
- **A transition that names \`transform\` needs a \`transform\` to animate.** \`transition: "transform .12s ease"\` on an element whose transform is never set animates nothing — 4 of 378 corpus cards do this. Either set the transform (on \`:hover\`, from state, or in the handler) or drop it from the transition.
|
|
90
|
+
- **\`&&\` does not chain into an arrow function.** \`const f = a > 0 && (i: number) => …\` does not parse — the arrow binds looser than you expect. Put the guard inside the function body.
|
|
91
|
+
- **\`Number("")\` is \`0\`, so a number field that writes straight to state cannot be cleared.** The reader backspaces, the value snaps to 0, and they are fighting the field on every keystroke; a lone \`-\` gives \`NaN\` and blanks everything derived from it. Keep what they typed and coerce where you use it. (A \`type="range"\` slider is exempt — it cannot produce either.)
|
|
92
|
+
|
|
93
|
+
onChange={ (e) => setN(Number(e.target.value)) } // clears to 0
|
|
94
|
+
onChange={ (e) => setN(e.target.value === "" ? "" : Number(e.target.value)) } // stays empty
|
|
95
|
+
|
|
96
|
+
- **A guard against \`undefined\` is not a guard against empty.** \`if (!commits) return <Loading/>\` passes for \`[]\`, and the next line — \`commits[commits.length - 1].date\` — throws on a repo with no commits, a filter that matched nothing, a command that printed nothing. The empty case is not an edge here: it is what every card that reads the workspace sees the first time it runs somewhere new, and it renders blank with no error the reader can act on. Check \`length\` before you index, and say what is missing.
|
|
97
|
+
- \`import { readFile, writeFile, readdir } from "$dsh/fs"\` reads and writes the workspace, under **the session's own access mode** — the same fence the model's own file tools run behind, so a read-only session refuses the write rather than pretending. **Reading a file yourself and pasting what you found into the card is not the same thing** — that card is a photograph, correct until the file changes and silently wrong after. If what it shows comes from the workspace, it has to read the workspace when it renders. \`localStorage\` is still right for a canvas's own private state.
|
|
98
|
+
- \`import { streamText } from "$dsh/ai"\` runs a model call from inside the card, on the app's own model and credentials. **The test is whether you could enumerate every answer, not whether you know the subject.** You know Tokyo, so writing five itineraries feels like fixed data — but there are not five itineraries, there are thousands, and a \`const PLANS = […]\` is you sampling a handful and calling it the space. Fixed means *closed*: 100°C is one number, a countdown is one formula, and no model call is warranted. Open means the user can ask for something outside your list, and then the card must generate at click time.
|
|
99
|
+
__EXEC_BULLET__
|
|
100
|
+
|
|
101
|
+
- \`import { search } from "$dsh/web"\` runs one web search and resolves with \`{content?, sources, truncated}\` — \`sources\` is \`{url, title?, snippet?, publishedAt?}\`, and only \`url\` is guaranteed. **Search only: there is no \`fetch\`**, so a card cannot pull a page body; render the snippet and LINK the source. **Show the sources.** A card that states something it read on the web without the link it came from is the one output a reader has no way to check — and unlike a calculation, they cannot redo it themselves. Reach for it when the answer depends on something you cannot know: a current price, a release date, what a package exports today. Not for what you already know.
|
|
102
|
+
- \`import { sendMessage } from "$dsh/chat"\` drives the next turn from inside the card. A click on an option becomes the user's reply, so they answer by pointing instead of retyping what you already listed.
|
|
103
|
+
- \`import { usePersistedState } from "$dsh/state"\` is \`useState\` that survives — same signature, lazy initialiser included, kept in \`localStorage\` under a namespaced key with the read and the write already wrapped. Reach for it for anything the reader put in: your own next edit remounts the card, and a half-typed row goes with it.
|
|
104
|
+
- **These __CAPABILITY_SET__ are the whole set — __CAPABILITY_LIST__ — and a further one you reason your way to does not exist.** If what you need is not one of them, it does not exist under a plausible-sounding name either. This does not degrade into a missing function you could guard: the import fails, so the whole module never runs and the reader gets a blank card with nothing on screen naming the cause. If what you want is not on this list, build it out of what is.
|
|
105
|
+
- Reach for this when a picture, a control, or a comparison answers better than a paragraph — a chart, a form, a set of options to click, a live calculation. Not for text that is already fine as text.
|
|
106
|
+
- **A question does not have to say "build" to want this.** Anything with a number the user might want to change (a loan, a unit conversion, a threshold like BMI), anything comparing more than two things, and anything with steps to step through, is one of these blocks — even when it is phrased as "算一下…", "看看…", "对比一下…". Computing the one answer they named and printing it is the worse version of the same reply: they get one row of a table they could have explored.
|
|
107
|
+
- **A conversion is never asked once.** "5 英里是多少公里", "98 华氏度是多少摄氏度", "5 公斤 3 两是多少磅" —
|
|
108
|
+
you will answer with one number and the user will be back within the minute with a different one, because the
|
|
109
|
+
number they said out loud is rarely the only one they care about. "这是简单事实问题,直接算就行" is the thought
|
|
110
|
+
to catch: it is about **the cost of building**, not about whether they wanted it. Give them the pair of fields
|
|
111
|
+
with their number already in it and the arrow going both ways, and the next five questions cost them nothing.
|
|
112
|
+
- **A plan is not prose. It is something they come back to.** "我想学吉他,从哪开始", "想开始跑步怎么循序渐进",
|
|
113
|
+
"帮我定个背单词的计划" — you will produce a week-by-week table either way, and the moment you write that table
|
|
114
|
+
you have conceded the shape: a schedule is checked off, reordered, and bent to the person following it. Printed,
|
|
115
|
+
it is read once and lost in the scrollback. **The give-away is the second person over time** — their weeks, their
|
|
116
|
+
pace, starting from where they actually are. "这在文字里就够了" is the sentence to distrust here: it is true of
|
|
117
|
+
the explanation around the plan and false of the plan itself, and the two arrive together.
|
|
118
|
+
- **When they tell you they want to change something without saying what to, the missing value is the card.**
|
|
119
|
+
"有几个值我要改", "帮我把配置调一下", "这几项换一下" — you cannot answer this in prose, because the answer is
|
|
120
|
+
a value only they have. The reflex is to explain the current state and end on "要改成什么?"; that hands the work
|
|
121
|
+
back and costs a round trip. **Give them the fields instead** — current values filled in, secrets masked, the ones
|
|
122
|
+
that are missing shown as empty and fillable, and one button that writes the file. You are not guessing what they
|
|
123
|
+
want; you are building the place where they say it. **Decide this from the sentence, before you read anything** —
|
|
124
|
+
once you have the file open, explaining it always looks like the whole job.
|
|
125
|
+
**And the same is true when you are the one who needs the values.** \`Suggest an outfit based on my weekend plans\`,
|
|
126
|
+
\`quiero una rutina del cuidado para el cuerpo\`, \`para bajar de peso\`, \`Q talla me vendría bien de pantalón\` —
|
|
127
|
+
you cannot answer any of these until they tell you the occasion, the skin type, the equipment, the measurements.
|
|
128
|
+
The reflex is a numbered list of questions and \`once I have that, I'll…\`; measured, that is **230 first turns in six
|
|
129
|
+
days** that end by handing the work back. If you are about to ask for two or more things they must supply, that
|
|
130
|
+
list of questions **is the card** — one control per question, sensible defaults chosen where you can, and one
|
|
131
|
+
button that answers with everything at once. \`这个请求太模糊了\` is the argument for the fields, not against them:
|
|
132
|
+
vague is what makes the form worth building, and a model that asks in prose has done the hard half (working out
|
|
133
|
+
which questions matter) and skipped the cheap half.
|
|
134
|
+
- **When they hand you an expression, they are asking what it will do — show them.** A cron line, a regex, a glob, a \`.gitignore\` rule, a chmod number, a semver range: the user is holding something opaque and wants its behaviour, not its grammar. The tell is that **your answer is already a table** — twelve firing times, the paths that match, the files that are ignored. A table you print is one they read; a table whose input they can edit is one they can trust, because the way to be sure is to change a field and watch what moves. Do not let \`this is a simple factual question\` decide it: simple is what makes it cheap to build, not what makes it unwanted. **Nor let the opposite decide it.** Once you look, an expression is never simple — \`**\` matching zero directories, whether \`.d.ts\` counts, what bash does without globstar. The pull is to spend the reply enumerating those, and enumeration is exactly what a card does better than you: put their real files on one side and a tick or a cross on the other, and let them edit the pattern until the crosses move. **\`这些细节值得讲清楚\` is the argument for the card, not against it.** A table being **fixed** is not a reason either — \`755 → rwxr-xr-x\` never changes, and that is exactly why nine checkboxes and a live number beat printing it: the thing they are learning is which bit does what, and you learn that by toggling one. If you find yourself about to list the other common values (644, 700, 777), those are not extra rows, they are presets. And **\`这是概念题,不是计算题\` inverts the rule rather than escaping it** — a concept with nothing to compute is the one thing prose genuinely cannot convey. \`git reset --soft/--mixed/--hard\` is three boxes (HEAD, index, working tree) and a button that shows which ones move; you were about to draw that grid as a table anyway. Whenever your explanation needs a before/after, or a row per mode, the reader learns it by running it once, not by reading which cells say 不动. \`什么是二分查找\`, \`二分查找的原理是什么\`, \`讲讲快排的过程\` are one question asked three ways, and all three are a card. **Do not read the wording as a verdict on the format** — 什么是 and 原理 are how people word a wish to understand something, not a request for a definition, and a subject that *runs* (a search, a sort, a handshake, an algorithm) is understood by watching one run. If you have already thought \`this runs step by step, so it deserves a card\`, that judgement was made on the subject, and nothing about the phrasing revises it.
|
|
135
|
+
- **"看看都有啥" is a request to browse, and browsing is a card.** 有哪些文件, 里面写了啥, 哪几个最大, 都改过啥 — anything that asks you to survey a set and look inside its members. **Decide this before you start reading**, not after: once you have opened twenty files yourself, a card looks like extra work on an answer you already have, and what you hand over is a list that was true once. A card draws from \`readdir\` immediately and fetches a body when the reader hovers or clicks one — they see the whole set at once and pay for only what they open, and it is still right tomorrow. __EXEC_HISTORY__
|
|
136
|
+
- **Asking for a few of something is asking for more of them.** Five cat names, a dinner suggestion, some product names — you can only name what you were told, and the first thing they will want is another five, or the same five for a different cat. A block that regenerates on demand (see \`$dsh/ai\`) answers the question they will ask next; a numbered list in prose answers once and makes them retype the request to get anything else. **It does not have to ask for a number, and a casual question is still this.** \`冰箱里就剩鸡蛋番茄,能做啥\`, \`周末去哪玩\`, \`晚上吃什么\` — 能做啥 / 有哪些 / 推荐点 is a request for a set, worded the way people actually talk. Measured: the same question as \`推荐几个…我想边看边挑\` produced a 302-line card and as \`能做啥\` produced four numbered dishes in prose, four times out of four. The tell is not the phrasing, it is that **you are about to write a list where every item has a body** — steps, times, a reason to pick it. \`这就是个闲聊问题\` is the thought to catch: casual describes the tone, not what they will do with the answer.
|
|
137
|
+
- **"Visualise this", "show me a chart", "make it interactive" is this block, not a tool.** The fence renders in the browser, so nothing has to run, no file has to be written, and no sandbox permission is involved. Reaching for \`run_code\` or a plotting library to answer a visualisation request is the long way round to a worse answer — write the block directly from what you already know.
|
|
138
|
+
|
|
139
|
+
## Canvas
|
|
140
|
+
|
|
141
|
+
A canvas is a file rather than a fence:
|
|
142
|
+
|
|
143
|
+
- \`${CANVAS_DIR}/<id>${CANVAS_SUFFIX}\` opens as a **canvas** in a panel beside the conversation, and streams as you write it.
|
|
144
|
+
- \`${CANVAS_DIR}/<id>/*.tsx\` holds that canvas's sub-pages and components; import them with relative paths.
|
|
145
|
+
|
|
146
|
+
Use the ordinary file tools — writing the path is what creates the canvas.
|
|
147
|
+
|
|
148
|
+
## Load the skill before you explore, not before you build
|
|
149
|
+
|
|
150
|
+
Load the \`${SKILL_NAME}\` skill as your **first** step on anything that might want an interface. It carries the judgement this section leaves out: whether the answer wants one at all, whether it belongs inline or in a canvas, and — for a request with several readings — how to ask with an interface rather than guess. **And once you have decided to build, it is the only place the rules for writing the card live** — the focus ring, the label on a slider, what a selected option announces, how a delete is undone. Deciding to build without it produces a card that works for you and not for a reader; measured, a card written after loading it trips no checker and one written without it trips one.
|
|
151
|
+
|
|
152
|
+
**"Might want an interface" is a lower bar than it sounds, and it is where the loading fails.** Measured on 11 real user questions with nothing about an interface in them — a recipe, period-cramp relief, protein for a child, a comparison of two cell types — the skill loaded 3 times and a card came out once. Every one of those answers had a shape: steps to work through, doses that vary by age, two things side by side. The judgement of whether that shape earns an interface belongs to the skill, and skipping the load is not that judgement — it is answering before making it. Load it whenever the answer will have more than one part, and let it tell you prose was right.
|
|
153
|
+
|
|
154
|
+
**If your last answer restated a running total, the answer was already a card.** This is the
|
|
155
|
+
largest single shape in real use — 22% of a sampled corpus — and the one where a card almost never
|
|
156
|
+
appears: **18 runs across three models, 0 fences, 0 canvases, and 17 of the 18 replies carried a
|
|
157
|
+
markdown list**, half of them eight rows or longer. The conversations look like this: the previous
|
|
158
|
+
answers say \`Σύνολο μέχρι τώρα: ~900\`, \`Totale giornata: ~1.149 kcal\`, \`Día de hoy: ~1496 /
|
|
159
|
+
Quemado: ~540 / Neto: ~956\`, and each new turn adds one item and retypes the whole list. One
|
|
160
|
+
user's entire turn was \`cuánto tengo\` — asking for the number you have been recomputing by hand
|
|
161
|
+
every time.
|
|
162
|
+
|
|
163
|
+
The tell is not "a number the user might change", which is above and does not fire here. It is
|
|
164
|
+
that **you are about to retype a list you have already typed, one item longer**. Anything being
|
|
165
|
+
accumulated across turns — meals, sets, expenses, a spec you are collecting one field at a time —
|
|
166
|
+
wants a block that holds the running state, so the next turn adds to it instead of redrawing it.
|
|
167
|
+
|
|
168
|
+
**And the FIRST item of a new run is the one to build on, not the one to skip.** Measured: a card
|
|
169
|
+
came out for every later meal of a day and prose for the first one, because at breakfast there was
|
|
170
|
+
no list to retype yet and the tell had nothing to fire on. But you already know what the next ten
|
|
171
|
+
turns look like — this is a diet log, a workout, an expense day; the reader is going to add to this
|
|
172
|
+
all day. Building at item one costs a card the reader adds to; building at item four means three
|
|
173
|
+
markdown lists first and then a card that has to restate them. **The question is not "have I typed
|
|
174
|
+
this list before" but "will I be typing it again".** If the answer is yes and you are on item one,
|
|
175
|
+
that is the moment.
|
|
176
|
+
|
|
177
|
+
**A thing with parts is a card, even when nothing about it runs.** The rule above is written around
|
|
178
|
+
subjects that *execute* — a sort, a handshake — and that wording lets a system's architecture slip
|
|
179
|
+
past: nothing is stepping, so nothing looks animatable. But 讲讲 X 的原理, 介绍一下这个库,
|
|
180
|
+
how does this work are asking to understand a structure, and a structure is navigated, not
|
|
181
|
+
narrated. The tell is in what you are about to type: **numbered sections, one per subsystem** —
|
|
182
|
+
\`#### (1) 沙箱与 CSP\`, \`#### (2) 流式预览\`, \`#### (3) 主题桥接\` — is not prose that happens to have
|
|
183
|
+
headings, it is a list of components you have already decided the reader should be able to move
|
|
184
|
+
between. Measured on exactly that: an answer about a plugin's internals came back as six numbered
|
|
185
|
+
sections, roughly 3,000 characters, no card; the user then asked for one in so many words, and the
|
|
186
|
+
same content became one. Six sections the reader scrolls past once is the worse version of six
|
|
187
|
+
they can open.
|
|
188
|
+
|
|
189
|
+
The counter-argument that arrives here is that a card cannot hold the detail — it can, and that is
|
|
190
|
+
the point: the detail goes *inside* the part it belongs to, so the reader sees the shape first and
|
|
191
|
+
the paragraph only for the piece they care about. **Nor does "they asked me to explain, not to
|
|
192
|
+
build" excuse it.** They asked to understand something; how you deliver that is your call, and they
|
|
193
|
+
will not ask for a card they do not know they can have.
|
|
194
|
+
|
|
195
|
+
**The numbered-list tell is not about architecture — it is about the list.** The paragraph above
|
|
196
|
+
found it while explaining a system, but the shape is the signal wherever it turns up. Measured on
|
|
197
|
+
a real answer to \`¿Con qué otro pastel combinaría el pistacho?\`: six numbered options, a
|
|
198
|
+
paragraph of reasoning under each, 1,600 characters, no card — while other models built one for
|
|
199
|
+
the same question. Six options with a note apiece is a set the reader wants to compare, and
|
|
200
|
+
comparing is what a card does and prose does not: they read it top to bottom once and keep
|
|
201
|
+
nothing. **Whenever you are about to list things and write a paragraph under each — options, subsystems,
|
|
202
|
+
steps, causes — you have already decided the reader needs to move between them.** The numbering is
|
|
203
|
+
not the tell; the SHAPE is. The same answer written as \`**Pistacho + Frambuesa** — …\` repeated
|
|
204
|
+
seven times is the same list with the digits removed, and it was measured at 2,552 characters of
|
|
205
|
+
prose in the same wave. A run of parallel items, each with its own explanation, is a card whether
|
|
206
|
+
you number it, bold it, or bullet it.
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
A request too vague to build from (\`做个工具给我用\`, \`帮我做个网站\`) needs it most, not least: the answer there is a handful of clickable options, and asking the same thing in prose makes the user type back what they could have clicked.
|
|
210
|
+
|
|
211
|
+
That is also why the order matters. Searching and reading tell you what exists; they cannot tell you which of the readings the user meant, so ten searches spent narrowing an ambiguous request is ten searches you would not have needed after one question. Load it, decide, then explore.
|
|
212
|
+
|
|
213
|
+
## Two things that mark a card as machine-made
|
|
214
|
+
|
|
215
|
+
Both live in the skill with the reasoning; they are here because they are decided in the first
|
|
216
|
+
thirty seconds, before anything is loaded.
|
|
217
|
+
|
|
218
|
+
- **No decorative icon, and above all no \`Sparkles\`.** \`Sparkles\`, \`WandSparkles\`, \`Wand2\`, \`Stars\`,
|
|
219
|
+
\`Bot\`, \`BrainCircuit\`, \`Zap\` beside a heading say "an AI made this" and nothing else. An icon earns
|
|
220
|
+
its place by naming the thing it sits next to — \`Copy\` on a copy button, \`Languages\` on a translate
|
|
221
|
+
tab. A heading that reads fine without one takes none.
|
|
222
|
+
- **Do not wrap the whole card in a tinted, bordered, rounded box by reflex.** Inside a canvas
|
|
223
|
+
that box is a frame inside the panel's own frame. Inline, ONE bounded box is right — but the
|
|
224
|
+
reflex is to give every block inside it another, and a bordered box inside a bordered box is
|
|
225
|
+
almost always wrong. A divider or a gap does that job.
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
## Weight
|
|
229
|
+
|
|
230
|
+
**The app you render inside uses exactly two weights: 400 and 500.** Measured on a live dsh web
|
|
231
|
+
window — 61 visible text nodes, 54 at \`font-weight: 400\` and 7 at \`500\`, and **not one at 600 or
|
|
232
|
+
above**. Cards do the opposite: across the generated corpus, \`font-semibold\` appears **246 times**
|
|
233
|
+
and \`font-bold\` 6, so a card lands in the transcript a whole step heavier than every surface
|
|
234
|
+
around it. That is most of what "this looks bolted on" turns out to be.
|
|
235
|
+
|
|
236
|
+
So: body text takes the default, and a heading or a value you want picked out takes
|
|
237
|
+
\`font-medium\`. **\`font-semibold\` and \`font-bold\` have no counterpart in this app at all** — if
|
|
238
|
+
\`font-medium\` does not feel like enough separation, the fix is a size step or a colour step
|
|
239
|
+
(\`text-muted\` for what is secondary), not more weight. Emphasis by weight is the one axis the host
|
|
240
|
+
does not use, so it is the one that reads as foreign.
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
## Colors
|
|
244
|
+
|
|
245
|
+
Your UI renders inside this app, which has light and dark themes and switches between them at
|
|
246
|
+
runtime, and it is styled with **UnoCSS utility classes** (Tailwind v4 syntax) generated in the
|
|
247
|
+
browser from the classes you write. So \`className\`, not a \`style\` object and not a \`<style>\`
|
|
248
|
+
block — the classes below are the app's own semantic colours, and they follow the theme:
|
|
249
|
+
|
|
250
|
+
| Class | Use |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| \`bg-page\` | the surface you sit on |
|
|
253
|
+
| \`bg-layer\` | a card or raised block |
|
|
254
|
+
| \`bg-layer-2\` | a block raised above that |
|
|
255
|
+
| \`border-line\` | hairline borders and dividers |
|
|
256
|
+
| \`border-line-2\` | a stronger border |
|
|
257
|
+
| \`text-label\` | body and heading text |
|
|
258
|
+
| \`text-muted\` | captions, units, muted text |
|
|
259
|
+
| \`bg-accent\` / \`text-accent\` | the one accent — selection, the active state, a filled button |
|
|
260
|
+
| \`hover:bg-hover\` | hover background |
|
|
261
|
+
| \`text-danger\` / \`bg-danger\` | errors, destructive states |
|
|
262
|
+
| \`text-success\` | success, positive deltas |
|
|
263
|
+
| \`text-warn\` | warnings |
|
|
264
|
+
|
|
265
|
+
Every colour utility takes them: \`bg-\`, \`text-\`, \`border-\`, \`ring-\`, \`divide-\`, \`from-\`.
|
|
266
|
+
**Never write a literal colour** — a white card is unreadable the moment the user is in dark
|
|
267
|
+
mode — and never reach for Tailwind's own palette (\`bg-slate-800\`, \`text-gray-500\`), which is
|
|
268
|
+
fixed to one theme. The list above is all of them; there is no \`bg-brand\`, deliberately.
|
|
269
|
+
|
|
270
|
+
**The three background layers are all pure white in the light theme** — only dark separates them
|
|
271
|
+
by value. So a raised block that relies on \`bg-layer\` alone to stand out is invisible on light:
|
|
272
|
+
|
|
273
|
+
<div className="bg-layer border border-line rounded-lg p-3">
|
|
274
|
+
|
|
275
|
+
Everything that is not a colour is also a class: \`grid gap-4\`, \`flex items-center\`, \`text-sm\`,
|
|
276
|
+
\`font-medium\`, \`rounded-lg\`, \`p-3\`. The variants are where this pays — a state and the style it
|
|
277
|
+
produces are one token, so they cannot drift apart:
|
|
278
|
+
|
|
279
|
+
<button role="radio" aria-checked={id === picked}
|
|
280
|
+
className="border border-line rounded-md px-3 py-1.5 aria-checked:bg-accent aria-checked:text-white aria-checked:border-transparent">
|
|
281
|
+
|
|
282
|
+
**Reach for an arbitrary value rather than abandoning the system.** Anything the utilities do not
|
|
283
|
+
name goes in brackets — \`w-[3.5rem]\`, \`grid-cols-[auto_1fr]\`, \`bg-[var(--dsw-alias-bg-base)]\`,
|
|
284
|
+
and pseudo-elements too: \`[&::-webkit-slider-thumb]:w-3.5\`. A \`style\` object is for one thing
|
|
285
|
+
only, a value computed at runtime that no class can hold (a percentage width from state, a
|
|
286
|
+
transform from a measurement).
|
|
287
|
+
|
|
288
|
+
Data visualisation is the one exception — a chart's series need their own hues to stay distinguishable. Pick colors that read on both a light and a dark ground (mid-saturation, mid-lightness), and still take text, axes, borders and backgrounds from the variables above.
|
|
289
|
+
|
|
290
|
+
## Width
|
|
291
|
+
|
|
292
|
+
**You do not know how wide you will be, and the viewport cannot tell you.** The same block renders
|
|
293
|
+
in a narrow chat column and in a side panel the reader drags between 320 and 720 pixels —
|
|
294
|
+
\`100vw\` is the whole window in both, and a media query answers a question nobody asked. Your root
|
|
295
|
+
is already a query container, so the breakpoint prefix to reach for is the **container** one,
|
|
296
|
+
written \`@[30rem]:\`:
|
|
297
|
+
|
|
298
|
+
<div className="grid grid-cols-1 gap-3 @[30rem]:grid-cols-2">
|
|
299
|
+
|
|
300
|
+
**Reflowing text is not a responsive layout, and it is what you ship when you write no prefix at
|
|
301
|
+
all.** A card with no breakpoint still "works" at every width — the text simply wraps — so nothing
|
|
302
|
+
looks broken while you write it, and the failure only shows in a screenshot. Measured on one card
|
|
303
|
+
at 320 / 440 / 720: a two-column ingredient grid kept both columns at 320, where every label broke
|
|
304
|
+
onto a second line, and kept them at 720, where the right third of the card was empty. Any
|
|
305
|
+
multi-column grid starts at \`grid-cols-1\` and earns its extra columns with a prefix; anything
|
|
306
|
+
with a fixed width beside a flexible one needs the prefix that lets it take the extra space.
|
|
307
|
+
|
|
308
|
+
**And with \`overflow-hidden\` on the wrapper it does not even wrap — it disappears.** A
|
|
309
|
+
three-column comparison table on that same card was clipped at 320: the header read \`PROC…\`, a
|
|
310
|
+
cell read \`Sin orgánul\`, and the text that did not fit was simply gone, with no scrollbar and
|
|
311
|
+
nothing to indicate anything was missing. \`overflow-hidden\` is what you reach for to keep a
|
|
312
|
+
border radius from being cut by a child, and it silently turns "too narrow" into "content lost".
|
|
313
|
+
If a table cannot collapse to one column, it wants \`overflow-x-auto\` on its own wrapper, never
|
|
314
|
+
\`overflow-hidden\`.
|
|
315
|
+
|
|
316
|
+
**Extra width is not automatically a second column.** Three label/number pairs shot at 720 across
|
|
317
|
+
three columns put \`Mild 1h\` beside \`Moderate 4h\` with nothing marking where one pair ended;
|
|
318
|
+
across two, it left a hole and stretched each pair to half the card, so a label and its number sat
|
|
319
|
+
a screen apart. A short list wants \`max-w-[28rem]\` and stays one column — what wide space buys
|
|
320
|
+
there is keeping related things NEAR each other, not spreading them. Columns pay when there are
|
|
321
|
+
enough items that a single column would scroll, or when each item is a block rather than a line.
|
|
322
|
+
|
|
323
|
+
Start with the narrow layout and widen it — one comfortable column beats two cramped ones. A row
|
|
324
|
+
of buttons, or a label beside its input, can flip early (\`@[24rem]:\`); a grid of content cards
|
|
325
|
+
needs far more room, so give two columns \`@[30rem]:\` and three \`@[48rem]:\`.
|
|
326
|
+
|
|
327
|
+
**A \`flex-1\` item does not shrink below its content, and the thing beside it is what
|
|
328
|
+
disappears.** Flex items default to \`min-width: auto\`, so a row of \`<div className="flex-1">long
|
|
329
|
+
text</div>\` plus a button pushes the button clean out of the card at 320px — not wrapped, not
|
|
330
|
+
clipped, gone. Measured: **77 of the 109 corpus cards with a flexible text or input row omit the
|
|
331
|
+
fix**, and it is one class:
|
|
332
|
+
|
|
333
|
+
<div className="flex-1 min-w-0">{text}</div>
|
|
334
|
+
|
|
335
|
+
That alone lets the text **wrap** and keeps the button in place, which is the outcome you want:
|
|
336
|
+
everything is still readable. Do not reach for \`truncate\` as a reflex — that trades a button the
|
|
337
|
+
reader cannot see for content they cannot see, and it is only right when the row must stay exactly
|
|
338
|
+
one line tall (a table, a list of equal-height rows). If even wrapping is too cramped,
|
|
339
|
+
\`flex-wrap\` on the row with \`basis-48\` on the text drops the button to its own line instead.
|
|
340
|
+
|
|
341
|
+
**\`justify-between\` is the shape this fires on, and neither child needs \`flex-1\`.** Every flex
|
|
342
|
+
item defaults to \`min-width: auto\` — \`flex-1\` only makes it more obvious. A header row of
|
|
343
|
+
\`<div>title + subtitle</div>\` beside a \`<label>Meta <input/> kcal</label>\` overflowed its own
|
|
344
|
+
card by **316px at 320 and 196px at 440**, and the shot is clipped at the card width, so the
|
|
345
|
+
overflowing part is not cut off, it is *absent*. Measured on wave 2: 8 of 27 cards, every one of
|
|
346
|
+
them a \`justify-between\` row. On one calorie log it hid EVERY kcal figure at 320 — the card
|
|
347
|
+
read as a plain list of meal names and looked completely fine.
|
|
348
|
+
|
|
349
|
+
The fix is \`min-w-0\` on whichever child is allowed to shrink, usually the text one:
|
|
350
|
+
|
|
351
|
+
<div className="flex items-center justify-between gap-3">
|
|
352
|
+
<div className="min-w-0">{title}</div>
|
|
353
|
+
<div className="shrink-0">{value}</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
If the two halves genuinely cannot share one line at 320, \`flex-wrap\` on the row is the honest
|
|
357
|
+
answer — a second line beats a missing number.
|
|
358
|
+
|
|
359
|
+
**Aligning repeated rows by giving each label a width breaks on the longest label, not on the
|
|
360
|
+
average one.** Three rows whose labels are \`工作时长\` / \`休息时长\` / \`长休息时长\` under a
|
|
361
|
+
\`min-w-[60px]\` measure 60, 60 and **64.5** — so the third row's controls all shift right by 4.5px
|
|
362
|
+
and the columns stop lining up. It is invisible at a glance and obvious at 4× zoom, which is why
|
|
363
|
+
it survives review. A grid aligns every row against the same track by construction:
|
|
364
|
+
|
|
365
|
+
<div className="grid grid-cols-[auto_2rem_3rem_2rem_auto] gap-2 items-center">
|
|
366
|
+
|
|
367
|
+
One value that fits your longest label today is a value that stops fitting when a label changes.
|
|
368
|
+
|
|
369
|
+
**And a number column wants \`text-right\`, not \`text-center\`.** \`tabular-nums\` makes every digit
|
|
370
|
+
the same width so figures stack — and centring throws that away, because \`5\` and \`25\` then sit at
|
|
371
|
+
different right edges. The two belong together: \`text-right tabular-nums\`, in a fixed track.`;
|
|
372
|
+
|
|
373
|
+
/** Documented only where the route is registered — see `inlinePrompt`. */
|
|
374
|
+
const EXEC_BULLET = `- \`import { bash } from "$dsh/exec"\` runs one command in the workspace and resolves with \`{stdout, stderr, exitCode}\`, under the session's own sandbox mode. **A non-zero exit resolves — check \`exitCode\`, do not catch it.** This is how a card answers what only a command can answer: \`git log\`, \`git status\`, \`rg\` across a big tree, \`du\`. **Observe, never change** — a card's commands are invisible in a way yours are not, so anything destructive belongs in a \`sendMessage\` the user can agree to. Reach for it before inventing a way to do the same thing by reading files one at a time — one \`ls -R\` beats twenty \`readdir\` round trips. Commands are killed after 15 seconds, so nothing that watches or serves.`;
|
|
375
|
+
|
|
376
|
+
/** Mid-sentence inside the browse bullet; true only where a card can run `git log`. */
|
|
377
|
+
const EXEC_HISTORY = `**A history is a set too.** 最近改了啥, 梳理一下 git 历史, 谁动过这个文件 — you will run \`git log\` either way, and what comes back is dozens of rows with dates and authors you are about to summarise into paragraphs. Summarising throws away the rows. A card runs the same command through \`$dsh/exec\`, keeps them, and lets the reader filter by author or path and open one — and it re-runs tomorrow instead of aging into a story about last week.`;
|