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
@@ -269,6 +269,7 @@ gen-card-test 第一轮就把收支占比条写成了静态 `43%` / `57%` 的 `D
269
269
  | **R17 叶子节点防压缩** ✅ | `lint_card.mjs` `r17-leaf-no-flexshrink` | Text/Icon/Image/MultiText 缺 flexShrink:0 → **ERROR** |
270
270
  | **R19 space-between 宽度** ✅ | `lint_card.mjs` `r19-space-between-no-width` | Row space-between 且宽度不确定(未设/auto)又未被父容器拉伸 → WARN |
271
271
  | **R22 width:100% 可压缩** ✅ | `lint_card.mjs` `r22-width100-flexshrink0` | width:100% + flexShrink:0 → **ERROR** |
272
+ | **R63 水平 margin 撞 width:100%** ✅ | `lint_card.mjs` `r63-width100-with-hmargin` | 交叉轴父容器(Root/Column/SingleColumn/flexDirection:column/AbsoluteLayout 的 abspos 子节点)里 width:100% + 正水平 margin(含全边 marginType:0)→ **ERROR**;主轴(Row/MultiColumn)靠 flexShrink 吸收,不报 |
272
273
  | **style itemId** | `validate_schema.py` | flexDirection 必须是 006 不是 002 等 → ERROR |
273
274
  | **Button/Icon click** | `lint_card.mjs` | Button 缺 click → WARN(祖先带 click 时降为 INFO `button-click-by-ancestor`:`Button.vue` 的 `$emit('click')` 未 `.stop`,原生 click 会冒泡到祖先 handler,按钮实际可点,只是热区被放大);Icon 缺 click → **ERROR**(R-FMT3) |
274
275
  | **Canvas onMeasured** | `lint_card.mjs` | Canvas 缺 onMeasured → WARN |
@@ -293,9 +294,9 @@ gen-card-test 第一轮就把收支占比条写成了静态 `43%` / `57%` 的 `D
293
294
  - 验收:渲染后 Playwright `getBoundingClientRect` 逐区块对照 spec 容器边界,±2px 内。
294
295
 
295
296
  ### R3 切图唯一路径 = slicePath.mjs;源缺失必须显式告警,禁止手写 @Nx
296
- - 图标一律 `resolveSliceExport(spec.slices, sliceElName)` + `sliceRefPath`(导出路径自带 @3x → **3x 自动优先**);被引用切片必须拷到 `src/slices/<扁平名>`(仅引用才拷,卡片主体外的 chrome 图标不拷)。
297
+ - 图标一律 `resolveSliceExport(spec.slices, sliceElName)` + `sliceRefPath`(导出路径自带 @3x → **3x 自动优先**);被引用切片必须拷到 `src/slices/<扁平名>`(仅引用才拷,卡片主体外的 chrome 图标不拷)。`sliceRefPath` 已内置 `sanitizeSliceFileName()`,**产出即消毒名 = 落盘名**,不要在外层再手动拼 `/slices/` 路径。
297
298
  - **`collectSlices` 返回 `missing` 清单**(spec slice 元素 + rect + reason),生成器**显式 WARN 告警**——禁止静默跳过。
298
- - 源缺失兜底:**从 card-N.png 按 spec slice rect 精确抠图占位**(spec rect 在画布上是精确坐标,注意 card-N.png 通常 @2x 需按画布→图内坐标换算),存 `src/slices/<flatname>.png`;路径含 `#` 必须 `%23` 编码(HTML img src 把 `#` 后当 fragment 截断)。
299
+ - 源缺失兜底:**从 card-N.png 按 spec slice rect 精确抠图占位**(spec rect 在画布上是精确坐标,注意 card-N.png 通常 @2x 需按画布→图内坐标换算),存 `src/slices/<flatname>.png`;**落盘名必须先过 `sanitizeSliceFileName()` 消毒为纯 ASCII 名**(`# ? % & +` 一律替换,如 `警示-#999` → `iconWarning@2x`),schema 引用写**消毒后**的名字——引用与磁盘名必须同源(lint `r31b`/`r31c` 硬拦)。**不要用 `%23` 转义**:文件名带字面 `%` 会在任何一层 URL 编码处二次编码成 `%2523`,且 `r31b` 会把 `%` 直接判 ERROR。
299
300
  - 已踩坑:手写 `@2x`(src/slices 只有 @3x → 404)且违反 3x 优先约定;上游导出遗漏(spec.slices/源目录都缺)时静默降级为 Text "?" 掩盖问题。
300
301
  - 验收:Image value 全部 @3x 且可访问(curl /slices/<扁平名> 200);missing 清单为空(Agent/上游修图后)。
301
302
 
@@ -497,7 +498,8 @@ gen-card-test 第一轮就把收支占比条写成了静态 `43%` / `57%` 的 `D
497
498
  - **绝不可能用 `"0"`**:Image 的 `itemTypeRange` 没有 `const` 项,`"0"` 不在合法枚举内。
498
499
  - **根因**:R25 初版写「`"0"` 或 `"2"` 均可」,但渲染器逻辑为 `if (itemValueFrom === '2') { /* 设置背景图 */ }`——`"0"` 不进入该分支,图片永远空白。已收紧为只允许 `"2"`。
499
500
  - **itemValueFrom 全局三态**(`dsl_structure.md`):`"0"`=常量(用于 Text 文案、数字等 `const` 类型字段)、`"1"`=变量/表达式、`"2"`=图片/文件。Image 字段 `itemTypeRange: "image,var"` 对应 `"2"` 和 `"1"`。
500
- - **验收**:① schema 中所有 `className: "Image"` 节点的 `value` 属性 `itemValueFrom` 为 `"2"`(静态路径)或 `"1"`(变量);② 渲染后 DOM 中 `.ce_image_fill` 的 `background-image` 不为 `none`。
501
+ - **反向陷阱:把 `$` 表达式塞进 `"2"` 图片槽**(2026-09-21 补,实际事故):`"2"` 槽在运行时是**原样透传**——`property.js onPreview` 的 `"2"` 分支就一句 `out[itemKey] = itemValue`,不做任何求值。所以 `{"itemValueFrom":"2","itemValue":"$icon"}` 会把字符串 `"$icon"` 直接当图片地址,渲染成 `background-image: url($icon)` ⇒ **画布空白**,而且 `validate_schema.py` 全绿、控制台不报错、HTTP 也不 404(DOM 里那个 background-image 就是错值本身)。典型形态:**列表行内的图标 Image** 绑当前项字段(`$icon`/`$avatar`),却被误标成 `"2"`——同一行里旁边的 Text 用 `"1"`(`$name`/`$time`)全部正常,只有 Image 那一张空着,极易被误判成「切图没托管 / 服务没起」。正解:图片槽放表达式一律改 `"1"`(并补 `itemDefaultValue`)。
502
+ - **验收**:① schema 中所有 `className: "Image"` 节点的 `value` 属性 `itemValueFrom` 为 `"2"`(静态路径)或 `"1"`(变量),**且 `"2"` 的 `itemValue` 不以 `$` 开头、`"1"` 的 `itemValue` 以 `$` 开头**(`preflight.mjs` Check 3 / `lint_card.mjs` R25 三个方向都拦);② 渲染后 DOM 中 `.ce_image_fill` 的 `background-image` 不为 `none`,且**不含未求值的 `$` 变量名**;③ `python3 scripts/autofix_schema.py <schema.json>` 可机械修复该错槽(日志 `itemValueFrom "2"→"1"(图片槽放了表达式)`)。
501
503
 
502
504
  ### R26 font lineHeight/textAlign itemId 写错(2026-08-21 固化)
503
505
  - **铁律**:font 组的 lineHeight itemId 是 **007**(不是 005),textAlign itemId 是 **003**(不是 006)。005/006 在 font 组里分别是 isMaxLine/maxLine,写错会导致行高/对齐失效甚至属性串位。
@@ -535,11 +537,14 @@ gen-card-test 第一轮就把收支占比条写成了静态 `43%` / `57%` 的 `D
535
537
  - **根因**:平台 eventaction 是 event + action 分离的嵌套结构,AI 从通用 JSON 经验写成扁平 key。lint 检查的是 ea.event.eventKey === click,扁平格式没有 event 字段。
536
538
  - **验收**:lint_card.mjs 无 icon-no-click ERROR。
537
539
 
