cardbuddy 0.14.2 → 0.15.0

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.
Files changed (68) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
  3. package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/mcp/client.mjs +8 -1
  7. package/dist/vendored/server/core/ai-chat.js +1521 -124
  8. package/dist/vendored/server/core/browser.js +12 -3
  9. package/dist/vendored/server/core/cardFiles.js +3 -0
  10. package/dist/vendored/server/core/cardHistory.js +1 -0
  11. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  12. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  13. package/dist/vendored/server/core/cardSave.js +1 -0
  14. package/dist/vendored/server/core/cardSync.js +3 -0
  15. package/dist/vendored/server/core/codeBridge.js +5 -1
  16. package/dist/vendored/server/core/logStream.js +1 -0
  17. package/dist/vendored/server/core/sliceStatic.js +24 -4
  18. package/dist/vendored/server/core/sliceUpload.js +1 -0
  19. package/dist/vendored/server/core/turnJournal.js +6 -0
  20. package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
  21. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  22. package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
  23. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
  24. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
  25. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
  26. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  27. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
  28. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  29. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
  30. package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
  31. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  33. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
  34. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  36. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
  37. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
  38. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
  41. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
  42. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  43. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  44. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  45. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
  46. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
  54. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
  55. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  56. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
  57. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
  58. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  59. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
  60. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
  61. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
  62. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
  63. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
  64. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
  65. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
  66. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
  67. package/package.json +1 -1
  68. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * v3_regression.mjs — v3 改动回归门禁(2026-09-21 优化方案 Phase 3:Pass@1 纳入回归指标)。
