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.
- package/dist/cli.js +0 -0
- package/dist/preview-editor/assets/{index-BN79pTkQ.js → index-CIIfMlwI.js} +43 -40
- package/dist/preview-editor/assets/index-DQn0zQBP.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +3 -0
- package/dist/vendored/mcp/client.mjs +8 -1
- package/dist/vendored/server/core/ai-chat.js +1521 -124
- package/dist/vendored/server/core/browser.js +12 -3
- package/dist/vendored/server/core/cardFiles.js +3 -0
- package/dist/vendored/server/core/cardHistory.js +1 -0
- package/dist/vendored/server/core/cardImportMeta.js +7 -6
- package/dist/vendored/server/core/cardOpenFolder.js +1 -0
- package/dist/vendored/server/core/cardSave.js +1 -0
- package/dist/vendored/server/core/cardSync.js +3 -0
- package/dist/vendored/server/core/codeBridge.js +5 -1
- package/dist/vendored/server/core/logStream.js +1 -0
- package/dist/vendored/server/core/sliceStatic.js +24 -4
- package/dist/vendored/server/core/sliceUpload.js +1 -0
- package/dist/vendored/server/core/turnJournal.js +6 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +196 -195
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +142 -380
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +65 -18
- package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +116 -2
- package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +58 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -7
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +14 -0
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +23 -4
- package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +51 -0
- package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +4 -3
- package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +32 -0
- package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +45 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +116 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +227 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +144 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +644 -42
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5443 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4108 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1116 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +214 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -2
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +4 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +150 -38
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
- package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +186 -0
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +18 -12
- package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +1 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -1
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +460 -49
- package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +157 -0
- package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +24 -0
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
* 12. 切图命名语义化(r59):引用名沿用设计稿图层名/导出目录前缀/中文名 → 看不出用途,必须按功能重命名。
|
|
23
23
|
* 13. 切图同内容重复(r60):src/slices 里存在 sha256 完全相同的副本 → 应复用同一份,不重复导入/上传。
|
|
24
24
|
* 14. 静态文案 / 字典默认值含疑似运行期数字(r61,提醒级):千分号数字 / ¥+数字 / 数字+单位词写死在静态 Text 或 defaultValue 里 → 提示按 rules.md §十四 做 AI 语义复核(运行期数字应抽变量 + 表达式拼接);固定话术误报可忽略。
|
|
25
|
+
* 15. 切图引用含 URL 保留字符(r31b):`# ? % & +` 会被浏览器当 fragment/query 截掉 → 图片静默不显示(R31 的补充,R31 只拦空格)。
|
|
26
|
+
* 16. 横向流父容器里的容器子节点未写 rect.width + flexShrink:0(r22b):渲染器按 component-meta 默认「100%」出 → 吃掉整行,同排兄弟被压成 0 宽或被顶出卡外。
|
|
27
|
+
* 17. 切图引用在 src/slices 里没有对应文件(r31c):渲染时 404,图片位置空白且控制台零报错 —— R31(空格)/R31b(保留字符)之外的第三种「引用与磁盘名不同源」。
|
|
25
28
|
*
|
|
26
29
|
* 用法:
|
|
27
30
|
* node lint_card.mjs --id <cardId> # 体检单张卡
|
|
@@ -39,7 +42,8 @@ import path from 'node:path'
|
|
|
39
42
|
|
|
40
43
|
import { fileURLToPath } from 'node:url'
|
|
41
44
|
import { cardsDir, cardSchemaPath, cardDataPath, readJson, metaDir, slicesDir } from './lib/paths.mjs'
|
|
42
|
-
import { buildContentIndex, checkSliceName, collectSliceRefStrings, duplicatePeers, sliceNameFromRef } from './lib/slice_dedupe.mjs'
|
|
45
|
+
import { buildContentIndex, buildSliceFileIndex, checkSliceName, collectSliceRefStrings, duplicatePeers, findUrlReservedChars, isRemoteSliceRef, resolveSliceRefOnDisk, sliceNameFromRef } from './lib/slice_dedupe.mjs'
|
|
46
|
+
import { collectRowChildNoWidth } from './lib/layout_rules.mjs'
|
|
43
47
|
|
|
44
48
|
// src/slices 内容索引(sha256 → 文件名)进程内只建一次,--all 体检全量卡时复用
|
|
45
49
|
let SLICE_CONTENT_INDEX = null
|
|
@@ -308,7 +312,7 @@ export function lint(schema, data) {
|
|
|
308
312
|
out.push({
|
|
309
313
|
severity: 'error',
|
|
310
314
|
code: 'r43-card-transparent-background',
|
|
311
|
-
message:
|
|
315
|
+
message: `卡片背景完全透明 —— 卡片背景不允许无背景/全透明(R43;半透明底色合法)。须从设计稿标注 cardFill 取背景色(可含 alpha)写入 Root/首层容器的 background.backgroundColor + backgroundType:"01";标注缺失时从聚焦截图取色兜底。`,
|
|
312
316
|
})
|
|
313
317
|
}
|
|
314
318
|
|
|
@@ -450,6 +454,12 @@ export function lint(schema, data) {
|
|
|
450
454
|
return sc ? String(sc.itemValue) : null
|
|
451
455
|
}
|
|
452
456
|
|
|
457
|
+
/** '311' / '311px' → 311;'100%' / 'auto' / 空 → null(比例宽无法静态判定溢出) */
|
|
458
|
+
function toPx(v) {
|
|
459
|
+
const m = /^(-?\d+(?:\.\d+)?)(?:px)?$/.exec(String(v ?? '').trim())
|
|
460
|
+
return m ? Number(m[1]) : null
|
|
461
|
+
}
|
|
462
|
+
|
|
453
463
|
function getWidthVal(n) {
|
|
454
464
|
const sp = (n.style && n.style.properties) || []
|
|
455
465
|
const rg = sp.find(p => p.itemKey === 'rect')
|
|
@@ -472,11 +482,21 @@ export function lint(schema, data) {
|
|
|
472
482
|
const hasVShow = (child) => ((child.directives) || []).some((d) => d && d.itemKey === 'v-show')
|
|
473
483
|
const isExclusiveVariantGroup = children.length >= 2 && children.every(hasVShow)
|
|
474
484
|
if (!isContentSize && allShrinkZero && !isExclusiveVariantGroup) {
|
|
485
|
+
// 真实溢出 vs 稳健性提示:只有「子节点显式定宽之和 > 父容器定宽」才真的会溢出。
|
|
486
|
+
// 其余情形(父容器按比例定宽 + 子节点内容自适应 / 百分比配比)当前渲染不会溢出,
|
|
487
|
+
// 报 error 会把模型逼去改结构换宽度 —— 实测(2026-09-21 demo 建卡回合)模型为消除这条
|
|
488
|
+
// error 把 Row 改成 width:auto,match 从 87.5% 掉到 62%,再改回来,来回震荡 40+ 步。
|
|
489
|
+
// 规则的本意是「防溢出」,不是「禁止定宽子节点」:降为 warning,信号不丢、门禁不再被它卡死。
|
|
490
|
+
const parentPx = toPx(parentWidth)
|
|
491
|
+
const fixedSum = children.reduce((a, c) => a + (toPx(getWidthVal(c)) || 0), 0)
|
|
492
|
+
const realOverflow = parentPx != null && fixedSum > parentPx
|
|
475
493
|
out.push({
|
|
476
|
-
severity: 'error',
|
|
494
|
+
severity: realOverflow ? 'error' : 'warn',
|
|
477
495
|
code: 'r35-multi-child-all-shrink0',
|
|
478
496
|
componentId: pid,
|
|
479
|
-
message:
|
|
497
|
+
message: realOverflow
|
|
498
|
+
? `${pcls} "${pid}" 定宽 ${parentPx}px 装不下 ${children.length} 个子节点的定宽之和 ${fixedSum}px(全部 flexShrink:0,压不动)→ 真溢出(R35)。把最宽的那个子节点改成 flexShrink:1,或按设计稿把父容器改宽/改 auto`
|
|
499
|
+
: `${pcls} "${pid}" 宽度非 auto 且 ${children.length} 个并列子节点全部 flexShrink:0 —— 当前宽度下不会溢出(子节点定宽之和 ${fixedSum}px${parentPx != null ? ` ≤ 父 ${parentPx}px` : ',父容器按比例定宽'}),仅稳健性提示:想消除就给最宽的子节点 flexShrink:1(不溢出时视觉零影响),**不要为此改 width:auto 或删节点**`,
|
|
480
500
|
})
|
|
481
501
|
}
|
|
482
502
|
}
|
|
@@ -500,6 +520,76 @@ export function lint(schema, data) {
|
|
|
500
520
|
}
|
|
501
521
|
})
|
|
502
522
|
|
|
523
|
+
// ── R31b: 切图引用含 URL 保留字符(# ? % & +)→ 浏览器截断请求 → 图片静默不显示 ──
|
|
524
|
+
// 事故(2026-09-21 demo/helloCard):引用 `/slices/警示-#999.png`,`#` 之后被当 fragment,
|
|
525
|
+
// 实际只请求到 `/slices/警示-`;又因切图未命中落到 SPA 兜底返回 200+HTML ⇒ 图空白、网络面板 200、
|
|
526
|
+
// 零报错、门禁全绿。R31 只拦空格,这里补上真正的元凶(保留字符)。
|
|
527
|
+
walk(view, (n) => {
|
|
528
|
+
if (n.className !== 'Image') return
|
|
529
|
+
for (const p of n.properties || []) {
|
|
530
|
+
if (p.itemKey !== 'value' || typeof p.itemValue !== 'string') continue
|
|
531
|
+
if (!p.itemValue.includes('/slices/')) continue
|
|
532
|
+
const hit = findUrlReservedChars(sliceNameFromRef(p.itemValue))
|
|
533
|
+
if (hit) {
|
|
534
|
+
out.push({
|
|
535
|
+
severity: 'error',
|
|
536
|
+
code: 'r31b-slice-url-reserved-char',
|
|
537
|
+
componentId: n.componentId || '',
|
|
538
|
+
message: `Image "${n.componentId}" 引用路径含 URL 保留字符 ${hit.map((c) => `"${c}"`).join(' ')}: "${p.itemValue}" → 浏览器会把保留字符之后当 fragment/query 截掉,请求的不是这个文件 → background-image 加载失败,且**不报错**(R31b)。把切图重命名为纯 ASCII 无语义字符名(如 iconWarning@2x.png)并同步改写引用`,
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
// ── R31b(兜底):style 背景图 / 表达式里内嵌的 /slices/ 引用同样要查(R31 只扫 Image.value)──
|
|
545
|
+
for (const ref of collectSliceRefStrings(schema)) {
|
|
546
|
+
const name = sliceNameFromRef(ref)
|
|
547
|
+
const hit = findUrlReservedChars(name)
|
|
548
|
+
if (!hit) continue
|
|
549
|
+
// Image.value 已被上面那条覆盖,避免同一处报两条
|
|
550
|
+
const already = out.some((f) => f.code === 'r31b-slice-url-reserved-char' && f.message.includes(`"${ref}"`))
|
|
551
|
+
if (already) continue
|
|
552
|
+
out.push({
|
|
553
|
+
severity: 'error',
|
|
554
|
+
code: 'r31b-slice-url-reserved-char',
|
|
555
|
+
componentId: '',
|
|
556
|
+
message: `切图引用含 URL 保留字符 ${hit.map((c) => `"${c}"`).join(' ')}: "${ref}" → 浏览器截断请求,图片会静默不显示(R31b)。重命名切图为纯 ASCII 名并同步改写引用`,
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── R31c: 引用的切图在工程 src/slices 里根本不存在 → 渲染 404 → 图片空白(R31 家族的第三种)──
|
|
561
|
+
// R31 拦「名字带空格」、R31b 拦「名字带 URL 保留字符」,都是「名字写了但用不了」;
|
|
562
|
+
// R31c 补「这个名字在磁盘上就没有」。2026-09-21 helloCard 同源事故:
|
|
563
|
+
// 审计报 `警示-#999.png`、落盘被消毒成 `警示-999.png`,引用与磁盘名不同源 ⇒ 请求 404,
|
|
564
|
+
// 而切片服务先前会落 SPA 兜底返回 200+HTML ⇒ 图空白、网络面板 200、零报错、门禁全绿。
|
|
565
|
+
// 远端引用(@filestore / http / data:)天然无本地文件,不参与判定。
|
|
566
|
+
if (fs.existsSync(slicesDir)) {
|
|
567
|
+
const sliceIndex = buildSliceFileIndex(slicesDir)
|
|
568
|
+
for (const ref of collectSliceRefStrings(schema)) {
|
|
569
|
+
if (isRemoteSliceRef(ref)) continue
|
|
570
|
+
const { resolved, name } = resolveSliceRefOnDisk(sliceIndex, ref)
|
|
571
|
+
if (resolved) continue
|
|
572
|
+
out.push({
|
|
573
|
+
severity: 'error',
|
|
574
|
+
code: 'r31c-slice-ref-missing-file',
|
|
575
|
+
componentId: '',
|
|
576
|
+
message: `切图引用 "${ref}" 在 src/slices 里不存在(期望文件名 ${name})→ 渲染时 404,图片位置一片空白且控制台不报错(R31c)。两条修法:① 把切图按引用名落盘(\`crop_slices.mjs --from-audit\` 会自动收养审计判定存在的资产并按消毒名落盘);② 把引用改成磁盘上真实存在的文件名 —— 两者必须同源,别只改一边`,
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── R22b: 横向流父容器里的容器子节点「未写 rect.width + flexShrink:0」→ 吃掉整行 ──
|
|
582
|
+
// 渲染契约:容器不写 rect.width ⇒ 落到 component-meta 默认 "100%" ⇒ 独占整行;再叠 flexShrink:0
|
|
583
|
+
// 就是锁死不让位。R22 只拦「显式写 width:"100%"」那一半,**不写 width**(渲染等价)完全没人管。
|
|
584
|
+
for (const hit of collectRowChildNoWidth(view)) {
|
|
585
|
+
out.push({
|
|
586
|
+
severity: 'error',
|
|
587
|
+
code: 'r22b-row-child-container-no-width',
|
|
588
|
+
componentId: hit.componentId,
|
|
589
|
+
message: hit.message,
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
503
593
|
// ── R59/R60: 切图语义化命名 + 同内容去重(slices_convention.md §1.1 / §3.1)──
|
|
504
594
|
for (const ref of collectSliceRefStrings(schema)) {
|
|
505
595
|
const name = sliceNameFromRef(ref)
|
|
@@ -604,6 +694,17 @@ export function lint(schema, data) {
|
|
|
604
694
|
message: `Image "${n.componentId}" itemValueFrom="1"(变量) 但值不以 $ 开头 → 表达式求值失败,空白(R25)`,
|
|
605
695
|
})
|
|
606
696
|
}
|
|
697
|
+
if (from === '2' && val.startsWith('$')) {
|
|
698
|
+
out.push({
|
|
699
|
+
severity: 'error',
|
|
700
|
+
code: 'r25-image-from-2',
|
|
701
|
+
componentId: n.componentId || '',
|
|
702
|
+
message:
|
|
703
|
+
`Image "${n.componentId}" itemValueFrom="2"(图片/文件值) 但值是表达式 "${val}" → ` +
|
|
704
|
+
`运行时对 "2" 是原样透传(property.js onPreview),该字符串直接进 background-image:url(${val}),` +
|
|
705
|
+
`画布必然空白(R25)。变量绑定必须改 "1"`,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
607
708
|
}
|
|
608
709
|
})
|
|
609
710
|
|
|
@@ -762,6 +863,66 @@ export function lint(schema, data) {
|
|
|
762
863
|
})
|
|
763
864
|
|
|
764
865
|
|
|
866
|
+
/* ── R63: width:100% 撞水平 margin —— 交叉轴定宽不可缩,节点被推出父容器右缘 ────────
|
|
867
|
+
* 渲染器语义(vendor/preview 的 StyleParser + adapter.doSize):"100%" 原样透传成 width:100%,
|
|
868
|
+
* 而 width:100% 已经等于**父容器内容盒**宽度;水平 margin 再叠加 = 元素外框超出父容器 → 向右溢出、
|
|
869
|
+
* 被 overflow:hidden 裁掉(Root / 组件默认 hidden)。实测事故:模型把「父容器水平内缩」写成
|
|
870
|
+
* 「子节点 width:100% + marginLeft/Right」,节点右侧一整块内容消失。
|
|
871
|
+
*
|
|
872
|
+
* 为什么只报交叉轴(父容器按列排布):
|
|
873
|
+
* 主轴(父容器 flex-direction:row)里 flexShrink 默认 1,会把这个节点缩回可用宽 —— margin 被吸收,
|
|
874
|
+
* 不溢出;只有交叉轴(column:Root / Column / SingleColumn / flexDirection:"column" /
|
|
875
|
+
* AbsoluteLayout 的 abspos 子节点——定宽对定位包含块解析、无 shrink 参与)才是**必溢出**。
|
|
876
|
+
* 实测:全仓 205 份 schema 里 4 处「width:100% + marginLeft」全在 Row 父容器(accountCreditedlist /
|
|
877
|
+
* memberLevelNone / pointsDetail / assetOverview),全靠 shrink 吃掉间距——故本规则不报主轴,零误报。
|
|
878
|
+
*/
|
|
879
|
+
const laysOutVertically = (parent) => {
|
|
880
|
+
if (!parent) return false
|
|
881
|
+
// AbsoluteLayout 子节点是 position:absolute 脱标:100% 对定位包含块(容器 padding box)取宽,
|
|
882
|
+
// margin 不参与任何收缩 → 与 column 同款必溢出。
|
|
883
|
+
if (parent.className === 'AbsoluteLayout') return true
|
|
884
|
+
const explicit = flexSub(parent, 'flexDirection')
|
|
885
|
+
if (explicit) return explicit === 'column' // 显式 flexDirection(内联样式)覆盖 CSS 类的默认方向
|
|
886
|
+
return COLUMN_FLOW.has(parent.className)
|
|
887
|
+
}
|
|
888
|
+
walk(view, (n, parent) => {
|
|
889
|
+
if (!parent) return
|
|
890
|
+
const cid = n.componentId || ''
|
|
891
|
+
const styleProps = (n.style && n.style.properties) || []
|
|
892
|
+
const rectGroup = styleProps.find((p) => p.itemKey === 'rect')
|
|
893
|
+
const widthChild = ((rectGroup && rectGroup.itemChildren && rectGroup.itemChildren.properties) || [])
|
|
894
|
+
.find((c) => c.itemKey === 'width')
|
|
895
|
+
if (!widthChild || String(widthChild.itemValue ?? '').trim() !== '100%') return
|
|
896
|
+
if (String(widthChild.itemValueFrom ?? '0') === '1') return // 表达式绑定(值不是字面 100%)
|
|
897
|
+
const marginGroup = styleProps.find((p) => p.itemKey === 'margin')
|
|
898
|
+
const marginChildren = (marginGroup && marginGroup.itemChildren && marginGroup.itemChildren.properties) || []
|
|
899
|
+
const constNum = (key) => {
|
|
900
|
+
const c = marginChildren.find((x) => x.itemKey === key)
|
|
901
|
+
if (!c || String(c.itemValueFrom ?? '0') === '1') return null // 表达式绑定:值不定,不判
|
|
902
|
+
const v = parseFloat(String(c.itemValue).replace(/[^0-9.\-]/g, ''))
|
|
903
|
+
return Number.isFinite(v) ? v : null
|
|
904
|
+
}
|
|
905
|
+
const mtype = String((marginChildren.find((c) => c.itemKey === 'marginType') || {}).itemValue ?? '')
|
|
906
|
+
const hmargin = []
|
|
907
|
+
if (mtype === '1') { // 逐边模式:只看左右
|
|
908
|
+
const ml = constNum('marginLeft')
|
|
909
|
+
const mr = constNum('marginRight')
|
|
910
|
+
if (ml !== null && ml > 0) hmargin.push(`marginLeft:${ml}`)
|
|
911
|
+
if (mr !== null && mr > 0) hmargin.push(`marginRight:${mr}`)
|
|
912
|
+
} else if (mtype === '0') { // 全边模式:margin 四边同值,左右也吃
|
|
913
|
+
const m = constNum('margin')
|
|
914
|
+
if (m !== null && m > 0) hmargin.push(`margin(全边):${m}`)
|
|
915
|
+
}
|
|
916
|
+
if (!hmargin.length || !laysOutVertically(parent)) return
|
|
917
|
+
out.push({
|
|
918
|
+
severity: 'error',
|
|
919
|
+
code: 'r63-width100-with-hmargin',
|
|
920
|
+
componentId: cid,
|
|
921
|
+
message: `${n.className} "${cid}" 同时写了 rect.width:"100%" 与水平 margin(${hmargin.join(' / ')})—— width:100% 已经等于父容器内容宽,水平 margin 只会把节点推出父容器右缘(父容器 ${parent.className} 按列排布,水平方向是交叉轴,flexShrink 救不回来)→ 右侧内容被 overflow:hidden 裁掉(R63)。正路:① 水平间距挪到父容器 paddingLeft/Right(或由父容器 gap 承担);② 流式父容器(Column/Root,alignItems 为空或 stretch)把 rect.width 由 "100%" 改 "auto"(显式写 auto;Text/MultiText 有组件样式 width:fit-content,只删 width 不会撑满,实测 86.67px vs 369px),靠 stretch 补宽、保留 margin(AbsoluteLayout 的 abspos 子节点 width:auto 是 fit-content、无 stretch —— 走 ① 或改显式像素宽);③ 并排分栏用「左 width:100%+flexShrink:1,右 auto+flexShrink:0」`,
|
|
922
|
+
})
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
|
|
765
926
|
// ── 格式铁律:值格式校验(生成器最常犯的格式错误) ──
|
|
766
927
|
|
|
767
928
|
// R-FMT1: borderRadius 值不得用元组格式 {a,b,c,d}(应为标量 "12" 或单独角 borderTopLeftRadius 等)
|
|
@@ -833,6 +994,55 @@ export function lint(schema, data) {
|
|
|
833
994
|
}
|
|
834
995
|
})
|
|
835
996
|
|
|
997
|
+
// R64: 装饰条不得靠流式布局定位 —— 「标题+下划线」没按范式做的形态。
|
|
998
|
+
// 为什么单开一条:R42 只在「已经是 AbsoluteLayout」的节点上校验,用流式堆叠的写法可以整条绕过它;
|
|
999
|
+
// R38 又只禁**负** margin,模型改用**正** marginTop 造间隙,形式上完全合规。实测(2026-09-21
|
|
1000
|
+
// transferRecord)Column > [Text, Row 42x5 marginTop:3] 的卡 lint 报 0 error / 0 warning,
|
|
1001
|
+
// 而设计稿是 68x8 的条子压在标题底部(band 底对齐)——结构、尺寸、重叠关系、位置四处都不对,
|
|
1002
|
+
// 装饰条还占掉 8px 流式高度把兄弟节点整体下推。
|
|
1003
|
+
// 判定:流式容器里紧跟 Text 之后、有实色背景、高 ≤10、宽为像素或 `$变量.length * 字号` 的 Row
|
|
1004
|
+
// (即「本应绝对定位的装饰条」)。已带 position 的(复用了范式)与 Divider 一律放过。
|
|
1005
|
+
walk(view, (n) => {
|
|
1006
|
+
if (n.className === 'AbsoluteLayout') return
|
|
1007
|
+
const kids = directChildren(n)
|
|
1008
|
+
if (kids.length < 2) return
|
|
1009
|
+
for (let i = 1; i < kids.length; i += 1) {
|
|
1010
|
+
const bar = kids[i]
|
|
1011
|
+
const prev = kids[i - 1]
|
|
1012
|
+
if (!bar || !prev || bar.className !== 'Row' || prev.className !== 'Text') continue
|
|
1013
|
+
const child = (node, groupKey, childKey) => {
|
|
1014
|
+
const g = (node.style?.properties || []).find((x) => x.itemKey === groupKey)
|
|
1015
|
+
const c = (g?.itemChildren?.properties || []).find((x) => x.itemKey === childKey)
|
|
1016
|
+
return c ? c.itemValue : ''
|
|
1017
|
+
}
|
|
1018
|
+
const bgType = child(bar, 'background', 'backgroundType')
|
|
1019
|
+
const bgColor = child(bar, 'background', 'backgroundColor')
|
|
1020
|
+
if (String(bgType) !== '01' || !bgColor) continue
|
|
1021
|
+
const barHeight = Number(child(bar, 'rect', 'height'))
|
|
1022
|
+
if (!(barHeight > 0 && barHeight <= 10)) continue
|
|
1023
|
+
const barWidth = String(child(bar, 'rect', 'width') || '')
|
|
1024
|
+
const pixelOrExpr = /^\d+(\.\d+)?$/.test(barWidth.trim()) || /\.length \*/.test(barWidth)
|
|
1025
|
+
if (!pixelOrExpr) continue
|
|
1026
|
+
const posTop = child(bar, 'position', 'top')
|
|
1027
|
+
const posLeft = child(bar, 'position', 'left')
|
|
1028
|
+
if (String(posTop).trim() || String(posLeft).trim()) continue
|
|
1029
|
+
const cid = bar.componentId || '(no id)'
|
|
1030
|
+
// 标题文案:常量在 text.text,变量绑定在 properties[].value(如 $title)——拿不到就退回节点 id
|
|
1031
|
+
const boundValue = (prev.properties || []).find((x) => x.itemKey === 'value')?.itemValue
|
|
1032
|
+
const title = String(child(prev, 'text', 'text') || boundValue || prev.componentId || '标题')
|
|
1033
|
+
out.push({
|
|
1034
|
+
severity: 'error',
|
|
1035
|
+
code: 'r64-title-underline-flow',
|
|
1036
|
+
componentId: cid,
|
|
1037
|
+
message: `Row "${cid}"(高 ${barHeight} / 宽 ${barWidth})是标题「${title}」的装饰条,却排在流式布局里 —— `
|
|
1038
|
+
+ `它会占掉流式高度把后续兄弟整体下推,且拿不到设计稿的「条子压在标题底部」的重叠关系。`
|
|
1039
|
+
+ `必须复用 title-underline-overlay 范式:父容器改 AbsoluteLayout(align:"BottomLeft", rect.height=标题行高),`
|
|
1040
|
+
+ `装饰条与标题各自设 style.position.left/top(条 top = 标注条.y − 标注标题.y,不是公式推算),`
|
|
1041
|
+
+ `子节点顺序 [装饰条, 标题]。IR 可照抄 references/ir-format.md §2d(脚本 expand_ir.mjs --ir 展开,禁正/负 margin 顶位)。`,
|
|
1042
|
+
})
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
1045
|
+
|
|
836
1046
|
// R44: 类型+值成对检查 —— 有值但缺类型开关的样式组(borderRadius/border/shadow)→ ERROR
|
|
837
1047
|
walk(view, (n) => {
|
|
838
1048
|
const cid = n.componentId || '(root)'
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* preflight.mjs — 生成前/后一键机械检查(替代 AI 手动过 Pre-flight Checklist)。
|
|
4
4
|
*
|
|
5
|
-
* 检查项(对应 quickref.md Pre-flight Checklist 12 条中可机械化的
|
|
5
|
+
* 检查项(对应 quickref.md Pre-flight Checklist 12 条中可机械化的 13 条):
|
|
6
6
|
* 1. 元素清单:spec text/slice/shape 数 vs schema 节点数(R29)
|
|
7
7
|
* 2. 组件白名单:className ∈ component-meta.json(R28)
|
|
8
|
-
* 3. Image itemValueFrom:静态路径必须 "2",变量 "1"
|
|
8
|
+
* 3. Image itemValueFrom:静态路径必须 "2",变量 "1"(双向都拦:`"0"` 常量放路径、
|
|
9
|
+
* `"1"` 变量放非 `$` 值、`"2"` 图片槽放 `$` 表达式——末者会被原样透传成 url($icon))(R25)
|
|
9
10
|
* 4. Image rect.height:必须有显式高度(R32,否则被 aspect-ratio:16/9 压缩)
|
|
10
11
|
* 5. 切图文件名:含空格/特殊字符必须重命名(R31)
|
|
11
12
|
* 6. Icon eventaction:嵌套格式 event.eventKey(R30)
|
|
@@ -13,6 +14,10 @@
|
|
|
13
14
|
* 8. 分割线用 Divider:禁止 Row 模拟分割线(R28)
|
|
14
15
|
* 9. 卡片根样式全量提取:bg shape 有 border 但 schema 根容器未设(R33)
|
|
15
16
|
* 10. 空样式 itemId:style 子项 itemId 为空串 → 运行时静默丢弃
|
|
17
|
+
* 11. 切图引用含 URL 保留字符(`# ? % & +`)→ 浏览器截断请求、图片静默不显示(R31b)
|
|
18
|
+
* 12. 横向流父容器里的容器子节点未写 rect.width + flexShrink:0 → 默认 100% 吃掉整行(R22b)
|
|
19
|
+
* 13. 切图引用在 src/slices 里没有对应文件 → 渲染 404、图空白零报错(R31c)
|
|
20
|
+
* —— 11/12/13 与 lint_card.mjs 共用 lib/layout_rules.mjs 与 lib/slice_dedupe.mjs,避免双写漂移。
|
|
16
21
|
*
|
|
17
22
|
* 用法:
|
|
18
23
|
* node preflight.mjs --spec UI/<画板>/spec.json --id <卡id>
|
|
@@ -24,6 +29,8 @@
|
|
|
24
29
|
import fs from 'node:fs'
|
|
25
30
|
import path from 'node:path'
|
|
26
31
|
import { metaDir, cardsDir, slicesDir } from './lib/paths.mjs'
|
|
32
|
+
import { buildSliceFileIndex, collectSliceRefStrings, findUrlReservedChars, isRemoteSliceRef, resolveSliceRefOnDisk, sliceNameFromRef } from './lib/slice_dedupe.mjs'
|
|
33
|
+
import { collectRowChildNoWidth } from './lib/layout_rules.mjs'
|
|
27
34
|
|
|
28
35
|
/* ── args ── */
|
|
29
36
|
const args = {}
|
|
@@ -125,6 +132,13 @@ for (const n of allNodes.filter(n => n.className === 'Image')) {
|
|
|
125
132
|
if (from === '1' && !val.startsWith('$')) {
|
|
126
133
|
check('r25-image-from-1', 'error', `Image "${n.componentId}" itemValueFrom="1"(变量) 但值不以 $ 开头 → 表达式求值失败,空白(R25)`)
|
|
127
134
|
}
|
|
135
|
+
if (from === '2' && val.startsWith('$')) {
|
|
136
|
+
check(
|
|
137
|
+
'r25-image-from-2',
|
|
138
|
+
'error',
|
|
139
|
+
`Image "${n.componentId}" itemValueFrom="2"(图片/文件值) 但值是表达式 "${val}" → 运行时对 "2" 是**原样透传**(property.js onPreview),该字符串会直接进 background-image:url(${val}),画布必然空白(R25)。变量绑定必须改 "1"`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/* ══ Check 4: Image rect.height (R32) ══ */
|
|
@@ -227,6 +241,38 @@ for (const n of allNodes) {
|
|
|
227
241
|
}
|
|
228
242
|
}
|
|
229
243
|
|
|
244
|
+
/* ══ Check 11: Slice refs with URL reserved chars (R31b) ══
|
|
245
|
+
* `#` 之后被当 fragment,浏览器根本不发这部分(实测 /slices/警示-#999.png → 请求 /slices/警示-)。
|
|
246
|
+
* 未命中时静态中间件返回 HTML 兜底(旧行为 200)⇒ 图空白、网络面板 200、零报错。 */
|
|
247
|
+
for (const ref of collectSliceRefStrings(schema)) {
|
|
248
|
+
const hit = findUrlReservedChars(sliceNameFromRef(ref))
|
|
249
|
+
if (hit) {
|
|
250
|
+
check('r31b-slice-url-reserved-char', 'error', `切图引用含 URL 保留字符 ${hit.map((c) => `"${c}"`).join(' ')}: "${ref}" → 浏览器会截断请求,图片静默不显示(R31b)。重命名切图为纯 ASCII 名并同步改写引用`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ══ Check 12: Row-child container without rect.width + flexShrink:0 (R22b) ══ */
|
|
255
|
+
for (const hit of collectRowChildNoWidth(schema.view)) {
|
|
256
|
+
check('r22b-row-child-container-no-width', 'error', hit.message, `${hit.className} ${hit.componentId} ← ${hit.parentClass} ${hit.parentId}(${hit.siblingCount} 个并列子节点)`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ══ Check 13: Slice ref target missing on disk (R31c) ══
|
|
260
|
+
* 审计给了正确的名字、但文件还在 bundle 里没落进 src/slices ⇒ 引用 404、图空白、零报错。
|
|
261
|
+
* 这一条把「引用名 ↔ 磁盘名同源」变成每轮迭代都能看到的硬约束。 */
|
|
262
|
+
{
|
|
263
|
+
const sliceIndex = buildSliceFileIndex(slicesDir)
|
|
264
|
+
for (const ref of collectSliceRefStrings(schema)) {
|
|
265
|
+
if (isRemoteSliceRef(ref)) continue
|
|
266
|
+
const { resolved, name } = resolveSliceRefOnDisk(sliceIndex, ref)
|
|
267
|
+
if (resolved) continue
|
|
268
|
+
check(
|
|
269
|
+
'r31c-slice-ref-missing-file',
|
|
270
|
+
'error',
|
|
271
|
+
`切图引用 "${ref}" 在 src/slices 里不存在(期望文件名 ${name})→ 渲染时 404,图片位置一片空白且控制台不报错(R31c)。把切图按引用名落盘(crop_slices.mjs --from-audit 会收养审计判定存在的资产),或把引用改成磁盘上真实存在的文件名 —— 两者必须同源`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
230
276
|
/* ── Output ── */
|
|
231
277
|
const errors = results.filter(r => r.severity === 'error')
|
|
232
278
|
const warns = results.filter(r => r.severity === 'warn')
|
|
@@ -463,6 +463,75 @@ function describeRuntimeEvents(events) {
|
|
|
463
463
|
}).filter(Boolean).slice(-20).join(' | ')
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
/**
|
|
467
|
+
* 抓取 `.phone__screen` 内全部 `[data-cid]` 元素的几何与文本度量。
|
|
468
|
+
*
|
|
469
|
+
* 返回 `{ elements, degenerate }`:
|
|
470
|
+
* - `elements`:正常尺寸节点(≥1px),供评分 / 归因 / 设计稿配对使用;
|
|
471
|
+
* - `degenerate`:**被压成零尺寸但仍参与布局**的节点(display:none / visibility:hidden 的
|
|
472
|
+
* v-show 变体已剔除)。旧实现把它们直接丢掉,于是「被兄弟挤成 0 宽 → 文本每字一行 →
|
|
473
|
+
* 高度爆炸」这类最致命的缺陷在度量里根本不存在,模型只能靠猜。
|
|
474
|
+
*
|
|
475
|
+
* 冷路径与热路径(keep-alive 复用页面)都必须调用,否则 keep-alive 模式下 elementBoxes 恒为空。
|
|
476
|
+
*/
|
|
477
|
+
async function extractElementBoxes(cdp) {
|
|
478
|
+
const elRes = await cdp.send('Runtime.evaluate', {
|
|
479
|
+
expression: `(function(){
|
|
480
|
+
var scr = document.querySelector('.phone__screen');
|
|
481
|
+
if (!scr) return JSON.stringify({ elements: [], degenerate: [] });
|
|
482
|
+
var sr = scr.getBoundingClientRect();
|
|
483
|
+
var els = scr.querySelectorAll('[data-cid]');
|
|
484
|
+
var out = [];
|
|
485
|
+
var degenerate = [];
|
|
486
|
+
els.forEach(function(el) {
|
|
487
|
+
var r = el.getBoundingClientRect();
|
|
488
|
+
var cs = window.getComputedStyle(el);
|
|
489
|
+
var fs = parseFloat(cs.fontSize) || 0;
|
|
490
|
+
var lhRaw = cs.lineHeight;
|
|
491
|
+
// lineHeight 供几何审计判「文本被折了几行」(h / lh > 2 即 3 行以上);
|
|
492
|
+
// 'normal' 时拿不到像素值,取 0 表示未知(调用方据此跳过该判定,不猜)。
|
|
493
|
+
var lh = (lhRaw && lhRaw !== 'normal') ? (parseFloat(lhRaw) || 0) : 0;
|
|
494
|
+
var text = (el.textContent || '').trim();
|
|
495
|
+
// Input 类组件的占位文本在内部 <input placeholder> 属性上,textContent 为空;
|
|
496
|
+
// 评分/位置配对需要把它当作可见文本捕获(否则占位行被误判为文本缺失)。
|
|
497
|
+
if (!text) {
|
|
498
|
+
var innerInput = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? el : el.querySelector('input,textarea');
|
|
499
|
+
if (innerInput) text = (innerInput.getAttribute('placeholder') || innerInput.value || '').trim();
|
|
500
|
+
}
|
|
501
|
+
var rec = {
|
|
502
|
+
cid: el.getAttribute('data-cid') || '',
|
|
503
|
+
cls: el.getAttribute('data-class') || '',
|
|
504
|
+
text: text.slice(0, 60),
|
|
505
|
+
x: Math.round((r.left - sr.left) * 100) / 100,
|
|
506
|
+
y: Math.round((r.top - sr.top) * 100) / 100,
|
|
507
|
+
w: Math.round(r.width * 100) / 100,
|
|
508
|
+
h: Math.round(r.height * 100) / 100,
|
|
509
|
+
fs: Math.round(fs * 100) / 100,
|
|
510
|
+
lh: Math.round(lh * 100) / 100,
|
|
511
|
+
color: cs.color || '',
|
|
512
|
+
scrollH: el.scrollHeight || 0,
|
|
513
|
+
clientH: el.clientHeight || 0,
|
|
514
|
+
};
|
|
515
|
+
if (r.width < 1 || r.height < 1) {
|
|
516
|
+
// display:none / visibility:hidden 是**有意隐藏**(v-show 变体),不算缺陷
|
|
517
|
+
if (cs.display === 'none' || cs.visibility === 'hidden') return;
|
|
518
|
+
degenerate.push(rec);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
out.push(rec);
|
|
522
|
+
});
|
|
523
|
+
return JSON.stringify({ elements: out, degenerate: degenerate });
|
|
524
|
+
})()`,
|
|
525
|
+
returnByValue: true,
|
|
526
|
+
})
|
|
527
|
+
const raw = elRes?.result?.value
|
|
528
|
+
if (!raw) return null
|
|
529
|
+
const parsed = JSON.parse(raw)
|
|
530
|
+
// 兼容旧形态(纯数组,无 degenerate)
|
|
531
|
+
if (Array.isArray(parsed)) return { elements: parsed, degenerate: [] }
|
|
532
|
+
return { elements: Array.isArray(parsed.elements) ? parsed.elements : [], degenerate: Array.isArray(parsed.degenerate) ? parsed.degenerate : [] }
|
|
533
|
+
}
|
|
534
|
+
|
|
466
535
|
/* ------------------------------------------------------------------ */
|
|
467
536
|
/* 可复用渲染会话 */
|
|
468
537
|
/* ------------------------------------------------------------------ */
|
|
@@ -507,6 +576,11 @@ export async function createRenderSession({ cardsRoot = cardsDir } = {}) {
|
|
|
507
576
|
} catch {}
|
|
508
577
|
let closed = false
|
|
509
578
|
let pageReady = false
|
|
579
|
+
// 热路径只允许「同一张卡」复用:热更新靠 history.replaceState 改 URL 再调 window.__bootCard(),
|
|
580
|
+
// 而 __bootCard 取卡片 id 的时机/来源不由本脚本控制。实测(2026-09-21):一个会话里连续
|
|
581
|
+
// 渲染 8 张不同的卡,全部返回**第一张**的度量(8 张高度全 = 341px)——静默错卡。
|
|
582
|
+
// 一旦接上几何门禁,错卡等于给出错误结论,故用 id 白名单把热路径钉死在同卡重渲染上。
|
|
583
|
+
let lastRenderedId = null
|
|
510
584
|
|
|
511
585
|
async function render({
|
|
512
586
|
id,
|
|
@@ -537,8 +611,8 @@ export async function createRenderSession({ cardsRoot = cardsDir } = {}) {
|
|
|
537
611
|
deviceScaleFactor: 1,
|
|
538
612
|
mobile: false,
|
|
539
613
|
})
|
|
540
|
-
//
|
|
541
|
-
if (hotReload && pageReady) {
|
|
614
|
+
// 热更新路径:同会话内**同一张卡**已渲染过时,通过 history.replaceState + 重新 boot 跳过整页导航
|
|
615
|
+
if (hotReload && pageReady && id === lastRenderedId) {
|
|
542
616
|
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
543
617
|
width: Math.ceil(Number(width)) + 40,
|
|
544
618
|
height: Math.max(2000, Math.ceil(Number(captureHeight || 2000)) + 40),
|
|
@@ -571,10 +645,19 @@ export async function createRenderSession({ cardsRoot = cardsDir } = {}) {
|
|
|
571
645
|
captureBeyondViewport: true,
|
|
572
646
|
})
|
|
573
647
|
const buffer2 = Buffer.from(shot2.data, 'base64')
|
|
648
|
+
// 热路径同样要抓元素度量:keep-alive 模式下每轮 iterate 都走这里,
|
|
649
|
+
// 漏掉就等于「除了第一轮,模型永远拿不到几何归因」。
|
|
650
|
+
let hotBoxes = null
|
|
651
|
+
if (captureElements) {
|
|
652
|
+
try { hotBoxes = await extractElementBoxes(cdp) } catch { /* best-effort */ }
|
|
653
|
+
}
|
|
654
|
+
lastRenderedId = id
|
|
574
655
|
return {
|
|
575
656
|
ok: true, source: 'standalone-hot', id,
|
|
576
657
|
width: Math.round(box2.width), height: Math.round(box2.height),
|
|
577
658
|
base64: buffer2.toString('base64'),
|
|
659
|
+
elementBoxes: hotBoxes?.elements || undefined,
|
|
660
|
+
elementBoxesDegenerate: hotBoxes?.degenerate || undefined,
|
|
578
661
|
outFile: outFile ? path.resolve(outFile) : null,
|
|
579
662
|
ms: Math.round(performance.now() - startedAt),
|
|
580
663
|
}
|
|
@@ -594,6 +677,7 @@ export async function createRenderSession({ cardsRoot = cardsDir } = {}) {
|
|
|
594
677
|
}).catch(() => {})
|
|
595
678
|
await renderDone
|
|
596
679
|
pageReady = true
|
|
680
|
+
lastRenderedId = id
|
|
597
681
|
// 截图前等网络静默:冷帧根因(背景图/字体在途时截图)在此闭环
|
|
598
682
|
await waitNetworkIdle(inflight)
|
|
599
683
|
const boxRes = await cdp.send('Runtime.evaluate', {
|
|
@@ -618,49 +702,12 @@ export async function createRenderSession({ cardsRoot = cardsDir } = {}) {
|
|
|
618
702
|
})
|
|
619
703
|
const buffer = Buffer.from(shot.data, 'base64')
|
|
620
704
|
let elementBoxes = null
|
|
705
|
+
let elementBoxesDegenerate = null
|
|
621
706
|
if (captureElements) {
|
|
622
707
|
try {
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (!scr) return JSON.stringify([]);
|
|
627
|
-
var sr = scr.getBoundingClientRect();
|
|
628
|
-
var els = scr.querySelectorAll('[data-cid]');
|
|
629
|
-
var out = [];
|
|
630
|
-
els.forEach(function(el) {
|
|
631
|
-
var r = el.getBoundingClientRect();
|
|
632
|
-
if (r.width < 1 || r.height < 1) return;
|
|
633
|
-
var cls = el.getAttribute('data-class') || '';
|
|
634
|
-
var text = (el.textContent || '').trim();
|
|
635
|
-
// Input 类组件的占位文本在内部 <input placeholder> 属性上,textContent 为空;
|
|
636
|
-
// 评分/位置配对需要把它当作可见文本捕获(否则占位行被误判为文本缺失)。
|
|
637
|
-
if (!text) {
|
|
638
|
-
// Input.vue 根元素即 <input>:自身优先,其次向后兼容包裹式实现
|
|
639
|
-
var innerInput = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? el : el.querySelector('input,textarea');
|
|
640
|
-
if (innerInput) text = (innerInput.getAttribute('placeholder') || innerInput.value || '').trim();
|
|
641
|
-
}
|
|
642
|
-
var cs = window.getComputedStyle(el);
|
|
643
|
-
var fs = parseFloat(cs.fontSize) || 0;
|
|
644
|
-
var color = cs.color || '';
|
|
645
|
-
out.push({
|
|
646
|
-
cid: el.getAttribute('data-cid') || '',
|
|
647
|
-
cls: cls,
|
|
648
|
-
text: text.slice(0, 60),
|
|
649
|
-
x: Math.round((r.left - sr.left) * 100) / 100,
|
|
650
|
-
y: Math.round((r.top - sr.top) * 100) / 100,
|
|
651
|
-
w: Math.round(r.width * 100) / 100,
|
|
652
|
-
h: Math.round(r.height * 100) / 100,
|
|
653
|
-
fs: Math.round(fs * 100) / 100,
|
|
654
|
-
color: color,
|
|
655
|
-
scrollH: el.scrollHeight || 0,
|
|
656
|
-
clientH: el.clientHeight || 0,
|
|
657
|
-
});
|
|
658
|
-
});
|
|
659
|
-
return JSON.stringify(out);
|
|
660
|
-
})()`,
|
|
661
|
-
returnByValue: true,
|
|
662
|
-
})
|
|
663
|
-
if (elRes?.result?.value) elementBoxes = JSON.parse(elRes.result.value)
|
|
708
|
+
const boxes = await extractElementBoxes(cdp)
|
|
709
|
+
elementBoxes = boxes?.elements || null
|
|
710
|
+
elementBoxesDegenerate = boxes?.degenerate || null
|
|
664
711
|
} catch (e) { /* element extraction is best-effort */ }
|
|
665
712
|
}
|
|
666
713
|
const json = {
|
|
@@ -670,6 +717,7 @@ export async function createRenderSession({ cardsRoot = cardsDir } = {}) {
|
|
|
670
717
|
height: Math.round(captureHeight ? Math.min(box.height, Number(captureHeight)) : box.height),
|
|
671
718
|
base64: buffer.toString('base64'),
|
|
672
719
|
elementBoxes: elementBoxes,
|
|
720
|
+
elementBoxesDegenerate: elementBoxesDegenerate,
|
|
673
721
|
}
|
|
674
722
|
|
|
675
723
|
if (format === 'json') return { ok: true, source: 'standalone', ...json }
|
|
@@ -34,7 +34,7 @@ const SKETCH_SKILL = path.join(hostSkillsDir, 'sketch-spec-to-card')
|
|
|
34
34
|
* 作为提示文本随 next/note 下发,让判定器输出即带门禁,不依赖模型记住文档。
|
|
35
35
|
*/
|
|
36
36
|
const SELECTION_GATE =
|
|
37
|
-
'# 多画板门禁(rules.md §十三 + §13.4):用 spec_tree.mjs --list-cards 把全量画板按「画板序号.卡片序号」列出(一个画板含 N 张卡则展开为 K.1 ~ K.N)+ 末尾加「全部」;让用户选的方式 = 正文逐条写清单 + 一句提问后停止输出、等用户回序号再继续(**没有** request_user_input 这个工具,不要去找弹窗,也不要替用户挑);序号格式「画板序号.卡片序号」(如 1.2),单独回画板序号默认取该画板第 1 张卡;UI 产物可回「全部」全量生成,产卡默认单卡片(回「全部」才算显式批量授权);禁止自行挑画板或挑卡、禁止擅自批量产卡;清单逐行配图:每个卡片行下紧跟它自己的 card-N.png 绝对路径,同一画板的卡共享画板预览图(artboard.png
|
|
37
|
+
'# 多画板门禁(rules.md §十三 + §13.4):用 spec_tree.mjs --list-cards 把全量画板按「画板序号.卡片序号」列出(一个画板含 N 张卡则展开为 K.1 ~ K.N)+ 末尾加「全部」;让用户选的方式 = 正文逐条写清单 + 一句提问后停止输出、等用户回序号再继续(**没有** request_user_input 这个工具,不要去找弹窗,也不要替用户挑);序号格式「画板序号.卡片序号」(如 1.2),单独回画板序号默认取该画板第 1 张卡;UI 产物可回「全部」全量生成,产卡默认单卡片(回「全部」才算显式批量授权);禁止自行挑画板或挑卡、禁止擅自批量产卡;清单逐行配图:每个卡片行下紧跟它自己的 card-N.png 绝对路径,同一画板的卡共享画板预览图(artboard.png)但各有聚焦图;与序号一一对应,缺图显式标缺失,禁止把图堆在清单末尾;路径照抄完整绝对路径——禁止用 … / ... 省略前缀、禁止包进反引号或代码块(缩写的路径渲染端取不到图,用户那几行只剩一串文字)。'
|
|
38
38
|
/**
|
|
39
39
|
* 要素包根目录 UI/ —— **锚在宿主工程根,不锚在脚本自身位置**。
|
|
40
40
|
*
|
|
@@ -88,7 +88,7 @@ function readTextCapped(file, cap = 64 * 1024 * 1024) {
|
|
|
88
88
|
return head.toString('utf8') + tail.toString('utf8')
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/** 命令里输出相对 cwd
|
|
91
|
+
/** 命令里输出相对 cwd 的短路径,跨盘/越界时退回绝对路径(分隔符按平台原生形态)。 */
|
|
92
92
|
function shortPath(abs) {
|
|
93
93
|
const rel = path.relative(process.cwd(), abs)
|
|
94
94
|
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : abs
|
|
@@ -96,7 +96,8 @@ function shortPath(abs) {
|
|
|
96
96
|
|
|
97
97
|
function pythonBin() {
|
|
98
98
|
if (process.env.DSL_CARD_PYTHON) return process.env.DSL_CARD_PYTHON
|
|
99
|
-
|
|
99
|
+
// py 是 Windows 官方启动器(python3 在 Windows 常是 Store 假别名:能启动、立刻 9009 退出)
|
|
100
|
+
for (const bin of ['python3', 'python', 'py']) {
|
|
100
101
|
const probe = spawnSync(bin, ['--version'], { stdio: 'ignore' })
|
|
101
102
|
if (probe.status === 0) return bin
|
|
102
103
|
}
|