538
- ### R31 切图文件名含空格/特殊字符必须重命名(2026-08-21 固化)
539
- - **铁律**:复制切图到 `src/slices/` 后,检查文件名是否含**空格、中文括号、特殊符号**(如 `矩形 4.png`、`icon (2).png`)。含则**必须重命名为纯 ASCII 安全名**(如 `矩形 4.png` → `btn_exchange.png`、`icon_arrow@2x.png` → `icon_arrow@2x.png` 保留)。schema 中 Image `value` 引用的路径必须与重命名后的文件名一致。
540
- - **根因**:渲染器将 `value` 拼接进 CSS `background-image: url(...)` 时,空格导致 URL 断裂(浏览器解析 `url(/slices/矩形` 然后把 `4.png)` 当非法值丢弃)→ `background-image: none` → 图片空白。即使后端 HTTP server 做了 URL decode,前端 CSS url() 拼接时未编码空格。
541
- - **处理时机**:`cp slices/*.png src/slices/` 后、写 schema 前,逐个检查文件名。安全命名规则:`小写字母_数字.png`,去掉空格和括号。
542
- - **验收**:schema 中所有 Image `value` 的路径不含空格;渲染后 DOM 中 `.ce_image_fill` 的 `background-image` 不为 `none`。
540
+ ### R31 切图「引用 ↔ 磁盘名」必须同源:空格 / 保留字符 / 悬空(2026-08-21 起,2026-09-21 补齐 b/c)
541
+ - **铁律**:复制切图到 `src/slices/` 后,检查文件名是否含**空格、中文括号、特殊符号、URL 保留字符**(如 `矩形 4.png`、`icon (2).png`、`警示-#999.png`)。含则**必须重命名为纯 ASCII 安全名**(如 `矩形 4.png` → `btn_exchange.png`、`警示-#999.png` → `iconWarning@2x.png`)。schema 中 Image `value` 引用的路径必须与重命名后的文件名**一致**。
542
+ - **根因(R31,空格)**:渲染器将 `value` 拼接进 CSS `background-image: url(...)` 时,空格导致 URL 断裂(浏览器解析 `url(/slices/矩形` 然后把 `4.png)` 当非法值丢弃)→ `background-image: none` → 图片空白。即使后端 HTTP server 做了 URL decode,前端 CSS url() 拼接时未编码空格。
543
+ - **根因(R31b,URL 保留字符 `# ? % & +`)**:`#` 之后被浏览器当 fragment、`?` 之后当 query,**请求根本没发到那个文件**。实测引用 `/slices/警示-#999.png` 时只请求到 `/slices/警示-`;又因切片未命中曾落到 SPA 兜底返回 `200 + HTML` ⇒ 图空白、网络面板 200、控制台零报错、门禁全绿,**静默失败**。**正解是重命名,不是转义**:文件名带字面 `%` 会被任何一层 URL 编码二次编码成 `%2523`;`sliceNameFromRef()` 还会先 `decodeURIComponent`,写 `%23` 解码回 `#` 照样被 `r31b` 拦。(旧版本本条曾写「`#` 必须 `%23` 编码」——**已废弃,别照做**。)
544
+ - **根因(R31c,悬空引用)**:引用的名字在 `src/slices/` 里**根本不存在**(审计给出的名字与落盘名不同源、只改了一边、切图漏拷)→ 渲染 404、图片空白。R31 拦「名字带空格」、R31b 拦「名字带保留字符」,都是「名字写了但用不了」;R31c 补「这个名字磁盘上就没有」。`@filestore` / `http(s)://` / `data:` 远端引用天然无本地文件,不参与判定。
545
+ - **处理时机**:`cp slices/*.png src/slices/` 后、写 schema 前,逐个检查文件名。落盘名统一走 `lib/slice_dedupe.mjs → sanitizeSliceFileName()`(单一来源,`crop_slices.mjs` 已内置);安全命名规则:纯 ASCII、`小写字母_数字.png`,去掉空格/括号/保留字符。
546
+ - **验收**:`lint_card.mjs` 无 `r31` / `r31b` / `r31c` ERROR;渲染后 DOM 中 `.ce_image_fill` 的 `background-image` 不为 `none`。
547
+ - **兜底**:`crop_slices.mjs --from-audit` 会「收养」审计判定存在的资产(原字节复制 + 同源消毒 + 像素判重),补齐「审计给了正确名字、文件还在 bundle 里」这最后一环。
543
548
 
544
549
  ### R32 Image 组件必须设显式 rect.height(否则被 CSS aspect-ratio:16/9 压缩)(2026-08-21 固化)
545
550
  - **铁律**:Image 组件的 `rect` 必须显式设 `height`(从 spec 标注读取),**不可省略**。仅设 `width:100%` 不够——渲染器 CSS 硬编码 `.ce_image { aspect-ratio:16/9 }`,不设高度时浏览器按 16:9 比例自动计算高度(如 width:162px → height:91px,远小于设计稿 110px),导致图片被压扁。
@@ -721,15 +726,15 @@ gen-card-test 第一轮就把收支占比条写成了静态 `43%` / `57%` 的 `D
721
726
  - **lint 实现**:`lint_card.mjs` 新增 `r38-negative-margin` 检查——遍历所有节点的 marginTop,值为负数 → ERROR;同时检查标题+装饰条拓扑:缺 `align:"BottomLeft"` 或装饰条排在标题之后 → ERROR,装饰条宽度未绑定标题长度表达式 → WARN。lint 编号 R38 对应 pitfalls R42。
722
727
  - **验收**:① schema 中无任何节点 marginTop 为负值;② 标题+装饰条用 `AbsoluteLayout(align:"BottomLeft")` 包裹;③ 子节点顺序为 [装饰条, 标题];④ 装饰条宽度绑定标题长度表达式;⑤ lint_card.mjs 0 ERROR。
723
728
 
724
- ### R43 卡片背景不透明铁律(2026-09-02 固化)
725
- - **铁律**:卡片背景**不存在透明的情况**。Root(view)或首层容器必须有明确的非透明背景色。
726
- - **根因**:卡片透明时,设计器预览背景(浅灰)与实际运行环境背景不一致,导致文字在某些场景不可读、视觉对比度不足。
729
+ ### R43 卡片背景禁完全透明(半透明允许)(2026-09-02 固化,2026-09-21 修订语义)
730
+ - **铁律**:卡片背景**不允许完全透明**——Root(view)或首层容器必须有明确的背景声明(backgroundColor + backgroundType:"01")。**带 alpha 的半透明底色/渐变允许**,按设计稿标注原样还原(含 alpha,不丢透明度)。
731
+ - **根因**:卡片完全透明时,设计器预览背景(浅灰)与实际运行环境背景不一致,导致文字在某些场景不可读、视觉对比度不足;半透明底色(弹层遮罩、毛玻璃风等)是明确的设计意图,正常还原即可。
727
732
  - **取色优先级**:
728
- 1. **首选**:从设计稿标注 `spec.cards[].cardFill` 获取背景色(写入 Root/首层容器的 `background.backgroundColor` + `backgroundType:"01"`)。
729
- 2. **兜底**:标注缺失时,从聚焦截图(`card-1.png`)的卡片主体区域**中心采样**取色。
730
- 3. **禁止**:使用透明(`backgroundType:"00"` 或缺省);禁止用画布灰底;禁止从透明化截图四角/边缘采样(聚焦图几何外已透明,见 rules.md §九)。
731
- - **lint 实现**:`lint_card.mjs` 新增 `r43-card-transparent-background` 检查——Root 的 background 组缺失、或 backgroundType 为 "00"/空、或有 backgroundType 但缺 backgroundColor → ERROR。
732
- - **验收**:① lint_card.mjs 对该规则 0 ERROR;② 渲染截图卡片背景为实色(非透明)。
733
+ 1. **首选**:从设计稿标注 `spec.cards[].cardFill` 获取背景色(**含 alpha 原样保留**,写入 Root/首层容器的 `background.backgroundColor` + `backgroundType:"01"`)。
734
+ 2. **兜底**:标注缺失时,从聚焦截图(`card-1.png`)的卡片主体区域**中心采样**取色(截图采样只能得实色,此时按实色处理)。
735
+ 3. **禁止**:使用完全透明(`backgroundType:"00"` 或缺省);禁止用画布灰底;禁止从透明化截图四角/边缘采样(聚焦图几何外已透明,见 rules.md §九)。
736
+ - **lint 实现**:`lint_card.mjs` 的 `r43-card-transparent-background` 检查——Root 的 background 组缺失、或 backgroundType 为 "00"/空、或有 backgroundType 但缺 backgroundColor → ERROR(只判「背景声明存在性」,**不判色值 alpha**——半透明色值合法)。
737
+ - **验收**:① lint_card.mjs 对该规则 0 ERROR;② 渲染截图卡片背景非全透明(实色或半透明均可)。
733
738
 
734
739
  ### R44 类型+值成对检查(borderRadius/border/shadow 的类型开关必须配值)(2026-09-02 固化)
735
740
  - **铁律**:样式系统中存在「类型开关 + 值」成对出现的样式组——设了值但缺类型开关,渲染器默认类型为空,值被**静默丢弃**。
@@ -776,7 +781,7 @@ gen-card-test 第一轮就把收支占比条写成了静态 `43%` / `57%` 的 `D
776
781
  | 属性 | 默认值 | 含义 |
777
782
  |---|---|---|
778
783
  | flex.gap | "0" | 子元素间距为 0 |
779
- | background | 透明(backgroundType:"00") | 无背景色(R43 会强制要求实际不透明) |
784
+ | background | 透明(backgroundType:"00") | 无背景色(R43 要求根节点显式给底色,半透明色值合法) |
780
785
  | padding | 全 0(paddingType:"1" + 四边=0) | 内边距为 0 |
781
786
  | borderRadius | 无(borderRadiusType:"0" + borderRadius:"0") | 无圆角 |
782
787
  - **expand_ir 实现**:`ensureRootDefaults(schema.view)` 在展开后只对根节点自动补入缺失的核心样式默认值。