4
+ *
5
+ * 冻结纪律(SKILL.md §11 / patterns §20)配套:改 v3_extract / v3_assemble 后必须跑本命令,
6
+ * 证明金标卡「首轮生成(无 decisions 干预)过全部门禁 + 与基线零漂移」不降。
7
+ *
8
+ * Pass@1 判据(每张金标卡):
9
+ * 1. generate 首轮成功(spec → schema/data,无 --decisions)
10
+ * 2. gate 全过(autofix → validate → derive --check → lint)
11
+ * 3. schema 与基线快照(training/pass1/baseline/<regId>.schema.json)语义等价
12
+ * (JSON 深比较,忽略格式差异;页 id/名称里的 regId 已归一)
13
+ *
14
+ * 金标注册表(CASES):v3 直组装覆盖的画板 × cardIndex。新增金标:跑
15
+ * `--update-baseline` 前把 case 加进 CASES 并先人工核对一轮产物质量。
16
+ *
17
+ * 用法:
18
+ * node scripts/v3_regression.mjs [--update-baseline] [--only <regId>] [--json]
19
+ *
20
+ * 退出码:0 = 全部通过;1 = gate 失败或基线漂移;2 = 用法/环境错误。
21
+ * --update-baseline:用当前产物刷新基线(仅在人工确认漂移是「预期改进」时使用,
22
+ * 基线文件随本次 v3 改动一起提交,reviewer 据此审漂移)。
23
+ */
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import { spawnSync } from 'node:child_process'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
30
+ const SCRIPTS = HERE
31
+ const SKILL = path.resolve(HERE, '..')
32
+ const REPO = path.resolve(SKILL, '..', '..')
33
+
34
+ // ---------- CLI ----------
35
+ const argv = process.argv.slice(2)
36
+ const argOf = (n) => (argv.includes(n) ? argv[argv.indexOf(n) + 1] : undefined)
37
+ const hasFlag = (n) => argv.includes(n)
38
+ const asJson = hasFlag('--json')
39
+ const updateBaseline = hasFlag('--update-baseline')
40
+ const only = argOf('--only')
41
+
42
+ // ---------- 金标注册表 ----------
43
+ // **用例数据不在本脚本内**(skills 内禁止硬编码业务 UI 产物路径):
44
+ // 注册表从 training/pass1/cases.json 读取(与 baseline 同属仓库训练数据区),
45
+ // 条目形状:{ regId, spec(相对仓库根), card(spec.cards 下标,0 起) }。
46
+ // 可用 --cases <path> 指向其他注册表(如专项回归集)。
47
+ const CASES_PATH_DEFAULT = 'training/pass1/cases.json'
48
+ function loadCases() {
49
+ const p = path.resolve(REPO, argOf('--cases') || CASES_PATH_DEFAULT)
50
+ if (!fs.existsSync(p)) {
51
+ console.error(`[error] 金标注册表不存在:${p}\n 请在仓库 training/pass1/cases.json 登记金标(形状:[{"regId","spec","card"}]),skill 脚本不持有业务路径。`)
52
+ process.exit(2)
53
+ }
54
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
55
+ }
56
+ const CASES = loadCases()
57
+
58
+ if (argv.includes('--help') || argv.includes('-h')) {
59
+ console.error('Usage: node scripts/v3_regression.mjs [--update-baseline] [--only <regId>] [--json] [--cases <path>]')
60
+ process.exit(2)
61
+ }
62
+
63
+ const workRoot = path.join(REPO, '.cache', 'v3-regression')
64
+ const workCards = path.join(workRoot, 'src', 'cards')
65
+ const baselineDir = path.join(REPO, 'training', 'pass1', 'baseline')
66
+ const node = process.execPath
67
+ const py = process.env.PYTHON || 'python3'
68
+
69
+ fs.rmSync(workRoot, { recursive: true, force: true })
70
+ fs.mkdirSync(workCards, { recursive: true })
71
+ fs.mkdirSync(baselineDir, { recursive: true })
72
+
73
+ /** 跑一条命令,返回 {ok, code, output}。 */
74
+ function run(cmd, args, env = {}) {
75
+ const res = spawnSync(cmd, args, {
76
+ cwd: REPO,
77
+ encoding: 'utf8',
78
+ timeout: 180_000,
79
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
80
+ })
81
+ const out = `${res.stdout || ''}${res.stderr || ''}`.trim()
82
+ return { ok: res.status === 0, code: res.status, output: out }
83
+ }
84
+
85
+ /** JSON 深比较(语义等价,忽略键序/格式)。 */
86
+ function jsonEqual(a, b) {
87
+ return JSON.stringify(a) === JSON.stringify(b)
88
+ }
89
+
90
+ const results = []
91
+ let cases = only ? CASES.filter((c) => c.regId === only) : CASES
92
+ if (!cases.length) {
93
+ console.error(`[error] --only 未命中任何金标:${CASES.map((c) => c.regId).join(', ')}`)
94
+ process.exit(2)
95
+ }
96
+
97
+ for (const c of cases) {
98
+ const specPath = path.join(REPO, c.spec)
99
+ if (!fs.existsSync(specPath)) {
100
+ results.push({ regId: c.regId, pass: false, reason: `spec 不存在:${c.spec}` })
101
+ continue
102
+ }
103
+ // 1) 首轮生成(无 decisions)
104
+ const gen = run(node, [
105
+ path.join(SCRIPTS, 'spec_to_schema_v3.mjs'),
106
+ '--spec', specPath,
107
+ '--id', c.regId,
108
+ '--cardIndex', String(c.card),
109
+ '--out', workCards,
110
+ '--force',
111
+ ])
112
+ if (!gen.ok) {
113
+ results.push({ regId: c.regId, pass: false, reason: 'generate 失败', detail: gen.output.slice(-600) })
114
+ continue
115
+ }
116
+ // 2) 门禁(临时工程根,避免触碰 src/cards 真卡)
117
+ const gate = run(node, [path.join(SCRIPTS, 'gate.mjs'), '--id', c.regId], {
118
+ DSL_CARD_PROJECT_ROOT: workRoot,
119
+ })
120
+ // 3) 基线漂移
121
+ const schemaPath = path.join(workCards, c.regId, 'schema.json')
122
+ const baselinePath = path.join(baselineDir, `${c.regId}.schema.json`)
123
+ let drift = 'none'
124
+ let baselineOk = true
125
+ if (fs.existsSync(baselinePath)) {
126
+ const cur = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
127
+ const base = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
128
+ // 归一:页 id/名称里含 regId(基线与当前一致),直接深比较即可
129
+ baselineOk = jsonEqual(cur, base)
130
+ if (!baselineOk) drift = 'schema-drift'
131
+ } else {
132
+ drift = 'no-baseline'
133
+ }
134
+ if (updateBaseline && fs.existsSync(schemaPath)) {
135
+ fs.copyFileSync(schemaPath, baselinePath)
136
+ drift = 'baseline-updated'
137
+ }
138
+ results.push({
139
+ regId: c.regId,
140
+ pass: gate.ok && baselineOk,
141
+ gateOk: gate.ok,
142
+ gateFailed: gate.ok ? [] : parseFailedSteps(gate.output),
143
+ drift,
144
+ baselinePath: path.relative(REPO, baselinePath),
145
+ detail: gate.ok ? undefined : gate.output.slice(-800),
146
+ })
147
+ }
148
+
149
+ function parseFailedSteps(gateOut) {
150
+ const m = gateOut.match(/\[FAIL\] (\d+)\/\d+ 项未过:(.+)/)
151
+ return m ? m[2].split(', ').map((s) => s.trim()) : ['gate']
152
+ }
153
+
154
+ const passed = results.filter((r) => r.pass).length
155
+ const passRate = results.length ? passed / results.length : 0
156
+ const allOk = passed === results.length
157
+
158
+ const report = {
159
+ generatedAt: new Date().toISOString(),
160
+ cases: results.length,
161
+ passed,
162
+ passAt1: Number(passRate.toFixed(4)),
163
+ ok: allOk,
164
+ results,
165
+ }
166
+ fs.writeFileSync(path.join(REPO, 'training', 'pass1', 'last-report.json'), JSON.stringify(report, null, 2) + '\n')
167
+
168
+ if (asJson) {
169
+ console.log(JSON.stringify(report, null, 2))
170
+ } else {
171
+ console.log('\n=== v3 Pass@1 回归 ===')
172
+ for (const r of results) {
173
+ const mark = r.pass ? '✓' : '✗'
174
+ const extra = r.drift && r.drift !== 'none' ? ` [${r.drift}]` : ''
175
+ console.log(`${mark} ${r.regId}${extra}${r.pass ? '' : ' → ' + (r.reason || 'gate: ' + (r.gateFailed || []).join(','))}`)
176
+ }
177
+ console.log(`\nPass@1 = ${passed}/${results.length} = ${(passRate * 100).toFixed(1)}%`)
178
+ if (!allOk) {
179
+ console.log('处置:漂移 = v3 改动改变了金标卡产物 → 逐卡核对;确属预期改进才 --update-baseline 并随改动提交基线。gate 失败 → 按 lint/validate 规则号修复。')
180
+ }
181
+ if (updateBaseline) console.log('[baseline] 已用当前产物刷新基线快照(记得随 v3 改动一起提交)。')
182
+ }
183
+
184
+ // 清理工作目录(保留基线)
185
+ fs.rmSync(workRoot, { recursive: true, force: true })
186
+ process.exit(allOk ? 0 : 1)
@@ -14,11 +14,11 @@ description: 读取 Sketch MeaXure 标注导出(index.html + preview/ + assets
14
14
  输入是**多画板**设计稿且用户**没有在同一条消息里指定画板**时,**第一件事是选画板,不是跑管线**:
15
15
 
16
16
  1. `python3 scripts/parse_meaxure.py --src <导出目录> --list` 机械取全量画板;
17
- 2. 按 **`1..n` 编号列出(序号 1 起,每行 `序号. 画板名 — 类型 / 状态`)+ 末尾一项 `全部`**;**每个画板行下面紧跟它自己那张预览图的绝对路径**(`图: <绝对路径>`;`parse_meaxure.py --list` 与门禁 exit-4 输出已机械逐行配图,取值优先 `UI/<稿>/<画板>/artboard.png` → 导出 `preview/<slug>.png`),**缺图该行显式写 `图: (预览图缺失)`**——**禁止把所有预览图统一堆在清单末尾**(部分缺图时序号与图会错位);**直接输出清单**——编辑器助手没有弹选项工具(不存在 `request_user_input`,不要去调用或寻找),一律「列出清单 + 一句提问后停下等用户回序号」;
17
+ 2. 按 **`1..n` 编号列出(序号 1 起,每行 `序号. 画板名 — 类型 / 状态`)+ 末尾一项 `全部`**;**每个画板行下面紧跟它自己那张预览图的绝对路径**(`图: <绝对路径>`;`parse_meaxure.py --list` 与门禁 exit-4 输出已机械逐行配图,取值优先 `UI/<稿>/<画板>/artboard.png` → 导出 `preview/<slug>.png`),**缺图该行显式写 `图: (预览图缺失)`**——**禁止把所有预览图统一堆在清单末尾**(部分缺图时序号与图会错位);脚本给的路径**一字不改照抄完整绝对路径**(**禁止用 `…` / `...` 缩写前缀、禁止包进反引号或代码块**——缩写的路径渲染端取不到图,用户那几行只剩一串文字);`text_only` 纯文本板**不进可建卡池**,脚本把它们列在清单末尾的「已过滤」段(只给画板名,不给图,序号仍按全量编);**直接输出清单**——编辑器助手没有弹选项工具(不存在 `request_user_input`,不要去调用或寻找),一律「列出清单 + 一句提问后停下等用户回序号」;
18
18
  3. 一句纯文本提问(「请回复要处理的画板序号,或回复 `全部`」)后**停止输出、结束本轮等回复**;
19
- 4. 拿到回复才继续:序号 k → `--indices <k-1>`;`全部` → `--scope-all`(UI 产物全量跑;产卡时视为显式批量授权)。
19
+ 4. 拿到回复才继续:**照抄清单行尾的 `--pick`**(板号 1 起、卡号 1 起:用户说「8.1」就 `--pick 8.1`;多张逗号分隔 `--pick 8.1,9.2`;同板连卡 `--pick 8.1~8.2`;连板 `--pick 7~9`)。等价的 0 起老写法是 `--indices <k-1>`,**不要去换算它**——实测换算错一位,建了错的画板后重跑一整次(34s + 一步 LLM)。`全部` → `--scope-all`(UI 产物全量跑;产卡时视为显式批量授权)。
20
20
 
21
- **「生成产物 / 前处理 / 跑管线」这类请求同样适用本门禁**——「UI 产物允许全量生成」的含义是**用户可以回答 `全部`**,**不是** Agent 可以默认全量。脚本层有机械卡点:`parse_meaxure.py` 不带 `--only/--indices/--sample/--scope-all` 的生成运行会打印 1..n+全部 清单并 **exit 4**(无人值守训练 `card-skill-trainer` 属显式批量例外,直接带 `--scope-all`)。详见 `dsl-card-generator/references/rules.md §十三` 与本文件 §2 步骤 2。
21
+ **「生成产物 / 前处理 / 跑管线」这类请求同样适用本门禁**——「UI 产物允许全量生成」的含义是**用户可以回答 `全部`**,**不是** Agent 可以默认全量。脚本层有机械卡点:`parse_meaxure.py` 不带 `--pick/--only/--indices/--sample/--scope-all` 的生成运行会打印 1..n+全部 清单并 **exit 4**(无人值守训练 `card-skill-trainer` 属显式批量例外,直接带 `--scope-all`)。详见 `dsl-card-generator/references/rules.md §十三` 与本文件 §2 步骤 2。
22
22
 
23
23
  ## -1. 视觉优先原则(最高优先级)
24
24
 
@@ -50,7 +50,7 @@ Sketch MeaXure 导出的目录,结构形如:
50
50
  ```
51
51
  UI/
52
52
  manifest.json # 全部画板清单:order / name / status(generated|skipped) / artboardType
53
- # / trainingEligible(普通卡=true,子卡/弹框=false)
53
+ # / trainingEligible(普通卡=true,子卡/弹框/纯文本板=false)
54
54
  <画布名>/ # 如「01转账_未登录」,目录名直接用画板原始中文名;若 parse 时传 --draft <稿名>,则整体落在 UI/<draft>/ 下(按稿分片)
55
55
  artboard.png # 整图截图(来自 preview/,已降采样至最长边≤640,仅作整体参考;卡片细节以 card-N.png 为准)
56
56
  card-N.png # 聚焦裁切图(每张卡片局部,@2x,RGBA;圆角/不规则外区域透明,非原始整稿)
@@ -71,12 +71,14 @@ UI/
71
71
  > 子卡独立成卡:subcard 视作独立 cardKey,仅通过 `parentKey` 与父卡建立交互关系。
72
72
  > **按稿分片(item 19,2026-08-31)**:`parse_meaxure.py --draft <稿名>` 时画板落在 `UI/<draft>/<画板>/`、manifest 在 `UI/<draft>/manifest.json`,聚类的 `cards/`+`clusters.json` 也落在各草稿内(不再有全局 `UI/cards`)。`cluster_states.py` 给每张卡产出英文 `pageId`(稳定序号 `card-N`,因无拼音库走序号方案)替代中文 cardKey 当下游 id。
73
73
 
74
+ > **纯文本板过滤(用户 2026-09-20)**:整板只有对话/问答/提示文字、查不到任何可建卡结构的画板,判 `artboardType=text_only`(`status=skipped`、`trainingEligible=false`)——**没有生成卡片的必要**,不进选板清单的可建卡池。这类画板**不产 `card-N.png`(连以前的整屏兜底裁切也不产,否则 `spec_tree.mjs` 会拿它当预览降级)**,`prompt.md` 只写「本画板不要生成卡片」+ 判定依据,不写建卡要素。规则与回归见 §5 / §6。
75
+
74
76
  ## 2. 工作流
75
77
  1. **解析**:`scripts/parse_meaxure.py` 从 `index.html` 提取 `let data={...}` JSON(括号配平后 `json.loads`),遍历全部画板,视觉驱动检测卡片区域 → 裁切聚焦图 → 序列化要素,生成 `manifest.json` + 各画板 `spec.json`/`card-*.png`/`slices/`;同时检测 JSAPI 交互控件(密码/验证码/toast/二次确认)与 Flex 布局。系统 chrome 由 `vision.py` 几何阈值排除(见 §4)。
76
78
  2. **选画板(硬门禁,先于解析/生成)**:先 `parse_meaxure.py --src <导出目录> --list` **机械列出全量画板**,按 **`1..n` 编号(1 起)+ 末尾 `全部`** 交给用户,**停止输出等回复**后才跑管线。**「UI 产物允许全量生成」= 用户可以回答 `全部`(此时加 `--scope-all` 整份导出全跑,素材可复用),不是 Agent 可以默认全量**——用户消息里没指定画板时,**包括「生成产物 / 前处理」在内的任何请求都必须先过本门禁**;**禁止**自行挑画板、**禁止**替用户选「全部」、**禁止**批量产卡、**禁止**「先建一张看看」。下游卡片生成仍以用户指定的单个画板 / 单个 UI 产物为单位(铁律见 `dsl-card-generator/references/rules.md §十三`,机械卡点见门禁 0)。
77
- - **选择交互(固定流程)**:① 把全量画板按 **`1..n` 编号列出**(序号 **1 起**,每行 `序号. 画板名 — 类型 / 状态`,**行下紧跟该画板自己的预览图绝对路径,缺图标缺失,禁止堆末尾**)+ 末尾追加一项 **`全部`**(清单是完整选项池,不得只列节选)。② **仅当工具清单里确实列有原生弹窗工具时才用**:选项放最具代表性的 2 个画板 + `全部`(每题上限 3 项,客户端自动追加 Other),问题文案写明「其余画板在 Other 里填清单序号或画板名」;返回空答案不反复追问,降级到 ③。**编辑器助手没有这个工具**(不存在 `request_user_input`),不要去调用或寻找,直接走 ③。③ **默认路径**→ 一句纯文本提问(如「请回复要处理的画板序号,或回复 `全部`」)后**停止输出、结束本轮等用户回序号**,不得在同一轮顺手产卡。④ 回 `全部` = 整份导出全量跑 UI 产物(**允许**);回单个序号 = 只跑该画板,并作为下游产卡的目标画板。转给用户时把每行图绝对路径**逐条内联成 Markdown 图片**看图选(与序号一一对应);系统级选择弹窗不可实现(Codex 无原生弹窗控件),③ 即等价物。
78
- - **清单来源与序号对齐**:已前处理的稿读 `UI/<稿名>/manifest.json` 的 `canvases[]`(`order` 1 起 / `name` / `artboardType` / `status`)与 `clusters.json` 的 `cards[]`(`cardKey` / `pageId` / `stateNames` / `recommendedBase`);`--list` 与门禁清单**展示统一 1 起**,用户回的序号 k 传 `--indices` 时 **-1(0 起)**。`overlay_dialog` 画板标注「不产卡」(补到父卡事件)。同卡多状态时选择单位是 **cardKey**,未指定状态用 `recommendedBase` 并告知。
79
- - `--only` 名称子串 / `--indices` 序号 / `--sample N` 是**用户指定后的范围裁剪**;`--sample` 抽样只是取代表性素材,**不等于用户已选定要产卡的画板**。
79
+ - **选择交互(固定流程)**:① 把全量画板按 **`1..n` 编号列出**(序号 **1 起**,每行 `序号. 画板名 — 类型 / 状态`,**行下紧跟该画板自己的预览图绝对路径,缺图标缺失,禁止堆末尾**)+ 末尾追加一项 **`全部`**(清单是完整选项池,不得只列节选;`text_only` 纯文本板已过滤,单独列在末尾「已过滤」段)。② **仅当工具清单里确实列有原生弹窗工具时才用**:选项放最具代表性的 2 个画板 + `全部`(每题上限 3 项,客户端自动追加 Other),问题文案写明「其余画板在 Other 里填清单序号或画板名」;返回空答案不反复追问,降级到 ③。**编辑器助手没有这个工具**(不存在 `request_user_input`),不要去调用或寻找,直接走 ③。③ **默认路径**→ 一句纯文本提问(如「请回复要处理的画板序号,或回复 `全部`」)后**停止输出、结束本轮等用户回序号**,不得在同一轮顺手产卡。④ 回 `全部` = 整份导出全量跑 UI 产物(**允许**);回单个序号 = 只跑该画板,并作为下游产卡的目标画板。转给用户时把每行图绝对路径**逐条内联成 Markdown 图片**看图选(与序号一一对应);系统级选择弹窗不可实现(Codex 无原生弹窗控件),③ 即等价物。**清单分两段**:先列**可建卡选项池**(带序号 + 各自预览图),末尾附一段**已过滤的纯文本板**(只列画板名,不配图)——序号仍按全量 `1..n` 编,用户若回了纯文本板的序号,脚本只打 `[WARN] 纯文本画板…不会出卡`,不产卡。
80
+ - **清单来源与序号对齐**:已前处理的稿读 `UI/<稿名>/manifest.json` 的 `canvases[]`(`order` 1 起 / `name` / `artboardType` / `status`)与 `clusters.json` 的 `cards[]`(`cardKey` / `pageId` / `stateNames` / `recommendedBase`);`--list` 与门禁清单**展示统一 1 起**、行尾自带可复制的 `--pick`(1 起、免换算,照抄即可),用户回的序号 k 传 `--indices` 时才是 **-1(0 起)**。`overlay_dialog` 画板标注「不产卡」(补到父卡事件)。同卡多状态时选择单位是 **cardKey**,未指定状态用 `recommendedBase` 并告知。
81
+ - `--pick` 板号(1 起,行尾提示即它)/ `--only` 名称子串 / `--indices` 序号(0 起)/ `--sample N` 是**用户指定后的范围裁剪**;`--sample` 抽样只是取代表性素材,**不等于用户已选定要产卡的画板**。
80
82
  3. **生成文档**:`scripts/build_prompt.py`(或 `--build` 一步式)由 `spec.json` 生成 `prompt.md` + `spec.md`(含列表检测、渐变/字体/flex 样式、JSAPI 交互)。
81
83
  4. **聚类聚合**:`scripts/cluster_states.py` 按卡片维度聚类画板 → `cards/<cardKey>/` 多状态产物 + `clusters.json` + `interactions/index.yaml`(弹框/子卡父子 + 维度切换 + JSAPI 控件)。
82
84
  5. **交付**:把某卡片的 `cards/<cardKey>/`(含多状态聚焦截图 + card.json)交给 `dsl-card-generator` 即可生成该卡片 schema。
@@ -87,7 +89,7 @@ UI/
87
89
  python skills/sketch-spec-to-card/scripts/parse_meaxure.py --list
88
90
 
89
91
  # ⚠️ 多画板选择门禁:不带范围参数的生成运行会打印 1..n + 全部 清单并 exit 4(不生成任何东西);
90
- # 用户回复序号 k → 加 --indices <k-1>;回复「全部」→ 加 --scope-all(无人值守训练可直接加,或 SKETCH_SPEC_SCOPE_ALL=1)
92
+ # 用户回复「8.1」→ 照抄清单行尾的 --pick 8.1;回复「全部」→ 加 --scope-all(无人值守训练可直接加,或 SKETCH_SPEC_SCOPE_ALL=1)
91
93
  python skills/sketch-spec-to-card/scripts/parse_meaxure.py --src <导出目录> --draft <稿名> --build --scope-all
92
94
 
93
95
  # 抽样 5 个代表性画布(按业务流去重 + 状态关键字打分,保证覆盖不同主屏)
@@ -136,17 +138,19 @@ python skills/sketch-spec-to-card/scripts/cluster_states.py --out <UI目录> --a
136
138
  - `prompt.md` 已是可直接消费的 prompt:引用 `artboard.png` 与 `slices/`,给出卡片意图与关键标注,并指示对方运行 `skills/dsl-card-generator/scripts/validate_schema.py` 自查。**§6「人力卡拓扑建议」**段落基于 `skills/dsl-card-generator/references/handcrafted/patterns.json`(52 张人力卡的统计规则库)生成,含 Root 直系骨架推荐 / slot 约定提醒 / 节点 style itemKey 必备度 / 列表场景提醒 / 文本绑定范式 / 相似人力卡参考链接 / 变量命名推荐集 / 生成后校验闭环共 8 个子段。规则库缺失时静默降级(不阻断 prompt 生成),仅占位说明「规则库未加载」。**⚠️ 跨 skill 依赖**:`build_prompt.py::_load_handcrafted()` 经相对路径 `../dsl-card-generator/references/handcrafted/` 读下游 skill 的产物;跨仓移动 `dsl-card-generator` 时需同步更新该路径(或把 handcrafted/ 提升为共享资源)。详见 `skills/dsl-card-generator/references/handcrafted/README.md`。
137
139
  - 当用户说「用某画板生成卡片」时,把 `UI/<画布名>/` 交给 dsl-card-generator(读 `artboard.png` + `prompt.md`),由其产出 `schema.json` + `data.json`。
138
140
 
139
- ## 4.5 画板分类:overlay_dialog(alertDialog)/ subcard(alertCard)/ card
140
- 设计稿里除了普通卡片主体,还常出现**弹框 / 确认框 / 消息提示**与**带标题+关闭按钮的弹出子卡**。这两类不按普通卡片还原,需识别后分流(用户 2026-08-13 补充):
141
+ ## 4.5 画板分类:overlay_dialog(alertDialog)/ subcard(alertCard)/ card / text_only(不产卡)
142
+ 设计稿里除了普通卡片主体,还常出现**弹框 / 确认框 / 消息提示**、**带标题+关闭按钮的弹出子卡**,以及**整板只有对话/问答/提示文字的纯文本页**。这三类都不按普通卡片还原(第三类干脆不产卡),需识别后分流(用户 2026-08-13 补充,`text_only` 2026-09-20 补充):
141
143
 
142
144
  - **overlay_dialog(→ 系统 `alertDialog` JSAPI)**:弹框 / 确认框 / 消息提示 / toast 类组件。**不要求严格还原外观**,实现方式是「在触发它的父卡片对应交互中调用原生 `alertDialog` JSAPI」,**不生成独立卡片**。`prompt.md` 改为输出「交互提示文档」(含 `alertDialog` 调用范式 + 从画板提取的 title/message/按钮 + 父卡归属提示 + 参考存量卡)。
143
145
  - **subcard(→ `alertCard` 父子卡片)**:带标题栏 + 关闭按钮(×)的模态子卡,是一种「子卡」实现。按普通卡片**正常生成一张独立 Dialog 卡**,并补 `alertCard` 打开调用 + `closeAlertCard` 关闭调用(参考存量 `monthlyBill→monthlyBillDialog`、`creditCardBill→billListDialog`)。`prompt.md` 在常规建卡指令后追加「§6 弹窗形态」说明。
144
146
  - **card(普通卡片主体)**:严格还原设计稿。
147
+ - **text_only(纯文本板,→ 不产卡)**:整板只有对话气泡/问答/提示文字,检测不到任何站得住的卡片区域 → `classify_artboard` 返回 `text_only`,manifest 标 `status=skipped` + `trainingEligible=false`,不裁 `card-N.png`(含兜底图)、不进产卡池。判据见 §5「纯文本画板过滤」。
145
148
 
146
149
  **识别规则(`parse_meaxure.py → classify_artboard`,保守、可解释)**:
147
150
  1. 名称含「子卡/子页面/子卡片/浮层卡/弹层卡/弹窗卡/…」→ **subcard**(名称优先,直接判父子卡)。
148
151
  2. 名称含「弹框/弹窗/对话框/确认框/提示框/消息提示/toast/二次确认/…」→ **overlay_dialog**。
149
- 3. 名称无弹层字样时,按**结构信号**兜底:画板含「近全屏半透明遮罩(scrim) + 关闭按钮(×) + 标题」→ subcard;仅含 scrim → overlay_dialog。**无 scrim 一律判 card**(避免把卡片内「可关闭的提示条」误判为弹框/子卡)。
152
+ 3. 名称无弹层字样时,按**结构信号**兜底:画板含「近全屏半透明遮罩(scrim) + 关闭按钮(×) + 标题」→ subcard;仅含 scrim → overlay_dialog。
153
+ 4. 无 scrim / 无底部抽屉时**先判纯文本板**:`cards` 为空 + 候选拒绝理由全属「本来就不是卡」+ 板上无卡片级大盘切图 → `text_only`;其余才判 card(避免把卡片内「可关闭的提示条」误判为弹框/子卡,也避免把纯文本对话页硬塞进产卡池)。
150
154
 
151
155
  #### 卡片聚焦验收门(Agent 多模态复核)
152
156
 
@@ -194,9 +198,11 @@ python skills/sketch-spec-to-card/scripts/cluster_states.py --out <UI目录> --a
194
198
  - **标注提取**。text 层含 `content/color(hex)/fontSize/fontFace/textAlign/lineHeight`;其余层取 `rect/fills/borders/opacity`。group 层递归展平,保留 `depth` 与 `parent`。
195
199
  - **双主题 / 预览区独立性**:本 skill 不动编辑器主题;切图按原样拷贝,颜色以标注 hex 为准。
196
200
  - **文本气泡误识别剔除**(`parse_meaxure.py::_is_pure_text_card`):`detect_cards` 会把 AI 应答文本气泡(纯文本气泡 / 大段应答多行文本)误识别为卡片。判定规则(纯结构/几何,不读名称/文案):①存在「大层」(rect 宽≥80%cw 且高≥50%ch 的元素 = 卡面/蒙版/top-bg 等背景装饰);②剥去所有大层与组(包装)后,剩余「真实内容」= slice + 小 symbol(图标) + 带填充小 shape(图标/按钮) + text;③若无 slice/图标/带填充小形状 且 text 段数≤2 → 文本气泡,剔除。剔除后删除对应 `card-N.png` 并对保留卡片重新 1..N 编号。无大层 → 保守保留(透明卡不杀)。真卡(空态/列表/结果卡,含图标或多段 label-value 文本)不受影响。
201
+ - **纯文本画板过滤**(`parse_meaxure.py::is_text_only_artboard`,用户 2026-09-20):整板就是对话/问答/提示文字的画板**没有生成卡片的必要**,不该继续占产卡池与训练集。判据(`classify_artboard` 在视觉弹层判定之后、名称兜底之前调用;`focus_artboard` 复用同一入口 `detect_artboard_cards`,两处判定不漂移):①`cards` 非空 → 不是纯文本;②`focusSkipReason` 属 overlay/底部抽屉 → 归弹层类,不走这条;③板上有**卡片级大盘切图**(中段、宽≥55%W、高≥12%H 的 slice,即卡片被整体导成一张图,`vision.has_card_like_visual`)→ 保留;④其余凡「至少有一个候选被拒」且**所有拒绝理由都落在纯文本类集合**(`text_only_or_low_structure_region` / `prose_paragraph_region` / `conversation_bubble_region` / `stacked_conversation_bubbles_region` / `conversation_inputDock_region` / `conversation_keyboard_region` / `empty_region`)→ `text_only`。**白名单式判定**是关键:理由只要沾上 `single_button_or_bar_region`(读起来像卡、规则没认的窄卡)或 `repeated_control_bar_region` 就一律保留——汇率牌价这类宽扁卡靠这条保住;0 候选的普通文字页同样保守保留。落地:`status=skipped` + `trainingEligible=false` + 不产 `card-N.png`(连兜底图也不产)+ `prompt.md` 只写「不要建卡」的判定依据;`--list` / 门禁清单把它归到「已过滤」段。
197
202
  - 详细 spec.json 字段见 `references/spec-schema.md`。
198
203
 
199
204
  ## 6. 已沉淀问题 / 优化(维护记录)
205
+ - **纯文本画板过滤(2026-09-20)**:用户反馈「纯文本对话的设计稿,里面都是文本,没有任何生成卡片的必要」。新增 `vision.has_card_like_visual`(卡片级大盘切图保险)+ `parse_meaxure.is_text_only_artboard`(纯文本拒绝理由白名单收口)+ `detect_artboard_cards` 统一检出入口(`focus_artboard` 与清单 `board_verdict` 同源,判定不漂移),并停掉纯文本板的兜底 `card-1.png`、给这类板单独产「不要建卡」prompt。**全量回归(24 稿 675 画板)**:`card=530 / subcard=82 / overlay_dialog=43 / text_only=20`;被过滤的 20 张全是各稿「无权限 / 知识问答 / 无返现记录 / 额度不够 / 交易超时」这类纯文本对话页;人工复核房贷 14 张、汇率 02、理财 06/25、我的账户 01 均为真卡或「对话 + 真卡」,零误伤。单测 `scripts/test_text_only_artboard.py`(6 例:纯文本对话页→text_only、对话+内嵌矢量卡→card、对话+卡片切图→card、底部抽屉→subcard、遮罩弹框→overlay_dialog、0 候选普通文字页→card)。
200
206
  - **`_is_repeated_control_bar` 误杀业务卡(2026-09-06)**:01基金_基金推荐 主卡(标题 + 3 编号选项 + 底部 3 列收益子卡)被控件条启发式误判——底部 3 个同构子卡恰好满足「3+ 横向等距重复块」全部数值条件(宽比 0.28/高比 0.32/等间距),导致主卡 0 检出、下游被迫手工补框。根因:规则只识别「横排重复」的几何形态,未区分「整卡=控件条」与「业务卡底部恰好有同构子卡行」。修复:加入**支配性守卫**——簇的纵向并集须覆盖卡高 ≥50% 才判控件条(真评分/开关组整卡只有一条横排);收益卡行只占卡高 32% → 不再误杀。回归:24 画板零崩溃,存量 UI 无其他画板曾被该规则拒绝(零回归面),主卡恢复 1 卡检出且自动裁切与人工标注框逐字节一致。
201
207
 
202
208
  > 详细回归与优化记录见 `references/regression-notes.md`。本 skill 经过「收支分析 → 信用卡查询与还款 / 转账」两稿回归,已从「单一稿写死」重构为「通用可配置」:
@@ -261,7 +267,7 @@ python skills/sketch-spec-to-card/scripts/cluster_states.py --out <UI目录> --a
261
267
  - 逐格比色 ≥0.9 即形状+颜色双重一致,直接采信(浅色描边的前景掩码不可靠,IoU 低不应否决);
262
268
  - IoU≥0.5 时以前景色度复核:抗锯齿只把描边色向白色混合,色度近似不变(色度差 ≤25 且亮度差 ≤100 采信;真实内容差色度通常 >150)。
263
269
  - 内容真实不一致(如标注"未选中"但像素是黄色选中态)仍判 `slice-visual-mismatch` 并保留画板裁切。
264
- 5. **manifest 部分运行继承**:`--only/--indices/--sample` 局部重跑时,未选中画板的 `status`/`trainingEligible`/`artboardType`/`preview` 从旧 manifest 继承,禁止默认清零——否则数据集构建会因资格字段塌缩而丢案例。
270
+ 5. **manifest 部分运行继承**:`--pick/--only/--indices/--sample` 局部重跑时,未选中画板的 `status`/`trainingEligible`/`artboardType`/`preview` 从旧 manifest 继承,禁止默认清零——否则数据集构建会因资格字段塌缩而丢案例。
265
271
 
266
272
  ## r146 视觉判定三修复:弹层 scrim 契约 / 键盘控件去重 / 气泡对纯度(2026-09-06)
267
273
 
@@ -13,7 +13,7 @@
13
13
  | `elements` | array | 全画板展平后的图层列表(含系统区/背景),见下「elements[]」 |
14
14
  | `styleSummary` | object | 聚合样式摘要(见下) |
15
15
  | `slices` | array<string> | 本画板 `slices/` 下切图相对路径(as posix),由 `collect_slice_names` + `copy_slices` 落地 |
16
- | `artboardType` | string | 画板类型:`card`(普通卡片主体,严格还原)/ `overlay_dialog`(弹框·确认框·消息提示·toast → `alertDialog` JSAPI,不建卡)/ `subcard`(带标题+关闭按钮的模态子卡 → `alertCard` 父子卡)。由 `classify_artboard` 判定 |
16
+ | `artboardType` | string | 画板类型:`card`(普通卡片主体,严格还原)/ `overlay_dialog`(弹框·确认框·消息提示·toast → `alertDialog` JSAPI,不建卡)/ `subcard`(带标题+关闭按钮的模态子卡 → `alertCard` 父子卡)/ `text_only`(纯文本对话/问答/提示板:板上查不到任何可建卡结构 → 不产卡、不进产卡池,见 SKILL.md §5)。由 `classify_artboard` 判定 |
17
17
  | `interaction` | string | 非空时说明该画板的交互归宿(如 `alertDialog 系统弹框/确认框/消息提示`、`alertCard 子卡:…`);card 为空串 |
18
18
  | `jsapiInteractions` | array<object> | JSAPI 交互控件检测(密码/验证码/数字键盘/toast/二次确认),见下 |
19
19
  | `skippedReason` | string | 仅当 `cards=[]`(视觉检测无独立卡片)时存在,说明跳过原因 |
@@ -574,6 +574,10 @@ def detect_list_in_card(card, min_items=2):
574
574
 
575
575
  def build_prompt_md(spec, cdir_name):
576
576
  name = spec["canvas"]
577
+ # 纯文本画板(对话 / 问答 / 提示):没有卡可建。产物只留判定依据,并在开头写死「不要建卡」,
578
+ # 免得下游读到 prompt.md 就顺手生成一张空壳卡(真实案例见 SKILL.md §5 纯文本画板过滤)。
579
+ if spec.get("artboardType") == "text_only":
580
+ return build_text_only_md(spec, cdir_name)
577
581
  w, h = spec["size"]["width"], spec["size"]["height"]
578
582
  st = spec.get("styleSummary", {})
579
583
  lines = []
@@ -722,11 +726,50 @@ def build_prompt_md(spec, cdir_name):
722
726
  return "\n".join(lines)
723
727
 
724
728
 
729
+ def build_text_only_md(spec, cdir_name):
730
+ """纯文本画板(artboardType=text_only)的 prompt.md。
731
+
732
+ 这种画板只有对话/问答/提示文字,板上没有任何卡片级结构。产物唯一目的是留判定依据 +
733
+ 明确写死「不要建卡」,所以不列聚焦图、不列切图、不给布局建议。
734
+ """
735
+ name = spec["canvas"]
736
+ w, h = spec["size"]["width"], spec["size"]["height"]
737
+ st = spec.get("styleSummary", {})
738
+ lines = []
739
+ lines.append(f"# 不建卡画板(纯文本):{name}\n")
740
+ lines.append("> **本画板不要生成卡片。** 视觉检测未在板上找到任何卡片级结构,只有对话/问答/提示类文字")
741
+ lines.append("> (`artboardType=text_only`):该画板已被过滤出选板清单的可建卡池,`trainingEligible=false`、`status=skipped`。")
742
+ lines.append("> 确需为它产卡时:换结构完整的画板,或先在 Sketch 标注里补出卡片区域再重跑 `parse_meaxure.py`。\n")
743
+ lines.append("## 判定依据\n")
744
+ lines.append(f"- 画板:{w} × {h}")
745
+ lines.append(f"- 类型:{spec.get('artboardType')}({spec.get('interaction', '')})")
746
+ lines.append(f"- 卡片数:0 / 视觉元素 {len(spec.get('elements', []))}")
747
+ if spec.get("skippedReason"):
748
+ lines.append(f"- 跳过原因:{spec['skippedReason']}")
749
+ rejected = spec.get("rejectedCards") or []
750
+ if rejected:
751
+ counter = Counter(r.get("reason") for r in rejected)
752
+ lines.append(f"- 候选区域被否 {len(rejected)} 个:"
753
+ + "、".join(f"`{r}`×{n}" for r, n in counter.most_common()))
754
+ lines.append("\n## 参考图(人工确认用,非建卡输入)\n")
755
+ lines.append(f"- `{cdir_name}/artboard.png` 整图")
756
+ lines.append("\n## 风格摘要(仅当人工判断需要)\n")
757
+ colors = st.get("colors", [])[:6]
758
+ if colors:
759
+ lines.append("- 主色:" + " ".join(f"`{c}`" for c in colors))
760
+ for f in st.get("fonts", [])[:4]:
761
+ lines.append(f"- 字体:{f.get('size')}px/{f.get('fontFace')} w{f.get('fontWeight')} align={f.get('textAlign')}")
762
+ lines.append("")
763
+ return "\n".join(lines)
764
+
765
+
725
766
  def build_spec_md(spec):
726
767
  name = spec["canvas"]
727
768
  lines = [f"# 标注摘要:{name}\n"]
728
769
  lines.append(f"- 尺寸:{spec['size']['width']} × {spec['size']['height']}")
729
770
  lines.append(f"- 类型:{spec.get('artboardType')} {spec.get('interaction','')}")
771
+ if spec.get("skippedReason"):
772
+ lines.append(f"- 跳过:{spec['skippedReason']}")
730
773
  lines.append(f"- 卡片数:{len(spec.get('cards', []))} / 视觉元素 {len(spec.get('elements', []))}\n")
731
774
  st = spec.get("styleSummary", {})
732
775
  lines.append("## 主色")
@@ -769,7 +812,10 @@ def run(out, only=None, all_flag=False):
769
812
  if all_flag:
770
813
  targets = [c["name"] for c in canvases]
771
814
  else:
772
- targets = [c["name"] for c in canvases if c.get("status") == "generated"]
815
+ # text_only(纯文本板)虽然 status=skipped,也要重写 prompt.md:它得写死「不要建卡」,
816
+ # 否则残留一份旧版「建卡要素」prompt,人工翻目录时会被误导。
817
+ targets = [c["name"] for c in canvases
818
+ if c.get("status") == "generated" or c.get("artboardType") == "text_only"]
773
819
  if only:
774
820
  kws = [k.strip() for k in only.split(",") if k.strip()]
775
821
  targets = [n for n in targets if any(k in n for k in kws)]