hd-garden 0.3.8 → 0.3.10
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/.agents/skills/garden-post-review/SKILL.md +2 -0
- package/.agents/skills/garden-ui/SKILL.md +111 -0
- package/.env.example +8 -0
- package/app/admin/page.tsx +14 -3
- package/app/admin/preview/[...path]/page.tsx +88 -0
- package/app/admin/ui/blocks/page.tsx +16 -0
- package/app/admin/ui/catalog/page.tsx +16 -0
- package/app/admin/ui/evals/page.tsx +19 -0
- package/app/admin/ui/page.tsx +22 -0
- package/app/admin-ui.css +227 -0
- package/app/api/admin/distribution/route.ts +4 -2
- package/app/api/admin/posts/source/route.ts +21 -0
- package/app/api/admin/preview/asset/route.ts +32 -0
- package/app/api/admin/thoughts/route.ts +1 -1
- package/app/api/admin/ui/blocks/route.ts +19 -0
- package/app/api/admin/ui/draft/route.ts +30 -0
- package/app/api/auth/github/route.ts +40 -1
- package/app/blog/page.tsx +6 -5
- package/app/columns/page.tsx +1 -1
- package/app/components/AdminDashboard.tsx +53 -1
- package/app/components/AdminUiPanels.tsx +258 -0
- package/app/components/AdminUiShell.tsx +56 -0
- package/app/components/ContentEmptyState.tsx +6 -6
- package/app/components/MarkdownArticle.tsx +15 -5
- package/app/components/UiBlock.tsx +22 -0
- package/app/components/UiStudio.tsx +422 -0
- package/app/components/ui-studio-keys.ts +3 -0
- package/app/globals.css +2 -0
- package/app/lib/admin-auth.ts +13 -1
- package/app/lib/content.ts +2 -1
- package/app/lib/fallback-site-config.ts +18 -18
- package/app/lib/github-content.ts +47 -4
- package/app/lib/repository-path.mjs +55 -0
- package/app/lib/ui-content.ts +79 -0
- package/app/lib/ui-model.ts +145 -0
- package/app/raw.d.ts +4 -0
- package/app/rss.xml/route.ts +2 -1
- package/app/ui-parity/page.tsx +120 -0
- package/bin/garden.mjs +71 -0
- package/eslint.config.mjs +3 -0
- package/package.json +15 -1
- package/scripts/build-content.mjs +35 -4
- package/scripts/build-ui-preview.mjs +29 -0
- package/scripts/check-post.mjs +26 -4
- package/scripts/ui-cli.mjs +303 -0
- package/site.config.yaml +19 -19
- package/templates/site/site.config.yaml +19 -19
- package/tsconfig.json +1 -1
- package/ui/core/apply.mjs +81 -0
- package/ui/core/catalog.mjs +536 -0
- package/ui/core/compile.mjs +63 -0
- package/ui/core/demos.mjs +223 -0
- package/ui/core/expr.mjs +310 -0
- package/ui/core/fence.mjs +201 -0
- package/ui/core/fix-prompt.mjs +35 -0
- package/ui/core/format.mjs +96 -0
- package/ui/core/index.mjs +37 -0
- package/ui/core/parse.mjs +229 -0
- package/ui/core/scan.mjs +53 -0
- package/ui/core/sha256.mjs +66 -0
- package/ui/core/starters.mjs +18 -0
- package/ui/core/state.mjs +259 -0
- package/ui/core/static.mjs +524 -0
- package/ui/core/transform.mjs +146 -0
- package/ui/core/validate.mjs +665 -0
- package/ui/evals/cases.yaml +155 -0
- package/ui/preview/dist/garden-ui.css +1182 -0
- package/ui/preview/dist/garden-ui.esm.js +367 -0
- package/ui/preview/dist/garden-ui.iife.js +367 -0
- package/ui/preview/index.tsx +191 -0
- package/ui/preview/mermaid-shim.ts +19 -0
- package/ui/prompt/build.mjs +24 -0
- package/ui/prompt/recipes/code-walkthrough.md +68 -0
- package/ui/prompt/recipes/compare.md +48 -0
- package/ui/prompt/recipes/converter.md +55 -0
- package/ui/prompt/recipes/data-chart.md +57 -0
- package/ui/prompt/recipes/inspect-grid.md +45 -0
- package/ui/prompt/recipes/param-bench.md +55 -0
- package/ui/prompt/recipes/plan.md +45 -0
- package/ui/prompt/recipes/stepper.md +52 -0
- package/ui/prompt/recipes/trace.md +62 -0
- package/ui/prompt/rules.md +104 -0
- package/ui/prompt/studio.md +20 -0
- package/ui/react/CatalogPage.tsx +166 -0
- package/ui/react/components/chart.tsx +470 -0
- package/ui/react/components/display.tsx +296 -0
- package/ui/react/components/inputs.tsx +268 -0
- package/ui/react/components/layout.tsx +236 -0
- package/ui/react/components/media.tsx +184 -0
- package/ui/react/index.tsx +59 -0
- package/ui/react/recipes.ts +25 -0
- package/ui/react/runtime.tsx +239 -0
- package/ui/react/types.ts +29 -0
- package/ui/react/ui.css +1182 -0
- package/vite.config.ts +4 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { CATALOG_VERSION, COMPONENT_NAMES } from "../core/catalog.mjs";
|
|
4
|
+
import { compileUiBlock } from "../core/compile.mjs";
|
|
5
|
+
import { scanFences, splitUiBody } from "../core/fence.mjs";
|
|
6
|
+
import { parseUiBody } from "../core/parse.mjs";
|
|
7
|
+
import { renderStatic } from "../core/static.mjs";
|
|
8
|
+
import { collectDataSources } from "../core/transform.mjs";
|
|
9
|
+
import { suggestComponent } from "../core/validate.mjs";
|
|
10
|
+
import { CatalogPage, type RecipeSource } from "../react/CatalogPage";
|
|
11
|
+
import { IntentCard, UiErrorCard, UiFigure, type Diagnostic, type UiPayload } from "../react/index";
|
|
12
|
+
|
|
13
|
+
export type PreviewOptions = {
|
|
14
|
+
theme?: "light" | "dark";
|
|
15
|
+
mode?: "interactive" | "static";
|
|
16
|
+
/** Reads a path relative to the Markdown file, for Data files. */
|
|
17
|
+
readFile?: (relativePath: string) => Promise<string | undefined> | string | undefined;
|
|
18
|
+
/** Same-name-directory path as written, mapped to a URL the host can display. */
|
|
19
|
+
resolveAsset?: (relativePath: string) => string;
|
|
20
|
+
/** Keep clicks and keys on controls from reaching the editor, which would otherwise jump into the source. */
|
|
21
|
+
isolateEvents?: boolean;
|
|
22
|
+
/** File line of the opening fence, so errors cite the line numbers the editor shows. */
|
|
23
|
+
fenceLine?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ParsedNode = { t: string; n?: string; c?: ParsedNode[] };
|
|
27
|
+
|
|
28
|
+
/** A name with no close catalog match is likely newer than this plugin rather than a typo. */
|
|
29
|
+
function hasNewerComponent(body: string) {
|
|
30
|
+
const { bodyText, error } = splitUiBody(body);
|
|
31
|
+
if (error) return false;
|
|
32
|
+
const { root } = parseUiBody(bodyText) as { root: ParsedNode | null };
|
|
33
|
+
const known = new Set<string>(COMPONENT_NAMES);
|
|
34
|
+
const unknown: string[] = [];
|
|
35
|
+
const walk = (node: ParsedNode) => {
|
|
36
|
+
if (node.t === "el" && node.n && !known.has(node.n)) unknown.push(node.n);
|
|
37
|
+
node.c?.forEach(walk);
|
|
38
|
+
};
|
|
39
|
+
if (root) walk(root);
|
|
40
|
+
return unknown.some((name) => !suggestComponent(name));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONTROLS = "input, button, select, textarea, a, [role='button'], [role='slider'], [role='tab'], [role='checkbox'], [tabindex]";
|
|
44
|
+
const ISOLATED_EVENTS = ["pointerdown", "mousedown", "mouseup", "click", "keydown", "keyup", "keypress", "input", "beforeinput"];
|
|
45
|
+
|
|
46
|
+
function isolate(element: HTMLElement) {
|
|
47
|
+
const stop = (event: Event) => {
|
|
48
|
+
const target = event.target;
|
|
49
|
+
if (target instanceof Element && target.closest(CONTROLS)) event.stopPropagation();
|
|
50
|
+
};
|
|
51
|
+
for (const type of ISOLATED_EVENTS) element.addEventListener(type, stop);
|
|
52
|
+
return () => {
|
|
53
|
+
for (const type of ISOLATED_EVENTS) element.removeEventListener(type, stop);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Accepts either a whole fence or just its body, as editors hand over either. */
|
|
58
|
+
function bodyOf(source: string) {
|
|
59
|
+
const trimmed = source.replace(/^\s+/u, "");
|
|
60
|
+
if (/^(`{3,}|~{3,})/u.test(trimmed)) {
|
|
61
|
+
const fence = scanFences(trimmed)[0];
|
|
62
|
+
if (fence) return fence.body;
|
|
63
|
+
}
|
|
64
|
+
return source;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Preview({ source, options }: { source: string; options: PreviewOptions }) {
|
|
68
|
+
const body = bodyOf(source);
|
|
69
|
+
const sources = collectDataSources(`\`\`\`ui\n${body}\n\`\`\``);
|
|
70
|
+
const sourcesKey = sources.join("\n");
|
|
71
|
+
const [files, setFiles] = useState<{ key: string; data: Record<string, string> }>({ key: "", data: {} });
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!sourcesKey || !options.readFile) return;
|
|
75
|
+
let active = true;
|
|
76
|
+
const read = options.readFile;
|
|
77
|
+
Promise.all(sourcesKey.split("\n").map(async (src) => [src, await read(src)] as const)).then((entries) => {
|
|
78
|
+
if (!active) return;
|
|
79
|
+
const data: Record<string, string> = {};
|
|
80
|
+
for (const [src, text] of entries) if (typeof text === "string") data[src] = text;
|
|
81
|
+
setFiles({ key: sourcesKey, data });
|
|
82
|
+
});
|
|
83
|
+
return () => {
|
|
84
|
+
active = false;
|
|
85
|
+
};
|
|
86
|
+
}, [sourcesKey, options.readFile]);
|
|
87
|
+
|
|
88
|
+
const loading = Boolean(sourcesKey && options.readFile && files.key !== sourcesKey);
|
|
89
|
+
const { payload, check } = compileUiBlock(body, {
|
|
90
|
+
lineOffset: options.fenceLine ?? 1,
|
|
91
|
+
readData: (src: string) => files.data[src],
|
|
92
|
+
skipData: !options.readFile,
|
|
93
|
+
markdownPath: null,
|
|
94
|
+
resolveAsset: null,
|
|
95
|
+
});
|
|
96
|
+
const theme = options.theme ?? "light";
|
|
97
|
+
if (loading) return <div className="garden-ui-preview" data-theme={theme}><p className="garden-ui-preview-note">正在读取数据文件…</p></div>;
|
|
98
|
+
if (!payload) {
|
|
99
|
+
const diagnostics = (check.diagnostics as Diagnostic[]).filter((item) => item.severity === "error");
|
|
100
|
+
const newer = diagnostics.some((item) => item.code === "ui-component") && hasNewerComponent(body);
|
|
101
|
+
const header = (check as { header?: { id?: unknown; caption?: unknown } | null }).header;
|
|
102
|
+
return (
|
|
103
|
+
<div className="garden-ui-preview" data-theme={theme}>
|
|
104
|
+
{newer ? <p className="garden-ui-preview-note">块里有这个插件不认识的组件,预览需要更新 Garden 插件。</p> : null}
|
|
105
|
+
<UiErrorCard
|
|
106
|
+
caption={typeof header?.caption === "string" ? header.caption : ""}
|
|
107
|
+
diagnostics={diagnostics}
|
|
108
|
+
id={typeof header?.id === "string" ? header.id : null}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const typed = payload as UiPayload;
|
|
114
|
+
if (options.resolveAsset) {
|
|
115
|
+
const rewrite = (node: UiPayload["root"]): UiPayload["root"] => {
|
|
116
|
+
if (node.t === "el" && node.n === "Image" && node.p?.src?.k === "lit") {
|
|
117
|
+
return { ...node, p: { ...node.p, src: { k: "lit", v: options.resolveAsset?.(String(node.p.src.v)) } } };
|
|
118
|
+
}
|
|
119
|
+
return node.c ? { ...node, c: node.c.map(rewrite) } : node;
|
|
120
|
+
};
|
|
121
|
+
typed.root = rewrite(typed.root);
|
|
122
|
+
}
|
|
123
|
+
const stale = (check.diagnostics as Diagnostic[]).some((item) => item.code === "ui-stale");
|
|
124
|
+
return (
|
|
125
|
+
<div className="garden-ui-preview" data-theme={theme}>
|
|
126
|
+
{stale ? <p className="garden-ui-preview-note">原话改过了,还没重编译。</p> : null}
|
|
127
|
+
{options.mode === "static" ? (
|
|
128
|
+
<figure className="garden-ui" lang="en-US">
|
|
129
|
+
<div className="garden-ui-body garden-ui-static" dangerouslySetInnerHTML={{ __html: renderStatic(typed, "html") }} />
|
|
130
|
+
<figcaption>{typed.caption}</figcaption>
|
|
131
|
+
</figure>
|
|
132
|
+
) : (
|
|
133
|
+
<UiFigure payload={typed} />
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function mount(element: HTMLElement, draw: (root: ReturnType<typeof createRoot>) => void) {
|
|
140
|
+
const root = createRoot(element);
|
|
141
|
+
draw(root);
|
|
142
|
+
return root;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function render(element: HTMLElement, source: string, options: PreviewOptions = {}) {
|
|
146
|
+
let current = { source, options };
|
|
147
|
+
const release = options.isolateEvents ? isolate(element) : null;
|
|
148
|
+
const root = mount(element, (target) => target.render(<Preview source={current.source} options={current.options} />));
|
|
149
|
+
return {
|
|
150
|
+
update(nextSource: string, nextOptions: PreviewOptions = current.options) {
|
|
151
|
+
current = { source: nextSource, options: nextOptions };
|
|
152
|
+
root.render(<Preview source={current.source} options={current.options} />);
|
|
153
|
+
},
|
|
154
|
+
destroy() {
|
|
155
|
+
release?.();
|
|
156
|
+
root.unmount();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type IntentOptions = { id?: string | null; theme?: "light" | "dark"; onCopyPrompt?: () => void; isolateEvents?: boolean };
|
|
162
|
+
|
|
163
|
+
export function renderIntent(element: HTMLElement, text: string, options: IntentOptions = {}) {
|
|
164
|
+
const release = options.isolateEvents ? isolate(element) : null;
|
|
165
|
+
const draw = (nextText: string, next: IntentOptions) => (
|
|
166
|
+
<div className="garden-ui-preview" data-theme={next.theme ?? "light"}>
|
|
167
|
+
<IntentCard text={bodyOf(nextText).trim()} id={next.id} onCopyPrompt={next.onCopyPrompt} />
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
let current = options;
|
|
171
|
+
const root = mount(element, (target) => target.render(draw(text, current)));
|
|
172
|
+
return {
|
|
173
|
+
update(nextText: string, nextOptions: IntentOptions = current) {
|
|
174
|
+
current = nextOptions;
|
|
175
|
+
root.render(draw(nextText, current));
|
|
176
|
+
},
|
|
177
|
+
destroy() {
|
|
178
|
+
release?.();
|
|
179
|
+
root.unmount();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function renderCatalog(element: HTMLElement, options: { recipes?: RecipeSource[]; theme?: "light" | "dark"; onStart?: (fence: string) => void } = {}) {
|
|
185
|
+
if (options.theme) element.setAttribute("data-theme", options.theme);
|
|
186
|
+
const root = mount(element, (target) => target.render(<CatalogPage recipes={options.recipes ?? []} onStart={options.onStart} />));
|
|
187
|
+
return { destroy: () => root.unmount() };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const catalogVersion = CATALOG_VERSION;
|
|
191
|
+
export { intentFence, STARTERS as starters } from "../core/starters.mjs";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Editor hosts (Obsidian, Typora) ship their own Mermaid; the preview bundle uses it instead of bundling a copy.
|
|
2
|
+
type MermaidLike = {
|
|
3
|
+
initialize: (config: Record<string, unknown>) => void;
|
|
4
|
+
render: (id: string, source: string) => Promise<{ svg: string }>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const host = () => (globalThis as { mermaid?: MermaidLike }).mermaid;
|
|
8
|
+
|
|
9
|
+
const mermaid: MermaidLike = {
|
|
10
|
+
// The host owns its Mermaid configuration; re-initializing would restyle the host's own diagrams.
|
|
11
|
+
initialize() {},
|
|
12
|
+
render(id, source) {
|
|
13
|
+
const instance = host();
|
|
14
|
+
if (!instance) return Promise.reject(new Error("这个编辑器没有提供 Mermaid"));
|
|
15
|
+
return instance.render(id, source);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default mermaid;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { catalogReference } from "../core/catalog.mjs";
|
|
2
|
+
|
|
3
|
+
export const RECIPE_FILES = [
|
|
4
|
+
"param-bench.md",
|
|
5
|
+
"stepper.md",
|
|
6
|
+
"inspect-grid.md",
|
|
7
|
+
"compare.md",
|
|
8
|
+
"trace.md",
|
|
9
|
+
"converter.md",
|
|
10
|
+
"plan.md",
|
|
11
|
+
"data-chart.md",
|
|
12
|
+
"code-walkthrough.md",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `garden ui prompt`: rules + component reference generated from the catalog
|
|
17
|
+
* + recipes. The admin workbench appends `studio` for its output contract.
|
|
18
|
+
* @param {{ rules: string, recipes: string[], studio?: string | null }} parts
|
|
19
|
+
*/
|
|
20
|
+
export function buildPrompt({ rules, recipes, studio = null }) {
|
|
21
|
+
const parts = [rules.trim(), catalogReference().trim(), "## 配方", ...recipes.map((recipe) => recipe.trim())];
|
|
22
|
+
if (studio) parts.push(studio.trim());
|
|
23
|
+
return `${parts.join("\n\n---\n\n")}\n`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# 配方:代码走查
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 代码是一张照片。读者看不出"这一步执行到哪一行、变量此刻是多少"。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 一段不长的代码(不超过 60 行),值得一行行讲:二分查找、锁的实现、状态机的处理函数。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 只需要展示代码(写普通代码块);代码太长(只截关键的一段)。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "一行行讲这段代码""每一步执行到哪""标出当前行,旁边列出变量"。
|
|
10
|
+
|
|
11
|
+
**骨架(阶段 6 之前):** 正文里先放普通代码块;这一块用 `Steps bind="step"` 讲每一步,配一张 `Table` 列出变量此刻的值,这一步变了的标成 `new`。阶段 6 起,用 `Code` 把代码放进块里,`highlight` 随步骤变化。
|
|
12
|
+
|
|
13
|
+
阶段 6 之前:
|
|
14
|
+
|
|
15
|
+
````md
|
|
16
|
+
```ui
|
|
17
|
+
---
|
|
18
|
+
id: binary-search-vars
|
|
19
|
+
caption: 每比较一次,搜索区间就缩小一半;8 个元素最多比较 4 次。
|
|
20
|
+
intent: |
|
|
21
|
+
在 [1 3 5 7 9 11 13 15] 里找 9,一步步列出 lo、hi、mid 和比较结果。
|
|
22
|
+
---
|
|
23
|
+
<State step={0} />
|
|
24
|
+
<Let los={[0, 4, 4]} his={[7, 7, 4]} mids={[3, 5, 4]} prev={max(step - 1, 0)} />
|
|
25
|
+
|
|
26
|
+
<Steps bind="step">
|
|
27
|
+
<Step title="第 1 次">mid = 3,a[3] = 7 小于 9,目标在右半边。</Step>
|
|
28
|
+
<Step title="第 2 次">lo 移到 4;mid = 5,a[5] = 11 大于 9,目标在左半边。</Step>
|
|
29
|
+
<Step title="第 3 次">hi 移到 4;mid = 4,a[4] = 9,找到。</Step>
|
|
30
|
+
</Steps>
|
|
31
|
+
|
|
32
|
+
<Table columns={["lo", "hi", "mid"]}>
|
|
33
|
+
<Row cells={[los[step], his[step], mids[step]]}
|
|
34
|
+
tones={step == 0 ? "plain" : [los[step] != los[prev] ? "new" : "plain", his[step] != his[prev] ? "new" : "plain", "new"]} />
|
|
35
|
+
</Table>
|
|
36
|
+
```
|
|
37
|
+
````
|
|
38
|
+
|
|
39
|
+
阶段 6 起,在同一块里加上:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
<Code lang="go" highlight={["4-5", "6-7", "8-10"][step]} code={`func search(a []int, x int) int {
|
|
43
|
+
lo, hi := 0, len(a)-1
|
|
44
|
+
for lo <= hi {
|
|
45
|
+
mid := (lo + hi) / 2
|
|
46
|
+
if a[mid] < x {
|
|
47
|
+
lo = mid + 1
|
|
48
|
+
} else if a[mid] > x {
|
|
49
|
+
hi = mid - 1
|
|
50
|
+
} else {
|
|
51
|
+
return mid
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return -1
|
|
55
|
+
}`} />
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**要点:**
|
|
59
|
+
|
|
60
|
+
- 每一步的变量值写成数组,用 `step` 取下标;值必须和代码真实执行的结果一致,自己先在心里跑一遍。
|
|
61
|
+
- 每一步的话说清"为什么走这一行",不要只念代码。
|
|
62
|
+
- 代码超过 60 行时只截关键的函数。
|
|
63
|
+
|
|
64
|
+
**常见错误:**
|
|
65
|
+
|
|
66
|
+
- 变量值和代码执行结果对不上(`hi` 的更新写错一步)。
|
|
67
|
+
- 第 0 步就把变量标成 `new`。
|
|
68
|
+
- 阶段 6 之前写了 `Code`。
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 配方:对照
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 两种做法只能上下翻着看,工作记忆装不下"左边这样、右边那样"。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 两三种方案在同一个输入下的表现差别:Mutex 和 RwLock、MHA 和 GQA、朴素实现和带缓存的实现、ECB 和 CBC。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 只是罗列各自的特点,没有共同的输入(写一张 Markdown 表格就够)。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "并排对比""左右各放一个""同样的条件下谁更快"。
|
|
10
|
+
|
|
11
|
+
**骨架:** `Columns` 里放两个 `Card`,两边读同一个状态;共同的输入(`Slider`、`Toggle`)放在上面。想强调胜出的一方时,给它 `tone="accent"`。
|
|
12
|
+
|
|
13
|
+
````md
|
|
14
|
+
```ui
|
|
15
|
+
---
|
|
16
|
+
id: mutex-vs-rwlock
|
|
17
|
+
caption: 读者越多,Mutex 越慢;RwLock 让读者并行,总耗时不变。
|
|
18
|
+
intent: |
|
|
19
|
+
对比 Mutex 和 RwLock:读者数量 1 到 8 可调,一个写者,每次读写 10 ms,看总耗时。
|
|
20
|
+
expect:
|
|
21
|
+
- given: { readers: 3 }
|
|
22
|
+
mutexMs: 40
|
|
23
|
+
rwMs: 20
|
|
24
|
+
---
|
|
25
|
+
<State readers={3} />
|
|
26
|
+
<Let mutexMs={(readers + 1) * 10} rwMs={20} />
|
|
27
|
+
|
|
28
|
+
<Slider bind="readers" label="同时到达的读者" min={1} max={8} unit="个" />
|
|
29
|
+
|
|
30
|
+
<Columns>
|
|
31
|
+
<Card title="Mutex">读者和写者一个接一个,共 **{mutexMs} ms**。</Card>
|
|
32
|
+
<Card title="RwLock" tone="accent">{readers} 个读者一起读,写者再独占,共 **{rwMs} ms**。</Card>
|
|
33
|
+
</Columns>
|
|
34
|
+
```
|
|
35
|
+
````
|
|
36
|
+
|
|
37
|
+
**要点:**
|
|
38
|
+
|
|
39
|
+
- 两边的结构保持一致:同样的组件、同样的顺序,只有数字和措辞不同。
|
|
40
|
+
- 共同输入只放一份,不要每边各放一个滑杆。
|
|
41
|
+
- 窄屏上 `Columns` 会自动上下叠放,两边的文字要能各自读懂。
|
|
42
|
+
- 两边都要算的量写进 `Let`,并为它们写 `expect`。
|
|
43
|
+
|
|
44
|
+
**常见错误:**
|
|
45
|
+
|
|
46
|
+
- 两边读的不是同一个状态,读者拧一边,另一边不动。
|
|
47
|
+
- 放了三个以上的 `Card`,变成罗列。
|
|
48
|
+
- 把"哪边更好"的结论只写在颜色里;结论要写进 `caption`。
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# 配方:换算器
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 一组量都跟着同一个数变化,作者只能给一两个例子,读者的情况往往不在其中。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 一个输入驱动好几个输出:按人数换算用料、按并发数换算资源、按规模换算成本、按单位换算。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 只有一个输出(用参数台);输出之间要分别调(先拆块)。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "按人数算要买多少""换算表""不同规模下各要多少"。
|
|
10
|
+
|
|
11
|
+
**骨架:** 一个 `Slider` 或 `Choice` 作为输入;把每一项的单位用量写进 `Let` 数组;`Table` 里用 `Each` 逐项生成行。
|
|
12
|
+
|
|
13
|
+
````md
|
|
14
|
+
```ui
|
|
15
|
+
---
|
|
16
|
+
id: roast-shopping
|
|
17
|
+
caption: 用料按人数线性换算;带骨羊腿每人 0.4 kg。
|
|
18
|
+
intent: |
|
|
19
|
+
按人数(2 到 12 人,默认 6 人)算周日烤羊腿要买多少:
|
|
20
|
+
带骨羊腿每人 0.4 kg,土豆每人 250 g,胡萝卜每人 1.5 根,甜点苹果每人半个。
|
|
21
|
+
expect:
|
|
22
|
+
- given: { guests: 6 }
|
|
23
|
+
lamb: 2.4
|
|
24
|
+
---
|
|
25
|
+
<State guests={6} />
|
|
26
|
+
<Let lamb={0.4 * guests}
|
|
27
|
+
items={[
|
|
28
|
+
{ name: "土豆", per: 250, unit: "g" },
|
|
29
|
+
{ name: "胡萝卜", per: 1.5, unit: "根" },
|
|
30
|
+
{ name: "苹果(甜点)", per: 0.5, unit: "个" },
|
|
31
|
+
]} />
|
|
32
|
+
|
|
33
|
+
<Slider bind="guests" label="人数" min={2} max={12} unit="人" />
|
|
34
|
+
|
|
35
|
+
<Table columns={[{ label: "食材" }, { label: "数量", align: "right" }]}>
|
|
36
|
+
<Row cells={["带骨羊腿", fixed(lamb, 1) + " kg"]} tones="accent" />
|
|
37
|
+
<Each of={items} as="it">
|
|
38
|
+
<Row cells={[it.name, ceil(it.per * guests) + " " + it.unit]} />
|
|
39
|
+
</Each>
|
|
40
|
+
</Table>
|
|
41
|
+
```
|
|
42
|
+
````
|
|
43
|
+
|
|
44
|
+
**要点:**
|
|
45
|
+
|
|
46
|
+
- 每一项的单位用量都来自原话;原话没给的项不要加。
|
|
47
|
+
- 按个数计的东西用 `ceil` 向上取整,按重量计的用 `fixed` 保留一位小数。
|
|
48
|
+
- 最关键的那一项可以单独写一行并标 `accent`,同时为它写 `expect`。
|
|
49
|
+
- 输入是几个离散档位(比如"小 / 中 / 大")时,用 `Choice` 代替 `Slider`。
|
|
50
|
+
|
|
51
|
+
**常见错误:**
|
|
52
|
+
|
|
53
|
+
- 用 `.map()` 生成行,而不是 `Each`。
|
|
54
|
+
- 按个数计的东西算出 7.5 个。
|
|
55
|
+
- 表格里堆了原话没提到的项。
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 配方:数据图
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 测出来的数字被贴成一张截图,读者看不清具体值,也没法对照。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 作者手里有一组实测数据:压测结果、基准测试、实验记录。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 数据可以由公式算出(用参数台);只有两三个数(写进句子)。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "把这组压测数据画出来""柱状图对比""数据在 xxx.csv 里"。
|
|
10
|
+
|
|
11
|
+
**骨架:** 原话里直接给了数字时,用 `Let` 数组照抄;原话提到同名目录里的文件时,用 `Data`。`Chart data` 画图,`Table data` 列出精确值。
|
|
12
|
+
|
|
13
|
+
下面的数字只是示例;真实的块里,每个数字都必须来自原话或数据文件。
|
|
14
|
+
|
|
15
|
+
````md
|
|
16
|
+
```ui
|
|
17
|
+
---
|
|
18
|
+
id: redis-io-threads
|
|
19
|
+
caption: I/O 线程从 1 加到 4,QPS 涨了两倍多;再加到 8,只多了一成多。
|
|
20
|
+
intent: |
|
|
21
|
+
把 Redis I/O 线程数的压测结果画成柱状图,再列一张表:
|
|
22
|
+
1 线程 98000,2 线程 181000,4 线程 312000,8 线程 356000。
|
|
23
|
+
---
|
|
24
|
+
<Let bench={[
|
|
25
|
+
{ threads: 1, qps: 98000 },
|
|
26
|
+
{ threads: 2, qps: 181000 },
|
|
27
|
+
{ threads: 4, qps: 312000 },
|
|
28
|
+
{ threads: 8, qps: 356000 },
|
|
29
|
+
]} />
|
|
30
|
+
|
|
31
|
+
<Chart data={bench} x={threads} xLabel="I/O 线程" yLabel="QPS" yFormat="si">
|
|
32
|
+
<Bar y={qps} label="QPS" />
|
|
33
|
+
</Chart>
|
|
34
|
+
|
|
35
|
+
<Table data={bench} rowTone={threads == 4 ? "accent" : null}
|
|
36
|
+
columns={[{ label: "I/O 线程", key: "threads" }, { label: "QPS", key: "qps", format: "thousands" }]} />
|
|
37
|
+
```
|
|
38
|
+
````
|
|
39
|
+
|
|
40
|
+
数据在文件里时,把 `Let` 换成:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
<Data name="bench" src="./redis-bench/bench.csv" />
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**要点:**
|
|
47
|
+
|
|
48
|
+
- 数字一个都不能改,也不能"顺手"补全缺的点。
|
|
49
|
+
- 离散的类别(线程数、版本号)用 `Bar`;连续变量的趋势用 `Line`。
|
|
50
|
+
- 想说明的拐点用 `rowTone` 在表里标出来,结论写进 `caption`。
|
|
51
|
+
- 要让读者在两组数据间切换时,加一个 `Choice`,`data` 用三元选择。
|
|
52
|
+
|
|
53
|
+
**常见错误:**
|
|
54
|
+
|
|
55
|
+
- 编造原话里没有的数据点。
|
|
56
|
+
- 原话已经给了完整数字,却还去引用一个并不存在的文件。
|
|
57
|
+
- 横轴是类别却用了 `Line`,暗示中间存在连续值。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 配方:检视格
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 二维结构被压成了表格或图片。Attention 分数、页锁 bitmap、加密块,本质上是格子,可格子不会说话。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 读者需要盯着某几格看,并且知道"这一格为什么特别"。ECB 的重复块、Attention 矩阵、缓存命中情况、二分查找里 lo / mid / hi 的位置。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 只是一份普通的数据表,没有哪一格需要特别解释(用 `Table`,或者干脆写 Markdown 表格)。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "让读者点格子看解释""标出哪些块是重复的""矩阵里高亮这几格"。
|
|
10
|
+
|
|
11
|
+
**骨架:** `Grid` + 每行一个 `Row`;用 `tones` 标出特别的格子,用 `notes` 写每格的话,用 `legend` 解释颜色。需要切换场景时,加一个 `Choice` 或 `Toggle`。
|
|
12
|
+
|
|
13
|
+
````md
|
|
14
|
+
```ui
|
|
15
|
+
---
|
|
16
|
+
id: ecb-vs-cbc
|
|
17
|
+
caption: ECB 下相同的明文块得到相同的密文块;CBC 把上一块的密文混进来,重复就消失了。
|
|
18
|
+
intent: |
|
|
19
|
+
明文四块 A B A A,让读者在 ECB 和 CBC 之间切换,看密文里重复的块。
|
|
20
|
+
---
|
|
21
|
+
<State mode="ecb" />
|
|
22
|
+
|
|
23
|
+
<Choice bind="mode" label="模式" options={[{ value: "ecb", label: "ECB" }, { value: "cbc", label: "CBC" }]} />
|
|
24
|
+
|
|
25
|
+
<Grid cols={["块1", "块2", "块3", "块4"]} legend={{ same: "密文与别的块相同" }}>
|
|
26
|
+
<Row label="明文" cells={["A", "B", "A", "A"]} />
|
|
27
|
+
<Row label="密文" cells={mode == "ecb" ? ["X", "Y", "X", "X"] : ["P", "Q", "R", "S"]}
|
|
28
|
+
tones={mode == "ecb" ? ["same", "plain", "same", "same"] : "plain"}
|
|
29
|
+
notes={mode == "ecb" ? ["和块3、块4相同", "", "和块1相同", "和块1相同"] : ["", "", "", ""]} />
|
|
30
|
+
</Grid>
|
|
31
|
+
```
|
|
32
|
+
````
|
|
33
|
+
|
|
34
|
+
**要点:**
|
|
35
|
+
|
|
36
|
+
- 颜色只用来标"特别"的格子,其余保持 `plain`。
|
|
37
|
+
- `notes` 写"这格为什么这样",不要重复格子里的值。
|
|
38
|
+
- 格子里放短内容(1–4 个字符);长内容放进 `notes`。
|
|
39
|
+
- 超过 16 × 16 时,只截出能说明问题的那一角。
|
|
40
|
+
|
|
41
|
+
**常见错误:**
|
|
42
|
+
|
|
43
|
+
- `cells`、`tones`、`notes` 长度和 `cols` 对不上。
|
|
44
|
+
- 整张格子都上色,读者不知道该看哪里。
|
|
45
|
+
- 用 `Grid` 摆一份普通数据表。
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# 配方:参数台
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 公式是活的,页面上的数字是死的。作者算好了一组结果,读者想换个参数只能自己掏计算器。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 结果由一两个输入决定,而且读者会好奇"换成别的值会怎样"。KV cache 显存、浮点精度、学习率曲线、复利、哈希表装载因子。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 只有一个固定的例子要讲;或者输入超过 3 个,读者拧不过来(先拆块)。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "让读者拧 batch 和序列长度""拖一下看看""从 1 到 32 可调""看曲线怎么变"。
|
|
10
|
+
|
|
11
|
+
**骨架:** `State` 放输入,`Let` 写公式,`Slider` 绑输入,一句带插值的话说出当前结果,需要看趋势时加 `Chart sweep`。
|
|
12
|
+
|
|
13
|
+
````md
|
|
14
|
+
```ui
|
|
15
|
+
---
|
|
16
|
+
id: kv-memory
|
|
17
|
+
caption: 显存与 batch、序列长度都成正比;batch 8、序列 32K 就要 32 GiB。
|
|
18
|
+
intent: |
|
|
19
|
+
让读者拧 batch 和序列长度,看 KV cache 占多少显存。
|
|
20
|
+
batch 1 到 32,默认 1;序列 1K 到 32K,默认 4K,用对数轴。
|
|
21
|
+
显存 = 2 × 32 层 × 8 个 KV 头 × 128 维 × 序列长度 × batch × 2 字节。
|
|
22
|
+
batch 1、序列 4096 时是 512 MiB。
|
|
23
|
+
expect:
|
|
24
|
+
- given: { batch: 1, seq: 4096 }
|
|
25
|
+
mem: 536870912
|
|
26
|
+
---
|
|
27
|
+
<State batch={1} seq={4096} />
|
|
28
|
+
<Let mem={2 * 32 * 8 * 128 * seq * batch * 2} />
|
|
29
|
+
|
|
30
|
+
<Columns>
|
|
31
|
+
<Slider bind="batch" label="batch" min={1} max={32} />
|
|
32
|
+
<Slider bind="seq" label="序列长度" min={1024} max={32768} scale="log" format="thousands" />
|
|
33
|
+
</Columns>
|
|
34
|
+
|
|
35
|
+
batch 为 {batch}、序列 {thousands(seq)} 时,KV cache 要 **{bytes(mem)}**。
|
|
36
|
+
|
|
37
|
+
<Chart sweep="seq" yLabel="KV 显存">
|
|
38
|
+
<Line y={mem} label="KV 显存" format="bytes" />
|
|
39
|
+
</Chart>
|
|
40
|
+
```
|
|
41
|
+
````
|
|
42
|
+
|
|
43
|
+
**要点:**
|
|
44
|
+
|
|
45
|
+
- 一句会变的话通常比一个孤零零的 `Stat` 更好读;要强调单个数字时才用 `Stat`。
|
|
46
|
+
- 横轴跨好几个数量级时用 `scale="log"`;端点都是 2 的幂时,滑杆自动只停在 2 的幂上。
|
|
47
|
+
- 横轴不是任何滑杆时(比如训练步数),先 `<State t={0} />`,再写 `<Chart sweep="t" from={0} to={10000}>`。
|
|
48
|
+
- 想和容量上限比,加一个 `Meter`。
|
|
49
|
+
- 原话里有已知点,一定写 `expect`。
|
|
50
|
+
|
|
51
|
+
**常见错误:**
|
|
52
|
+
|
|
53
|
+
- 公式里的常数和原话对不上(层数、头数抄错)。
|
|
54
|
+
- 对数滑杆的 `min` 写成 0。
|
|
55
|
+
- 两根滑杆都画在 `Chart` 的横轴上:`sweep` 只能是一个状态,另一个保持当前值。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 配方:计划与清单
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 步骤多、要跟着做,读者一边操作一边回来找"做到哪了"。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 部署步骤、排查清单、做菜时间线、迁移步骤。读者真的会照着一项项做。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 只是概念上的几个阶段(写列表);步骤之间有状态变化要观察(用步进器)。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "给一张可勾选的清单""照着一步步做""时间线"。
|
|
10
|
+
|
|
11
|
+
**骨架:** 一个 `Checklist`,每项写清时间或顺序、要做什么、注意什么。前面可以放一个决定清单内容的输入。
|
|
12
|
+
|
|
13
|
+
````md
|
|
14
|
+
```ui
|
|
15
|
+
---
|
|
16
|
+
id: roast-timeline
|
|
17
|
+
caption: 大部分活都在客人到之前做完;开饭前一小时只剩烤土豆和做肉汁。
|
|
18
|
+
intent: |
|
|
19
|
+
给一张周日烤羊腿的时间线清单,假设 5 点开饭,让读者一项项勾掉。
|
|
20
|
+
---
|
|
21
|
+
<Checklist title="周日时间线" items={[
|
|
22
|
+
{ time: "前一天", title: "做好苹果酥,腌羊腿", note: "盐、蒜、迷迭香、橄榄油,冷藏过夜" },
|
|
23
|
+
{ time: "12:30", title: "羊腿出冰箱回温" },
|
|
24
|
+
{ time: "13:30", title: "羊腿进烤箱", note: "用温度计判断熟度,不要只看时间" },
|
|
25
|
+
{ time: "14:30", title: "土豆焯水、沥干、摇出毛边" },
|
|
26
|
+
{ time: "15:30", title: "羊腿出炉静置,烤土豆和蔬菜" },
|
|
27
|
+
{ time: "16:15", title: "做肉汁,烫青菜" },
|
|
28
|
+
{ time: "16:45", title: "切肉装盘" },
|
|
29
|
+
{ time: "17:00", title: "开饭,苹果酥回炉加热" },
|
|
30
|
+
]} />
|
|
31
|
+
```
|
|
32
|
+
````
|
|
33
|
+
|
|
34
|
+
**要点:**
|
|
35
|
+
|
|
36
|
+
- 每一项以动作开头("腌""切""检查"),不要写成名词。
|
|
37
|
+
- 注意事项放进 `note`,不要塞进 `title`。
|
|
38
|
+
- 清单里的时间和数量来自原话;原话只给了开饭时间时,可以按常识倒推,但要在回复里说明哪些是推出来的。
|
|
39
|
+
- 读者需要先选一个参数(人数、环境)时,前面加一个 `Choice`,`items` 用三元在两套清单间切换。
|
|
40
|
+
|
|
41
|
+
**常见错误:**
|
|
42
|
+
|
|
43
|
+
- 超过 30 项(拆成准备、当天两块)。
|
|
44
|
+
- 把要观察的过程写成清单。
|
|
45
|
+
- 想让勾选状态影响别的组件:`Checklist` 不能绑定。
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# 配方:步进器
|
|
2
|
+
|
|
3
|
+
**读者卡在哪:** 过程被拍成了一张图。读者没法停在第 3 步,也没法问"再走一步,哪里变了"。
|
|
4
|
+
|
|
5
|
+
**什么时候用:** 状态随时间变化的机制:Prefill / Decode、反向传播、Raft 选举、GC 三色标记、锁的获取与释放、算法的每一轮。
|
|
6
|
+
|
|
7
|
+
**什么时候不用:** 只有两三个静态阶段,一张 Mermaid 或一个列表就能说清;步骤之间没有"状态在变"这件事。
|
|
8
|
+
|
|
9
|
+
**作者可能这样说:** "让读者一步步看""停在某一拍""每步标出新算的是哪一行""第几轮发生了什么"。
|
|
10
|
+
|
|
11
|
+
**骨架:** `State step={0}`,`Steps bind="step"` 里每步一句话;旁边放一个随 `step` 变化的 `Grid` 或 `Table`,把这一步变了的地方标成 `new`。
|
|
12
|
+
|
|
13
|
+
````md
|
|
14
|
+
```ui
|
|
15
|
+
---
|
|
16
|
+
id: prefill-decode
|
|
17
|
+
caption: Prefill 一次写满 3 行 K/V;之后每步只新算 1 行,其余读缓存。
|
|
18
|
+
intent: |
|
|
19
|
+
让读者一步步看 KV cache 怎么增长:prompt 3 个 token,再 decode 两步。
|
|
20
|
+
每步标出哪一行是新算的,哪些是读缓存。
|
|
21
|
+
---
|
|
22
|
+
<State step={0} />
|
|
23
|
+
<Let rows={step + 3} />
|
|
24
|
+
|
|
25
|
+
<Steps bind="step">
|
|
26
|
+
<Step title="Prefill">一次算完 3 个 prompt token 的 K/V,全部写进缓存。</Step>
|
|
27
|
+
<Step title="Decode 1">新 token 只算自己的 K/V,追加第 4 行;前 3 行直接读缓存。</Step>
|
|
28
|
+
<Step title="Decode 2">再追加一行。缓存里已有 {rows} 行,本步新算 1 行。</Step>
|
|
29
|
+
</Steps>
|
|
30
|
+
|
|
31
|
+
<Grid cols={["K", "V"]} legend={{ new: "本步新算", cached: "读缓存" }}>
|
|
32
|
+
<Each of={range(0, rows)} as="i">
|
|
33
|
+
<Row label={"t" + (i + 1)} cells={["k" + (i + 1), "v" + (i + 1)]}
|
|
34
|
+
tones={step == 0 || i == rows - 1 ? "new" : "cached"} />
|
|
35
|
+
</Each>
|
|
36
|
+
</Grid>
|
|
37
|
+
```
|
|
38
|
+
````
|
|
39
|
+
|
|
40
|
+
**要点:**
|
|
41
|
+
|
|
42
|
+
- 每步的话只说这一步变了什么,一两句。
|
|
43
|
+
- 想让别的组件跟着步骤变,`Steps` 必须写 `bind`;只是翻页看文字时可以不写。
|
|
44
|
+
- 每一步的数据用 `step` 推出来(`Let`、三元、数组下标),不要为每一步复制一份 `Grid`。
|
|
45
|
+
- 末步做"前后对比"时,可以在最后一个 `Step` 里放 `Columns`。
|
|
46
|
+
- 一般不开 `autoplay`;读者自己翻更容易停下来想。
|
|
47
|
+
|
|
48
|
+
**常见错误:**
|
|
49
|
+
|
|
50
|
+
- 超过 12 步还硬塞在一块里(先拆成两块)。
|
|
51
|
+
- `Step` 里的话和旁边格子的状态对不上(第 2 步说"追加第 4 行",格子却已有 5 行)。
|
|
52
|
+
- 用 `Button set` 自己做上一步、下一步:`Steps` 已经带了。
|