@@ -911,7 +916,7 @@ gen-card-test 第一轮就把收支占比条写成了静态 `43%` / `57%` 的 `D
911
916
  ## 13. 建卡收敛四红线(CardBuddy 黄金跑固化,2026-09-16)
912
917
 
913
918
  - **背景**:CardBuddy 内建助手与 Codex 用同一条 query(07 画板建卡)各跑一遍:助手 26 步、4 次整份重写 IR(单次 40-110s)、终产物 validate 3 ERROR、match 靠删内容刷到 89.96(渲染高 260 vs 设计 464);黄金跑 1 次写 IR + 5 轮 `--set` 微调收敛到 **match 98.82 / heightDelta 0 / validate 0 error / lint 0 error**。差距全部落在下列红线。
914
- - **R-a iterate.mjs 是唯一门禁**:单份报告内含 autofix + 真 `validate_schema.py` + lint + render + 像素对比。历史上 iterate 硬编码 `validate:{errors:0}`、expand_ir 又因 autofix 的 exit 1 误报「validate 未执行(未找到 python)」,两个假信号叠加让模型 ship 了 flexEnd 硬错误。现在两处都已修:非零退出码是正常结果,只有 ENOENT 才算 python 缺失。
919
+ - **R-a iterate.mjs 是唯一门禁**:单份报告内含 autofix + 真 `validate_schema.py` + lint + render + 像素对比。历史上 iterate 硬编码 `validate:{errors:0}`、expand_ir 又因 autofix 的 exit 1 误报「validate 未执行(未找到 python)」,两个假信号叠加让模型 ship 了 flexEnd 硬错误。现在两处都已修:非零退出码是正常结果;python 缺失改按「真跑一次 --version」判定(Windows 的 `python3` 常是应用执行别名——能启动、立刻 9009 退出,不是 ENOENT,只认 ENOENT 会钉死这个假解释器),且缺 python 时报告带 `envError` ⇒ 门禁判 `BLOCKED(环境问题,不是卡片的错)`,明确让模型停手告知用户,而不是接着 --set 盲调。
915
920
  - **R-b 并集像素对比**:`pixelDiff` 按 max(渲染, 设计) 画布采样,超出重叠区一律计差异 → 删内容 / 高度塌缩只会降分(旧坏卡从「89.96」被打回 50.41)。收敛条件:`pixelDiff.match ≥ 95` 且 `pixelDiff.size.heightDelta = 0` 且 validate/lint 0 error。match 不升反降或 hotspots 集中在已有内容区 = 在删内容凑分,回退该轮 `--set`。
916
921
  - **R-c IR 只写一次**:首写 `fs_write .cache/ai-scratch/<id>.ir.json`,之后一律 `iterate.mjs --set '点路径=值'` 原子微调(一轮可多条;中段不存在响亮报错)。整份重写 IR 是纯输出浪费,只有结构级推翻才允许。
917
922
  - **R-d 默认值三陷阱**:① Column/Row 默认 `gap=4`,不需要间距就显式 `gap:"0"`;② Text 不写 `lineHeight` 时渲染器按 1.5×fontSize 撑高(fs15→22.5、fs12→18),整卡累计漂移 +5~6px,标注的 lineHeight 必须逐条抄;③ Row 父级里的 Row / AbsoluteLayout 子节点默认 `width:100%`,space-between 行里会互相挤出画布——定宽子簇显式 `rect.width`,且并列子节点至少一个 `flexShrink:"1"`(lint R19 / R35)。flex 枚举 kebab-case(`flex-end` 不是 `flexEnd`)。
@@ -1046,3 +1051,45 @@ Canvas 自绘柱状图会让标注文本在组件计数中**消失**(schemaCom
1046
1051
  - **识别兼容拆分标注**:allTexts 里「70分」常被拆成 大号纯数字(fs≥20) + 同基线右邻「分」单位两个条目;判据 = 数字 + 单位 + 下方 fs≤13 标签,三者均未被任何通道消费。allTexts 条目是归一化形状(顶层 fontSize/color + sourceEl),几何/样式必须双源回退(anyRect/anyFs/anyColor),只认 rect()/textStyle 会全 null 静默跳过。
1047
1052
  - **零流高叠放**:仪表盘与金额行竖直重叠 → 行 marginTop=semanticTopGap(gy)、marginBottom=−(84+顶距)(marginType=1),流式高度归零不推偏头部;addSemanticBound(container=true) 让带内行忽略该边界、带下行以其底沿为间距基准。
1048
1053
  - Canvas 出码按 `self.state.score/scoreLabel` 字面读:变量被 uniqueKey 重命名(命名冲突)时放弃仪表盘,禁改出码。
1054
+
1055
+ ## 25. width:100% 撞水平 margin:交叉轴定宽不可缩(lint r63,2026-09-20 固化)
1056
+ - **现象**:节点右侧内容被切掉/整块消失。模型想表达「父容器里水平内缩 6/12px」,写成 `rect.width:"100%"` + `margin.marginLeft:6`。
1057
+ - **根因(确证,读渲染源码)**:`StyleParser.parseRect` 把 `100%` 交给 `adapter.doSize`,doSize 见 `%` 原样返回 → CSS `width:100%`,**已等于父容器内容盒宽**;`parseMargin` 再把 `margin-left/right` 加上去 = 元素外框超出父容器。父容器按列排布时水平方向是**交叉轴**,定宽不可缩 → 必溢出,被 `overflow:hidden`(Root / 组件默认 hidden)裁掉。
1058
+ - **为什么不报主轴**:父容器 `flex-direction:row` 时 `flexShrink` 默认 1,节点会被缩回「容器宽 − margin」,margin 被吸收、不溢出。全仓 205 份 schema 实测 4 处 `width:100% + marginLeft`(accountCreditedlist `column_m` / memberLevelNone `comp_8` / pointsDetail `comp_19` / assetOverview `text_55`)**全在 Row 父容器**,全靠 shrink 吃掉间距——故 R63 只盯交叉轴(`Root`/`Column`/`SingleColumn`/`flexDirection:"column"`/`AbsoluteLayout` 的 abspos 子节点),主轴不报,零误报。
1059
+ - **正路**:① 水平间距挪到父容器 `paddingLeft/Right`(或 `gap`);② 流式父容器(`Column`/`Root` 且 `alignItems` 为空/`stretch`)把 `rect.width` 改成 `"auto"`(**必须显式写**),靠 stretch 自动补宽、保留 margin;③ 并排分栏用「左 `width:100%`+`flexShrink:1`,右 `auto`+`flexShrink:0`」。详见 `rules.md §十六`。
1060
+ - **实测(375 宽卡,`render_card.mjs` + `elementBoxes`,2026-09-20)**——父容器 375,子节点 `marginLeft:6`:
1061
+
1062
+ | 子节点形态 | 父容器 | 实测 w / 右缘 | 结论 |
1063
+ |---|---|---|---|
1064
+ | `rect.width:"100%"` | Column | 375 / 381 | **溢出 6px**(被裁)|
1065
+ | `rect.width:"100%"` | Row | 369 / 375 | shrink 吸收 margin,不溢出(故主轴不报)|
1066
+ | `rect.width:"100%"` | AbsoluteLayout | 375 / 381 | **溢出 6px**(abspos 无 shrink)|
1067
+ | `rect.width:"auto"` | Column | 369 / 375 | 正路② 成立 |
1068
+ | 无 `rect.width`(Text) | Column | **86.67** / 92.67 | **只删 width 无效**——`.ce_text{width:fit-content}` 顶掉 stretch |
1069
+ | 无 `rect.width`(Column) | Column | 375 / 375 | 容器类无组件 CSS width,靠 stretch 撑满 |
1070
+
1071
+ > 结论:正路② 一律**显式**写 `rect.width:"auto"`;AbsoluteLayout 的 abspos 子节点无 stretch,只能用 ① 或显式像素宽。
1072
+ - **护栏**:lint `r63-width100-with-hmargin`(ERROR,渲染前拦截,iterate `--gate` 要求 lint.errors=0)+ `tools/tests/card-gen-gates.test.mjs` 正反用例。
1073
+
1074
+ ## 26. 要素线:设计稿的文案是固定真值,迭代只许改样式布局(2026-09-21 demo 回合固化)
1075
+
1076
+ 一次 61 步 / 15′31″ / 460 万 input 的低效建卡回合,逐项复盘出的**七条**可机械拦截的根因(按代价排序):
1077
+
1078
+ | # | 形态 | 代价 | 现在被什么拦住 |
1079
+ |---|---|---|---|
1080
+ | 1 | **要素真值整批丢失**:设计稿「金钱包40号E款 / 1.37 / 每日申赎」被换成自编占位值「理财优选1号 / 2.45 / 每日申购」 | 卡片内容真值全错,人工比对才发现 | `check_fidelity.mjs` 的 `elements.missing` / `elements.invented` + `iterate.mjs --gate` 的 `elements.missing=0`;`gate.stage=structure` 明说「先补要素再调像素」;最好成绩改判**字典序(先要素后像素)**,丢要素的轮次不算进步也不进 `ir.best` |
1081
+ | 2 | **像素分靠删节点凑**:为消一条 lint ERROR 把 Row 改 `width:auto`,match 87.5→61.95,再改回 | 40+ 步来回震荡 | `r35-multi-child-all-shrink0` 只有在「子定宽之和 > 父定宽」时才 ERROR,其余降 WARN 且文案写明「不要为此改 width:auto 或删节点」;`stalled.hint` 在要素下滑时点名「这是在删要素凑像素」 |
1082
+ | 3 | **「改 IR → 原样重跑检查」被当成打转**:调用指纹只含脚本 + 参数,`ir_patch` 改了 IR 再跑同一条 iterate 判定为「同一调用在最近 8 轮出现 3 次」→ 强制收尾 | 回合在第 61 步被误熔断(当时正在收敛轨道上) | `skillInputFingerprint()`:指纹带上 `--ir` 文件的大小 + mtime,**输入变了就不是打转**(`--out` 刻意排除:每轮都写 schema.json,算进去等于永不触发) |
1083
+ | 4 | **退避短路的 key 不含可变输入** | 2 次「该调用已连续失败 2 次,本次不再执行」,每次白付 ~11 万 token | 同上,`skillCallKey` 走同一指纹 |
1084
+ | 5 | **`iterate --gate` 用 exit 1**,与「脚本报错收尾」同码 → 被记账成连续失败 | 误导模型去改别的地方 | `--gate` 未达标改 **exit 2**(gate-fail ≠ 脚本报错);quickref / SKILL.md 同步 |
1085
+ | 6 | **`card_screenshot` 指向 5180**(服务在 8787)`ERR_CONNECTION_REFUSED` ×2 | 纯噪音 2 步 | `browser.js` 的 `BASE_URL` 改懒取 `baseUrl()`;`standalone.js` 在 preview 模式把 `CARD_SCREENSHOT_BASE` 设为实际 apiBase |
1086
+ | 7 | **正文暴走**:单步正文 24754 字 / 2 分 38 秒,超上限的 23254 字被丢弃(output token 照付) | 一步烧掉 2.6 分钟 | `BODY_BURST_ABORT_CHARS=8000` + `BODY_BURST_MAX=2`:截断后仍在吐就**掐断本步**并注入「正文只写结论」提醒,本步零副作用可安全重跑 |
1087
+
1088
+ > **总纲**:要素是设计稿给定的**真值**,UI 差异终究只是样式与布局的差异。可以调节点结构与位置,不能丢要素、不能自造内容 —— 门禁因此是**两段式**:`stage=structure`(要素齐全 + 结构合法)→ `stage=pixel`(像素 / 布局收敛)。
1089
+
1090
+ ### 26.1 顺带修掉的静默数据损坏:HTTP 请求体逐块 `toString`
1091
+
1092
+ `req.on('data', (c) => (body += c))` 在 `c` 是 Buffer 时**逐块**解码,跨块的多字节字符(中文)会被劈成 `U+FFFD`。
1093
+ 影响:`/__ai/chat`(整轮对话历史 + 卡片片段,动辄几百 KB 中文)每次错一个字符 ⇒ 下一轮前端回传的历史与服务端上一轮实际发送的 prompt **不再逐字节一致**,
1094
+ **前缀缓存从该处起整段失效**(这是「同一张卡每轮都吃满 input token」的一个隐藏放大器);`/__save` 更糟 —— 被劈坏的字符直接写进 `schema.json`。
1095
+ 修法:所有请求体读取前 `req.setEncoding('utf8')`(StringDecoder 会正确拼接跨块序列)。已覆盖 `ai-chat / cardFiles / cardSave / cardHistory / cardSync / sliceUpload / codeBridge / logStream / cardOpenFolder` 与 e2e 脚本。
@@ -6,6 +6,11 @@
6
6
  > 让 AI 直接输出完整 schema 会浪费约 **4-5x** token(节点树实测压缩比 4.4x);若 AI 省略 `dictionary`(展开器从 `$` 绑定自动推断 `inputParams`),
7
7
  > **整体 token 节省约 75-80%**。改「AI 输出紧凑 IR + 脚本展开」后,首次生成与每轮视觉闭环迭代都只传输 IR,链路 token 大幅下降。
8
8
 
9
+ > **两段式写法(2026-09-21 固化):先骨架、后样式**。第一版 IR 只求**要素铺全**(设计稿的每条文案 / 每张切图都有对应节点,位置与尺寸取审计值即可),
10
+ > 跑一轮 `iterate.mjs --spec <spec.json> --design <card-N.png>` 确认 `gate.stage !== 'structure'`(`elements.missing=0`);
11
+ > 之后每一轮的 `ir_patch` / `--set` **只动样式与布局**(`s.*`:尺寸 / 间距 / 颜色 / 字号 / flex / 位置),**不删节点、不换文案**。
12
+ > 丢要素会被门禁按 `structure` 段拦下,且「像素更高但要素更少」的轮次不算进步(不进 `ir.best`)—— 因为设计稿的要素是固定真值。
13
+
9
14
  ---
10
15
 
11
16
  ## 1. 节点字段契约
@@ -91,6 +96,115 @@
91
96
 
92
97
  ---
93
98
 
99
+ ## 2c. 分片写 IR(大卡 / 被输出上限截断过时)
100
+
101
+ IR 的常规写法是**一次 `fs_write` 写完整份**(最省步骤)。但工具是在**流结束后**才执行的:整份 IR 压在
102
+ 一段流里时,这段流中途被单次输出上限截断(`finish_reason=length`)/ 网关静默 / 墙钟到点 = 盘上**什么都
103
+ 没有**,整轮白跑(实测一次建卡 `fs_write` 花 88s 纯吐参数,全程零落盘)。满足任一条就走分片:
104
+
105
+ - 紧凑 JSON 预计 > 10KB(约 8 个以上区块,或带大 `dictionary`);
106
+ - 本轮已被单次输出上限截断过一次(原样重写还会被截断);
107
+ - 上一轮写 IR 中途被打断过(片段还在盘上,可续跑)。
108
+
109
+ 三步走:
110
+
111
+ 1. **骨架**(小,一次 `fs_write`):顶层字段 + 根容器,容器数组留空
112
+ `{"id":"<id>","name":"…","width":"375","children":[{"c":"Column","children":[]}]}`
113
+ 2. **一片一个步骤**:每片只放**一个区块**(一行 / 一组 tile / 一段自绘),`fs_write` 到
114
+ `.cache/ai-scratch/<id>.p01.json`(名字随意,合并时按 `--part` 顺序应用):
115
+ ```json
116
+ { "ops": [ { "path": "children.0.children", "append": [ <区块 IR> ] } ] }
117
+ ```
118
+ `path` 是点路径(数字段=数组下标),**中间段必须已存在**(容器建在骨架里;拼错的键不会自动创建,
119
+ 会报错并列出该级实际可用的键)。往已有对象上补字段用 `merge`:
120
+ `{ "path": "children.0.s", "merge": { "paddingType": "1" } }`。
121
+ 3. **最后合并一次**:`node skills/dsl-card-generator/scripts/ir_merge.mjs --ir <ir> --part <p01.json> --part <p02.json> …`
122
+ - **幂等**:合并过的片段名记在 IR 的 `_parts` 里,重复合并直接跳过(重发同一次调用不会把区块加两遍);
123
+ - **原子**:任何一条 op 失败则整份不写,盘上保留上一次成功合并的状态;
124
+ - **形状不对也整批不写**:片段里出现缺 `c` 的节点 / 不是对象的项,报错并保持 IR 原样(盘上永远只有
125
+ 能进管线的内容);改好这一片可以原样重跑(片段名没进 `_parts`,不会被当成已合并跳过)。
126
+ - 返回的 `nodes` 是当前节点总数(据此判断结构是否完整)。
127
+
128
+ 合并完成后照常跑 `expand_ir.mjs`。**要改已合并区块的内容**:细节用 `iterate.mjs --set`;整块结构推翻就
129
+ 写一片**新名字**的片段,用 `merge` 覆盖容器键(`merge` 是浅合并,数组键会被整体替换,如
130
+ `{"path":"children.0","merge":{"children":[<重写后的全部区块>]}}`)。
131
+
132
+ 代价要认:**每片一个步骤**,实测每个 LLM 步骤 40–140s —— 小卡(一次能写完 < ~6KB)拆开只会更慢。
133
+ 分片是给「一次写不进去 / 写了会丢」兜底,不是提速手段。
134
+
135
+ ---
136
+ ## 2d. 标题 + 装饰条/下划线(重叠样式:照抄,不要临场发明)
137
+
138
+ 设计稿里标题底部压着一条彩色装饰条时,**整段照抄下面这个片段**塞进所在容器的 `children`,
139
+ 只把 `$title` 换成标题变量、字号/颜色/宽高换成标注值。字段契约:容器属性走 `p`(组件属性,如 `align`)、
140
+ 定位走 `s.position`(样式组,itemId 由 style-meta 给出,写成 `s.rect.top` 会得到空 itemId 被静默丢弃)。
141
+
142
+ ```json
143
+ {
144
+ "c": "AbsoluteLayout",
145
+ "p": {
146
+ "align": "BottomLeft"
147
+ },
148
+ "s": {
149
+ "rect": {
150
+ "width": "auto",
151
+ "height": "24"
152
+ }
153
+ },
154
+ "children": [
155
+ {
156
+ "c": "Row",
157
+ "s": {
158
+ "rect": {
159
+ "width": "$title.length * 17",
160
+ "height": "8"
161
+ },
162
+ "background": {
163
+ "backgroundType": "01",
164
+ "backgroundColor": "rgba(0,164,109,0.2)"
165
+ },
166
+ "position": {
167
+ "left": "0",
168
+ "top": "16"
169
+ }
170
+ }
171
+ },
172
+ {
173
+ "c": "Text",
174
+ "v": "$title",
175
+ "s": {
176
+ "font": {
177
+ "fontSize": "17",
178
+ "fontWeight": "500",
179
+ "color": "#111111",
180
+ "lineHeight": "24"
181
+ },
182
+ "position": {
183
+ "left": "0",
184
+ "top": "0"
185
+ }
186
+ }
187
+ }
188
+ ]
189
+ }
190
+ ```
191
+
192
+ **为什么必须照抄**(三条都是实测踩出来的,不是洁癖):
193
+
194
+ 1. **条子绝不能排在流式布局里**。`Column > [Text, Row(h5, marginTop3)]` 这种写法看着「只差一点点」,
195
+ 但条子会占掉自己的高度把后续兄弟整体下推,且拿不到「压在标题底部」的重叠关系 ——
196
+ 实测这么写的卡 lint 报 0 error / 0 warning(R42 只在已经是 AbsoluteLayout 时才校验,整条绕过去了)。
197
+ 现在 `r64-title-underline-flow` 硬拦这个形态。
198
+ 2. **几何读标注差值,别套公式**。`position.top = 标注条.y − 标注标题.y`。
199
+ 例:标题 rect(32,250,68,24) + 条子 rect(32,266,68,8) → 容器 `height:24`、条子 `top:16`、`w:68`、`h:8`
200
+ (条子底边 274 = 标题底边 274,压在标题最后 8px)。myLoan 那张的 `top:28` 是「条子挂在线框下方」的另一种设计,
201
+ 照抄到「压在标题底部」的稿子上会偏 12px。
202
+ 3. **子节点顺序是 `[装饰条, 标题]`**:装饰条先绘制,标题后绘制压在上面;顺序反了文字会被条子盖住。
203
+
204
+ 范式全文(适用场景、颜色/宽度绑定、存量样例)见 `recipe_snippets/title-underline-overlay.json` ——
205
+ 本节的片段与它同源,改动必须同步(`tools/tests/title-underline-recipe.test.mjs` 会校验两处一致)。
206
+
207
+ ---
94
208
  ## 3. 完整示例(list 卡)
95
209
 
96
210
  ```json
