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
@@ -6,17 +6,68 @@
6
6
  (preview/*.png,8-bit RGB、无交织)裁切聚焦卡片区域。
7
7
  """
8
8
  import math
9
+ import os
9
10
  import struct
10
11
  import zlib
11
12
 
12
13
  _SIG = b"\x89PNG\r\n\x1a\n"
13
14
 
15
+ # 解码结果缓存:同一张图在一次运行里会被反复读(取色 / 掩码 / 逐切图裁切都各读一遍全图),
16
+ # 实测单画板构建 52 次 read_png 里 40 次是同一张 preview,占掉 31s / 35s。键带上
17
+ # mtime 与 size,文件被改写即自动失效;行是 bytes、调用方只读,故可直接共享同一份 rows。
18
+ # 上限按「张」计:一张 750×2204 RGB 解出来约 5MB,8 张封顶约 40MB。
19
+ _READ_CACHE = {}
20
+ _READ_CACHE_MAX = 8
21
+
22
+
23
+ def read_png_size(path):
24
+ """只解析 IHDR 拿 (width, height, channels),不解 IDAT。
25
+
26
+ 给「只要尺寸」的调用方用(例如按设计宽度推缩放比例):33 字节 vs 整图重建,
27
+ 大图上差三个数量级。
28
+ """
29
+ with open(path, "rb") as fh:
30
+ head = fh.read(33)
31
+ if not head.startswith(_SIG):
32
+ raise ValueError("不是 PNG 文件: " + str(path))
33
+ (width, height, bit_depth, color_type,
34
+ _comp, _filt, interlace) = struct.unpack(">IIBBBBB", head[16:29])
35
+ if bit_depth != 8:
36
+ raise ValueError(f"仅支持 8-bit PNG(实际 {bit_depth})")
37
+ if interlace != 0:
38
+ raise ValueError("不支持交织 PNG")
39
+ if color_type == 0:
40
+ channels = 1
41
+ elif color_type == 2:
42
+ channels = 3
43
+ elif color_type == 6:
44
+ channels = 4
45
+ else:
46
+ raise ValueError(f"不支持的 colorType={color_type}")
47
+ return width, height, channels
48
+
14
49
 
15
50
  def read_png(path):
16
51
  """解码 PNG -> (width, height, channels, rows)。
17
52
  rows: list[bytes],每行 width*channels 字节(已去 filter)。
18
53
  channels: 1=灰度 3=RGB 4=RGBA
54
+
55
+ 带进程内缓存:键是 (路径, mtime_ns, 大小),同一次运行里重复读同一张图只解码一次。
19
56
  """
57
+ st = os.stat(path)
58
+ key = (str(path), st.st_mtime_ns, st.st_size)
59
+ hit = _READ_CACHE.get(key)
60
+ if hit is not None:
61
+ return hit
62
+ result = _read_png_uncached(path)
63
+ if len(_READ_CACHE) >= _READ_CACHE_MAX:
64
+ _READ_CACHE.clear()
65
+ _READ_CACHE[key] = result
66
+ return result
67
+
68
+
69
+ def _read_png_uncached(path):
70
+ """真正的解码实现;外部一律走 read_png(带缓存)。"""
20
71
  data = open(path, "rb").read()
21
72
  if not data.startswith(_SIG):
22
73
  raise ValueError("不是 PNG 文件: " + str(path))
@@ -251,11 +302,6 @@ def crop_png_masked(path, box, out_path, scale=1.0, shapes=None):
251
302
  write_png(out_path, cw, ch, 4, out_rows)
252
303
 
253
304
 
254
- def _paeth(a, b, c):
255
- pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
256
- return a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
257
-
258
-
259
305
  def write_png(path, width, height, channels, rows):
260
306
  """写 8-bit 非交织 PNG(filter 全 0,简单可靠)。"""
261
307
  stride = width * channels
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """纯文本画板过滤的回归自检(直跑:python skills/sketch-spec-to-card/scripts/test_text_only_artboard.py)
3
+
4
+ 覆盖用户 2026-09-20 规则:整屏纯文本的对话 / 问答 / 提示画板不产卡,且不能误伤
5
+ 「对话里嵌着一张真卡」的状态。用例按真实稿(喜豆AI银行 24 稿)的特征构造:
6
+
7
+ 1. 纯文本对话页(信用卡返现 01/03 型):气泡里只有文字 → text_only,被过滤;
8
+ 2. 对话 + 内嵌矢量卡(理财 06 型):卡壳里是 3 宫格选项,检测器会读成「控件条」→ 保留;
9
+ 3. 对话 + 卡片切图(房贷 32 型):卡片被整体导成一张切图 → 保留;
10
+ 4. 底部抽屉(喜豆 04/05/06 型):alertCard 子卡形态 → 保留(subcard),
11
+ 且**产 1 张卡**(矩形由 `vision._bottom_sheet_rect` 几何直取,非候选检测);
12
+ 5. 全屏遮罩弹框 → 保留(overlay_dialog);
13
+ 6. 0 候选的普通文字页:保守保留,交给下游裁决(维持既有行为)。
14
+
15
+ 断言分两层:`kind/skip`(画板类型判定,6 例)+ `cards` 数(产出,注意卡片型用例
16
+ 允许 0 —— kind 与 cards 数解耦是上游有意设计)+ 抽屉元素归属(边界气泡不得渗入)。
17
+ """
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ import parse_meaxure as pm # noqa: E402
23
+ import vision # noqa: E402
24
+
25
+
26
+ def _fill(hexv):
27
+ return [{"fillType": "Color", "color": {"color-hex": hexv}}]
28
+
29
+
30
+ def L(name, type_, x, y, w, h, radius=None, hexv=None, layers=None, opacity=None):
31
+ d = {"name": name, "type": type_, "rect": {"x": x, "y": y, "width": w, "height": h}}
32
+ if hexv:
33
+ d["fills"] = _fill(hexv)
34
+ if radius:
35
+ d["radius"] = radius
36
+ if layers:
37
+ d["layers"] = layers
38
+ if opacity is not None:
39
+ d["opacity"] = opacity
40
+ return d
41
+
42
+
43
+ def ab(name, layers, w=375, h=812):
44
+ return {"name": name, "width": w, "height": h, "layers": layers}
45
+
46
+
47
+ def chrome():
48
+ """状态栏 / 标题栏 / 弥散装饰 / 输入坞 / 底部导航——四类画板共用的界面壳。"""
49
+ return [
50
+ L("98.全局/系统控件/状态栏", "symbol", 0, 0, 375, 44),
51
+ L("5.导航/标题栏/黑-标题+三icon", "symbol", 0, 0, 375, 88),
52
+ L("椭圆形", "shape", 0, 0, 229, 260, hexv="#FFF3E0FF"),
53
+ L("全局_底部导航", "symbol", 0, 733, 375, 91),
54
+ L("输入框", "shape", 15, 733, 290, 44, radius=[22], hexv="#FFFFFFFF"),
55
+ L("发送", "slice", 315, 741, 30, 30),
56
+ ]
57
+
58
+
59
+ def bubble(x, y, w, h, text_layer, radius=None):
60
+ return [L("用户问-单行备份", "group", x, y, w, h, layers=[
61
+ L("矩形", "shape", x, y, w, h, radius=radius or [21], hexv="#FFD24AFF"),
62
+ text_layer,
63
+ ])]
64
+
65
+
66
+ def text(name, x, y, w, h):
67
+ return L(name, "text", x, y, w, h)
68
+
69
+
70
+ # ── 1. 纯文本对话页:只有气泡(用户问 + AI 长文应答),板上没有任何卡片结构 ──
71
+ CHAT_TEXT_ONLY = ab("01信用卡返现_登录用户角色类型为D", chrome() + bubble(
72
+ 196, 100, 163, 42, text("查询信用卡返现", 208, 110, 140, 22)) + [
73
+ L("应答气泡", "shape", 16, 156, 343, 108, radius=[0, 12, 12, 12], hexv="#FFFFFFFF"),
74
+ text("当前登录账户无权限操作此交易,建议您使用信用卡或证件号码登录。", 30, 166, 315, 88),
75
+ ])
76
+
77
+ # ── 2. 对话 + 内嵌矢量卡:卡壳里 3 个选项块(检测器读成「控件条」,但它就是卡)──
78
+ CARD_TILES = [
79
+ L("卡壳", "shape", 16, 238, 343, 184, radius=[12], hexv="#FFFFFFFF"),
80
+ text("您对这笔钱的规划是:", 28, 252, 240, 24),
81
+ L("选项1", "shape", 28, 288, 99, 118, radius=[8], hexv="#FFF7E6FF"),
82
+ L("选项2", "shape", 138, 288, 99, 118, radius=[8], hexv="#FFF7E6FF"),
83
+ L("选项3", "shape", 248, 288, 99, 118, radius=[8], hexv="#FFF7E6FF"),
84
+ text("随时要用", 36, 300, 80, 20), text("2.23%", 36, 330, 80, 30),
85
+ text("放1-6个月", 146, 300, 80, 20), text("3.21%", 146, 330, 80, 30),
86
+ text("放6个月以上", 256, 300, 80, 20), text("3.65%", 256, 330, 80, 30),
87
+ ]
88
+ CHAT_WITH_VECTOR_CARD = ab("06理财_理财推荐", chrome() + bubble(
89
+ 196, 100, 163, 42, text("理财产品推荐", 208, 110, 140, 22)) + [
90
+ L("应答气泡", "shape", 16, 156, 343, 68, radius=[0, 12, 12, 12], hexv="#FFFFFFFF"),
91
+ text("理财产品这么多,怎么找到适合自己的呢?", 30, 166, 315, 48),
92
+ ] + CARD_TILES)
93
+
94
+ # ── 3. 对话 + 卡片切图:卡片被设计稿整体导成一张 slice(检测器看不到结构)──
95
+ CHAT_WITH_SLICED_CARD = ab("32房贷利率调整_有房贷_可以调整", chrome() + bubble(
96
+ 196, 100, 163, 42, text("调整下我的房贷利率", 208, 110, 140, 22)) + [
97
+ L("应答气泡", "shape", 16, 156, 343, 108, radius=[0, 12, 12, 12], hexv="#FFFFFFFF"),
98
+ text("您的贷款为深圳地区二套住房贷款……", 30, 166, 315, 88),
99
+ L("编组 4", "slice", 16, 276, 343, 280),
100
+ ])
101
+
102
+ # ── 4. 底部抽屉(alertCard 子卡)──
103
+ # 形态照真实稿(喜豆 04/05/06 型):抽屉贴底通栏、顶圆角底直角,覆盖下半屏;
104
+ # 被盖住的区域**不再有**底部导航/输入坞图层(不是 chrome 叠加)。抽屉上沿 500
105
+ # 紧邻上一条气泡(其中心 503 恰好越过上沿)—— 用来验证元素归属的覆盖率门槛。
106
+ BOTTOM_SHEET = ab("08个人购汇_有人民币子账户_个人购汇申请书", [
107
+ L("98.全局/系统控件/状态栏", "symbol", 0, 0, 375, 44),
108
+ L("5.导航/标题栏/黑-标题+三icon", "symbol", 0, 0, 375, 88),
109
+ ] + bubble(238, 482, 118, 42, text("修改家庭地址", 252, 492, 90, 22)) + [
110
+ L("5.反馈/弹窗/底部弹窗/选择付款卡", "group", 0, 499, 375, 313, layers=[
111
+ L("矩形", "shape", 0, 500, 375, 312, radius=[20, 20, 0, 0], hexv="#FFFFFFFF"),
112
+ text("请选择所在地区", 128, 517, 119, 24),
113
+ text("确定", 325, 517, 30, 24),
114
+ text("取消", 20, 517, 30, 24),
115
+ ]),
116
+ ])
117
+
118
+ # ── 5. 全屏遮罩弹框(alertDialog)──
119
+ OVERLAY_DIALOG = ab("05综合推荐_总资产提示", chrome() + [
120
+ L("遮罩", "shape", 0, 0, 375, 812, hexv="#00000066", opacity=0.4),
121
+ L("对话框", "shape", 48, 320, 279, 172, radius=[12], hexv="#FFFFFFFF"),
122
+ text("总资产提示", 72, 344, 200, 24),
123
+ text("您的总资产已更新", 72, 380, 220, 20),
124
+ ])
125
+
126
+ # ── 6. 0 候选的普通文字页(无气泡、无卡壳):保守保留 ──
127
+ PLAIN_NO_CANDIDATE = ab("03信用卡返现_有信用卡_无返现记录", chrome() + [
128
+ text("当前没有可展示的内容", 30, 166, 315, 40),
129
+ ])
130
+
131
+ CASES = [
132
+ (CHAT_TEXT_ONLY, "text_only", True, 0, "纯文本对话页(只有问答气泡)"),
133
+ (CHAT_WITH_VECTOR_CARD, "card", False, 0, "对话 + 内嵌矢量卡(3 宫格选项)"),
134
+ (CHAT_WITH_SLICED_CARD, "card", False, 0, "对话 + 卡片切图"),
135
+ (BOTTOM_SHEET, "subcard", False, 1, "底部抽屉(alertCard 子卡)→ 几何直取 1 张卡"),
136
+ (OVERLAY_DIALOG, "overlay_dialog", False, 0, "全屏遮罩弹框(alertDialog)"),
137
+ (PLAIN_NO_CANDIDATE, "card", False, 0, "0 候选的普通文字页(保守保留)"),
138
+ ]
139
+
140
+
141
+ def main():
142
+ failed = 0
143
+ for board, kind, skip, exp_cards, why in CASES:
144
+ found = pm.detect_artboard_cards(board)
145
+ verdict = pm.board_verdict(board["name"], board)
146
+ got_cards = len(found["cards"])
147
+ ok = (verdict["kind"] == kind and verdict["skip"] == skip
148
+ and got_cards == exp_cards)
149
+ reasons = sorted({r["reason"] for r in found["rejected"]})
150
+ mark = "PASS" if ok else "FAIL"
151
+ if not ok:
152
+ failed += 1
153
+ print(f"[{mark}] {why}: kind={verdict['kind']}(期望 {kind}) "
154
+ f"skip={verdict['skip']}(期望 {skip}) "
155
+ f"cards={got_cards}(期望 {exp_cards}) "
156
+ f"conv={bool(found['conversationFlow'])} rejected={reasons}")
157
+
158
+ # 抽屉卡的元素归属(真实稿 04/05/06 形态):上沿外的气泡不得渗入,跨沿标题必须保留
159
+ found = pm.detect_artboard_cards(BOTTOM_SHEET)
160
+ assert found["cards"], "底部抽屉应产 1 张卡"
161
+ sx, sy, sw, sh, dock_els, dock_shapes, _m = found["cards"][0]
162
+ assert (sx, sy, sw, sh) == (0, 500, 375, 312), f"抽屉卡矩形应取面板几何,实际 {(sx, sy, sw, sh)}"
163
+ assert dock_shapes == [((0, 500, 375, 312), [20, 20, 0, 0])], f"外壳几何(含顶圆角)应原样带入,实际 {dock_shapes}"
164
+ dock_names = {e.name for e in dock_els}
165
+ assert "修改家庭地址" not in dock_names, "抽屉上沿外的气泡渗入了(覆盖率门槛 0.6 失效)"
166
+ assert {"请选择所在地区", "确定", "取消"} <= dock_names, f"跨沿的抽屉标题必须保留,实际 {sorted(dock_names)}"
167
+ print("[PASS] 抽屉卡元素归属:上沿外气泡滤掉、跨沿标题保留")
168
+
169
+ # 端到端:focus_artboard 必须真产出 card-1.png + mask 圆角(几何直取的卡)
170
+ import shutil
171
+ import tempfile
172
+ import png_utils # noqa: E402
173
+ tmp = Path(tempfile.mkdtemp(prefix="cb-subcard-"))
174
+ try:
175
+ prev = tmp / "artboard.png"
176
+ pw, ph = 750, 1624
177
+ png_utils.write_png(str(prev), pw, ph, 4, [b"\xff" * (pw * 4)] * ph)
178
+ cdir = tmp / "out"
179
+ cdir.mkdir()
180
+ spec = pm.focus_artboard(BOTTOM_SHEET, prev, cdir)
181
+ assert len(spec["cards"]) == 1, f"focus_artboard 应为抽屉产 1 张卡,实际 {len(spec['cards'])}"
182
+ card = spec["cards"][0]
183
+ assert card.get("image") == "card-1.png" and (cdir / "card-1.png").exists(), "card-1.png 必须实产"
184
+ assert (card.get("mask") or {}).get("radius") == [20, 20, 0, 0], \
185
+ f"mask 圆角应取自面板几何,实际 {card.get('mask')}"
186
+ assert spec["focusSkipReason"] == "bottom_sheet_geometry", \
187
+ "focusSkipReason 不得改变(classify_artboard / trainer 依赖它)"
188
+ print("[PASS] focus_artboard 端到端:card-1.png + mask.radius 实产,focusSkipReason 不变")
189
+ finally:
190
+ shutil.rmtree(tmp, ignore_errors=True)
191
+
192
+ # 卡片级切图判定(房贷 32 型)单测:中段大盘切图算「板上有卡」,图标切图不算
193
+ flat = vision._flatten_with_tree(CHAT_WITH_SLICED_CARD["layers"])
194
+ assert vision.has_card_like_visual(flat, 375, 812) is True, "343x280 的中段切图应判为卡片级"
195
+ flat_text_only = vision._flatten_with_tree(CHAT_TEXT_ONLY["layers"])
196
+ assert vision.has_card_like_visual(flat_text_only, 375, 812) is False, "30x30 图标切图不该判为卡片"
197
+ print("[PASS] has_card_like_visual:中段大盘切图算卡,小图标不算")
198
+
199
+ # 同名画板消歧(2026-09-24):Sketch 允许两个画板**完全同名**——实测「地址管理」稿
200
+ # 25 板里两个「11…_失败_再次修改」(375×1232/129 层 vs 375×1252/130 层,两个不同设计)。
201
+ # 目录名直接用 name 时后跑的会整板覆盖先跑的(产物静默丢失),且 manifest 两条 entry
202
+ # 指向同一目录、下游按 name 定位(generate_cards.mjs)两行同名无法区分。
203
+ uniq = pm.unique_canvas_names(["A_首页", "A_首页", "B_备用", "A_首页"])
204
+ assert uniq == ["A_首页", "A_首页-2", "B_备用", "A_首页-3"], f"同名消歧结果不符,实际 {uniq}"
205
+ assert len(set(uniq)) == len(uniq), "消歧后必须两两不同(否则仍会互相覆盖)"
206
+ assert pm.unique_canvas_names(["唯一板"]) == ["唯一板"], "无重名时不得改动画板名(下游零感知)"
207
+ print("[PASS] 同名画板消歧:第 2 个起追加 -2/-3,无重名时原名不动")
208
+
209
+ if failed:
210
+ print(f"\n{failed} 个用例失败")
211
+ return 1
212
+ print(f"\n全部 {len(CASES)} 个用例 + 抽屉元素归属 + 端到端产出 + 同名消歧 通过")
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ sys.exit(main())
@@ -17,7 +17,6 @@ TOP_SYSTEM_RATIO = 0.12 # 顶部状态栏+导航占画板高度比例(
17
17
  BOTTOM_SYSTEM_RATIO = 0.13 # 底部导航+AI输入框+Home Indicator 高度比例(约 100px @812)
18
18
  FULLSCREEN_BG_RATIO = 0.85 # 面积 >= 画板 85% 视为全屏背景(蒙版/背景)
19
19
  CARD_MIN_AREA_RATIO = 0.045 # 卡片最小面积 = 画板面积的 4.5%(375*812≈305k → ~13.7k px²)
20
- CARD_MAX_AREA_RATIO = 0.75 # 卡片最大面积 < 画板 75%(排除整屏)
21
20
  EDGE_DECOR_RATIO = 0.45 # 与画板顶/底边粘连且高度 < 45%H 的块视为装饰(椭圆/弥散背景)
22
21
  MERGE_GAP_RATIO = 0.02 # 两块之间间隙小于画板宽 2% 且高度相似时合并
23
22
  OVERLAY_MIN_WIDTH_RATIO = 0.55 # 居中对话框通常明显窄于画板
@@ -32,7 +31,11 @@ OVERLAY_CENTER_TOLERANCE = 0.10 # 对话框水平中心容差
32
31
  # 全 24 稿回归:新增 8 处检出均为真弹框(总资产提示/网点报错提示×4/交易
33
32
  # 密码框×2/转账计划时间修正提示),0 丢失。
34
33
  OVERLAY_MIN_INSET_X_RATIO = 0.008
35
- OVERLAY_MIN_INSET_Y_RATIO = 0.035
34
+ # 内壳纵向允许略微溢出外壳的下界(负值 = 允许向上溢出画板高度的比例)。
35
+ # 与 X 侧的 0.008 是两个不同的界:X 侧要求正留白,Y 侧只排除「内外壳纵向完全对齐」的
36
+ # 普通内容列。历史值 0.035 从未被引用(死常量),实际生效判据是 _detect_overlay_metrics
37
+ # 里内联的 -0.012;此处提为具名常量,**值不变**(零行为变更)。
38
+ OVERLAY_MIN_INSET_Y_RATIO = -0.012
36
39
 
37
40
 
38
41
  class VisualElement:
@@ -118,22 +121,6 @@ class VisualElement:
118
121
  return out
119
122
 
120
123
 
121
- def _hex_of(color_obj):
122
- if not isinstance(color_obj, dict):
123
- return None
124
- for key in ("color-hex", "argb-hex", "rgba-hex"):
125
- v = color_obj.get(key)
126
- if isinstance(v, str) and v.startswith("#"):
127
- return v.split()[0][:7] if " " not in v else v[:7]
128
- return None
129
-
130
-
131
- def _fill_hex(f):
132
- if isinstance(f, dict) and f.get("color"):
133
- return _hex_of(f["color"])
134
- return None
135
-
136
-
137
124
  def _flatten_raw(raw_layers):
138
125
  out = []
139
126
  def walk(ls, parent_count=0):
@@ -203,11 +190,6 @@ def _is_edge_decor(ve, W, H):
203
190
  return False
204
191
 
205
192
 
206
- def _touch_screen_edges(ve, W):
207
- x, y, w, h = ve.rect
208
- return (x <= 0 and w >= 0.5 * W) or (x + w >= W and w >= 0.5 * W)
209
-
210
-
211
193
  def _overlap(a, b):
212
194
  ax, ay, aw, ah = a
213
195
  bx, by, bw, bh = b
@@ -431,7 +413,7 @@ def detect_overlay_dialog(elements, width, height):
431
413
  inset_y = min(iy - oy, (oy + oh) - (iy + ih))
432
414
  if (not _rect_inside(inner.rect, outer.rect, tol=0.015 * H) or
433
415
  inset_x < OVERLAY_MIN_INSET_X_RATIO * W or
434
- inset_y < -0.012 * H or oh < 1.5 * ih):
416
+ inset_y < OVERLAY_MIN_INSET_Y_RATIO * H or oh < 1.5 * ih):
435
417
  continue
436
418
  metrics["outerRect"] = outer.rect
437
419
  metrics["inset"] = {"x": inset_x, "y": inset_y}
@@ -650,12 +632,14 @@ def detect_conversation_flow(elements, width, height):
650
632
  if 0 <= gap <= 0.16 * H:
651
633
  stacked_bubbles += 1
652
634
 
653
- if keyboard and input_dock:
654
- pass
655
- elif not bubbles or (len(bubbles) < 2 and not keyboard):
656
- return None
657
- elif not keyboard and stacked_bubbles == 0:
658
- return None
635
+ # 键盘与输入坞同时存在即为对话输入态(直接认定),否则按气泡/堆叠条件否决。
636
+ # 原写法是 `if keyboard and input_dock: pass` + `elif` 链,空分支不可读;此处等价改写成
637
+ # 「非输入态才进否决链」——判定结果逐条相同(零行为变更)。
638
+ if not (keyboard and input_dock):
639
+ if not bubbles or (len(bubbles) < 2 and not keyboard):
640
+ return None
641
+ if not keyboard and stacked_bubbles == 0:
642
+ return None
659
643
 
660
644
  return {
661
645
  "inputDock": input_dock,
@@ -665,6 +649,30 @@ def detect_conversation_flow(elements, width, height):
665
649
  }
666
650
 
667
651
 
652
+ def has_card_like_visual(elements, width, height):
653
+ """画板里有没有「卡片级整块视觉」——设计稿把卡片整体导出成一张切图时(如「编组 4」),
654
+ 图层里就只剩这张大图:检测器看不到它的结构(0 卡),但它显然不是纯文本。
655
+
656
+ 只认贴在中段的大切图,避免把界面 chrome 当卡:
657
+ · 宽度 ≥ 55% 画板宽、高度 ≥ 12% 画板高;
658
+ · 纵向落在 10%..95% 之间(标题栏之下、输入坞 / Home Indicator 之上)。
659
+ 20-40px 的图标切图、顶部导航栏、底部输入坞都命中不了。
660
+ """
661
+ W, H = float(width), float(height)
662
+ if W <= 0 or H <= 0:
663
+ return False
664
+ for ve in elements:
665
+ if not ve.is_slice:
666
+ continue
667
+ x, y, w, h = ve.rect
668
+ if w < 0.55 * W or h < 0.12 * H:
669
+ continue
670
+ if y < 0.10 * H or (y + h) > 0.95 * H:
671
+ continue
672
+ return True
673
+ return False
674
+
675
+
668
676
  def _is_repeated_control_bar(elements, rect):
669
677
  """识别横向重复图标/选项控件条,例如评分、开关组或快捷操作条。"""
670
678
  x, y, w, h = rect
@@ -1014,10 +1022,7 @@ def detect_cards(raw_layers, width, height, interaction_flow=None):
1014
1022
  break
1015
1023
  if rejected:
1016
1024
  continue
1017
- elements_in = []
1018
- for ve in elements:
1019
- if _point_in_rect(ve_center(ve), c) or _overlap(ve.rect, c) > 0.3 * ve.area:
1020
- elements_in.append(ve)
1025
+ elements_in = elements_in_rect(elements, c)
1021
1026
  # 同 rect 的外壳层去重:优先保留有圆角的卡面,剔除同 rect 的
1022
1027
  # 无圆角背景层。否则无圆角矩形会把圆角外背景重新遮成不透明。
1023
1028
  by_rect = {}
@@ -1046,6 +1051,31 @@ def _point_in_rect(pt, r):
1046
1051
  return x <= px <= x + w and y <= py <= y + h
1047
1052
 
1048
1053
 
1054
+ def elements_in_rect(elements, rect, min_cover=None):
1055
+ """卡片内元素归属的**唯一口径**:`detect_cards`(自动候选检测)与
1056
+ `parse_meaxure.detect_artboard_cards`(底部抽屉转卡)都调这里,两处不会各自
1057
+ 实现一遍而漂移。
1058
+
1059
+ min_cover=None(默认 = detect_cards 既有口径):
1060
+ 元素中心落在 rect 内,**或**与 rect 的重叠 > 30% 自身面积。
1061
+ min_cover=0.7(贴底抽屉专用):
1062
+ 只认「与 rect 的重叠 ≥ 70% 自身面积」的元素,不看中心。抽屉面板贴底,
1063
+ 其上沿会切过相邻元素(相邻气泡、气泡内文字层的大头刚过沿就会被默认口径
1064
+ 收进来),覆盖率门槛正好滤掉 —— 否则抽屉里会凭空多出「被盖住的气泡」。
1065
+ 真实稿实测(喜豆 04/05/06 三张 subcard):阈值在 0.65~0.90 区间元素集合
1066
+ 完全一致(不敏感),跨沿的抽屉标题覆盖 ≈98% 始终保留。
1067
+ """
1068
+ out = []
1069
+ for ve in elements:
1070
+ if min_cover is not None:
1071
+ if _intersect_ratio(ve.rect, rect) >= min_cover:
1072
+ out.append(ve)
1073
+ continue
1074
+ if _point_in_rect(ve_center(ve), rect) or _overlap(ve.rect, rect) > 0.3 * ve.area:
1075
+ out.append(ve)
1076
+ return out
1077
+
1078
+
1049
1079
  # ---------- Flex 布局推断(纯几何,对齐平台节点 flex 属性) ----------
1050
1080
  def infer_flex_layout(container_rect, child_rects):
1051
1081
  """从容器与其子元素几何推断 flex 布局参数(纯视觉,无文本依赖)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cardbuddy",
3
- "version": "0.14.3",
3
+ "version": "0.16.0",
4
4
  "description": "Local dev service for the CardBuddy card editor — decouples card CRUD (sync/save/validate/render-status/screenshot) from the static public editor, so the editor can be deployed to any static host while each developer runs a local service against their own card source.",
5
5
  "type": "module",
6
6
  "bin": {