cardbuddy 0.14.3 → 0.16.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 (108) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
  3. package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/card-editor/runtime/adapter.js +2 -2
  7. package/dist/vendored/mcp/client.mjs +9 -2
  8. package/dist/vendored/server/core/ai-chat.js +1702 -134
  9. package/dist/vendored/server/core/browser.js +12 -3
  10. package/dist/vendored/server/core/cardFiles.js +3 -0
  11. package/dist/vendored/server/core/cardHistory.js +1 -0
  12. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  13. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  14. package/dist/vendored/server/core/cardSave.js +1 -0
  15. package/dist/vendored/server/core/cardSync.js +3 -0
  16. package/dist/vendored/server/core/cardValidate.js +16 -5
  17. package/dist/vendored/server/core/codeBridge.js +5 -1
  18. package/dist/vendored/server/core/logStream.js +1 -0
  19. package/dist/vendored/server/core/sliceStatic.js +24 -4
  20. package/dist/vendored/server/core/sliceUpload.js +1 -0
  21. package/dist/vendored/server/core/turnJournal.js +6 -0
  22. package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
  23. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
  24. package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
  26. package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
  27. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
  28. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
  29. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
  30. package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
  31. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
  32. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
  33. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
  34. package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
  35. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  36. package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
  37. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
  38. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
  39. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
  40. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
  41. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
  42. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  43. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
  44. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  45. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
  46. package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
  47. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
  48. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  49. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
  50. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
  51. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  52. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
  53. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
  54. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
  55. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  56. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
  57. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  58. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
  59. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
  60. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  61. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  62. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  63. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
  64. package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
  65. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  66. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
  67. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  68. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
  69. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
  70. package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
  71. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
  72. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
  73. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
  74. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
  75. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
  76. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
  77. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
  78. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
  79. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  80. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  81. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
  82. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
  83. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
  84. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
  85. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
  86. package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
  87. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
  88. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  89. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
  90. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
  91. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  92. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
  93. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
  94. package/dist/vendored/skills/shared/exit-codes.md +79 -0
  95. package/dist/vendored/skills/shared/spec-contract.md +118 -0
  96. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
  97. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
  98. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
  99. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
  100. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
  101. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
  102. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
  103. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
  104. package/package.json +1 -1
  105. package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
  106. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
  107. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
  108. package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
@@ -16,22 +16,24 @@ parse_meaxure.py — 解析 Sketch MeaXure 标注导出,视觉驱动生成「
16
16
  card.json 状态 / 交互 / 样式 / 列表 聚合信息
17
17
  states/<画板名>/
18
18
  artboard.png 整图(参考)
19
- card-1.png(...) 聚焦裁切图(每张卡片或每板一图,@2x)
19
+ card-1.png(...) 聚焦裁切图(每张卡片或每板一图,@2x;text_only 纯文本板不产,含兜底图)
20
20
  spec.json 视觉要素标注(该状态)
21
21
  slices/ 切图素材
22
22
  clusters.json 卡片聚类 + 交互维度(由 cluster_states.py 补全)
23
23
 
24
24
  用法:
25
25
  python parse_meaxure.py --list # 仅列画板
26
- # 多画板选择门禁:不带 --only/--indices/--sample/--scope-all 的生成运行会打印 1..n+全部 清单并 exit 4,
27
- # 等用户回复序号/「全部」后再带 --indices <k-1> / --only / --scope-all 重跑(rules.md §十三)。
26
+ # 多画板选择门禁:不带 --pick/--only/--indices/--sample/--scope-all 的生成运行会打印 1..n+全部 清单并 exit 4,
27
+ # 等用户回复序号/「全部」后照抄清单行尾的 --pick 重跑(rules.md §十三)。
28
28
  python parse_meaxure.py --sample 5 # 抽样代表性画板
29
29
  python parse_meaxure.py --only "未登录,有交易" # 按名称子串
30
- python parse_meaxure.py --indices 0,9,18 # 按序号
30
+ python parse_meaxure.py --pick 8.1 # 按清单序号(1 起,可带卡号)——推荐,免换算
31
+ python parse_meaxure.py --indices 0,9,18 # 按 0 基序号(老写法,与 --pick 等价)
31
32
  python parse_meaxure.py --src <导出目录> --out <UI> --build
32
33
  python parse_meaxure.py --build # 一步式(含 build_prompt)
33
34
  """
34
35
  import argparse
36
+ import difflib
35
37
  import hashlib
36
38
  import json
37
39
  import math
@@ -83,17 +85,6 @@ def hex_of(color_obj):
83
85
  return None
84
86
 
85
87
 
86
- def rgba_of(color_obj):
87
- """MeaXure color -> 'rgba(r,g,b,a)'(含透明度)。"""
88
- if not isinstance(color_obj, dict):
89
- return None
90
- css = color_obj.get("css-rgba") or color_obj.get("css-hsla")
91
- if css:
92
- return css
93
- h = hex_of(color_obj)
94
- return h
95
-
96
-
97
88
  def extract_data(index_html: Path) -> dict:
98
89
  html = index_html.read_text(encoding="utf-8", errors="ignore")
99
90
  marker = "let data = "
@@ -301,7 +292,8 @@ def _segment_hint(e, card_w, card_rect=None):
301
292
 
302
293
 
303
294
  def detect_segments(card_els, card_rect):
304
- """按 y 几何聚类视觉段。返回 [{y, height, gap_top, hint, items}](y/height 为卡内相对坐标)。
295
+ """按 y 几何聚类视觉段。返回 [{y, height, hint, items}](y/height 为卡内相对坐标;
296
+ 段间距可由相邻 y/height 推导,不再冗余写 gap_top)。
305
297
  规则:
306
298
  1. 排除卡外装饰(全屏背景/蒙版:元素在卡 y 轴交集 < 元素高 50%);
307
299
  2. decor-bg(装饰背景层)元素**强制独立成段**(背景层是独立容器,其下内容必须切段);
@@ -351,26 +343,18 @@ def detect_segments(card_els, card_rect):
351
343
  for i, s in enumerate(segs):
352
344
  bottom = max((it["rect"]["y"] + (it["rect"]["height"] or 0)) for it in s["items"])
353
345
  top = s["y"]
354
- seg = {
346
+ # gap_top 已删(2026-09-24):纯冗余推导值——首段等于 y,后续段可由相邻 y/height 推出
347
+ out.append({
355
348
  "y": round(top - cy, 2),
356
349
  "height": round(bottom - top, 2),
357
350
  "hint": s["hint"],
358
351
  "items": len(s["items"]),
359
- }
360
- if i == 0:
361
- seg["gap_top"] = round(top - cy, 2)
362
- else:
363
- # 修正坐标混用 bug:统一用卡内相对坐标计算 gap_top
364
- prev_bottom_rel = out[i - 1]["y"] + out[i - 1]["height"]
365
- seg["gap_top"] = round(seg["y"] - prev_bottom_rel, 2)
366
- out.append(seg)
352
+ })
367
353
  # 后处理:消除段重叠(前一段底部超出后一段顶部时,截断前一段高度)
368
354
  for i in range(1, len(out)):
369
355
  overlap = (out[i - 1]["y"] + out[i - 1]["height"]) - out[i]["y"]
370
356
  if overlap > 0:
371
357
  out[i - 1]["height"] = round(out[i - 1]["height"] - overlap, 2)
372
- # 重算 gap_top(截断后应 ≥ 0)
373
- out[i]["gap_top"] = round(out[i]["y"] - (out[i - 1]["y"] + out[i - 1]["height"]), 2)
374
358
  return out
375
359
 
376
360
 
@@ -515,25 +499,86 @@ def _deduplicate_shell_elements(els):
515
499
  return output
516
500
 
517
501
 
518
- def focus_artboard(ab_raw, preview_path, cdir):
519
- """对一个画板:检测独立卡片区域 → 结构过滤 → 逐一裁切 → 序列化标注。
502
+ def detect_artboard_cards(ab_raw):
503
+ """画板级几何分析(不读图、不裁切):检出卡片候选 → 逐块聚焦判定。
520
504
 
521
- 返回 spec dict(含卡片区域、聚焦截图、样式汇总)。
505
+ 返回 dict:W / H / layers / overlay / sheet / conversationFlow /
506
+ cards [(x,y,w,h,els,shapes,focusMetrics)] / rejected [{rect,reason,metrics}] /
507
+ focusSkipReason("" | overlay_dialog_geometry | bottom_sheet_geometry)。
508
+
509
+ 这是**唯一**的卡片检出入口:focus_artboard(真跑,随后按卡片裁图)与门禁清单的
510
+ board_verdict(只想知道「这个画板有没有卡可建」)都走它,两处判定不会漂移。
511
+
512
+ 底部抽屉(bottom_sheet ⇒ artboardType=subcard)走**几何直取**而非候选检测:
513
+ 抽屉面板矩形由 `vision._bottom_sheet_rect` 精确给出,落成 1 张卡。所以
514
+ `focusSkipReason=bottom_sheet_geometry` 与 `cards` 非空**同时成立** —— 该字段的
515
+ 语义是「跳过了自动候选检测」,不是「0 卡」。
522
516
  """