@@ -162,7 +276,7 @@
162
276
 
163
277
  ## 4.9 收敛门禁(iterate.mjs 报告)
164
278
 
165
- `iterate.mjs` 每轮把报告落盘 `.cache/ai-scratch/<cardId>.iterate.json`,含:`gate`(机器判定:validate/lint/preflight 零 error ∧ match≥95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x|≤2)、`history`(逐轮 match/热区)、`stalled`(连续多轮无进展时给出换证据链的 hint)、`contentOffset1x` + `contentOffsetHint`(内容整体平移)、`renderImage`(渲染原图路径)、`lint.warningDetails`、`preflight`(机械化 Pre-flight 10 条)。收尾用 `node iterate.mjs --gate --out src/cards/<卡>/` 单独判收敛(schema 在报告之后被改过 ⇒ 判 FAIL)。正文只引用 gate 的 verdict,不得自述达标。
279
+ `iterate.mjs` 每轮把报告落盘 `.cache/ai-scratch/<cardId>.iterate.json`,含:`gate`(机器判定:validate/lint/preflight 零 error ∧ match≥95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ |contentOffset1x|≤2)、`history`(逐轮 match/热区)、`stalled`(**连续 3 轮未刷新历史最好成绩**——震荡下滑同样触发——时给出可执行 hint:先用 `ir_restore.mjs` 回退到 best 快照,再一轮只改一个分项)、`best`(持久化的最好成绩 `{match,round,irSnapshot}`,history 只留最近 10 条、best 不会被挤掉)、`contentOffset1x` + `contentOffsetHint`(内容整体平移)、`renderImage`(渲染原图路径)、`lint.warningDetails`、`preflight`(机械化 Pre-flight 10 条)。收尾用 `node iterate.mjs --gate --out src/cards/<卡>/` 单独判收敛(schema 在报告之后被改过 ⇒ 判 FAIL)。正文只引用 gate 的 verdict,不得自述达标。
166
280
 
