cardbuddy 0.16.0 → 0.18.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 (52) hide show
  1. package/dist/init.js +0 -2
  2. package/dist/preview-editor/assets/index-DPK_SisH.css +1 -0
  3. package/dist/preview-editor/assets/index-kaTaTm71.js +343 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/vendored/server/core/ai-chat.js +923 -138
  6. package/dist/vendored/server/core/browser.js +17 -0
  7. package/dist/vendored/server/core/cardFiles.js +49 -1
  8. package/dist/vendored/server/core/cardImage.js +103 -2
  9. package/dist/vendored/server/core/chatHistory.js +40 -0
  10. package/dist/vendored/server/core/screenshot.js +94 -0
  11. package/dist/vendored/skills/card-skill-trainer/SKILL.md +7 -7
  12. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +1 -1
  13. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +7 -2
  14. package/dist/vendored/skills/dsl-card-generator/references/component_rules.md +14 -0
  15. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +48 -0
  16. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +21 -0
  17. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +140 -3
  18. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scaffold.mjs +48 -1
  19. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +41 -4
  20. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +57 -6
  21. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +17 -3
  22. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +8 -1
  23. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +46 -2
  24. package/dist/vendored/skills/mbank-design-gen/SKILL.md +76 -0
  25. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-apps.png +0 -0
  26. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-arrow-right-blue.png +0 -0
  27. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-check.png +0 -0
  28. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-settings.png +0 -0
  29. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-sound.png +0 -0
  30. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-voice-switch.png +0 -0
  31. package/dist/vendored/skills/mbank-design-gen/assets/icons/progress-thumb.png +0 -0
  32. package/dist/vendored/skills/mbank-design-gen/assets/icons/tag-bg.png +0 -0
  33. package/dist/vendored/skills/mbank-design-gen/assets/icons/vs-badge.png +0 -0
  34. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-chat-options.png +0 -0
  35. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-home-scene.png +0 -0
  36. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-product-detail.png +0 -0
  37. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-rate-table.png +0 -0
  38. package/dist/vendored/skills/mbank-design-gen/assets/robot.png +0 -0
  39. package/dist/vendored/skills/mbank-design-gen/references/component-library.md +127 -0
  40. package/dist/vendored/skills/mbank-design-gen/references/design-spec.md +261 -0
  41. package/dist/vendored/skills/mbank-design-gen/templates/mockup-template.html +138 -0
  42. package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +34 -0
  43. package/dist/vendored/skills/shared/spec-contract.md +10 -1
  44. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +1 -1
  45. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +30 -0
  46. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +25 -3
  47. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +15 -3
  48. package/dist/vendored/templates/helloCard/schema.json +62 -1
  49. package/dist/vendored/tools/finmall-env.mjs +1 -18
  50. package/package.json +1 -1
  51. package/dist/preview-editor/assets/index-BkbBxDTT.css +0 -1
  52. package/dist/preview-editor/assets/index-Bro07DTa.js +0 -313
@@ -49,6 +49,22 @@ async function getBrowser() {
49
49
  return browserPromise
50
50
  }
51
51
 