523
517
  W, H = ab_raw.get("width", 0), ab_raw.get("height", 0)
524
518
  raw_layers = ab_raw.get("layers") or []
525
- flattened_layers = vision._flatten_with_tree(raw_layers)
526
- overlay = vision.detect_overlay_dialog(vision._flatten_with_tree(raw_layers), W, H)
519
+ flattened = vision._flatten_with_tree(raw_layers)
520
+ overlay = vision.detect_overlay_dialog(flattened, W, H)
527
521
  bottom_sheet = vision._bottom_sheet_rect(raw_layers, W, H)
528
- conversation_flow = vision.detect_conversation_flow(flattened_layers, W, H)
522
+ conversation_flow = vision.detect_conversation_flow(flattened, W, H)
523
+ cards, rejected, focus_skip_reason = [], [], ""
529
524
  if overlay or bottom_sheet:
530
- cards = []
531
525
  focus_skip_reason = (
532
526
  "overlay_dialog_geometry" if overlay else "bottom_sheet_geometry"
533
527
  )
528
+ # 底部抽屉(alertCard 子卡)产 1 张卡:面板几何 `vision._bottom_sheet_rect`
529
+ # 早已算出精确 rect + 顶圆角,此前却只写 focusSkipReason 不产 cards —— 结果是
530
+ # cluster_states 承认它是独立逻辑卡(pageId=card-N、isDialog=true),而 spec 层
531
+ # 给不出任何卡级数据,下游只能读整图自己猜边界。
532
+ # ⚠️ focusSkipReason 必须保持 bottom_sheet_geometry:`classify_artboard`(:1667)
533
+ # 靠它判 artboardType=subcard,改它会连带打翻 `is_text_only_artboard`(:1643)、
534
+ # `board_verdict` 与 trainer 的 `is_popup_case`(build_dataset.py:188)。
535
+ if bottom_sheet and not overlay:
536
+ sx, sy, sw, sh, s_radii = bottom_sheet
537
+ # min_cover=0.7:抽屉上沿会切过相邻元素(气泡、气泡内文字层的大头刚过沿
538
+ # 就会被默认口径收进来),用覆盖率门槛滤掉;跨沿的抽屉标题(覆盖≈98%)保留。
539
+ sheet_els = vision.elements_in_rect(flattened, (sx, sy, sw, sh), min_cover=0.7)
540
+ cards.append((
541
+ sx, sy, sw, sh, sheet_els,
542
+ [((sx, sy, sw, sh), list(s_radii or []))],
543
+ vision.card_focus_metrics(sheet_els, (sx, sy, sw, sh), W, H),
544
+ ))
534
545
  else:
535
- cards = vision.detect_cards(raw_layers, W, H, conversation_flow)
536
- focus_skip_reason = ""
546
+ for x, y, w, h, els, shapes in vision.detect_cards(raw_layers, W, H, conversation_flow):
547
+ eligible, reason, focus_metrics = vision.classify_card_focus(
548
+ els, (x, y, w, h), W, H, conversation_flow)
549
+ if eligible:
550
+ cards.append((x, y, w, h, els, shapes, focus_metrics))
551
+ else:
552
+ rejected.append({
553
+ "rect": {"x": round(x, 2), "y": round(y, 2),
554
+ "width": round(w, 2), "height": round(h, 2)},
555
+ "reason": reason,
556
+ "metrics": focus_metrics,
557
+ })
558
+ return {
559
+ "W": W, "H": H, "layers": raw_layers,
560
+ "overlay": bool(overlay), "sheet": bool(bottom_sheet),
561
+ "conversationFlow": conversation_flow,
562
+ "cards": cards, "rejected": rejected,
563
+ "focusSkipReason": focus_skip_reason,
564
+ }
565
+
566
+
567
+ def focus_artboard(ab_raw, preview_path, cdir, canvas_name=None):
568
+ """对一个画板:检测独立卡片区域 → 结构过滤 → 逐一裁切 → 序列化标注。
569
+
570
+ 返回 spec dict(含卡片区域、聚焦截图、样式汇总)。
571
+
572
+ `canvas_name`:写进 `spec["canvas"]` 的画板标识。调用方传**唯一化后**的名字
573
+ (同名画板已追加 -2/-3);缺省回落 Sketch 原始名。下游 `spec_tree.mjs:140`
574
+ 用 `spec.canvas || 目录名` 当展示名,两板同名时不传就会在清单里撞成两行一模一样的字。
575
+ """
576
+ found = detect_artboard_cards(ab_raw)
577
+ W, H = found["W"], found["H"]
578
+ raw_layers = found["layers"]
579
+ overlay, bottom_sheet = found["overlay"], found["sheet"]
580
+ conversation_flow = found["conversationFlow"]
581
+ focus_skip_reason = found["focusSkipReason"]
537
582
 
538
583
  for old_png in cdir.glob("card-*.png"):
539
584
  old_png.unlink()
@@ -544,21 +589,8 @@ def focus_artboard(ab_raw, preview_path, cdir):
544
589
  elements.append(serialize_element(ve, ve.raw))
545
590
 
546
591
  card_regions = []
547
- rejected_regions = []
548
- eligible_cards = []
549
- for x, y, w, h, els, shapes in cards:
550
- rect = (x, y, w, h)
551
- eligible, reject_reason, focus_metrics = vision.classify_card_focus(
552
- els, rect, W, H, conversation_flow)
553
- if not eligible:
554
- rejected_regions.append({
555
- "rect": {"x": round(x, 2), "y": round(y, 2),
556
- "width": round(w, 2), "height": round(h, 2)},
557
- "reason": reject_reason,
558
- "metrics": focus_metrics,
559
- })
560
- continue
561
- eligible_cards.append((x, y, w, h, els, shapes, focus_metrics))
592
+ rejected_regions = found["rejected"]
593
+ eligible_cards = found["cards"]
562
594
 
563
595
  from build_prompt import detect_list_in_card # 列表检测复用 build_prompt(无循环依赖)
564
596
  for i, (x, y, w, h, els, shapes, focus_metrics) in enumerate(eligible_cards, 1):