167
281
  ## 5. CLI
168
282
 
@@ -187,7 +301,7 @@ IR 只表达语义,**lint 规则仍需在 IR 里显式写出**,展开器不
187
301
  - **R35**(受外宽约束的 Row/Column 多并列子节点全 `flexShrink:0` 溢出):至少一个子节点 `s.flex.flexShrink: "1"`;`width:"auto"` 的内容自适应容器可保留固定子元素。
188
302
  - **R9/R10**(Root 设非零 padding/margin):IR 顶层 / Root 不写 padding/margin(裁切后间距归零铁律)。
189
303
 
190
- - **R25**(Image itemValueFrom 三态精确映射):Image 静态路径用 `pi: {value: "/slices/xxx.png"}`(展开器自动 `itemValueFrom="2"`),变量绑定用 `p: {value: "$img"}`(自动 `"1"`)。
304
+ - **R25**(Image itemValueFrom 三态精确映射):Image 静态路径用 `pi: {value: "/slices/xxx.png"}`(展开器自动 `itemValueFrom="2"`),变量绑定用 `p: {value: "$img"}`(自动 `"1"`)。**`pi` 里给 `$` 表达式展开器直接抛错**(`"2"` 是原样透传,会渲染成 `url($icon)` 空白);反向也别把静态路径写进 `p`——它会被判成 `"0"` 常量,Image 拿不到图片分支同样空白(preflight/lint `r25-image-from-0` 拦)。
191
305
  - **R31**(切图文件名含空格必须重命名):IR 中的 `pi.value` 路径不含空格;展开器会自动把被引用切图汇聚进 `src/slices/`,来源优先 `--slices-dir <要素包>/slices`(设计稿要素包就在该目录,免拷贝),未指定时回落 IR 同目录 `slices/`。
192
306
  - **切图语义名 + 同内容去重**(`slices_convention.md §1.1 / §3.1`,lint `r59` / `r60`):`pi.value` 直接写**按功能/作用取的语义名**(`/slices/iconArrowRight@3x.png`、`/slices/bgCardHeaderDiffuse@3x.png`),不要沿用设计稿图层名 / 导出目录前缀 / 中文名 / `_alt2` 副本后缀。展开器汇聚时按**内容 sha256** 判重:`src/slices/` 已有同内容文件 → **不拷贝**,并把 IR 展开后的引用改写到既有文件名(日志 `[slices] 内容已存在 → 复用 …`,schema/data 自动回写)——即同内容时最终以既有语义名为准,不要指望靠换个名字多存一份。
193
307
  - **R32**(Image 必须设显式 rect.height):Image 节点 `s.rect.height` 必须写设计稿标注高度(如 `"110"`),否则被 CSS `aspect-ratio:16/9` 压缩。
@@ -0,0 +1,58 @@
1
+ # 视觉闭环迭代协议(iterate.mjs 多轮收敛纪律)
2
+
3
+ > 从 SKILL.md Workflow A 步骤 5 外迁(2026-09-21 优化方案 1.1)。生成时按需查阅:第一轮迭代前速览「要素线」与「停滞回退」,其余按报告字段名定位本文件对应小节。
4
+
5
+ ## 1. 多轮节奏
6
+
7
+ 第一轮修系统性问题(整卡偏移、缺失区域、主色错误);第二轮修细节(间距、字号、对齐);第三轮验收(逐元素比对)。通常 2-3 轮收敛。
8
+
9
+ **批量迭代 keep-alive**:连续多卡用 `iterate.mjs --keep-alive` 复用 Chrome 进程,每轮 ~0.6s(stdin 逐行 `{"ir":"...","out":"...","design":"...","width":343}`)。
10
+
11
+ ## 2. 预算硬纪律(2026-09-21 起,硬性)
12
+
13
+ - **每卡 iterate ≤ 15 轮**;连续 3 轮未刷新最好成绩(`stalled`)立即停止微调,按 §3 回退。
14
+ - 触顶(轮数或 stalled)后**禁止**继续 --set 盲调、**禁止**全量重写 IR:走 `ir_restore.mjs --best` 回退,或带证据链(`diff_bands.mjs` + `generation_pitfalls.md` 对应规则号)换思路。
15
+ - `ir_merge` 分片每片多付一个 LLM 步骤(实测 40-140s),**小卡禁止拆分**;仅当单次输出被截断/写不进时用。
16
+ - 改已存在 IR 优先 `ir_patch`(MCP)或 `iterate.mjs --set`,**一轮只改一个分项**(同时改多项无法归因)。
17
+
18
+ ## 3. 停滞与回退
19
+
20
+ `stalled` = 连续 3 轮没有刷新历史最好成绩(`round - best.round >= 3`),**震荡下滑同样触发**(实测:r10 做到 92.42 后 13 轮 11 轮更差,旧"几乎不变"判据 23 份报告 0 次触发,模型盲调 707 万 token 被人工中止;新判据第 13 轮即触发)。
21
+
22
+ ```bash
23
+ node scripts/ir_restore.mjs --list --id <卡id> # 列轮次快照与各轮 match(只读)
24
+ node scripts/ir_restore.mjs --best .cache/ai-scratch/<卡id>.ir.best.json --ir .cache/ai-scratch/<卡id>.ir.json
25
+ node scripts/ir_restore.mjs --round 10 --id <卡id> --ir .cache/ai-scratch/<卡id>.ir.json
26
+ ```
27
+
28
+ 回退后必须重跑一轮 iterate 拿新报告(旧报告 schemaHash 已失效)。快照由 `iterate.mjs` 自动落:每轮 `.cache/ai-scratch/<id>.ir.rNN.json`,刷新最好成绩另存 `.ir.best.json`;报告持久化 `best:{match,round,irSnapshot}`(history 留最近 10 条,best 不被窗口挤掉);快照留最近 12 轮 + best,超出自动清理。
29
+
30
+ ## 4. 要素线(先骨架、后样式,两段式门禁)
31
+
32
+ 设计稿里每条文案/每张切图都是**既定要素**,迭代只许改样式与布局,**不许丢要素、不许自造内容**。报告 `fidelity`(`total/matched/missing/invented/slices`)与 `gate.stage`:
33
+
34
+ - `gate.stage=structure`(`elements.missing>0` 或 validate/lint/preflight 未过)⇒ **先补要素,别调像素**:把设计稿文案补回对应节点;绑定变量的把 `defaultValue` 改成设计稿真值(`check_fidelity.mjs` 的 `bindHints`)。改样式不会把丢掉的文案变回来。
35
+ - `gate.stage=pixel` ⇒ 要素齐了才进入像素/布局收敛(一轮只改一个分项)。
36
+
37
+ **最好成绩判据是字典序:先要素、后像素**(`best.elementsMissing` 一起落盘)。像素更高但要素更少的一轮**不算进步**、不存 `ir.best`。实测事故:模型把产品名/利率/期限整批换成自编占位值,`match` 照样刷到 89.56%、门禁全绿,内容真值整批丢失。`elements.invented` 会逐条点名自造文案;要素下滑时 `stalled.hint` 直接写明"这是在删要素凑像素"。
38
+
39
+ ## 5. 分项归因 contributors
40
+
41
+ 报告 `contributors[]` 回答「差在哪个节点」:每个差异热区给一组具体节点(最内层优先),字段 `cid / cls / text / rect / h / hSibling(同类中位高)/ designH / dH(与设计稿高差,需 --spec)/ flags / hotCover`。
42
+
43
+ 用法:**一轮只改 `contributors[0].elements[0]` 这一个节点的一个属性**,改完立刻重跑 iterate 看是否刷新最好成绩。
44
+
45
+ flags 读法:
46
+ - `text-clipped` ⇒ 文本被裁剪(查 isMaxLine / maxLine / lineHeight)
47
+ - `vs-design ±N` ⇒ 与设计稿高度差 N(查它自己的 paddingTop/Bottom 与 lineHeight)
48
+ - `vs-siblings ±N` ⇒ **同类元素里只有它高度不同**(查它自己的 padding/margin,不是整层 gap)
49
+ - `bottom-edge` / `right-edge` ⇒ 差异贴边界外(查下边距 / 圆角 / 阴影)
50
+
51
+ `designH`/`dH` 为 null 表示没给 `--spec` 或文本匹配不上——只按 flags 与同类中位判断,**不要自己编设计稿高度**。
52
+
53
+ 背景:实测 23 次 iterate 的报告只说「差 2.58 分、高度差 2px」,模型只能整份重写 IR 去试(16 次全量重写、12.4 万字符 ≈ 全部输出的一半)。`widthMismatch` 出现时先修 `--width`(宽度不对,match 与 dy 全都不可信)。
54
+
55
+ ## 6. IR 修改通道
56
+
57
+ - 首次写 IR:`fs_write`(同轮第 2 次全量写同文件收提醒、第 3 次起被拒;确需重排结构传 `rewrite:"true"`;与盘上逐字节相同的重写直接跳过)。
58
+ - 迭代修改:`ir_patch`(MCP,`{file, ops:[{path, op, value}]}`,点路径 + 原子写 + 回旧值→新值摘要,不吃引号转义)或 `iterate.mjs --set '<path>=<value>'`(点路径数字段=数组下标)。
@@ -17,7 +17,7 @@
17
17
  ## 2. 绝对定位模型
18
18
 
19
19
  - **position 样式组(left/top)预览器不支持**,写了被忽略。
20
- - AbsoluteLayout 子节点定位 = **`align` 停靠 + margin 偏移**(margin 在 abspos 子节点上有效,实测可用)。
20
+ - AbsoluteLayout 子节点定位 = **`align` 停靠 + margin 偏移**(margin 在 abspos 子节点上有效,实测可用)。**边界**:margin 偏移只对 `width:auto` / 定宽子节点成立——abspos 子节点 `width:100%` 已等于容器 padding box 宽,再叠水平 margin 会顶出容器右缘(交叉轴不可缩,lint `r63-width100-with-hmargin`)。
21
21
  - **禁负 margin**:重叠效果(标题文本+下划线/高亮条)用 AbsoluteLayout 两子节点同 `align` 停靠实现(装饰条排前,lint R42),不用负 margin 拉叠。