52
+ /** 立即关闭单例浏览器并复位(测试 / 脚本收尾用;生产路径靠空闲 5 分钟自动关) */
53
+ async function closeBrowser() {
54
+ if (idleTimer) {
55
+ clearTimeout(idleTimer)
56
+ idleTimer = null
57
+ }
58
+ const p = browserPromise
59
+ browserPromise = null
60
+ if (p) {
61
+ try {
62
+ const b = await p
63
+ if (b && typeof b.close === 'function') await b.close()
64
+ } catch (_) { /* 关闭失败忽略 */ }
65
+ }
66
+ }
67
+
52
68
  function resetIdleTimer() {
53
69
  if (idleTimer) clearTimeout(idleTimer)
54
70
  idleTimer = setTimeout(async () => {
@@ -112,6 +128,7 @@ export {
112
128
  NAV_TIMEOUT_MS,
113
129
  SETTLE_MS,
114
130
  getBrowser,
131
+ closeBrowser,
115
132
  serialize,
116
133
  openCardScreen,
117
134
  }
@@ -8,6 +8,54 @@ import { deleteJournal, turnsDir } from './turnJournal.js'
8
8
  import { log } from './logger.js'
9
9
  import { buildDataFromSchema } from './buildDataFromSchema.js'
10
10
 
11
+ /**
12
+ * 空白卡根样式:margin / padding / gap 显式归零(而非「未设置」)。
13
+ * 渲染器对无显式样式的容器会注入默认值(padding:2px / gap:4px / justify-content:center),
14
+ * 空白卡必须显式归零压掉注入,否则新卡自带隐性间距且属性面板显示「未设置」。
15
+ * 形状取自存量卡根节点(见 style-meta.json:marginType / paddingType,'0' = 全边统一值)。
16
+ * 同一形状还有两份内联副本(禁跨包 import):skills/dsl-card-generator lib/scaffold.mjs、
17
+ * skills/finmall-card-sync api_batch_sync.mjs 的 buildBlankSchema —— 改这里必须同步改那两处。
18
+ */
19
+ function blankResetStyle() {
20
+ return {
21
+ properties: [
22
+ {
23
+ itemId: '100',
24
+ itemKey: 'flex',
25
+ itemValueFrom: '0',
26
+ itemValue: {},
27
+ itemChildren: {
28
+ properties: [{ itemId: '003', itemKey: 'gap', itemValueFrom: '0', itemValue: '0' }],
29
+ },
30
+ },
31
+ {
32
+ itemId: '400',
33
+ itemKey: 'padding',
34
+ itemValueFrom: '0',
35
+ itemValue: {},
36
+ itemChildren: {
37
+ properties: [
38
+ { itemId: '001', itemKey: 'paddingType', itemValueFrom: '0', itemValue: '0' },
39
+ { itemId: '002', itemKey: 'padding', itemValueFrom: '0', itemValue: '0' },
40
+ ],
41
+ },
42
+ },
43
+ {
44
+ itemId: '500',
45
+ itemKey: 'margin',
46
+ itemValueFrom: '0',
47
+ itemValue: {},
48
+ itemChildren: {
49
+ properties: [
50
+ { itemId: '001', itemKey: 'marginType', itemValueFrom: '0', itemValue: '0' },
51
+ { itemId: '002', itemKey: 'margin', itemValueFrom: '0', itemValue: '0' },
52
+ ],
53
+ },
54
+ },
55
+ ],
56
+ }
57
+ }
58
+
11
59
  /**
12
60
  * 单张卡片 / 卡片列表的按需读取 / 写回服务端处理(dev 下实时读磁盘,无缓存)。
13
61
  * - GET /__cards :实时扫描 src/cards/* 目录,返回卡片列表 [{id,name,pageId}]。
@@ -344,7 +392,7 @@ export function createCardFilesMiddleware({ root, notify = null }) {
344
392
  view: {
345
393
  width: '375',
346
394
  className: 'Root',
347
- style: { properties: [] },
395
+ style: blankResetStyle(),
348
396
  properties: [],
349
397
  eventactions: [],
350
398
  components: [],
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { takeScreenshot } from './screenshot.js'
3
+ import { takeScreenshot, generateCardCover } from './screenshot.js'
4
+ import { serialize, MAX_QUEUED } from './browser.js'
4
5
 
5
6
  /**
6
7
  * 卡片预览图本地静态服务:把卡片目录下的「同名预览图」映射为
@@ -14,6 +15,15 @@ import { takeScreenshot } from './screenshot.js'
14
15
  * 若卡片目录没有同名预览图,则回退到实时无头截图(takeScreenshot,复用 /__screenshot
15
16
  * 同一套渲染内核),保证任何卡片都能出预览。截图失败(如缺 Chrome)则 404,
16
17
  * 由调用方回退占位。仅服务 src/cards 下的卡片预览图,禁止目录穿越。
18
+ *
19
+ * POST /__card-image/<id>:为该卡生成封面预览图并写盘(顶栏「预览图」按钮触发)。
20
+ * 无头渲染磁盘态 schema → 透明底、与卡片实际渲染尺寸 1:1 的 PNG → 覆盖写 src/cards/<id>/<id>.png。
21
+ * 用于手动搭建 / 生成卡片后补齐封面:省去手工截图 + 裁切。约定:
22
+ * - 未保存的编辑器改动不参与渲染,前端负责先落盘再调用;
23
+ * - 同 id 其它扩展名的旧预览图(jpg/webp 等)一并清除,保证「同名预览图」单一来源
24
+ * (GET 按 readdir 顺序取第一个同名位图,双封面并存会让新 PNG 输给陈旧旧图);
25
+ * - 空白卡(schema 无 components)拒绝生成(422),与 GET 回退 404 的语义一致;
26
+ * - 原子写(临时文件 + rename),并发读取不会拿到半截 PNG。
17
27
  */
18
28
 
19
29
  const MIME = {
@@ -46,6 +56,98 @@ export function createCardImageMiddleware({ root }) {
46
56
  if (!dir.startsWith(cardsRoot + path.sep) && dir !== cardsRoot) return next()
47
57
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return next()
48
58
 
59
+ // POST:生成封面预览图并覆盖写 <id>.png(顶栏「预览图」按钮触发,见文件头注释)
60
+ if ((req.method || 'GET').toUpperCase() === 'POST') {
61
+ // 空白卡(schema 无 components)截出来是无意义纯透明图:直接拒绝
62
+ let cardWidth = 375
63
+ try {
64
+ const schema = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'))
65
+ const comps = schema && schema.view && schema.view.components
66
+ if (Array.isArray(comps) && !comps.length) {
67
+ res.statusCode = 422
68
+ res.setHeader('Content-Type', 'application/json')
69
+ return res.end(
70
+ JSON.stringify({ error: 'empty-card', message: '空白卡(无组件)没有可预览的内容,不生成预览图' }),
71
+ )
72
+ }
73
+ const w = parseInt((schema && schema.view && schema.view.width) || '', 10)
74
+ if (w > 0) cardWidth = w
75
+ } catch {
76
+ /* schema 缺失 / 损坏:按默认卡宽走,渲染失败会有结构化报错 */
77
+ }
78
+
79
+ const shot = await serialize(() => generateCardCover(id, { width: cardWidth }))
80
+ if (shot.tooBusy) {
81
+ res.statusCode = 429
82
+ res.setHeader('Content-Type', 'application/json')
83
+ return res.end(
84
+ JSON.stringify({ error: 'too-busy', message: '截图队列已满(并发上限 ' + MAX_QUEUED + '),请稍后重试' }),
85
+ )
86
+ }
87
+ if (!shot.buffer) {
88
+ res.statusCode = shot.code || 503
89
+ res.setHeader('Content-Type', 'application/json')
90
+ return res.end(JSON.stringify({ error: shot.error, message: shot.message, fix: shot.fix }))
91
+ }
92
+
93
+ const file = path.join(dir, id + '.png')
94
+ const overwritten = fs.existsSync(file)
95
+ // 原子写:先落临时文件再 rename,并发读取(网格缩略图)不会拿到半截 PNG
96
+ const tmp = file + '.tmp'
97
+ fs.writeFileSync(tmp, shot.buffer)
98
+ fs.renameSync(tmp, file)
99
+ // 清掉同 id 其它扩展名的旧预览图:GET 按 readdir 顺序取第一个同名位图,
100
+ // 双封面并存会让「刚生成的 PNG」可能输给陈旧的 jpg/webp(单一来源纪律)
101
+ for (const entry of fs.readdirSync(dir)) {
102
+ const dot = entry.lastIndexOf('.')
103
+ if (dot < 0) continue
104
+ const base = entry.slice(0, dot)
105
+ const ext = entry.slice(dot).toLowerCase()
106
+ if (base.toLowerCase() === id.toLowerCase() && MIME[ext] && entry !== id + '.png') {
107
+ try {
108
+ fs.unlinkSync(path.join(dir, entry))
109
+ } catch {
110
+ /* 清理失败不阻塞:多一张旧图只是回退序靠前,不影响本次交付 */
111
+ }
112
+ }
113
+ }
114
+
115
+ res.statusCode = 200
116
+ res.setHeader('Content-Type', 'application/json')
117
+ res.setHeader('Cache-Control', 'no-store')
118
+ return res.end(
119
+ JSON.stringify({
120
+ ok: true,
121
+ id,
122
+ width: shot.width,
123
+ height: shot.height,
124
+ file: 'src/cards/' + id + '/' + id + '.png',
125
+ overwritten,
126
+ }),
127
+ )
128
+ }
129
+
130
+ // GET ?probe=1:轻量探测「同名预览图是否已存在」——只 readdir、命中即返回,
131
+ // 不触发回退截图。供顶栏「截图」按钮在覆盖已有封面之前弹二次确认。
132
+ const q = new URL(req.url, 'http://localhost')
133
+ if (q.searchParams.get('probe') === '1') {
134
+ let found = null
135
+ for (const entry of fs.readdirSync(dir)) {
136
+ const dot = entry.lastIndexOf('.')
137
+ if (dot < 0) continue
138
+ const base = entry.slice(0, dot)
139
+ const ext = entry.slice(dot).toLowerCase()
140
+ if (base.toLowerCase() === id.toLowerCase() && MIME[ext]) {
141
+ found = 'src/cards/' + id + '/' + entry
142
+ break
143
+ }
144
+ }
145
+ res.statusCode = 200
146
+ res.setHeader('Content-Type', 'application/json')
147
+ res.setHeader('Cache-Control', 'no-store')
148
+ return res.end(JSON.stringify({ exists: !!found, file: found }))
149
+ }
150
+
49
151
  // 取与 id 同名的位图(大小写不敏感,扩展名见 MIME)
50
152
  let file = null
51
153
  for (const entry of fs.readdirSync(dir)) {
@@ -84,7 +186,6 @@ export function createCardImageMiddleware({ root }) {
84
186
 
85
187
  // 无同名预览图:回退实时无头截图(复用 /__screenshot 渲染内核)。
86
188
  // width 由查询参数决定(默认 390),与 /__screenshot 一致。
87
- const q = new URL(req.url, 'http://localhost')
88
189
  const width = parseInt(q.searchParams.get('width') || '390', 10) || 390
89
190
  const shot = await takeScreenshot(id, width, 'png')
90
191
  if (!shot || !shot.buffer) {
@@ -58,6 +58,46 @@ export function mergeServerTurn(chatDir, cardId, msg) {
58
58
  return writeHistory(chatDir, cardId, kept)
59
59
  }
60
60
 
61
+ /**
62
+ * 浏览器 POST 整表时的「合并校对」:服务端已按 turnId 写过权威消息,这里只补浏览器独有的东西。
63
+ *
64
+ * 铁律:**已收尾的回合,服务端那份永远赢** —— 不是「客户端自认不是权威才丢」,而是
65
+ * 「只要盘上已有该 turnId 的权威消息,客户端同 turnId 的任何副本一律丢弃」。
66
+ *
67
+ * 为什么不能用 `!m.serverAuthored` 当条件(2026-10-09 实测修掉的缺陷):
68
+ * 客户端 `revive()` 会把 `serverAuthored: true` 还原到内存消息上,`serialize()` 又原样写回。
69
+ * 于是浏览器 POST 上来的副本**也带着 serverAuthored=true**,旧条件
70
+ * `srvTurns.has(turnId) && !m.serverAuthored` 永远为假 → 副本被 `put()` 按 id 覆盖权威消息。
71
+ * 而 `serialize()` 是**有损白名单**(刻意剥掉 base64 截图 / 流式态),于是这次覆盖必然丢字段。
72
+ * 现场:greetingRecommend 第一回合 usage(缓存命中率)与 17 条 toolCall 的 truncated 全部消失,
73
+ * 摘要条退化成「未提供」、工具行不再显示截断提示。**服务端日志里数据是全的** ——
74
+ * 说明不是采集问题,是落盘被覆盖。
75
+ *
76
+ * @param {any[]} stored 已落盘的消息(服务端权威侧)
77
+ * @param {any[]} incoming 浏览器 POST 上来的整表
78
+ * @returns {any[]} 合并结果,按 ts 稳定排序
79
+ */
80
+ export function mergeIncomingMessages(stored, incoming) {
81
+ const storedMsgs = Array.isArray(stored) ? stored : []
82
+ // 盘上已收尾的回合:这些 turnId 的任何浏览器副本都不许覆盖权威消息
83
+ const settled = new Set(storedMsgs.filter((m) => m && m.serverAuthored && m.turnId).map((m) => m.turnId))
84
+ const byId = new Map()
85
+ const anon = []
86
+ const put = (m) => {
87
+ if (!m || typeof m !== 'object') return
88
+ if (m.id) byId.set(m.id, m)
89
+ else anon.push(m)
90
+ }
91
+ for (const m of storedMsgs) put(m)
92
+ for (const m of Array.isArray(incoming) ? incoming : []) {
93
+ if (!m || typeof m !== 'object') continue
94
+ // 已收尾回合的客户端副本:无论它自称什么,一律丢掉(上面已说明为什么不能信它的标记)
95
+ if (m.role === 'assistant' && m.turnId && settled.has(m.turnId)) continue
96
+ put(m)
97
+ }
98
+ return [...byId.values(), ...anon].sort((x, y) => (Number(x.ts) || 0) - (Number(y.ts) || 0))
99
+ }
100
+
61
101
  export function writeHistory(chatDir, cardId, messages) {
62
102
  const file = historyFile(chatDir, cardId)
63
103
  if (!file) return { ok: false, error: 'missing-card-id' }
@@ -12,6 +12,25 @@ import { getBrowser, serialize, openCardScreen, MAX_QUEUED } from './browser.js'
12
12
  * 浏览器单例/页面加载逻辑已抽到 ./browser.js,与 /__bbox 共用同一实例。
13
13
  */
14
14
 
15
+ /**
16
+ * 压掉编辑器壳层的悬浮物(AI FAB / toast / 抽屉面板 / 右键菜单等 position:fixed 元素)。
17
+ * 元素截图(ElementHandle.screenshot)截的是页面合成结果:任何叠在目标区域上的
18
+ * fixed 悬浮物都会被一起画进去 —— 「封面截图把 ai-fab 圆钮截进去」事故的根因。
19
+ * 按 computed position 判定而非类名清单:悬浮组件会不断增加,清单式每加一个就漏一个。
20
+ * 卡片自身(.ce_root 子树内)的 fixed 定位属于卡片设计,保留。页面随后即 close,无需恢复。
21
+ * (导出仅为测试可断言其 DOM 语义。)
22
+ */
23
+ export async function hideFloatingOverlays(page) {
24
+ await page.evaluate(() => {
25
+ document.querySelectorAll('body *').forEach((el) => {
26
+ if (el.closest('.ce_root')) return
27
+ if (getComputedStyle(el).position === 'fixed') {
28
+ el.style.setProperty('visibility', 'hidden', 'important')
29
+ }
30
+ })
31
+ })
32
+ }
33
+
15
34
  /**
16
35
  * 对单卡截图。纯函数、无 HTTP。任何失败都返回 { error, code, message, ... } 而非抛出。
17
36
  * @param {string} id 卡片 id
@@ -42,6 +61,7 @@ export async function takeScreenshot(id, width, format) {
42
61
  if (!screen) {
43
62
  return { error: 'capture-target-not-found', code: 500, message: '.phone__screen 元素未找到' }
44
63
  }
64
+ await hideFloatingOverlays(page)
45
65
 
46
66
  const buf = await screen.screenshot({ type: 'png' })
47
67
  if (format === 'json') {
@@ -76,6 +96,80 @@ export async function takeScreenshot(id, width, format) {
76
96
  }
77
97
  }
78
98
 
99
+ /**
100
+ * 生成卡片封面预览图:透明底(不含编辑器外壳背景色)、尺寸 = 卡片实际渲染尺寸 1:1。
101
+ * 与 takeScreenshot 的三点差异:
102
+ * - deviceScaleFactor:1 —— 截图像素 = CSS 像素(takeScreenshot 是 2 倍视网膜);
103
+ * - 截图目标是卡片根元素 .ce_root —— .phone__screen 是固定高度的手机屏滚动容器,
104
+ * 直截会带大片空白;.ce_root 高度即内容实际高度;
105
+ * - omitBackground + 透明化外壳底色(chrome=off 下手机屏固定挂 --bg-white),
106
+ * 产出透明底 PNG;卡片自身根节点的背景 / 渐变属于卡片设计,保留。
107
+ * 纯函数、无 HTTP、不写盘:返回 { buffer, width, height },落盘由调用方(/__card-image POST)负责。
108
+ * 任何失败返回 { error, code, message, fix? } 结构化结果,不抛出(与 takeScreenshot 同约定)。
109
+ */
110
+ export async function generateCardCover(id, { width: cardWidth = 375 } = {}) {
111
+ let browser
112
+ try {
113
+ browser = await getBrowser()
114
+ } catch (e) {
115
+ return {
116
+ error: 'browser-launch-failed',
117
+ code: 503,
118
+ message: String((e && e.message) || e),
119
+ fix: '系统 Chrome 未找到或未启动。请安装 Google Chrome,并确保 playwright 以 channel:"chrome" 启动(本端点固定使用该通道)。',
120
+ }
121
+ }
122
+
123
+ let context
124
+ try {
125
+ context = await browser.newContext({
126
+ viewport: { width: cardWidth + 80, height: 900 },
127
+ deviceScaleFactor: 1,
128
+ })
129
+ const { page, screen } = await openCardScreen(context, id)
130
+ try {
131
+ if (!screen) {
132
+ return { error: 'capture-target-not-found', code: 500, message: '.phone__screen 元素未找到' }
133
+ }
134
+ // 压掉编辑器外壳的底色与投影:omitBackground 只能去掉「页面默认白底」,管不到
135
+ // 元素自己的样式 —— .app(深色)/.phone(径向渐变)/.phone__screen--bg-white 这些
136
+ // 祖先/外壳会把不透明底画在卡片后面,整条祖先链必须一并透明化。
137
+ await page.addStyleTag({
138
+ content:
139
+ 'html,body,.app,.app__stage,.phone,.phone__frame,.phone__scaler,.phone-viewport,.phone__screen' +
140
+ '{background:transparent!important;background-image:none!important;box-shadow:none!important}',
141
+ })
142
+ await hideFloatingOverlays(page)
143
+ const cardEl = await page.$('.phone__screen .ce_root')
144
+ if (!cardEl) {
145
+ return { error: 'card-root-not-found', code: 500, message: '.ce_root 卡片根元素未找到' }
146
+ }
147
+ const buf = await cardEl.screenshot({ type: 'png', omitBackground: true })
148
+ const box = await cardEl.boundingBox()
149
+ return {
150
+ buffer: buf,
151
+ width: box ? Math.round(box.width) : null,
152
+ height: box ? Math.round(box.height) : null,
153
+ }
154
+ } finally {
155
+ await page.close().catch(() => {})
156
+ }
157
+ } catch (e) {
158
+ // 导航超时 / data-card-ready 未出现 / 截图异常:转成结构化错误,绝不向上抛
159
+ const isTimeout = /Timeout\s*\d+ms exceeded/.test(String((e && e.message) || e))
160
+ return {
161
+ error: isTimeout ? 'render-wait-timeout' : 'screenshot-failed',
162
+ code: isTimeout ? 504 : 500,
163
+ message: String((e && e.message) || e),
164
+ fix: isTimeout
165
+ ? '卡片首帧未在限定时间内挂载(data-card-ready 未出现)。检查该卡是否在 chrome=off 模式下渲染报错,或卡片依赖真实运行时数据(customAction/RPC)而无法在纯渲染模式落定。'
166
+ : undefined,
167
+ }
168
+ } finally {
169
+ if (context) await context.close().catch(() => {})
170
+ }
171
+ }
172
+
79
173
  export function createScreenshotMiddleware({ root }) {
80
174
  return async function screenshotMiddleware(req, res, next) {
81
175
  const pathname = decodeURIComponent((req.url || '').split('?')[0])
@@ -103,7 +103,7 @@ DSL_CARD_SLICES_DIR=<training-run>/slices
103
103
 
104
104
  - `annotationCompleteness`:画板内叶子标注是否被完整归属到聚焦卡。
105
105
  - `textRetention`:卡片内文案是否被完整保留。
106
- - `structuralFocus`:候选是否具备卡片级结构,而不是气泡、按钮条或空壳。
106
+ - `structuralFocus`:候选是否具备卡片级结构,而不是气泡、按钮条或空壳。**图像卡通道(r264 起)**:整卡被设计稿导成一张大切图时(解析器兜底产 1 张卡、卡内只有该 slice),`sliceAreaRatio ≥ 0.6` 即视为结构成立,按正常像素/尺寸门评分。
107
107
  - `trainingEligible`:弹窗、toast、底部抽屉、纯文本板(`artboardType=text_only`,整板只有对话/问答/提示文字)必须为 false。
108
108
 
109
109
  任一门失败优先归因 `PARSER`,修复目标是 `vision.py` / `parse_meaxure.py` / `boundary.json` 的通用几何规则,不允许用业务名救某个样本。
@@ -178,7 +178,7 @@ DSL_CARD_SLICES_DIR=<training-run>/slices
178
178
  > 基线载体是**冻结锚点轮目录**(含 `reports/` 全量证据)+ **入库压缩锚点** `training/anchors/<round>.json`
179
179
  > (P0-2 起:干净克隆与 CI 上唯一的锚点载体,`round_diff.mjs` / `plan_next_round.mjs` 都直接吃它)。
180
180
  > 本 Skill 目录下的历史 `baselines/v3-r0*.json` 范式已废弃并删除(指标快照改由 summary.json + 压缩锚点承载)。
181
- > 晋级 = 新轮成为下一冻结锚点候选,在 `training/plans/` 记录轮号与指标,并重新生成压缩锚点入库。
181
+ > 晋级 = 新轮成为下一冻结锚点候选,通用规则回灌 `training/lessons/lessons.jsonl`(`sink_rules.mjs`),并重新生成压缩锚点入库。
182
182
 
183
183
  ### 泛化盲测门(generalization gate)
184
184
 
@@ -197,7 +197,7 @@ DSL_CARD_SLICES_DIR=<training-run>/slices
197
197
  **固定跨批次晋级盲测集**(解决单批次 eval/holdout 只有 2 例、凑不出 24 例的问题):
198
198
  用 `build_sample.mjs` 从 v6 + v7 全量池各抽代表,合并成 `training/datasets/blindset/train-set.jsonl`
199
199
  (≥24 例,结构聚类去重后覆盖列表/表单/结果/媒体/空状态),晋级轮一律用它盲测,
200
- 并在 `training/plans/` 记录该盲测集的 provenance 与轮号。盲测集**不参与任何修复迭代**。
200
+ 并在 `training/lessons/lessons.jsonl` 记录该盲测集的 provenance 与轮号。盲测集**不参与任何修复迭代**。
201
201
  ### 收敛检测与早停
202
202
 
203
203
  当出现以下任一信号时,**停止脚本微调,转而修订 Skill 文档规则或范式**:
@@ -238,7 +238,7 @@ r198 记录的共享 40 例均值为 fidelity 96.4% / pixel 3.8% / position 95.7
238
238
 
239
239
  - v5(转账二批次 17 代表)r199 = 14/17、avgFid 95.8%、avgPix 4.0%;
240
240
  - v6(存款+理财 30 代表)r200 = 4/30、avgFid 81.7%——新布局族缺口,仍是泛化主战场
241
- (见 `training/plans/v6-dataset-baseline.md`、`v6-iteration-plan.md`);
241
+ (见 `training/datasets/v6/dataset-summary.json` 与本节指标水位);
242
242
  - v7(焦点集)r243→r262 逐例收敛:`945b562caad1f0f4` 散点带/段落壳/评分仪表盘通道落地后
243
243
  pix 0.1348→0.0681、pos 0.532→0.905、comp 0.852;`86ae6d41957935d8` 渐变壳绑定修正后 pix 0.055。
244
244
  对应规则见 `dsl-card-generator/references/generation_pitfalls.md §21-24` 与 lessons `r255-l01..l05` / `r261-l01`。
@@ -272,7 +272,7 @@ r198 记录的共享 40 例均值为 fidelity 96.4% / pixel 3.8% / position 95.7
272
272
  3. 修复优先级:可见媒体与重影 → 语义顺序与纵向节奏 → 背景/圆角/留白 → 未见样本泛化。
273
273
  4. 缺失/状态错切图优先画板裁切(`cropSlice`,含宽条切图与纯矢量 logo 组;矩形内有文本叠加或背景级大矩形禁裁),裁切不可用才纯色近似并标记缺陷;弥散/光晕类纹理特事特办,计入局部还原度豁免。
274
274
  5. 焦点样本通过后必须复跑冻结锚点全量;每 3 轮做不少于 24 例未见样本盲测(固定跨批次盲测集见上文)。
275
- 6. 晋级门、焦点 ID 和产物路径以 `training/plans/` 下最新 plan 与本轮 `reports/next-round-plan.json` 为准。
275
+ 6. 晋级门、焦点 ID 和产物路径以本轮 `reports/summary.json` 与 `training/anchors/<最新>.json` 为准。
276
276
  7. 新布局通道(如散点带 §21、段落壳归属 §22、行锚点/尾随切片 §23)落盘生成器后,同步 lessons.jsonl + generation_pitfalls/quickref/previewer_contract,并复跑冻结锚点确认 0 回归;通道判据只认几何/样式结构,不认业务词。
277
277
 
278
278
  训练清单可记录样本 ID 和画板名作为证据;运行时代码、Skill 判定和校验器不得引用业务词、画板名或样本专属文案。
@@ -291,7 +291,7 @@ r198 记录的共享 40 例均值为 fidelity 96.4% / pixel 3.8% / position 95.7
291
291
  7. 通用规则写入 `training/lessons/lessons.jsonl`(新格式 `{lessonId, signature, failureClass, evidenceCases,
292
292
  rule, targetFiles, status, round}`;`plan_next_round.mjs` 产的 `lesson-drafts.jsonl` 是待补 rule 的草稿),
293
293
  并同步到对应 Skill 的 `SKILL.md` 或 `references/`;业务词、画板名和样本专属文案不得进入规则。
294
- 8. 通过后以新轮目录作为下一冻结锚点候选并登记 `training/plans/`,同时重生成压缩锚点入库
294
+ 8. 通过后以新轮目录作为下一冻结锚点候选,回灌 lessons 并重生成压缩锚点入库
295
295
  (`training/anchors/<round>.json`);未通过的候选规则保持 `candidate`,不修改正式 Skill 行为。
296
296
  9. `npm run ci` 全绿(9 硬门禁:card-check / smoke-preview / verify-event-model / verify-model-expose /
297
297
  magic-values / regress-data / unit-tests / mcp-doc-sync / lint-ratchet;另有 regress-fidelity、lint 两道软门禁):评分尺子改动被
@@ -463,7 +463,7 @@ python3 training/tools/split_report.py <runDir> [topN] # 按 split 聚合指
463
463
 
464
464
  > `training/datasets/`、`training/runs/` 仍被 gitignore(体积 + 源稿版权)。数据集可复现性靠
465
465
  > `dataset-summary.json` 的 `provenance`(sourceRoot / sourceNames / toolchainRev / 解析器参数)
466
- > 与 `training/plans/` 的轮次记录;锚点回归靠冻结轮目录(`training/runs/r176`)**或入库压缩锚点**
466
+ > 与 `training/lessons/lessons.jsonl` 的轮次记录;锚点回归靠冻结轮目录(`training/runs/r176`)**或入库压缩锚点**
467
467
  > (`training/anchors/r176.json`,干净克隆唯一可用载体)。已入库的训练设施还有
468
468
  > `training/tools/*`、`training/reports/font-metrics.json`、`training/fixtures/ui-drafts/`(保真度软门禁兜底稿)。
469
469
 
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * 此前的闭环断点:run_round 产出 review-queue / semantic-review,score_round 产出签名与门禁,
6
6
  * round_diff 产出回归清单,但「下一轮到底修什么、修哪个文件、是否已经收敛」全靠人读三份产物。
7
- * 单卡实测(training/plans/card-gen-speed-analysis.md):99.7% 的时间花在分析与返工上,
7
+ * 单卡实测:99.7% 的时间花在分析与返工上,
8
8
  * 不是工具耗时——所以这里把归因、聚类、焦点集、门禁判定、收敛早停一次算完。
9
9
  *
10
10
  * 用法:
@@ -317,8 +317,13 @@ function upstreamQuality(spec, card) {
317
317
  ? retainedTextCount / expectedTexts.length
318
318
  : 1
319
319
  const metrics = card.focusMetrics || {}
320
- const structuralFocus = (metrics.richComponentCount || 0) >= 2 &&
321
- ((metrics.textCount || 0) > 0 || (metrics.sliceCount || 0) > 0)
320
+ // 结构聚焦两条路(2026-09-28):
321
+ // a) 常规卡:rich ≥ 2 且有文本或切片;
322
+ // b) 图像卡:切片覆盖 ≥ 60% 卡区(整卡被设计稿导成一张大图,卡内无其他标注)。
323
+ // 该形态解析器兜底产 1 张卡,结构上只有这张切片,按像素/尺寸门正常评分。
324
+ const structuralFocus = ((metrics.richComponentCount || 0) >= 2 &&
325
+ ((metrics.textCount || 0) > 0 || (metrics.sliceCount || 0) > 0)) ||
326
+ (metrics.sliceAreaRatio || 0) >= 0.6
322
327
  const passed = card.trainingEligible !== false &&
323
328
  !!card.image &&
324
329
  structuralFocus &&
@@ -74,3 +74,17 @@
74
74
  | 脚手架占位文本残留 | **lint** |
75
75
 
76
76
  未覆盖的(人工兜底):Icon 双触发、Canvas 坐标系、v-show tab↔header 面板泄漏、compiled 与 code 同步、孤儿字段保留。
77
+
78
+ ---
79
+ ### single-button-width-magic-ratio(r273 训练回灌)
80
+ > lesson: `r273-l02` status: `promoted`
81
+
82
+ 单按钮宽度:标注宽度 < 卡宽 ⇒ 用设计宽 + flexShrink:0;只有按钮宽 ≈ 卡宽时才撑满行(width:100%)。旧判据用『卡宽 × 0.8』这条魔数比例,设计宽落在 80%~100% 区间的按钮被并进『撑满行』分支、宽度被抹成行内容盒宽(实测 279/343 的按钮渲染成 311 满宽,两侧各丢 16px 内缩;右锚按钮还会因撑满行把左缘推到卡片左缘)。水平位置仍按断言侧给单边几何内缩(flex-end→右缘、flex-start→左缘、居中→通用卡内边距)。反面纪律:切忌两侧同时写几何内缩——那会把 width:100% 的按钮挤进『卡宽 − 左内缩 − 右内缩』的内容盒,右锚按钮文案换行、盒高溢出(实测 33 例 textOverflow 由 1.0 掉到 0.667,整轮 33 例 pass→fail,平均还原度 −0.8pp)。诊断可复用:把两轮产物 schema 按节点逐行 diff,若差异集中在同一类节点的单个样式子项(本例 33 例全部只有一行 paddingLeft 差异),即为单点成因,先定位再改,不要凭猜分组回退。
83
+
84
+
85
+ ---
86
+ ### amount-currency-prefix-must-reach-expression(r285 训练回灌)
87
+ > lesson: `r285-l01` status: `promoted`
88
+
89
+ 金额表达式必须输出货币符号:`amountExpr` 算出 `curPrefix`/`hasCurrency` 却从不写进返回值,spec 把「¥」与「5,638.00」拆成两层时裸符号层被合并段吞掉,表达式是符号唯一载体 —— 不输出前缀渲染就只剩数字(「¥5,638.00」→「5,638.00」)。前缀取自原文(`currencyPrefixOf`,兼容 HK$/US$ 字母+符号组合),与运行时负号拼接顺序固定(负号在前);纯数字原文不写前缀。`amountValueExpr(..., {currencyPrefix:false})` 依赖 ` + '字面量'` 形态做剥除,改写表达式时必须保持该形态。
90
+
@@ -1218,3 +1218,51 @@ sameRow 只能消费带 rect/sourceEl 的原始标注对象;语义投影对象
1218
1218
 
1219
1219
  列表标题、列表项和查看更多必须注册语义边界;相邻块间距只计一次。列表行内边距由标注行高减内容高推导,ListLayout 行间距显式归零,容器高度继续由内容撑开。
1220
1220
 
1221
+
1222
+ ## auto. shell-right-column-column-edge(r273 训练回灌)
1223
+ > lesson: `r273-l01` status: `promoted`
1224
+
1225
+ 内容壳(面板壳/渐变壳)内的「前导媒体 + 右列文本」两列拓扑:右列左界此前固定取『壳宽 45%』,该比例隐含『前导媒体宽 ≈ 壳宽一半』的假设。前导媒体较窄时(前导图 80px / 壳 311px ≈ 26%)右列文本起点落在比例线左侧,整条右列被划到左列一侧 → 左列文本数恒 < 2 → 两列拓扑被整条判死,右列文本退化成全宽竖排行,继而被辅助文案的『x 坐标 > 100 即视为居中』判据甩到内容盒中央且丢掉 spec 左缩进(实测 dy 26~46px / dx 26px)。修复两处:① 列界改由壳内前导媒体的右缘决定(判据纯几何:高 ≥ max(24, 壳内文本高中位数×1.8)、右缘落在壳左半区的切片或整块图),无前导媒体才回退『壳宽 45%』;② 左列成员放宽为『≥2 条文本 或 存在前导媒体』——设计上左列常是一张整块图而非文本,只认文本会让该形态永远进不了两列通道。
1226
+
1227
+
1228
+ ## auto. single-button-width-magic-ratio(r273 训练回灌)
1229
+ > lesson: `r273-l02` status: `promoted`
1230
+
1231
+ 单按钮宽度:标注宽度 < 卡宽 ⇒ 用设计宽 + flexShrink:0;只有按钮宽 ≈ 卡宽时才撑满行(width:100%)。旧判据用『卡宽 × 0.8』这条魔数比例,设计宽落在 80%~100% 区间的按钮被并进『撑满行』分支、宽度被抹成行内容盒宽(实测 279/343 的按钮渲染成 311 满宽,两侧各丢 16px 内缩;右锚按钮还会因撑满行把左缘推到卡片左缘)。水平位置仍按断言侧给单边几何内缩(flex-end→右缘、flex-start→左缘、居中→通用卡内边距)。反面纪律:切忌两侧同时写几何内缩——那会把 width:100% 的按钮挤进『卡宽 − 左内缩 − 右内缩』的内容盒,右锚按钮文案换行、盒高溢出(实测 33 例 textOverflow 由 1.0 掉到 0.667,整轮 33 例 pass→fail,平均还原度 −0.8pp)。诊断可复用:把两轮产物 schema 按节点逐行 diff,若差异集中在同一类节点的单个样式子项(本例 33 例全部只有一行 paddingLeft 差异),即为单点成因,先定位再改,不要凭猜分组回退。
1232
+
1233
+
1234
+ ## auto. badge-row-content-double-claim(r265 训练回灌)
1235
+ > lesson: `r265-l01` status: `promoted`
1236
+
1237
+ 序号徽章行(数字壳 + 右侧内容)的成员文本必须由徽章行通道整体接管,不得再被列表标题 / 列表项 / 首列文本通道认领。判据只用几何归属(该文本元素是否已被徽章行通道收编),不得涉及文案内容。二次认领有两个后果:同一文本双份渲染(徽章行 + 列表项重影,重复度门扣分),且被误认成列表标题后会把辅助文案的 y 上界截断在该假标题处,其下方的段落与页脚文案整批静默丢失(实测 5 段文案蒸发、整卡内容缺失)。
1238
+
1239
+
1240
+ ## auto. aux-geometry-normalized-shape-and-bound(r266 训练回灌)
1241
+ > lesson: `r266-l01` status: `promoted`
1242
+
1243
+ 辅助文案通道有三处口径必须与渲染端严格同源,否则整卡纵向漂移:① 按钮数组是归一化对象(几何挂在顶层 x/y/width/height,没有 rect 字段)——用取 rect 的通用 helper 读会恒取 0,使表单为空时『表单顶沿』塌成 0,上半段辅助文案整批并进页脚段,行内边距计算返回空值又被当作单值 padding 解析、paddingTop 回落 0(实测整卡下漂 400+px);读归一化字段优先、spec 元素 rect 兜底。② 行底沿估算必须复用与文本节点盒高策略同源的估算函数,不得另用膨胀系数,否则下一行顶距压小、逐行累积上移。③ 多行段落块(按视觉行重组的富文本块)占据真实纵向空间,必须登记语义边界(顶 = 块顶,底 = max(块顶 + 容器高, 最后一条视觉行的底沿)),否则下游行的顶距只能从更早的语义底沿起算、逐段累积下漂(实测首段之后整体 +88px)。
1244
+
1245
+
1246
+ ## auto. amount-currency-prefix-must-reach-expression(r285 训练回灌)
1247
+ > lesson: `r285-l01` status: `promoted`
1248
+
1249
+ 金额表达式必须输出货币符号:`amountExpr` 算出 `curPrefix`/`hasCurrency` 却从不写进返回值,spec 把「¥」与「5,638.00」拆成两层时裸符号层被合并段吞掉,表达式是符号唯一载体 —— 不输出前缀渲染就只剩数字(「¥5,638.00」→「5,638.00」)。前缀取自原文(`currencyPrefixOf`,兼容 HK$/US$ 字母+符号组合),与运行时负号拼接顺序固定(负号在前);纯数字原文不写前缀。`amountValueExpr(..., {currencyPrefix:false})` 依赖 ` + '字面量'` 形态做剥除,改写表达式时必须保持该形态。
1250
+
1251
+
1252
+ ## auto. collapse-btn-bottom-band-truth-gate(r285 训练回灌)
1253
+ > lesson: `r285-l02` status: `promoted`
1254
+
1255
+ 「卡底折叠/展开控件」判据必须锚在卡底区域,不能只锚「按钮区之下」:无按钮的卡里按钮形状集为空会让该下界塌成 0,于是卡中任何「小尺寸近方形 + 横向居中」的切片都被误认(如账户行左侧的圆形卡标),同一图标渲染两遍且卡底多出的那一行把底部按钮顶出内容区。补两道几何真值门:切片底沿必须落在卡内下缘带内(带高 ≤ 卡高 22%),且顶沿不低于内容底沿(文本+媒体)减去自身高度容差 —— 控件在内容之下,不在内容之中。
1256
+
1257
+
1258
+ ## auto. formrow-icon-single-consumption(r285 训练回灌)
1259
+ > lesson: `r285-l03` status: `promoted`
1260
+
1261
+ 表单行认领的图标(左侧 icon / 值前导媒体 / 行尾媒体 / 右侧 affordance)渲染后必须从孤立媒体兜底池同步回收 —— 回收逻辑此前只存在于对比面板的连接件分支,普通表单行走不到:同一张切片渲染两遍(重影 + duplication 掉分),兜底行还会另起一段流式内容把后续按钮整体下推。
1262
+
1263
+
1264
+ ## auto. fixed-px-chips-exempt-from-shrink-relax(r285 训练回灌)
1265
+ > lesson: `r285-l04` status: `promoted`
1266
+
1267
+ `fixFlexShrinkOverflow` 的「全 shrink:0 时放开末位为可缩」必须排除「子节点全为固定 px 宽」的情形:并排药丸/标签/统计块的宽度是设计稿逐个标注出来的真值,强行压缩会让内部文案换行、行高撑高,连带 textOverflow 与 structure 一起掉(实测 149+12+150 恰等于组宽 311 却被压成两行,textOverflow 0.889 < 门 0.90)。真正的溢出兜底是子节点混有 auto/百分比宽(内容长度不可知)的场景。
1268
+
@@ -62,3 +62,24 @@
62
62
  - other.overflow 缺省为 hidden:需要溢出可见(标签宽于槽位等)必须显式写 other.overflow=visible。
63
63
  - margin 组(marginType=1 逐边)支持负值,是 flex 行内唯一回拉/重叠手段。**逐边负值必须显式 marginType='1'**:只写 marginLeft 时渲染器按统一 margin 解析,负值静默失效(散点带列重叠还原全丢,r253→r254 实测)。
64
64
  - 同一 style 组内同名子项必须唯一:绑定值改写 makeStyle 自带子项(itemValueFrom=1),追加同名子项会导致取值错乱(背景透明实测)。
65
+
66
+ ---
67
+ ### shell-right-column-column-edge(r273 训练回灌)
68
+ > lesson: `r273-l01` status: `promoted`
69
+
70
+ 内容壳(面板壳/渐变壳)内的「前导媒体 + 右列文本」两列拓扑:右列左界此前固定取『壳宽 45%』,该比例隐含『前导媒体宽 ≈ 壳宽一半』的假设。前导媒体较窄时(前导图 80px / 壳 311px ≈ 26%)右列文本起点落在比例线左侧,整条右列被划到左列一侧 → 左列文本数恒 < 2 → 两列拓扑被整条判死,右列文本退化成全宽竖排行,继而被辅助文案的『x 坐标 > 100 即视为居中』判据甩到内容盒中央且丢掉 spec 左缩进(实测 dy 26~46px / dx 26px)。修复两处:① 列界改由壳内前导媒体的右缘决定(判据纯几何:高 ≥ max(24, 壳内文本高中位数×1.8)、右缘落在壳左半区的切片或整块图),无前导媒体才回退『壳宽 45%』;② 左列成员放宽为『≥2 条文本 或 存在前导媒体』——设计上左列常是一张整块图而非文本,只认文本会让该形态永远进不了两列通道。
71
+
72
+
73
+ ---
74
+ ### aux-geometry-normalized-shape-and-bound(r266 训练回灌)
75
+ > lesson: `r266-l01` status: `promoted`
76
+
77
+ 辅助文案通道有三处口径必须与渲染端严格同源,否则整卡纵向漂移:① 按钮数组是归一化对象(几何挂在顶层 x/y/width/height,没有 rect 字段)——用取 rect 的通用 helper 读会恒取 0,使表单为空时『表单顶沿』塌成 0,上半段辅助文案整批并进页脚段,行内边距计算返回空值又被当作单值 padding 解析、paddingTop 回落 0(实测整卡下漂 400+px);读归一化字段优先、spec 元素 rect 兜底。② 行底沿估算必须复用与文本节点盒高策略同源的估算函数,不得另用膨胀系数,否则下一行顶距压小、逐行累积上移。③ 多行段落块(按视觉行重组的富文本块)占据真实纵向空间,必须登记语义边界(顶 = 块顶,底 = max(块顶 + 容器高, 最后一条视觉行的底沿)),否则下游行的顶距只能从更早的语义底沿起算、逐段累积下漂(实测首段之后整体 +88px)。
78
+
79
+
80
+ ---
81
+ ### fixed-px-chips-exempt-from-shrink-relax(r285 训练回灌)
82
+ > lesson: `r285-l04` status: `promoted`
83
+
84
+ `fixFlexShrinkOverflow` 的「全 shrink:0 时放开末位为可缩」必须排除「子节点全为固定 px 宽」的情形:并排药丸/标签/统计块的宽度是设计稿逐个标注出来的真值,强行压缩会让内部文案换行、行高撑高,连带 textOverflow 与 structure 一起掉(实测 149+12+150 恰等于组宽 311 却被压成两行,textOverflow 0.889 < 门 0.90)。真正的溢出兜底是子节点混有 auto/百分比宽(内容长度不可知)的场景。
85
+