@@ -607,16 +639,29 @@ def focus_artboard(ab_raw, preview_path, cdir):
607
639
  region["detectedList"] = detect_list_in_card(region) or None
608
640
  card_regions.append(region)
609
641
 
642
+ # 纯文本板(对话/问答/提示)连兜底截图都不产:一张整屏裁切图叫 card-1.png,下游
643
+ # 会当成「这个板有卡」(spec_tree.mjs 就拿 card-1.png 当预览降级),正好抵消本次过滤。
644
+ # 判定输入与 classify_artboard 同源:候选全被纯文本类理由否掉 + 板上无卡片级大盘切图。
645
+ text_only = is_text_only_artboard(
646
+ {"cards": card_regions,
647
+ "rejectedCards": rejected_regions,
648
+ "focusSkipReason": focus_skip_reason or (
649
+ "no_structural_card_region" if not card_regions else ""),
650
+ "size": {"width": W, "height": H}},
651
+ raw_layers,
652
+ )
653
+
610
654
  # Fallback(2026-08-31):视觉检测 0 个独立卡片区域时,仍裁一张图 card-1.png,
611
655
  # 保证每个普通画板都有 card-* 截图(下游聚类/训练参考用)。不写入 card_regions,
612
656
  # 避免误触发下游卡片生成(spec.cards 仍空 → status 维持 skipped/generated)。
613
657
  # 仅对普通画板生效;overlay_dialog / bottom_sheet 是弹框,不应裁卡截图。
614
- # 会话流画板(气泡/输入坞/键盘)同样不产兜底图:整屏截图含对话气泡与纯文本,
658
+ # 纯文本板(text_only)与对话流画板(气泡/输入坞/键盘)都同样不产兜底图:
659
+ # 整屏截图含对话气泡与纯文本,易被误读为「把整屏做成了卡片」
615
660
  # 易被误读为「把整屏做成了卡片」,且 artboard.png 已可承担参考职责。
616
661
  # 裁切去掉系统 chrome(状态栏 / Home Indicator),保留应用 UI(顶部导航/底部 tab),
617
662
  # 对齐「仅卡片主体」约定。偏移按设计稿高度粗判(刘海屏状态栏 44、老屏 20;
618
663
  # Home Indicator 仅刘海屏 34),均为设计 px,crop_png_masked 内部乘 PREVIEW_SCALE。