22
22
  - 绝对布局容器自身高度必须 ≥ 子内容最大下沿,否则裁切。
23
23
 
@@ -24,12 +24,15 @@
24
24
  |---|---|
25
25
  | **输入路由(建卡第一步)** | `node scripts/route_input.mjs <输入路径> [--json]`:判输入形态并直接给出下一步命令。`meaxure-export`(Sketch MeaXure 标注导出:index.html + preview/ + assets/)→ **必须先跑 sketch-spec-to-card 前处理**,工具会打印 `parse_meaxure.py --src --draft --build` + `cluster_states.py --out UI/<稿名>` 两条命令(若 `UI/*/manifest.json` 的 `export` 已匹配该导出则跳过前处理、直接给存量产物路径);`ui-bundle` → Workflow A 逐卡;`image` → Workflow A 纯视觉;`card-dir` → Workflow B 改卡;`unknown`(exit 3) → 先问用户别猜。见 SKILL.md §8 Workflow 0 |
26
26
  | 秒出合法骨架 | `node scripts/scaffold_card.mjs --intent <blank\|list\|detail\|form\|dialog\|tab\|canvas-head> --id <卡id> --name <名称>` |
27
- | **列画板 + 选卡门禁(多画板)** | 取清单:未前处理 `python3 skills/sketch-spec-to-card/scripts/parse_meaxure.py --src <导出目录> --list`(**展示 1 起**,传 `--indices` 时 -1);已前处理 `cat UI/<稿名>/manifest.json`(`canvases[].order` **1 起**/`name`/`artboardType`/`status`)+ `clusters.json`(`cards[].cardKey`/`pageId`/`stateNames`/`recommendedBase`)。交互:按 **`1..n` 列出 + 末尾 `全部`** → 一句纯文本提问 → **停止输出等用户回序号**(**没有** `request_user_input`,不要去找弹窗工具)。**清单逐行配图**(每画板行下 `图: <绝对路径>`,缺图标缺失,禁止堆末尾);**机械卡点**:`parse_meaxure.py` 不带范围参数的生成运行直接 **exit 4** 并打印清单;用户回复序号 k → `--indices <k-1>`、「全部」→ `--scope-all`(「全部」只能是用户的回答,Agent 不得默认全量)。**UI 产物可回 `全部` 全量生成;产卡默认单画板 / 单 cardKey,回 `全部` 才算显式批量授权**;禁止自行挑画板。见 `rules.md §十三` |
27
+ | **列画板 + 选卡门禁(多画板)** | 取清单:未前处理 `python3 skills/sketch-spec-to-card/scripts/parse_meaxure.py --src <导出目录> --list`(**展示 1 起**,传 `--indices` 时 -1);已前处理 `cat UI/<稿名>/manifest.json`(`canvases[].order` **1 起**/`name`/`artboardType`/`status`)+ `clusters.json`(`cards[].cardKey`/`pageId`/`stateNames`/`recommendedBase`)。交互:按 **`1..n` 列出 + 末尾 `全部`** → 一句纯文本提问 → **停止输出等用户回序号**(**没有** `request_user_input`,不要去找弹窗工具)。**清单逐行配图**(每画板行下 `图: <绝对路径>`,缺图标缺失,禁止堆末尾;**路径必须完整绝对路径**——禁止 `…`/`...` 缩写前缀、禁止反引号或代码块包裹);**机械卡点**:`parse_meaxure.py` 不带范围参数的生成运行直接 **exit 4** 并打印清单;用户回复序号 k → `--indices <k-1>`、「全部」→ `--scope-all`(「全部」只能是用户的回答,Agent 不得默认全量)。**UI 产物可回 `全部` 全量生成;产卡默认单画板 / 单 cardKey,回 `全部` 才算显式批量授权**;禁止自行挑画板。见 `rules.md §十三` |
28
28
  | **设计稿层级树(AI 读图)** | `node scripts/spec_tree.mjs --spec UI/<画板>/spec.json [--card 1] [--max-depth 3] [--compact]`:把 `elements` 按矩形包含关系几何重建为 AI 可读的缩进树(类型+名称+尺寸+样式+文案)。Workflow A 步骤 1 用 |
29
29
  | **设计稿 vs 渲染对比(视觉闭环)** | `node scripts/render_diff.mjs --card <卡id> --design UI/<画板>/card-1.png [--out diff.png] [--json]`:并排合成对比图(左=设计稿,右=渲染),Workflow A 步骤 5 核心工具;省略 `--width` 自动读 `schema.view.width` |
30
- | **一条龙迭代(首选)** | `node scripts/iterate.mjs --ir <IR> --out src/cards/<卡id>/ --design <card-N.png> --width 343 [--spec <spec.json>] [--elements] [--slices-dir <要素包 slices 目录>]`:单进程 expand+lint+preflight+render+pixel-diff(含切图汇聚 + 同内容去重)。结果 JSON 带 `gate`(机器门禁判定)、`pixelDiff.hotspots`(>3% 差异热区,1× 设计 px)、`contentOffset1x`+`contentOffsetHint`(内容整体平移)、`renderImage`(渲染原图)、`lint.warningDetails`、`preflight`、`stalled`(连轮无进展 ⇒ 照 hint 换证据链)。多卡连生成用 `--keep-alive`(stdin 逐行 JSON) |
31
- | **收敛判定(收尾必跑)** | `node scripts/iterate.mjs --gate --out src/cards/<卡id>/`:读 `.cache/ai-scratch/<卡id>.iterate.json` 做机器判定(match≥95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ \|contentOffset1x\|≤2 ∧ 零 error,且 schema 未在报告后变更),未达标 exit 1。正文只引用它的 verdict,禁止自述「门禁全绿」 |
32
- | **IR 微调随迭代下发** | `iterate.mjs` 加 `--set 'children.0.children.2.children.1.s.margin.marginTop="7"'`(可重复):点路径(数字段=数组下标)定位 + 原子写回 IR(tmp+rename,崩溃不留半状态),随后照常 expand→lint→render→diff 一次完成。中间路径不存在会响亮报错且不写文件 |
30
+ | **一条龙迭代(首选)** | `node scripts/iterate.mjs --ir <IR> --out src/cards/<卡id>/ --design <card-N.png> --width 343 [--spec <spec.json>] [--elements] [--slices-dir <要素包 slices 目录>]`:单进程 expand+lint+preflight+render+pixel-diff(含切图汇聚 + 同内容去重)。结果 JSON 带 `gate`(机器门禁判定)、`pixelDiff.hotspots`(>3% 差异热区,1× 设计 px)、**`contributors`(分项归因:每个热区 → 最内层具体节点 `cid/cls/text/rect/h/hSibling/designH/dH/flags/hotCover`;**一轮只改 `contributors[0].elements[0]` 的一个属性**,flags 见 SKILL.md §S5)**、`widthMismatch`(`--width` 与设计稿卡面宽不符 ⇒ 先改 width,否则 match 与 dy 全不可信)、`contentOffset1x`+`contentOffsetHint`(内容整体平移)、`renderImage`(渲染原图)、`lint.warningDetails`、`preflight`、**`fidelity`(要素线:`total/matched/missing/invented/slices` —— 设计稿有而 schema 里没有的文案、schema 里自造的文案;`elements.missing=0` 是门禁项)**、`stalled`(**连续 3 轮未刷新最好成绩,含震荡下滑形态 ⇒ 照 hint 先 `ir_restore.mjs` 回退 best、再单分项改,禁止继续盲调)。省略 `--width` 时自动取设计稿卡面宽。多卡连生成用 `--keep-alive`(stdin 逐行 JSON) |
31
+ | **收敛判定(收尾必跑)** | `node scripts/iterate.mjs --gate --out src/cards/<卡id>/`:读 `.cache/ai-scratch/<卡id>.iterate.json` 做机器判定(match≥95 ∧ heightDelta=0 ∧ widthDelta=0 ∧ \|contentOffset1x\|≤2 ∧ **elements.missing=0** ∧ 零 error,且 schema 未在报告后变更),未达标 exit 2(gate-fail,与「脚本报错 exit 1」区分开)。**`gate.stage` 给两段式节奏**:`structure`(要素缺失 / validate / lint / preflight 未过)⇒ 先补要素别调像素;`pixel` ⇒ 要素齐了才谈像素收敛。正文只引用它的 verdict,禁止自述「门禁全绿」 |
32
+ | **IR 微调(首选 `ir_patch`,别整份重写)** | MCP 工具 `ir_patch({file, ops:[{path, op, value}]})`:点路径最小写(数字段=数组下标)、`op ∈ set/merge/push/delete`、原子(任一条失败整份不写)、返回旧值→新值摘要、不吃引号转义。**改已存在的 IR 就用它**;`fs_write` 整份重写只在首次写时用 —— 同轮内第 2 次全量写会收到提醒、**第 3 次起被拒**(确实要重排结构才传 `rewrite:"true"`),与盘上逐字节相同则直接跳过不写。跑完仍要跑 iterate 才有门禁结论(ir_patch 不渲染、不校验) |
33
+ | **IR 微调随迭代下发** | `iterate.mjs` 加 `--set 'children.0.children.2.children.1.s.margin.marginTop="7"'`(可重复):点路径(数字段=数组下标)定位 + 原子写回 IR(tmp+rename,崩溃不留半状态),随后照常 expand→lint→render→diff 一次完成。中间路径不存在会响亮报错且不写文件。注意这是**字符串字面量语法**(引号/转义易错),结构化改 IR 优先用 `ir_patch` |
34
+ | **回退到最好成绩(停滞时第一步)** | `node scripts/ir_restore.mjs --best .cache/ai-scratch/<卡id>.ir.best.json --ir .cache/ai-scratch/<卡id>.ir.json`:把工作 IR 原子写回历史最好成绩那一轮的结构(写回前自动备份当前 IR 为 `<IR>.pre-restore`);`--list --id <卡id>` 只读列出各轮 match 与快照文件,`--round 10 --id <卡id>` 按轮次回退(history 窗口外也能按 `.ir.rNN.json` 文件名直取)。快照由 `iterate.mjs` 自动落(`<卡id>.ir.rNN.json` + 刷新最好成绩时另存 `.ir.best.json`);目标不是合法 JSON 时 exit 3 拒绝写坏工作 IR。**回退后必须重跑一轮 iterate**(旧报告 schemaHash 已失效、`--gate` 会判 FAIL) |
35
+ | **分片写 IR(大卡 / 被输出上限截断过)** | 触发条件三条之一:预计紧凑 JSON **>10KB**、本轮 `finish_reason=length` 截断过、上一轮写 IR 中途被打断过。做法:`fs_write` 骨架(区块数组留空)→ 每区块一片 `.cache/ai-scratch/<id>.pNN.json`,内容 `{"ops":[{"path":"children.0.children","append":[<区块 IR>]}]}` → `node scripts/ir_merge.mjs --ir <IR> --part <片>…`(或 `--parts-dir <目录>`,按文件名排序)合并一次 → 再 `expand_ir.mjs`。**幂等**:合并过的片段名记进 IR 的 `_parts`,重发同一调用不会把区块加两遍;**原子**:任一 op 失败或节点缺 `c` 就整份不写,盘上只有合法状态。**不是提速手段**:每片多付一个 LLM 步骤(实测 40-140s/步),小卡别拆。契约见 `ir-format.md §2c` |
33
36
  | **量化偏差比对(防幻影调查)** | `node scripts/diff_bands.mjs --id <卡id> --design <card-N.png> [--tol 2] [--out report.json]`:输出文本带逐条 dy/dh、总高/内容底边/尾部留白、上中下分区主色直方图(背景色错误直接定位分区)。凡「渲染与设计不一致」、或 iterate 报告出现 `stalled` / `heightDelta≠0` / match 卡住,**先跑它拿证据再改**,不要肉眼下结论、不要手写 node -e 像素脚本;已有渲染图可 `--render <png>`(iterate 报告的 `renderImage` 就是现成渲染原图)跳过重渲 |
34
37
  | **切图判重三层(强制)** | 落盘/裁切前依次:① sha256 字节;② **像素容差**(裁切图 ≡ 导出资产/已重命名共用切图,`lib/slice_pixels.mjs`);③ 都不命中才写新文件。审计缺口按指纹**分组**派生(同组一张图),`_altN` 仅限真不同内容组;按名查找走 manifest 别名(`renamedFrom`)。见 `slices_convention.md §3.1`、`pitfalls §15` |
35
38
  | **数字语义变量提取(中文+数字混排,强制)** | 落盘文本先过 **AI 语义解析**:含**运行期数字**(金额含千分号 / 笔数 / 利率 / 期限;判定看数字是否随数据变化,版本号 / 固定话术不提取)→ 数字抽 `inputParams`(金额 `type:"number"` + 原始数字 defaultValue),混排串用表达式拼接 `'静态文案' + <expr> + '静态文案'`(`itemValueFrom:"1"` + `itemDefaultValue` 兜底);**带千分号金额 = 原始数字变量 + 格式化表达式**(`amount_formatting.md §3.4b`,中文前后缀拼表达式外)。禁止整串写死 / 整串绑 string / 格式化串进字典 / 正则见数字就拆。lint `r61` 提醒级。见 `rules.md §十四` |
@@ -76,6 +79,11 @@
76
79
  { "className": "Image", "properties": [ { "itemKey":"value","itemValueFrom":"1","itemValue":"$img","itemDefaultValue":"" } ] }
77
80
  { "className": "Icon", "properties": [ { "itemKey":"icon", "itemValueFrom":"1","itemValue":"$icon","itemDefaultValue":"angle_right" } ] }
78
81
 
82
+ // ⚠ Image/backgroundImage 的两个来源槽不能串:`"1"`=变量(值**必须**以 $ 开头)、`"2"`=静态图片(值是
83
+ // `/slices/…` 或 `@filestore/…` 字面量)。把 $ 表达式塞进 `"2"` → 运行时对 "2" 是**原样透传** →
84
+ // `background-image: url($icon)` → 画布那张图静默空白,而 validate 全绿、切图 HTTP 全 200(R25 反向陷阱)。
85
+ // IR 侧对应:变量写 `p:{value:"$img"}`(自动判成 "1"),静态写 `pi:{value:"/slices/x.png"}`(自动 "2",给 $ 值直接抛错)。
86
+
79
87
  // 缺素材占位:Image 的 value 留空 → 自动显示打包内 default-image.png 占位图(renderer-proven);Icon 缺 glyph 时改用占位 Image 顶位。详见 references/generation_pitfalls.md §3.1
80
88
 
81
89
  // ListLayout(列表;slotScope 下子项:表达式用 $field / $$ / $_index,事件脚本用 this.state.<field> 直读、禁 $$)
@@ -113,7 +121,7 @@ $title.length * 16 // 按文字长度算宽度
113
121
  - **铁律 r56(JSAPI 裸调)**:事件里直接 `ynet.callJSAPI(name, params, cb)`;禁止 `window.ynet` 前缀与 `typeof window` 存在性守卫,lint `r56` ERROR 拦截。
114
122
  - **甜甜圈/环形比例图**:UI 出现环形占比图 → DSL 无图表组件,用 **Canvas 自绘**:`onMeasured` 事件里用 `event.canvas` 画;环形段 = `arc(外)` 正向 + `arc(内, true)` 反向 + `closePath()`,内半径取外半径 1/3 出镂空;先 `canvas.width=width` 防糊;占比由 `onReady` 从原始值算 `ratio` 写回。中心文字用另叠 Text。范式见 `references/recipes.md §10` + 片段 `canvas-donut.json`。
115
123
  - **角标 / 角标签**:内容块角落的小标签(『付款账户』『收款账户』『NEW』折扣标)→ DSL 无 badge 组件,用 **AbsoluteLayout 叠加**:外层包 AbsoluteLayout(`align` 选角,留空=TopLeft),内放 ① 基础内容(满尺寸子元素)+ ② 角标(小尺寸 Text/Image 子元素)重叠;角标用非对称 `borderRadius`(左上+右下=8)+ `backgroundColor` + 橙字做成胶囊。角标必须作为 AbsoluteLayout 的**第二个小尺寸子元素**,别塞进基础内容 Column。范式见 `references/recipes.md §11` + 片段 `absolute-badge.json`。
116
- - **标题+下划线/高亮条(强制配方)**:标题底部紧贴或重叠彩色条时,禁止只写 AbsoluteLayout 后手调 top。必须复用 `title-underline-overlay.json`:`AbsoluteLayout(align:"BottomLeft") > [装饰条 Row/Divider, 标题 Text]`;装饰条先绘制、标题后绘制,宽度绑定 `$titleVar.length * fontSize`。lint 会拦截缺 `BottomLeft`、错误 z-order 和写死宽度。
124
+ - **标题+下划线/高亮条(强制配方)**:标题底部紧贴或重叠彩色条时,禁止只写 AbsoluteLayout 后手调 top。**整段照抄 `ir-format.md §2d` 的 IR 片段**(与 `recipe_snippets/title-underline-overlay.json` 同源):`AbsoluteLayout(align:"BottomLeft") > [装饰条 Row(s.position.top/left), 标题 Text(s.position.top/left)]`;装饰条先绘制、标题后绘制;几何读标注差值(`top = 标注条.y − 标注标题.y`,别套「行高 + gap」公式),宽度可绑 `$titleVar.length * fontSize`。**条子排在流式里、或用正/负 margin 顶位 → lint `r64-title-underline-flow` ERROR**;缺 `BottomLeft`、错误 z-order、写死宽度由 R42 拦。
117
125
 
118
126
  ## 3. 4 个事件范式(actionType 仅 4 种合法值)
119
127
 
@@ -171,7 +179,7 @@ $title.length * 16 // 按文字长度算宽度
171
179
 
172
180
  ## 7. 生成避坑速查(精简版——自动化规则由 lint/preflight 拦截,此处仅保留设计原则)
173
181
 
174
- > **已自动化(lint/preflight 自动拦截,AI 无需记忆)**:itemValueFrom 必填、style itemId 映射、flexShrink 分流(R17/R22/R24/R35)、Image itemValueFrom(R25)、Image rect.height(R32)、切图文件名空格(R31)、百分比 Math.round(R23)、Icon eventaction(R30)、分割线用 Divider(R28)、元素计数(R29)、**透明度表达式(R57,机械改写 `apply_opacity_pattern.mjs`)**。生成后跑 `preflight.mjs` + `lint_card.mjs` 即可,报错信息含修复指引。
182
+ > **已自动化(lint/preflight 自动拦截,AI 无需记忆)**:itemValueFrom 必填、style itemId 映射、flexShrink 分流(R17/R22/**R22b**/R24/R35)、Image itemValueFrom(R25)、Image rect.height(R32)、切图文件名空格(R31)、**切图引用含 URL 保留字符(R31b:`# ? % & +`,改写=重命名不是转义)**、**切图引用在 src/slices 不存在(R31c:引用必须与磁盘名同源)**、百分比 Math.round(R23)、Icon eventaction(R30)、分割线用 Divider(R28)、元素计数(R29)、**透明度表达式(R57,机械改写 `apply_opacity_pattern.mjs`)**。生成后跑 `preflight.mjs` + `lint_card.mjs` 即可,报错信息含修复指引。
175
183
 
176
184
  **AI 必须主动判断的设计原则**:
177
185
 
@@ -181,7 +189,7 @@ $title.length * 16 // 按文字长度算宽度
181
189
  - **根节点零间距**:spec 的「距边缘 16px」是画板上下文,聚焦裁切后卡片独立渲染 → Root 与首层容器 padding/margin 必须归零。详见 `§9-R10`。
182
190
  - **渲染器默认样式必须显式覆盖**:Row/Column 默认 `padding:2px+gap:4px+justify-content:center`,ListLayout 默认 `edge-insets {2,2,2,2}`——必须归零/指定。详见 `§9-R11`。
183
191
  - **运行期数据必须绑定**:金额/日期/姓名/枚举 Tab/利率行等一律 inputParams;金额 `type:"number"` + 千分号表达式。详见 `§9-R12` + `amount_formatting.md`。