619
- if (not card_regions and not overlay and not bottom_sheet
664
+ if (not text_only and not card_regions and not overlay and not bottom_sheet
620
665
  and not conversation_flow
621
666
  and preview_path and preview_path.exists()):
622
667
  try:
@@ -634,8 +679,7 @@ def focus_artboard(ab_raw, preview_path, cdir):
634
679
  focus_skip_reason = "conversation_flow_geometry"
635
680
 
636
681
  spec = {
637
- "canvas": ab_raw.get("name", ""),
638
- "schemaVersion": "1.0",
682
+ "canvas": canvas_name or ab_raw.get("name", ""),
639
683
  "size": {"width": W, "height": H},
640
684
  "cards": card_regions,
641
685
  "rejectedCards": rejected_regions,
@@ -1115,17 +1159,6 @@ def _foreground_mean_color(image_path, rect=None, scale=1.0):
1115
1159
  return (red / count, green / count, blue / count), count
1116
1160
 
1117
1161
 
1118
- def foreground_color_distance(asset_path, preview_path, rect, scale):
1119
- """资产与设计裁切的前景均值色距离;任一侧无前景时返回 None。"""
1120
- asset_mean, asset_count = _foreground_mean_color(asset_path)
1121
- design_mean, design_count = _foreground_mean_color(preview_path, rect, scale)
1122
- if not asset_mean or not design_mean:
1123
- return None
1124
- return math.sqrt(sum(
1125
- (left - right) ** 2 for left, right in zip(asset_mean, design_mean)
1126
- ))
1127
-
1128
-
1129
1162
  def foreground_chroma_distance(asset_path, preview_path, rect, scale):
1130
1163
  """前景均值色的色度距离(r-g / b-g 两分量)。
1131
1164
 
@@ -1198,7 +1231,7 @@ def mark_missing_slice_elements(spec, preview_path, asset_index, asset_root=None
1198
1231
  element.pop("annotationDefect", None)
1199
1232
  element.pop("visualIdentity", None)
1200
1233
  spec.pop("annotationDefects", None)
1201
- image_width, _, _, _ = png_utils.read_png(preview_path)
1234
+ image_width, _, _ = png_utils.read_png_size(preview_path)
1202
1235
  design_width = float((spec.get("size") or {}).get("width") or 375)
1203
1236
  scale = image_width / design_width if design_width else PREVIEW_SCALE
1204
1237
 
@@ -1412,6 +1445,25 @@ def copy_slices(src_assets, dst_slices, matched_rel):
1412
1445
  d.parent.mkdir(parents=True, exist_ok=True)
1413
1446
  shutil.copy2(s, d)
1414
1447
  return list(matched_rel or [])
1448
+
1449
+
1450
+ def require_out_dir(target: Path, what: str) -> None:
1451
+ """落盘前确认输出目录还在。
1452
+
1453
+ 目录是本脚本自己建的(main 里 mkdir(parents=True, exist_ok=True)),但**解析途中**可能被
1454
+ 外部清理掉:另一次 `npm run drill` / `cardbuddy init` / `rm -rf` 撞上同一个 UI/ 就会这样。
1455
+ 此时 write_text 抛的是一句裸 FileNotFoundError: '…/spec.json',看的人只会以为是脚本不会建
1456
+ 目录,排查方向整个跑偏(真实事故:一次 drill 把正在跑的回合连同 UI/ 一起清空了)。
1457
+ 这里显式判一次,把「是谁把目录删了」写进结论里。
1458
+ """
1459
+ if not target.is_dir():
1460
+ raise SystemExit(
1461
+ f"[FAIL] {what}的输出目录在解析途中消失了:{target}\n"
1462
+ f" 脚本开头已建过该目录,多半是另一个进程在清理同一个 UI/ ——\n"
1463
+ f" 比如 npm run drill / cardbuddy init / rm -rf 撞上了本次运行。\n"
1464
+ f" 重跑本命令即可;若反复出现,先确认没有别的清理任务在同时跑。"
1465
+ )
1466
+
1415
1467
  # ---------- 画板选择(与业务无关,纯通用关键字) ----------
1416
1468
  PRIORITY_KEYWORDS = [
1417
1469
  "未登录", "无数据", "无记录", "无交易", "空", "成功", "失败", "错误",
@@ -1420,10 +1472,96 @@ PRIORITY_KEYWORDS = [
1420
1472
  ]
1421
1473
 
1422
1474
 
1475
+ def _split_pick_token(t, total):
1476
+ """单个 --pick token → (板号, 卡号或 None),越界/非数字就在这里报错。"""
1477
+ board_part, _, card_part = t.partition(".")
1478
+ if not board_part.isdigit():
1479
+ raise SystemExit(f"[ERR] --pick 的画板序号必须是数字(1 起,如 8 或 8.1):{t}")
1480
+ board = int(board_part)
1481
+ if board < 1 or board > total:
1482
+ raise SystemExit(f"[ERR] --pick {t} 超范围:本导出共 {total} 个画板,序号 1..{total}")
1483
+ if card_part and not (card_part.isdigit() and int(card_part) >= 1):
1484
+ raise SystemExit(f"[ERR] --pick 的卡号必须是 1 起的数字(如 8.1):{t}")
1485
+ return board, (int(card_part) if card_part else None)
1486
+
1487
+
1488
+ def parse_pick(spec, total):
1489
+ """解析 --pick:画板序号 **1 起**(与清单同口径),可带 `.卡号`(1 起),逗号分隔多个。
1490
+
1491
+ 存在意义:清单上是 1 起的序号、--indices 是 0 起,差一位靠脑补——实测模型先用
1492
+ --indices 8 建了错的画板(34s 白跑 + 一步 LLM),自己发现后才重跑 --indices 7。
1493
+ 用户说「8.1」时,模型只需把用户原话搬进 --pick,不换算。
1494
+
1495
+ 也认清单行尾那种区间写法:`7~9`(连板)、`8.1~8.3`(同板连卡,一板多卡的批量建卡就靠它)。
1496
+ 清单提示与解析必须是同一套语法——提示打得出来、解析器却不认,等于让模型照抄一句必错的命令。
1497
+
1498
+ 返回 (indices, cards):indices 为 0 基画板索引(给 select_canvases 用,去重保序);
1499
+ cards 为 [(板号, 卡号)],卡号供下游 spec_audit --card 用。
1500
+ """
1501
+ idxs, cards = [], []
1502
+
1503
+ def add_board(b):
1504
+ if b - 1 not in idxs:
1505
+ idxs.append(b - 1)
1506
+
1507
+ for token in str(spec).split(","):
1508
+ t = token.strip()
1509
+ if not t:
1510
+ continue
1511
+ if "~" not in t:
1512
+ board, card = _split_pick_token(t, total)
1513
+ add_board(board)
1514
+ if card:
1515
+ cards.append((board, card))
1516
+ continue
1517
+ lo, _, hi = t.partition("~")
1518
+ b1, c1 = _split_pick_token(lo.strip(), total)
1519
+ b2, c2 = _split_pick_token(hi.strip(), total)
1520
+ if c1 is None and c2 is None:
1521
+ if b2 < b1:
1522
+ raise SystemExit(f"[ERR] --pick 区间写反了(起点应不大于终点):{t}")
1523
+ for b in range(b1, b2 + 1):
1524
+ add_board(b)
1525
+ elif b1 == b2 and c1 and c2:
1526
+ if c2 < c1:
1527
+ raise SystemExit(f"[ERR] --pick 卡号区间写反了(起点应不大于终点):{t}")
1528
+ add_board(b1)
1529
+ for c in range(c1, c2 + 1):
1530
+ cards.append((b1, c))
1531
+ else:
1532
+ raise SystemExit(
1533
+ f"[ERR] --pick 的区间两端要同型(都带卡号或都不带,如 7~9、8.1~8.3):{t}")
1534
+ if not idxs:
1535
+ raise SystemExit("[ERR] --pick 至少要给一个画板序号")
1536
+ return idxs, cards
1537
+
1538
+
1539
+ def unique_canvas_names(names):
1540
+ """同名画板消歧:Sketch 允许两个画板**完全同名**(实测「地址管理」稿 25 板里有两个
1541
+ 「11…_失败_再次修改」,一个 375×1232/129 层、一个 375×1252/130 层,是两个不同设计)。
1542
+
1543
+ 目录名直接用 name 时,后跑的那个会覆盖先跑的(整板产物——artboard.png / card-N.png /
1544
+ spec.json / slices/ ——静默丢失,且 manifest 里两条 entry 指向同一目录);下游也是按
1545
+ name 拼路径定位(`generate_cards.mjs`:`uiDir/<name>/spec.json`),两行同名时用户
1546
+ 在选板清单里同样无法区分。
1547
+
1548
+ 故第 2 个及以后的同名画板追加 `-2` / `-3` 后缀,**name 与 slug 同步唯一化**——
1549
+ 下游零改动即正确(它们只认 name)。
1550
+ """
1551
+ seen = {}
1552
+ out = []
1553
+ for nm in names:
1554
+ seen[nm] = seen.get(nm, 0) + 1
1555
+ out.append(nm if seen[nm] == 1 else f"{nm}-{seen[nm]}")
1556
+ return out
1557
+
1558
+
1423
1559
  def select_canvases(all_names, args):
1424
1560
  n = len(all_names)
1425
1561
  if args.list:
1426
1562
  return None
1563
+ if args.pick:
1564
+ return parse_pick(args.pick, n)[0]
1427
1565
  if args.only:
1428
1566
  kws = [k.strip() for k in args.only.split(",") if k.strip()]
1429
1567
  return [i for i, nm in enumerate(all_names) if any(k in nm for k in kws)]
@@ -1509,6 +1647,50 @@ def detect_modal_layers(raw_layers, W=375, H=812):
1509
1647
  return has_scrim, has_bottom_sheet
1510
1648
 
1511
1649
 
1650
+ # 判定「这块区域不是卡片」的拒绝理由全集:纯文本 / 散文段落 / 对话气泡 / 输入坞 / 空区域。
1651
+ # 落在这组里说明区域「本来就不是卡片」,而不是「是卡片但结构弱或结构读错了」——
1652
+ # 后者(single_button_or_bar / repeated_control_bar 之类)一律不在此列:汇率牌价那种宽扁卡
1653
+ # 会被读成「按钮条」,3 宫格选项卡会被读成「控件条」,它们明明是卡,不能当纯文本滤掉。
1654
+ TEXT_ONLY_REJECT_REASONS = {
1655
+ "text_only_or_low_structure_region",
1656
+ "prose_paragraph_region",
1657
+ "conversation_bubble_region",
1658
+ "stacked_conversation_bubbles_region",
1659
+ "conversation_inputDock_region",
1660
+ "conversation_keyboard_region",
1661
+ "empty_region",
1662
+ }
1663
+
1664
+
1665
+ def is_text_only_artboard(spec, raw_layers=None):
1666
+ """画板级「无卡可建」判定:整屏都是文字的对话 / 问答 / 提示页一律不产卡。
1667
+
1668
+ 这类画板(AI 应答气泡 + 输入坞、知识问答、纯文字提示)里没有任何值得落地的卡片结构,
1669
+ 留在产卡池里只会让选板清单变噪音、让下游白跑一轮——用户 2026-09-20 规则。
1670
+
1671
+ 注意「无卡」≠「纯文本」,收口条件要卡死,否则会把「对话里嵌着一张卡」的画板一起丢掉:
1672
+ · 弹框 / 底部抽屉同样 0 卡,但那是 alertDialog / alertCard 的形态,不在这里判;
1673
+ · 候选区域被否的理由只要有一个不属于「本来就不是卡」(例如 single_button_or_bar,
1674
+ 那是「读起来像卡但规则没认」的窄卡)就一律保留——汇率牌价这类宽扁卡就靠这条保住;
1675
+ · 板上有卡片级大盘切图(卡片被整体导成一张图)也保留。
1676
+ 只有「所有候选都被纯文本类理由否掉、且找不到任何卡片级结构」才判纯文本。
1677
+ 其余「0 卡但结构丰富」的画板(骨架屏、全屏状态页等)保持现状,交给下游裁决。
1678
+ """
1679
+ if spec.get("cards"):
1680
+ return False
1681
+ if spec.get("focusSkipReason") in ("overlay_dialog_geometry", "bottom_sheet_geometry"):
1682
+ return False
1683
+ # 卡片被设计稿整体导成一张切图时(画板里只剩那张大图),检测器看不到结构,但板上
1684
+ # 明明有卡——这类一律保留,否则「对话 + 内嵌卡片」的状态会被当成纯文本丢掉。
1685
+ if raw_layers:
1686
+ size = spec.get("size") or {}
1687
+ if vision.has_card_like_visual(vision._flatten_with_tree(raw_layers),
1688
+ size.get("width") or 375, size.get("height") or 812):
1689
+ return False
1690
+ reasons = {r.get("reason") for r in (spec.get("rejectedCards") or [])}
1691
+ return bool(reasons) and reasons <= TEXT_ONLY_REJECT_REASONS
1692
+
1693
+
1512
1694
  def classify_artboard(name, spec, raw_layers, H=812):
1513
1695
  """返回 (artboardType, interaction)。视觉信号优先,名称仅兜底(保守)。
1514
1696
  判定顺序:
@@ -1527,7 +1709,10 @@ def classify_artboard(name, spec, raw_layers, H=812):
1527
1709
  return "subcard", "alertCard 子卡(视觉信号:底部抽屉面板,由父卡 alertCard 打开)"
1528
1710
  if has_scrim:
1529
1711
  return "overlay_dialog", "alertDialog 系统弹框(视觉信号:全屏遮罩)"
1530
- # 2) 名称兜底:通用 UI 容器类型词(非业务实体名)
1712
+ # 2) 画板级:纯文本(对话/问答/提示)整板无卡 → text_only,下游不得为其建卡
1713
+ if is_text_only_artboard(spec, raw_layers):
1714
+ return "text_only", "无卡可建(纯文本对话/问答/提示,未检出卡片区域)"
1715
+ # 3) 名称兜底:通用 UI 容器类型词(非业务实体名)
1531
1716
  nm = normalize_name(name)
1532
1717
  if any(rx.search(nm) for rx in _SUBCARD_RE):
1533
1718
  return "subcard", "alertCard 子卡(名称兜底:通用容器类型词)"
@@ -1606,10 +1791,46 @@ def preview_image_for(src, base, ab, name):
1606
1791
  return None
1607
1792
 
1608
1793
 
1609
- def print_board_line(i, nm, ab, src, base):
1794
+ def board_tag(verdict):
1795
+ """清单行尾的体检标记:一眼看出这个画板能不能建卡。"""
1796
+ if not verdict:
1797
+ return ""
1798
+ if verdict["kind"] == "text_only":
1799
+ return " [纯文本,无卡可建 · 已过滤]"
1800
+ if verdict["kind"] == "subcard":
1801
+ return " [子卡 alertCard]"
1802
+ if verdict["kind"] == "overlay_dialog":
1803
+ return " [弹框 alertDialog]"
1804
+ if verdict.get("cards"):
1805
+ return f" [{verdict['cards']} 卡]"
1806
+ return " [0 卡 · 结构丰富,按整图参考建卡]"
1807
+
1808
+
1809
+ def board_verdict(name, ab):
1810
+ """画板体检(纯几何,不读图):这个画板进不进「可建卡选项池」,以及它是什么类型。
1811
+
1812
+ 返回 {kind, cards, skip, interaction}。kind 取 classify_artboard 的口径(card /
1813
+ subcard / overlay_dialog / text_only),所以清单上写的话与真跑产物里的 artboardType
1814
+ 永远一致——两处都走 detect_artboard_cards + classify_artboard,没有第二套判定。
1815
+ """
1816
+ ab = ab or {}
1817
+ found = detect_artboard_cards(ab)
1818
+ probe = {
1819
+ "cards": found["cards"],
1820
+ "rejectedCards": found["rejected"],
1821
+ "conversationFlow": found["conversationFlow"],
1822
+ "focusSkipReason": found["focusSkipReason"],
1823
+ "size": {"width": found["W"], "height": found["H"]},
1824
+ }
1825
+ at, interaction = classify_artboard(name, probe, found["layers"], found["H"])
1826
+ return {"kind": at, "cards": len(found["cards"]), "interaction": interaction,
1827
+ "skip": at == "text_only"}
1828
+
1829
+
1830
+ def print_board_line(i, nm, ab, src, base, verdict=None):
1610
1831
  """门禁/清单统一行格式:序号行 + 下一行该画板自己的预览图(一一对应;缺图标缺失)。"""
1611
1832
  sz = f'{ab.get("width")}x{ab.get("height")}' if ab else "?"
1612
- print(f" {i + 1:>2}. {nm} ({sz})")
1833
+ print(f" {i + 1:>2}. {nm} ({sz}){board_tag(verdict)}{pick_hint(i, verdict)}")
1613
1834
  img = preview_image_for(src, base, ab, nm)
1614
1835
  if img:
1615
1836
  print(f" 图: {img}")
@@ -1617,15 +1838,184 @@ def print_board_line(i, nm, ab, src, base):
1617
1838
  print(" 图: (预览图缺失:preview/<slug>.png 与 UI 产物 artboard.png 均不存在)")
1618
1839
 
1619
1840
 
1841
+ def pick_hint(i, verdict):
1842
+ """清单行尾的「照抄就能用」参数:板号 1 起、卡号 1 起,直接喂 --pick。
1843
+
1844
+ 0 基的 --indices 与用户嘴里的「第 8 个画板第 1 张卡」差一位,实测模型会用错、
1845
+ 发现后重跑一整次构建(34s + 一步 LLM)。把可复制的 --pick 打在它自己那行上,
1846
+ 模型只需要搬字,不需要换算。
1847
+ """
1848
+ if verdict and verdict.get("kind") == "text_only":
1849
+ return ""
1850
+ cards = (verdict or {}).get("cards") or 0
1851
+ if cards > 1:
1852
+ return f" --pick {i + 1}.1~{i + 1}.{cards}"
1853
+ if cards == 1:
1854
+ return f" --pick {i + 1}.1"
1855
+ return f" --pick {i + 1}"
1856
+
1857
+
1858
+ def print_board_pool(names, artboards, src, base, all_label):
1859
+ """清单分两段打:可建卡选项池(带序号 + 各自预览图)与已过滤(纯文本,无卡可建)。
1860
+
1861
+ 过滤只针对**整屏纯文本**的画板(is_text_only_artboard):它们放在选项池里只会让用户
1862
+ 选到没有卡可建的板子。序号保持全量 1..n 不变(回复 k → --indices k-1 仍对得上),
1863
+ 被过滤的板子只列名字、不配图,不制造「这是个选项」的错觉。
1864
+ 返回 (可建卡序号列表, 已过滤序号列表)。
1865
+ """
1866
+ verdicts = [board_verdict(names[i], artboards[i] if i < len(artboards) else None)
1867
+ for i in range(len(names))]
1868
+ pool = [i for i, v in enumerate(verdicts) if not v["skip"]]
1869
+ filtered = [i for i, v in enumerate(verdicts) if v["skip"]]
1870
+ for i in pool:
1871
+ print_board_line(i, names[i], artboards[i] if i < len(artboards) else None,
1872
+ src, base, verdicts[i])
1873
+ print(all_label)
1874
+ if filtered:
1875
+ print(f" —— 另有 {len(filtered)} 个纯文本画板(对话/问答/提示)无卡可建,已过滤不进选项池"
1876
+ f"(序号仍按全量 1..n 编,回复其序号也不会产卡):")
1877
+ for i in filtered:
1878
+ print(f" {i + 1}. {names[i]}")
1879
+ return pool, filtered
1880
+
1881
+
1882
+ def _candidate_export_dirs(roots, max_depth=3, limit=600):
1883
+ """在若干根目录下做**有限广度**扫描,收集「含 index.html 的目录」(Sketch MeaXure 导出根的判据)。
1884
+
1885
+ - 深度 3 是实测出来的:真实导出根常住在 `~/Downloads/<资料包>/<已确认的>/<稿名>/`,
1886
+ 只扫 2 层会正好漏掉它(本次自测就是这么漏的)。
1887
+ - 命中导出根就不再往下钻(它内部还有 preview/ assets/,再钻只会灌满噪音)。
1888
+ - 按 **realpath** 去重:`/tmp` 与 `/private/tmp` 是同一份目录的两种写法,
1889
+ 不去重会让「唯一高分候选」变成两个并列项,把下面的自动修正判据直接顶掉。
1890
+ """
1891
+ found, seen, seen_real = [], set(), set()
1892
+ for root in roots:
1893
+ root = Path(root).expanduser()
1894
+ if not root.is_dir():
1895
+ continue
1896
+ stack = [(root, 0)]
1897
+ while stack:
1898
+ d, depth = stack.pop()
1899
+ key = str(d)
1900
+ if key in seen:
1901
+ continue
1902
+ seen.add(key)
1903
+ # 探测整体兜住 OSError:本机 python 是 3.9,`Path.is_file()` 在无权限目录上**会抛
1904
+ # PermissionError**(3.12 起才改成返回 False)。扫到 /private/var/root 这类目录时不兜,
1905
+ # 整个脚本就崩在探测里——比原来「直接报错」还糟。
1906
+ try:
1907
+ if (d / "index.html").is_file():
1908
+ real = str(d.resolve())
1909
+ if real not in seen_real:
1910
+ seen_real.add(real)
1911
+ found.append(d)
1912
+ continue
1913
+ if depth >= max_depth:
1914
+ continue
1915
+ children = sorted(d.iterdir())
1916
+ except (PermissionError, OSError):
1917
+ continue
1918
+ for child in children:
1919
+ if child.name.startswith("."):
1920
+ continue
1921
+ # 同上的 3.9 坑:`is_dir()` 在受保护目录(如 /private/var/db/DifferentialPrivacy)
1922
+ # 上会抛 PermissionError,逐个子项兜
1923
+ try:
1924
+ if child.is_dir():
1925
+ stack.append((child, depth + 1))
1926
+ except (PermissionError, OSError):
1927
+ continue
1928
+ if len(found) > limit:
1929
+ return found
1930
+ return found
1931
+
1932
+
1933
+ def _path_affinity(src, cand):
1934
+ """候选与请求路径的「同侧程度」= 公共父级目录层数。
1935
+
1936
+ 同一份稿常被拷到 Downloads 与 Desktop 两处(同名同分)。此时该选离调用方给的路径更近的那个,
1937
+ 而不是让两个同分候选把自动修正判据顶掉。
1938
+ """
1939
+ n = 0
1940
+ for x, y in zip(src.parent.parts, cand.parent.parts):
1941
+ if x != y:
1942
+ break
1943
+ n += 1
1944
+ return n
1945
+
1946
+
1947
+ def _dir_mtime(d):
1948
+ """目录最近改动时间(探测失败取 0):没给 --src 时用它把「刚拖进来的那份稿」排前面。"""
1949
+ try:
1950
+ return d.stat().st_mtime
1951
+ except (PermissionError, OSError):
1952
+ return 0.0
1953
+
1954
+
1955
+ def resolve_src(raw_src, explicit=True):
1956
+ """把 --src 解析成真实导出根:能唯一定位就自动修正,否则回一份候选清单。
1957
+
1958
+ 实测两次白烧(2026-09-21 建卡回合各烧掉一步工具调用):
1959
+ ① 没带 --src ⇒ 默认 cwd(工程根)没有 index.html,直接 [ERR] 退出;
1960
+ ② 目录名拼重复(黑马大赛-黑马大赛积分助手ui设计稿)⇒ 路径不存在,同样直接退出。
1961
+ 两次都「差一点点就对」。这里把它们变成一次自动修正,或一份可直接照抄的候选清单。
1962
+
1963
+ 自动修正只在高置信时发生(名字高度相似且明显强于第二名),且必定打 [WARN] 说明——
1964
+ 宁可让调用方多看一眼,也不要在多稿目录里默默建错稿。
1965
+
1966
+ @return (src_or_None, ranked) —— ranked 为 [(相似度, Path), ...],供失败时打印候选。
1967
+ """
1968
+ src = Path(raw_src).expanduser()
1969
+ if (src / "index.html").is_file():
1970
+ return src, []
1971
+
1972
+ roots = []
1973
+ for r in (Path.cwd(), Path.cwd().parent, Path.home() / "Downloads", Path.home() / "Desktop"):
1974
+ if r not in roots:
1975
+ roots.append(r)
1976
+ # 调用方给的路径本身是条线索:它的父目录最值得先扫
1977
+ if src.parent.is_dir() and src.parent not in roots:
1978
+ roots.insert(0, src.parent)
1979
+
1980
+ cands = _candidate_export_dirs(roots)
1981
+ if not cands:
1982
+ return None, []
1983
+ want = src.name or ""
1984
+ ranked = sorted(
1985
+ ((difflib.SequenceMatcher(None, want, c.name).ratio(), _path_affinity(src, c), c) for c in cands),
1986
+ key=lambda x: (-x[0], -x[1]),
1987
+ )
1988
+ # ① 没显式给 --src:全库只有一个导出根 ⇒ 就是它;否则按「最近改动」排候选
1989
+ # (刚拖进来的那份稿排最前,比按名字乱序有用)
1990
+ if not explicit:
1991
+ if len(cands) == 1:
1992
+ return cands[0], ranked
1993
+ recent = sorted(cands, key=_dir_mtime, reverse=True)
1994
+ return None, [(0.0, 0, c) for c in recent]
1995
+ best_ratio, _aff, best = ranked[0]
1996
+ # ② 给了但拼错:名字高度相似、且明显强于**名字不同的**第二名,才敢自动修正。
1997
+ # 同名候选(同一份稿拷到 Downloads 与 Desktop)是同一个答案、不是竞争答案,
1998
+ # 不排除它会让「唯一定位」永远不成立。
1999
+ rival = next((r for r, _a, c in ranked if c.name != best.name), None)
2000
+ if best_ratio >= 0.75 and (rival is None or rival < best_ratio - 0.05):
2001
+ return best, ranked
2002
+ return None, ranked
2003
+
2004
+
1620
2005
  def main():
1621
2006
  ap = argparse.ArgumentParser(description="Sketch MeaXure 标注 → 卡片要素包(视觉驱动)")
1622
- ap.add_argument("--src", default=str(DEFAULT_SRC))
2007
+ ap.add_argument("--src", default=None,
2008
+ help="Sketch MeaXure 导出根(含 index.html)。缺省或用错时自动探测:能唯一定位就自动修正,"
2009
+ "否则打印候选清单(照抄其一作为 --src)")
1623
2010
  ap.add_argument("--out", default=str(DEFAULT_OUT))
1624
2011
  ap.add_argument("--list", action="store_true")
1625
2012
  ap.add_argument("--scope-all", action="store_true",
1626
2013
  help="显式授权跑全部画板:仅当用户回复「全部」后才可加(无人值守批量训练可直接加或设 SKETCH_SPEC_SCOPE_ALL=1)")
1627
2014
  ap.add_argument("--only", default=None)
1628
2015
  ap.add_argument("--indices", default=None)
2016
+ ap.add_argument("--pick", default=None,
2017
+ help="按清单序号选板(1 起,可带卡号:8 或 8.1,逗号分隔多个)——"
2018
+ "清单行尾的 --pick 值照抄即用,不必换算 0 基的 --indices")
1629
2019
  ap.add_argument("--sample", type=int, default=0)
1630
2020
  ap.add_argument("--build", action="store_true")
1631
2021
  ap.add_argument("--artboard-max-side", type=int, default=640,
@@ -1634,13 +2024,26 @@ def main():
1634
2024
  help="按稿分片(可选):画板落盘到 <out>/<draft>/<canvas>/,并写 <out>/<draft>/manifest.json;缺省平铺(兼容旧布局)")
1635
2025
  args = ap.parse_args()
1636
2026
 
1637
- src = Path(args.src).expanduser()
2027
+ explicit_src = args.src is not None
2028
+ raw_src = args.src or str(DEFAULT_SRC)
2029
+ src, cands = resolve_src(raw_src, explicit=explicit_src)
1638
2030
  out = Path(args.out).expanduser().resolve()
1639
2031
  # 按稿分片:指定 --draft 时,所有画板与 manifest 落在 <out>/<draft>/ 下(UI 多稿治理,item 19)
1640
2032
  base = out / args.draft if args.draft else out
1641
- if not (src / "index.html").exists():
1642
- print(f"[ERR] 未找到 {src / 'index.html'},请用 --src 指定导出目录", file=sys.stderr)
2033
+ if src is None:
2034
+ wanted = Path(raw_src).expanduser() / "index.html"
2035
+ if cands:
2036
+ print(f"[ERR] {wanted} 不存在——它不是 Sketch MeaXure 导出根(导出根必须直接含 index.html)", file=sys.stderr)
2037
+ print("[ERR] 已发现这些导出根,照抄其一作为 --src 重跑:", file=sys.stderr)
2038
+ for _ratio, _aff, c in cands[:8]:
2039
+ print(f" --src {c}", file=sys.stderr)
2040
+ else:
2041
+ print(f"[ERR] 未找到 {wanted},且常见位置(cwd / 上级 / ~/Downloads / ~/Desktop)也没扫到导出根。", file=sys.stderr)
2042
+ print("[ERR] 请用 --src 指向 Sketch MeaXure 导出目录(该目录下应直接有 index.html)。", file=sys.stderr)
1643
2043
  sys.exit(1)
2044
+ if src != Path(raw_src).expanduser():
2045
+ why = "未显式指定 --src" if not explicit_src else f"--src={Path(raw_src).expanduser()} 下没有 index.html"
2046
+ print(f"[WARN] {why}:已自动选用 {src}(含 index.html)。若不对,请显式 --src 指定。", file=sys.stderr)
1644
2047
 
1645
2048
  data = extract_data(src / "index.html")
1646
2049
  artboards = data.get("artboards", [])
@@ -1648,36 +2051,52 @@ def main():
1648
2051
  print(f"[INFO] 解析到 {total} 个画布(来源:{src.name})")
1649
2052
 
1650
2053
  names = [ab.get("name", f"artboard-{i}") for i, ab in enumerate(artboards)]
2054
+ # 同名画板唯一化:name 同时是目录名、manifest 标识与下游定位键,必须唯一(详见
2055
+ # unique_canvas_names)。清单展示也用唯一名——用户看到的行与落盘目录、下游定位键三者一致。
2056
+ dir_names = unique_canvas_names(names)
1651
2057
  if args.list:
1652
2058
  # 展示统一 1 起(与多画板门禁清单同口径);--indices 仍为 0 起,用户序号 k → k-1
1653
2059
  # 每个画板行下面紧跟它自己那张预览图(一一对应;缺图显式标缺失),禁止把图堆在末尾
1654
- for i, nm in enumerate(names):
1655
- print_board_line(i, nm, artboards[i] if i < len(artboards) else None, src, base)
1656
- print(" 全部. 全部")
2060
+ print_board_pool(dir_names, artboards, src, base, " 全部. 全部")
1657
2061
  return
1658
2062
 
1659
2063
  # ── 多画板选择门禁(rules.md §十三 / SKILL.md §2 步骤 2,2026-09-17 机械兜底)──
1660
2064
  # 未指定范围的生成运行必须先让用户从 1..n + 全部 里选;「全部」是用户的回答,不是 Agent 的默认值。
1661
2065
  # 这里做机械卡点:打印清单后 exit 4,拿到用户回答再用 --indices / --only / --scope-all 重跑。
1662
- has_scope = bool(args.only or args.indices or args.sample)
2066
+ has_scope = bool(args.only or args.indices or args.pick or args.sample)
1663
2067
  scope_all_env = os.environ.get("SKETCH_SPEC_SCOPE_ALL", "") in ("1", "true", "yes")
1664
2068
  if not has_scope and not args.scope_all and not scope_all_env:
1665
2069
  print(f"[GATE] 多画板选择门禁(rules.md §十三):本导出共 {total} 个画板,本次运行未指定范围"
1666
2070
  f"(--only/--indices/--sample/--scope-all 均未给)。")
1667
2071
  print("[GATE] 按铁律必须先把下面的清单(1..n + 全部)交给用户并**停止输出等回复**,"
1668
2072
  "不得自行跑全量(「生成产物 / 前处理」请求同样适用):")
1669
- for i, nm in enumerate(names):
1670
- print_board_line(i, nm, artboards[i] if i < len(artboards) else None, src, base)
1671
- print(" 全部. 全部(整份导出全量跑 UI 产物;产卡时视为显式批量授权)")
1672
- print("[GATE] 拿到回复后重跑:回复序号 k → --indices <k-1>(0 起,多个用逗号);"
1673
- "回复「全部」→ --scope-all;无人值守批量训练可直接 --scope-all 或 SKETCH_SPEC_SCOPE_ALL=1。")
2073
+ print("[GATE] 清单已按体检结果分段:纯文本画板(对话/问答/提示)无卡可建,已过滤出选项池。")
2074
+ print_board_pool(dir_names, artboards, src, base,
2075
+ " 全部. 全部(整份导出全量跑 UI 产物;产卡时视为显式批量授权"
2076
+ "——纯文本画板只出参考图,不出卡)")
2077
+ print("[GATE] 拿到回复后重跑:**直接照抄清单行尾的 --pick**(板号 1 起、卡号 1 起,"
2078
+ "如用户说「8.1」就 --pick 8.1;多张逗号分隔 --pick 8.1,9.2);"
2079
+ "等价的老写法是 --indices <k-1>(0 起)。回复「全部」→ --scope-all;"
2080
+ "无人值守批量训练可直接 --scope-all 或 SKETCH_SPEC_SCOPE_ALL=1。")
1674
2081
  sys.exit(4)
1675
2082
 
1676
- sel = select_canvases(names, args)
2083
+ sel = select_canvases(dir_names, args)
1677
2084
  if not sel:
1678
2085
  print("[WARN] 没有匹配到任何画布。", file=sys.stderr)
1679
2086
  sys.exit(0)
1680
- print(f"[INFO] 本次生成 {len(sel)} 个画布:{', '.join(names[i] for i in sel)}")
2087
+ # 显式点名纯文本画板时不拦(用户可能只想拿参考图),但必须说清楚不会出卡
2088
+ for i in sel:
2089
+ v = board_verdict(dir_names[i], artboards[i] if i < len(artboards) else None)
2090
+ if v["skip"]:
2091
+ print(f"[WARN] {dir_names[i]}:纯文本画板(对话/问答/提示),无卡可建——"
2092
+ f"本次只会产出整图参考与判定依据,不会出卡。", file=sys.stderr)
2093
+ print(f"[INFO] 本次生成 {len(sel)} 个画布:{', '.join(dir_names[i] for i in sel)}")
2094
+ if args.pick:
2095
+ _, picked_cards = parse_pick(args.pick, total)
2096
+ if picked_cards:
2097
+ print("[PICK] 卡号提示:" + " / ".join(
2098
+ f"画板 {b} 第 {c} 张卡 → 下游 spec_audit.mjs 用 --card {c}" for b, c in picked_cards))
2099
+ print(f"[PICK] 等价写法 --indices {','.join(str(i) for i in sel)}(0 起)")
1681
2100
 
1682
2101
  assets_dir = src / "assets"
1683
2102
  asset_index = build_asset_index(assets_dir)
@@ -1710,7 +2129,8 @@ def main():
1710
2129
  previous_entries = {}
1711
2130
 
1712
2131
  for i, ab in enumerate(artboards):
1713
- name = names[i]
2132
+ name = dir_names[i] # 目录名 / manifest 标识 / 下游定位键(同名已唯一化)
2133
+ orig_name = names[i] # Sketch 原始名:只用于分类兜底与预览图兜底探测
1714
2134
  status = "generated" if i in sel else "pending"
1715
2135
  entry = {
1716
2136
  "order": i + 1,
@@ -1719,6 +2139,9 @@ def main():
1719
2139
  "status": status,
1720
2140
  "trainingEligible": False,
1721
2141
  }
2142
+ if name != orig_name:
2143
+ # 同名画板被消歧(name 追加了 -2/-3):留下原始名便于人回溯这板在 Sketch 里叫什么
2144
+ entry["nameOriginal"] = orig_name
1722
2145
  if i not in sel and name in previous_entries:
1723
2146
  for inherited in ("status", "trainingEligible", "artboardType", "preview"):
1724
2147
  if inherited in previous_entries[name]:
@@ -1734,7 +2157,11 @@ def main():
1734
2157
  preview_rel = unquote(ab.get("imagePath", "") or "")
1735
2158
  src_png = (src / preview_rel) if preview_rel else None
1736
2159
  if not (src_png and src_png.is_file()):
1737
- cand = [c for c in src.glob(f"preview/*{name.split('_', 1)[-1]}*.png") if c.is_file()]
2160
+ # 注意:只对 orig_name 兜底——唯一化后缀(-2)在导出目录里不存在,
2161
+ # 拿 name 去 glob 必然空手。
2162
+ # 已知局限:同名画板且 imagePath 也缺失时,两板 tail 相同、glob 命中多张
2163
+ # 且顺序不定,可能取到同一张图。这是「双缺失」场景,属兜底兜不住,不在此展开。
2164
+ cand = [c for c in src.glob(f"preview/*{orig_name.split('_', 1)[-1]}*.png") if c.is_file()]
1738
2165
  src_png = cand[0] if cand else None
1739
2166
  if src_png:
1740
2167
  shutil.copy2(src_png, cdir / "artboard.png")
@@ -1754,16 +2181,20 @@ def main():
1754
2181
  print(f" [WARN] {name}: 找不到整图 {preview_rel}", file=sys.stderr)
1755
2182
 
1756
2183
  # 视觉聚焦:卡片区域检测 + 裁切
1757
- spec = focus_artboard(ab, src_png, cdir)
2184
+ # 传唯一名:spec.canvas 是下游展示名(spec_tree.mjs:140)与 prompt.md 标题
2185
+ spec = focus_artboard(ab, src_png, cdir, canvas_name=name)
1758
2186
 
1759
2187
  # 交互分类(视觉信号优先,名称兜底 — 详见 classify_artboard)
1760
- at, interaction = classify_artboard(name, spec, ab.get("layers") or [], ab.get("height", 812))
2188
+ # 传 orig_name:名称模式是子串匹配,唯一化后缀(-2)不该参与语义判定
2189
+ at, interaction = classify_artboard(orig_name, spec, ab.get("layers") or [], ab.get("height", 812))
1761
2190
  spec["artboardType"] = at
1762
2191
  spec["interaction"] = interaction
1763
2192
  # JSAPI 交互控件检测(密码/验证码/数字键盘/toast/二次确认)
1764
2193
  spec["jsapiInteractions"] = detect_jsapi_interactions(spec, ab.get("layers") or [])
1765
2194
  entry["artboardType"] = at
1766
- entry["trainingEligible"] = at == "card"
2195
+ # 训练资格:卡片类型 + 真检出卡片区域。0 卡的画板(纯文本对话/问答/提示)没有
2196
+ # 可训练对象,标了 true 会一路污染训练集,这里按「有没有卡」收口。
2197
+ entry["trainingEligible"] = at == "card" and bool(spec["cards"])
1767
2198
 
1768
2199
  # 切图:逐元素建立导出映射,未命中素材的标注降级为整图采样纯色。
1769
2200
  slice_names = spec.pop("slices", [])
@@ -1779,13 +2210,36 @@ def main():
1779
2210
  )
1780
2211
  spec["slices"] = slices_rel
1781
2212
 
2213
+ # 切图索引(C5):slices/ 是嵌套目录(如 8.icon/通用/警示-#999@3x.png),没有索引时
2214
+ # 模型只能 fs_list 逐层摸索真名(gen-1 实测 6 次 fs_list + 2 次 fs_copy 手工搬运)。
2215
+ # 有了索引,第一次写 IR 就能对上真名(引用名按 R31 消毒,expand_ir 递归命中)。
2216
+ try:
2217
+ _slice_root = cdir / "slices"
2218
+ _slice_idx = [
2219
+ {"file": str(p.relative_to(_slice_root)).replace(os.sep, "/"), "bytes": p.stat().st_size}
2220
+ for p in sorted(_slice_root.rglob("*"))
2221
+ if p.is_file() and p.name != "index.json" and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".svg"}
2222
+ ]
2223
+ (_slice_root / "index.json").write_text(
2224
+ json.dumps(_slice_idx, ensure_ascii=False, indent=2), encoding="utf-8")
2225
+ except Exception as e: # noqa
2226
+ print(f" [WARN] slices/index.json 写入失败(不阻塞要素包):{e}", file=sys.stderr)
2227
+
1782
2228
  # 画板是否无卡(0 卡 → 标记跳过,保留整图供参考)
1783
2229
  # 仅当既无独立卡片、又元素极少(<3)才视为真正空画板;
1784
2230
  # 元素丰富但无卡的情况可能是底部弹窗/全屏覆盖层,保留 status 供下游决策。
1785
- if not spec["cards"] and len(spec.get("elements", [])) < 3:
2231
+ if at == "text_only":
2232
+ # 整屏纯文本(对话/问答/提示):没有任何可建卡结构,直接跳过不产卡。
2233
+ # 选板清单里也不再作为候选出现(见 print_board_line / board_verdict)。
2234
+ entry["status"] = "skipped"
2235
+ spec["skippedReason"] = (
2236
+ "纯文本画板(对话/问答/提示):未检出任何可建卡区域,不产卡"
2237
+ )
2238
+ elif not spec["cards"] and len(spec.get("elements", [])) < 3:
1786
2239
  entry["status"] = "skipped"
1787
2240
  spec["skippedReason"] = "视觉检测无独立卡片元素(纯对话/提示/无卡片画板)"
1788
2241
 
2242
+ require_out_dir(cdir, f"画板 {name} 的要素包")
1789
2243
  (cdir / "spec.json").write_text(
1790
2244
  json.dumps(spec, ensure_ascii=False, indent=2), encoding="utf-8")
1791
2245
 
@@ -1796,6 +2250,7 @@ def main():
1796
2250
 
1797
2251
  manifest["canvases"].append(entry)
1798
2252
 
2253
+ require_out_dir(base, "画板清单")
1799
2254
  (base / "manifest.json").write_text(
1800
2255
  json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
1801
2256
  print(f"[DONE] manifest.json 已写入 {base / 'manifest.json'}")