184
- - **水平 margin 不叠 width:100%**:叶子设左右 margin 时用 `width:auto` 靠 stretch 补宽;`width:100% + marginLeft/Right` 会溢出。分栏用“左 100%+shrink:1,右 auto+shrink:0”。
192
+ - **水平 margin 不叠 width:100%**:叶子设左右 margin 时用 `width:auto` 靠 stretch 补宽;`width:100% + marginLeft/Right` 会溢出。分栏用“左 100%+shrink:1,右 auto+shrink:0”。lint `r63-width100-with-hmargin` ERROR 拦截(**只**拦交叉轴父容器:Root/Column/SingleColumn/`flexDirection:"column"`/AbsoluteLayout 的 abspos 子节点;Row/MultiColumn 是主轴,`flexShrink` 把 margin 吃掉、不溢出,故不报)。补宽一律**显式**写 `rect.width:"auto"`——Text/MultiText 的 `width:fit-content` 组件样式会顶掉 stretch,只删 width 不撑满(实测 86.67px vs 369px)。详见 `rules.md §十六`。
185
193
  - **space-between 三要素公式**:父 Row `100%+shrink:1+space-between`;左 `100%+shrink:1`;右 `auto+shrink:0`。详见 `§9-R27`。
186
194
  - **预览器行为契约(排雷速查)**:容器默认 width:100%(自适应须显式 auto)、组件默认 overflow:hidden(有意溢出须显式 visible)、abspos 用 align+margin(position 组无效)、Canvas 禁 save/scale/transform/drawImage、fontWeight 只用 400/500/600、font 组无 fontFamily(特殊字体宽度匹配或走 Canvas)、预览默认 line-height:1.5(单行须显式行高)。完整版见 `references/previewer_contract.md`。
187
195
  - **语义组件由 AI 裁决**:脚本给信号,AI 结合截图+层级树定语义。详见 `§9-R36`。
@@ -2,20 +2,68 @@
2
2
  "name": "标题+下划线重叠(AbsoluteLayout 绝对定位范式)",
3
3
  "when": "标题文本下方紧贴一条彩色装饰条/下划线(如蓝条高亮),两者在视觉上部分重叠或紧贴,设计稿常表现为标题底部与装饰条零间距或负间距",
4
4
  "source": "export-proven:myLoan(用户手动确认)、loanCompare、pointsDetail、memberLevelNone、integralCouponList",
5
- "diffFrom": "禁止用负 marginTop 实现重叠——负外边距会导致 flex 流式布局错乱(后续兄弟节点位置计算偏差、容器高度塌缩)。改用 AbsoluteLayout 绝对定位,标题和装饰条各自独立定位,互不干扰。",
6
- "structure": "AbsoluteLayout(align:BottomLeft, height:titleLineHeight) > [Row#underline(position.top:N, position.left:0), Text#title(position.top:0, position.left:0)]",
7
- "bindingNote": "下划线宽度可绑定为 $title.length * fontSize(随标题文字数等宽)。下划线 top = round(fontSize * 1.4) + gap(通常 约 fontSize + 4~8px)。AbsoluteLayout 的 height = round(fontSize * 1.4)(=渲染器标题行高),使后续兄弟节点从标题底部开始排列。",
5
+ "diffFrom": "禁止用 margin 实现标题与装饰条的位置关系——**负 margin 与正 margin 都不行**。负外边距会让 flex 流式布局错乱;正 margin(实测 Row h5 + marginTop3)更隐蔽:装饰条一旦排在流式里就占掉自己的高度,把后续兄弟整体下推,而且它的位置永远由「上一个兄弟的高度」决定,拿不到设计稿那种「条子压在标题底部」的与标题内部对齐的关系。改 AbsoluteLayout 绝对定位,标题与装饰条各自独立定位。lint:R38 拦负 margin,`r64-title-underline-flow` 拦流式写法。",
6
+ "structure": "AbsoluteLayout(align:BottomLeft, rect.height=标题行高) > [Row#underline(position.top/left), Text#title(position.top/left)]",
7
+ "geometry": "**几何读标注,不要套公式**:容器 rect.height = round(标题fontSize × 1.4)(渲染器行高);条子 rect.width / rect.height 直接抄标注;position.left = 标注条.x − 标注标题.x;position.top = 标注条.y − 标注标题.y。例(transferRecord 实测稿):标题 rect(32,250,68,24) + 条子 rect(32,266,68,8) → 容器 height=24、条子 top=266−250=**16**、left=0、w=68、h=8(条子底边 274 = 标题底边 274,即压在标题最后 8px 上)。**不要照抄 myLoan 的 top=28**:那是「条子挂在线框下方」的另一种设计(top = 行高 + 4),套到「压在标题底部」的稿子上会偏 12px。",
8
+ "bindingNote": "条子宽度在标题可变时才需绑定 `$title.length × fontSize`(示例 4 字 × 17 = 68,与标注 68 对得上);标注给死值就用死值。颜色读标注:实色写实色,标注 opacity<1 写同色 rgba(如 rgba(0,164,109,0.2))。",
9
+ "ir": {
10
+ "c": "AbsoluteLayout",
11
+ "p": {
12
+ "align": "BottomLeft"
13
+ },
14
+ "s": {
15
+ "rect": {
16
+ "width": "auto",
17
+ "height": "24"
18
+ }
19
+ },
20
+ "children": [
21
+ {
22
+ "c": "Row",
23
+ "s": {
24
+ "rect": {
25
+ "width": "$title.length * 17",
26
+ "height": "8"
27
+ },
28
+ "background": {
29
+ "backgroundType": "01",
30
+ "backgroundColor": "rgba(0,164,109,0.2)"
31
+ },
32
+ "position": {
33
+ "left": "0",
34
+ "top": "16"
35
+ }
36
+ }
37
+ },
38
+ {
39
+ "c": "Text",
40
+ "v": "$title",
41
+ "s": {
42
+ "font": {
43
+ "fontSize": "17",
44
+ "fontWeight": "500",
45
+ "color": "#111111",
46
+ "lineHeight": "24"
47
+ },
48
+ "position": {
49
+ "left": "0",
50
+ "top": "0"
51
+ }
52
+ }
53
+ }
54
+ ]
55
+ },
56
+ "irNote": "上面 ir 片段可**整段粘进 IR 的 children**(字段契约见 references/ir-format.md §2d):把 $title 换成标题变量、字号/颜色/宽高换成标注值即可 —— 这就是「照抄」的可复制形态,不要再临场翻译。实测该片段经 expand_ir.mjs 展开后 validate 0 error,且 p.align → properties[].align、s.position → style.position 的 itemId 均非空。",
8
57
  "rules": [
9
- "禁止用负 marginTop 实现标题与装饰条重叠——必须用 AbsoluteLayout + position.top(lint R38 拦截)",
10
- "AbsoluteLayout 设 align:BottomLeft(让后续兄弟从标题底部排起)",
11
- "AbsoluteLayout height = round(titleFontSize * 1.4)(渲染器行高,非标题 rect.height)",
12
- "标题 Text: position.top=0, position.left=0",
13
- "装饰条 Row: position.top = round(titleFontSize * 1.4) + gap(通常 4~8px),position.left 与标题对齐(或设计稿标注的 marginLeft)",
14
- "装饰条宽度可表达式绑定:$titleVar.length * titleFontSize(随标题文字等宽)",
15
- "装饰条颜色读标注;设计稿常见为品牌色 20% 透明(如 rgba(47,134,246,0.2))"
58
+ "父容器用 AbsoluteLayout + align:\"BottomLeft\"(让后续兄弟从标题底部排起,而不是从条子底部)",
59
+ "装饰条与标题各自设 style.position.left/top —— 不是 margin,正负都不行(R38 禁负、r64 禁流式)",
60
+ "子节点顺序 [装饰条, 标题]:装饰条先绘制、标题后绘制压在条子上(反了下划线会盖住文字)",
61
+ "容器 rect.height = round(标题fontSize × 1.4)(渲染器行高,不是标注 rect.height)",
62
+ "几何取标注差值,别套 top = 行高 + gap 的公式(那只对条子挂在线框下方的稿子成立)",
63
+ "条子颜色/宽度读标注;宽度绑定表达式只在标题长度可变时才必要"
16
64
  ],
17
65
  "apply": {
18
- "_template": "标题+下划线重叠用 AbsoluteLayout,不用负 margin"
66
+ "_template": "标题+下划线重叠用 AbsoluteLayout,位置靠 position,不用 margin(正负都不行)"
19
67
  },
20
68
  "notes": "此范式泛用于所有「标题 + 底部高亮条」场景。关键洞察:装饰条通过 AbsoluteLayout 溢出到容器声明高度之外,但 align:BottomLeft 让后续兄弟只感知标题行高,不会因装饰条而下移。"
21
69
  }
@@ -837,3 +837,17 @@ node scripts/apply_opacity_pattern.mjs --id <cardId> --node comp_8 --container C
837
837
 
838
838
  数据驱动升级路径:柱高/标签改绑 `$chartBars[i].value` 表达式 + onMeasured 读 `self.state`
839
839
  (与 radar 范式同),静态回放仅作为标注缺省值首帧。
840
+
841
+ ## 20. 半透明大椭圆装饰(radial-gradient 叠层,train-proven:seq4-l05)
842
+
843
+ **识别(结构信号,非业务词)**:卡顶部装饰椭圆——`opacity <= 0.5`、无圆角、宽/高 ≥ 卡宽 40%、顶沿贴卡顶(弥散光斑/氛围色块族)。
844
+
845
+ **正解**:**逐颗椭圆 radial-gradient 叠进卡片背景**,不采样成硬停色带——
846
+ 1. 每颗椭圆按 spec 生成一层 `radial-gradient(circle at <中心x> <中心y>, <主色带 alpha> 0%, <主色 alpha:0> 70%)`;
847
+ 2. **CSS 渐变首层在最上 → 按 spec 元素序反排**(底层椭圆排最后);
848
+ 3. 写入 Root/首层容器的 `background.backgroundGradient`(与卡底色共存,背景声明组必须存在——铁律 §0-8)。
849
+ 4. 卡身另有不透明磨砂玻璃壳罩在其上时,标注 alpha 需 **×0.5 置淡校准**,否则视觉过浓(实测 +5.6pp 偏差)。
850
+
851
+ **禁止**:用单条色带/硬停渐变近似(带底沿留矩形硬边,实测单带还原 32% 像素差);用纯色块伪装弥散。
852
+
853
+ **Workflow A 裁决**:此类装饰判 `decoration`,宽度/位置读标注 rect,透明度读标注 opacity(半透明合法,见铁律 §0-8);无导出切片时禁画板裁切(会带前景文字)。