基于 FigmaMCP 的 D2C 工具(七): verify——skeleton 与运行时量测
在前六篇中,我们已经完成:
- 同一份设计稿为什么会生成出漂移的结果
- 为什么我把归一化做在工具侧,而不是让设计师再出一份干净稿
- catalog 三层:generated / overrides / links,合并成只读的 CatalogSnapshot
- ingest 产出 DesignIntentIR
- decide 产出 DecisionRecord、verdict 和 BindingPlan
- verify 上:从源码抽 ComponentUsageIR,静态 ProofObligation 逐条回检
本篇在此基础上把 verify 的另一半讲完:静态阶段证不了的布局,怎么在浏览器里量出来。主要是 SkeletonNode 怎么从 LayoutConstraintGraph 投影出来、RuntimeScenario 怎么写、Playwright 跑起来量什么、截图 diff 为什么只做诊断,以及运行时义务的 pass / fail / unknown。
我的目的是把运行时验证的流程讲清楚 不是把每个字段都列全 所以下面贴的类型和 JSON 都按真实实现精简过 字段名是真的 数量不全
1. 静态证不完的那一部分
先说一个我遇到的场景。一个 Dialog 的 DecisionRecord 写的是 reuse,静态 verify 也是 accepted:组件名对、必填 prop 都在、slot 字面量匹配、没有多余包层。页面打开、点触发按钮、Dialog 弹出来。设计稿是两栏,左边表单右边说明。浏览器里却是一栏,右边掉到了下面。
我第一反应是生成结果又漂移了。把模板和 BindingPlan 对了一遍,prop 一个不少。把类名和设计稿标注对了一遍,flex、gap 都在。最后发现问题在运行时:主题样式里有一段规则把父级的 flex-direction 覆盖成了 column。这条覆盖链不在源文件里,静态抽 usage 布局图的时候根本看不见。
只读源码,永远量不到被覆盖之后的结果。这就是运行时验证要解决的事。
要点:
- 静态 verify 证的是源码声称了什么
- 运行时 verify 证的是浏览器最终算出来了什么
- 两边共用同一套
ProofObligation和同一套 skeleton 投影,差别只在证据来源
2. SkeletonNode:把布局约束投影成骨架
2.1 类型
我们先来看类型。SkeletonNode 是布局约束投影出来的语义骨架,里边装的是离散的意图值,不是原始像素。
export type SkeletonAxis = 'flex-row' | 'flex-col' | 'grid' | 'stack' | 'inline'
export type SkeletonJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'
export type SkeletonAlign = 'start' | 'center' | 'end' | 'stretch' | 'baseline'
export type SkeletonGap = 'none' | 'tight' | 'normal' | 'loose'
export type SkeletonPadding = 'none' | 'tight' | 'normal' | 'spacious'
export interface SkeletonNode {
id: string
// role 只是信息 不参与等价比较
role?: string
layout: {
// undefined 表示这一路没拿到这个事实 不是默认值
axis: SkeletonAxis | undefined
justify: SkeletonJustify | undefined
align: SkeletonAlign | undefined
gap: SkeletonGap | undefined
padding: SkeletonPadding | undefined
sizing: 'hug' | 'fill' | 'fixed' | 'min' | 'max' | undefined
wrap: 'nowrap' | 'wrap' | 'wrap-reverse' | undefined
overflow: 'visible' | 'hidden' | 'clip' | 'scroll' | 'auto' | undefined
}
}role 我特意标成只作信息。布局证明比的是轴、对齐、间距档位,不是它叫不叫 Dialog。角色推断是 ingest 和 decide 的事,混进布局比较会把角色标签的误差传成布局 fail。
2.2 从 LayoutConstraintGraph 投影
上一篇讲过 DesignIntentIR 里有一张 LayoutConstraintGraph。relation 的种类有 axis、alignment、gap、padding、sizing、wrap、overflow 等,每一条的 value 要么是具体值,要么是一个 UnresolvedFact。
投影就是把一个节点相关的 relation 挑出来,逐条翻译成 skeleton 字段。核心分支:
function extractLayout(relations: LayoutRelation[]): SkeletonNode['layout'] {
for (const rel of relations) {
switch (rel.kind) {
case 'axis':
// unresolved 的不硬填 保持 undefined
if (isResolved(rel.value))
axis = rel.value === 'row' ? 'flex-row' : rel.value === 'column' ? 'flex-col' : rel.value
break
case 'alignment':
// 主轴进 justify 交叉轴进 align
if (isResolved(rel.value))
rel.axis === 'main' ? (justify = rel.value) : (align = rel.value)
break
case 'gap':
if (typeof rel.value === 'number') gap = projectGap(rel.value)
break
case 'padding':
// 取四边最大的一边定档位 不对称的 padding 不能躲在 top 后面
if (isResolved(rel.value))
padding = projectPadding(Math.max(rel.value.top, rel.value.right, rel.value.bottom, rel.value.left))
break
// sizing / wrap / overflow 同理 略
}
}
return { axis, justify, align, gap, padding, sizing, wrap, overflow }
}设计侧的 projectDesignNode 和源码侧的 projectUsageNode 都调这同一个函数,差别只是从哪张图里挑 relation。这样两边字段语义一定一致。
要点:
- unresolved 一律不填。设计师没标、抽取器没抽到、值是表达式,这三种情况在骨架上长得一样,比较时统一处理
- padding 取最大边。我一开始只取 top,后来一个左右 24 上下 0 的容器被投成了
none,才改成取最大值
2.3 gap / padding 为什么按档位不按像素
Figma 里 gap 写 16。生成出来的代码可能是 gap-4,也可能是一个 token 解析后等于 16,也可能设计师手抖写成 15。如果 skeleton 直接比数字,15 和 16 永远 fail,管线会被这种噪声淹没。
所以我把像素收成档位:
// 边界写死在这里 改边界等于改证明语义 要走评审
export function projectGap(value: unknown): SkeletonGap | undefined {
if (typeof value !== 'number') return undefined
if (value === 0) return 'none'
if (value >= 1 && value <= 8) return 'tight'
if (value >= 9 && value <= 24) return 'normal'
if (value >= 25) return 'loose'
return undefined
}
// 9~11 和 17~19 是空洞 故意不映射 落不进档位就当没拿到事实
export function projectPadding(value: unknown): SkeletonPadding | undefined {
if (typeof value !== 'number') return undefined
if (value === 0) return 'none'
if (value >= 1 && value <= 8) return 'tight'
if (value >= 12 && value <= 16) return 'normal'
if (value >= 20) return 'spacious'
return undefined
}约束:
- 档位边界是工程取舍,不是审美。改边界要过评审
- 证明的是疏密意图是否同档,不是像素级还原。要像素级,那是设计验收另一条线
有同事问过为什么不直接比 token 名。因为运行时 getComputedStyle 吐出来的是解析后的长度,不一定还带 token 名;源码侧有时是 utility class,有时是 CSS 变量。档位是三路都能落到的公共语言。token 有自己的证明通道,第 7 节讲。
2.4 skeletonDiff:逐字段比
两个骨架的比较就是逐字段比,没有加权,没有分数:
export function skeletonDiff(a: SkeletonNode, b: SkeletonNode): SkeletonDiff[] {
const diffs: SkeletonDiff[] = []
for (const field of ['axis', 'justify', 'align', 'gap', 'padding', 'sizing', 'wrap', 'overflow'] as const) {
const aVal = a.layout[field]
const bVal = b.layout[field]
// 两边都没有 跳过 不算差异
if (aVal === undefined && bVal === undefined) continue
// 一边有一边没有 算 error
if (aVal === undefined || bVal === undefined) {
diffs.push({ path: `layout.${field}`, expected: aVal ?? bVal, actual: undefined, severity: 'error' })
continue
}
// 都有但不一样 axis / justify / align / gap 是 error 其余 warn
if (aVal !== bVal)
diffs.push({ path: `layout.${field}`, expected: aVal, actual: bVal,
severity: ['axis', 'justify', 'align', 'gap'].includes(field) ? 'error' : 'warn' })
}
return diffs
}diff 再转成 ProofObligation,id 是 skeleton:<designNodeId>,category 是 layout。没有 diff 就 pass,有 diff 就 fail。
这里说清楚一点:目前 warn 级别的 diff 也落 fail。我留了 severity 字段是为了以后分级,但现在还没放开。放开之前我想先看几个月账本里 warn 到底有多少是真错。
2.5 一个 skeleton 对照
用第 1 节那个 Dialog 的内容区举例。设计侧投出来,和浏览器 computed style 投出来,放在一起比:
// design skeleton // computed skeleton
{ {
"id": "12:340", "id": "computed",
"role": "dialog-body",
"layout": { "layout": {
"axis": "flex-row", "axis": "flex-col",
"justify": "start", "justify": "start",
"align": "start", "align": "stretch",
"gap": "normal", "gap": "normal",
"padding": "spacious", "padding": "spacious",
"sizing": "fill", "sizing": "fill",
"wrap": "nowrap" "wrap": "nowrap"
} }
} }比出来的义务:
{
"id": "skeleton:12:340",
"category": "layout",
"phase": "runtime",
"outcome": "fail",
"differences": [
{ "path": "layout.axis", "expected": "flex-row", "actual": "flex-col", "message": "Skeleton diff: layout.axis" },
{ "path": "layout.align", "expected": "start", "actual": "stretch", "message": "Skeleton diff: layout.align" }
]
}axis 那一条就是两栏变一栏。align 是连带的:主题覆盖了方向,align-items 也回到了默认。看到这两条,排查方向就很明确,不用去猜。
3. 三路投影到同一骨架
skeleton 有三个来源:设计侧从 DesignIntentIR.layout 投影;源码侧从 ComponentUsageIR.layout 投影,这是静态阶段用的;浏览器侧从 getComputedStyle 读一组固定属性,再投影一次。
flowchart TD designIR["DesignIntentIR.layout"] --> designSkel["projectDesignNode"] usageIR["ComponentUsageIR.layout"] --> usageSkel["projectUsageNode"] computed["getComputedStyle"] --> computedSkel["projectComputedStyle"] designSkel --> diff["skeletonDiff"] usageSkel --> diff computedSkel --> diff diff --> pass["无 diff: pass"] diff --> fail["有 diff: fail"] noElement["selector 找不到元素"] --> unknown["unknown: runtime-required"] screenshot["screenshot / visual-diff"] -.->|diagnosticOnly| human["给人看 不进 status"]
浏览器侧读的属性是固定的一组:display、flexDirection、justifyContent、alignItems、flexWrap、gap、padding*、width、height。projectComputedStyle 把这些字符串翻成骨架:display: flex 加 flexDirection 定 axis;justify-content 的 flex-start / start 归一成 start,space-between 归一成 between;gap 取第一个数字过 projectGap;width 是 100% 或 auto 算 fill。
比之前还有一步过滤,这一步很重要:
export function proveDesignAgainstComputed(designNode, designGraph, computed, evidence): ProofObligation[] {
const id = `skeleton:${designNode.id}`
// 元素不存在 → unknown 不是 fail 修映射和修 flex 是两件工作
if (!computed)
return [unknown(id, 'layout', unresolvedFact('runtime-required', `No element was found for design node ${designNode.id}.`, evidence))]
const designSkeleton = projectDesignNode(designNode, designGraph)
const computedSkeleton = projectComputedStyle(computed)
// 只保留设计真正声称的字段
// 浏览器对没写的属性也有默认值 把默认值当设计期望 几乎每张稿都会误挂
const filtered = emptySkeleton()
for (const field of FIELDS)
if (designSkeleton.layout[field] !== undefined)
filtered.layout[field] = computedSkeleton.layout[field]
return toObligations(id, skeletonDiff(designSkeleton, filtered), 'runtime')
}要点:
- 过滤是单向的:设计标记了、运行时采不到,仍然 fail;设计没标记、运行时有默认值,不算差异
- 元素找不到落
unknown,reason 是runtime-required。这不是布局错了,是没采到
4. RuntimeScenario:把打开再量写成可复现的剧本
运行时验证的入口是一份 RuntimeScenario。它不是随便开个浏览器看看,是把环境固定住。第 1 节那个 Dialog 的 scenario 写出来是这样:
{
"schemaVersion": 2,
"kind": "runtime-scenario",
"id": "settings-dialog-open",
"target": { "kind": "route", "path": "/settings" },
"viewport": { "width": 1280, "height": 720 },
"theme": "light",
"locale": "zh-CN",
"setup": {
"localStorage": { "feature.settingsDialog": "on" }
},
"designReference": {
"nodeId": "12:300",
"viewport": { "width": 1280, "height": 720 },
"theme": "light",
"locale": "zh-CN"
},
"selectors": {
"12:300": "[data-d2c='dialog']",
"12:340": "[data-d2c='dialog-body']",
"12:341": "[data-d2c='dialog-form']",
"12:342": "[data-d2c='dialog-aside']"
},
"expectedStyles": {
"12:340": { "flex-direction": "row" }
},
"actions": [
{ "kind": "wait-for", "selector": "[data-d2c='open-settings']", "state": "visible" },
{ "kind": "click", "selector": "[data-d2c='open-settings']" },
{ "kind": "wait-for", "selector": "[data-d2c='dialog']", "state": "visible" }
],
"network": {
"blockExternal": true,
"mocks": [
{ "url": "/api/settings", "method": "GET", "status": 200, "body": "{\"items\":[{\"id\":1}]}" }
]
}
}字段说明:
target:走页面route,或走组件component的 harness 路径。后者用 Vue SSR 先渲成 HTML,再page.setContent灌进 Playwright,适合 Button、Input 这类不依赖路由状态的局部布局setup:导航前写进去的 cookies / localStorage / sessionStorage。Dialog 常依赖登录态或功能开关designReference:设计稿是在哪个画板尺寸、哪个主题下标注的selectors:designNodeId 到 CSS selector 的映射,没有映射就采不到actions:只有 click / fill / press / check / select / hover / wait-for 七种,按顺序执行network:blockExternal默认 true,mocks按 url 包含匹配
约束:
designReference的 viewport / theme / state / locale 必须和 scenario 一致。不一致的话运行时记一条layout:scenario-reference的 unknown,geometry 比较全部降级。用 375 宽去证 1440 宽的两栏意图没有意义mocks把接口数据固定住。空态经常把两栏折叠掉,那不是布局错,是数据没给够actions里的wait-for不能省。有人在 click 之后立刻量,Dialog 还在 opacity 过渡,geometry 是中间态,间歇性红
5. 运行时验证的流程
d2c verify runtime 的入口是 verifyRuntime:
flowchart TD start["verifyRuntime(decision, design, scenario)"] --> fp["四条指纹对账<br/>boundary:fingerprint:*"] fp --> launch["chromium.launch + newContext<br/>viewport / locale / colorScheme"] launch --> net["configureNetwork<br/>mocks 优先 外网 abort"] net --> setup["applyContextSetup<br/>cookies / storage"] setup --> goto["page.goto(target.path)<br/>behavior:navigation"] goto --> actions["逐条执行 actions<br/>behavior:action:*"] actions --> observe["observeNodes<br/>每个 selector 采 dom / box / computed / scroll"] observe --> prove["proveRuntimeNodes<br/>structure:* / layout:*"] prove --> ref["designReferenceProblem<br/>layout:scenario-reference"] ref --> artifacts["写 artifacts<br/>dom / computed-style / geometry / scroll-metrics"] artifacts --> shot["screenshot + visual-diff<br/>diagnosticOnly"] shot --> status["reportStatus → VerifyReport"] launch -.->|任一步抛异常| catchUnknown["behavior:runtime-setup<br/>unknown: missing-setup"] catchUnknown --> status
对应的伪代码,思路在注释里:
export async function verifyRuntime(options): Promise<VerifyReport> {
const obligations: ProofObligation[] = []
// 1. 先对指纹 对不上后面量什么都没意义
for (const name of ['extractor', 'catalog', 'design', 'source'])
obligations.push(fingerprintProof(name, decision.fingerprints[name], current[name]))
try {
// 2. 环境固定住
const context = await browser.newContext({ viewport: scenario.viewport, locale: scenario.locale, colorScheme: scenario.theme })
await configureNetwork(context, baseURL, scenario)
await applyContextSetup(context, baseURL, scenario)
// 3. 导航 状态码不对直接 fail
const response = await page.goto(new URL(scenario.target.path, baseURL).toString(), { waitUntil: 'domcontentloaded' })
obligations.push(response?.ok() ? pass('behavior:navigation') : fail('behavior:navigation', `Route returned ${response?.status()}.`))
// 4. 动作逐条执行 每条一个义务 点不到就 fail
for (const action of scenario.actions)
obligations.push(await executeAction(page, action))
// 5. 采样 + 证明
const observations = await observeNodes(page, design, scenario)
obligations.push(...proveRuntimeNodes(design, scenario, observations))
// 6. 对照前提是否成立
const problem = designReferenceProblem(scenario, design, observations)
obligations.push(problem ? unknown('layout:scenario-reference', 'missing-scenario', problem) : pass('layout:scenario-reference'))
// 7. 落 artifacts 前四类是证据 后两类只诊断
artifacts.push(...writeObservationArtifacts(observations))
artifacts.push(artifact('screenshot', await page.screenshot(), /* diagnosticOnly */ true))
artifacts.push(visualDiffArtifact({ baselinesDir, nodeId: decision.designNodeId, actualPath }))
}
catch (error) {
// 8. 环境起不来 整段 unknown 不是 fail 也不是 pass
obligations.push(unknown('behavior:runtime-setup', 'missing-setup', `Runtime scenario could not be established: ${error.message}`))
}
const status = reportStatus(obligations)
return VerifyReportSchema.parse({
id: `verify:${decision.id}:${scenario.id}`,
decisionId: decision.id,
fingerprints, target, obligations, artifacts, evidence, status,
disposition: status === 'pass' ? 'accepted' : status === 'fail' ? 'rejected' : 'needs-review',
})
}命令行这样跑:
d2c verify runtime \
--decision .d2c/decisions/settings-dialog.json \
--design .d2c/design/settings.json \
--scenario scenarios/settings-dialog-open.json \
--base-url http://127.0.0.1:3000 \
--source-fingerprint 9f2c...e1a0 \
--out .d2c/verify/settings-dialog.runtime.json要点:
- 每一步都留义务,不留口头结论。导航、每个 action、每个 selector 是否存在,都有自己的 id
- 环境起不来整段落
behavior:runtime-setupunknown。我不在半吊子环境里假装量过了 - 报告 id 是
verify:<decisionId>:<scenarioId>,同一个 decision 可以有多个 scenario 的报告
6. 量什么
observeNodes 对 selectors 里每个设计节点做一次观察,在页面里一次 evaluate 拿回四类数据:getBoundingClientRect 的盒子、expectedStyles 里声明的属性加 display / visibility、scrollWidth / clientWidth 这组滚动指标,以及用 designNodeId 表达的父子关系。然后 proveRuntimeNodes 把观察翻成义务:
| 义务 id | category | 判定 |
|---|---|---|
structure:present:<node> |
structure | selector 找不到元素 → fail,这个节点后面的义务不再产出 |
layout:geometry:<node> |
layout | 设计节点有 bounds 且采到 box 时比几何;没有有效 designReference → unknown |
structure:visibility:<node> |
structure | 设计声明了 visible 时,比 display / visibility / 宽高是否大于 0 |
layout:style:<node>:<prop> |
layout | expectedStyles 里声明的属性,字面量精确比 |
layout:scroll:<node>:<metric> |
layout | expectedScroll 里声明的指标,精确比 |
structure:containment:<parent>><child> |
structure | 设计图里的 containment,比parentDesignNodeId |
structure:order:<parent> |
structure | 设计图里的 order,比childDesignNodeIds 的顺序 |
geometry 那一条多说两句。设计 bounds 和运行时 box 都要先减去 designReference 节点的原点再比,页面滚动位置和外层布局就不影响比较。compareGeometry 里有容差,文本节点比盒子节点松一点,超过容差的落到 fail,differences 里带 delta 和 tolerance。
geometry 和 computed style 是两个通道,各自能抓到对方抓不到的错:
- 父级
flex-direction: column被误写:computed 直接冲突,geometry 也会看到右栏的 y 掉到左栏下面 - 绝对定位把两个盒子画成并排:computed 说 column,geometry 说两个盒子横向重叠。只看截图是两栏,只看 computed 是一栏,两边一起看才完整
- 内容溢出被
overflow: hidden藏住:scroll-metrics 里 scrollWidth 大于 clientWidth,视觉上看不出来
7. 双通道合并
静态和运行时对同一个 obligation id 各有一个结论,最后要合并。规则我写得很死:
export function mergeDualChannel(staticObs, runtimeObs, evidence): ProofObligation[] {
for (const s of staticObs) {
const r = runtimeById.get(s.id)
if (!r) { merged.push(s); continue } // 运行时没这条 留静态
if (s.outcome === 'unknown') { merged.push(r); continue } // 静态 unknown → 运行时顶上 这是升级通道
if (r.outcome === 'unknown') { merged.push(s); continue } // 运行时 unknown → 留静态
if (s.outcome === r.outcome) { merged.push(r); continue } // 一致 → 留运行时 证据更强
// 冲突 → unknown 带说明 交给人
merged.push(unknown(s.id, 'unknown', `Dual-channel conflict for ${s.id}: static=${s.outcome}, runtime=${r.outcome}.`))
}
// 只有运行时有的义务 直接加进来
return merged
}冲突落 unknown 这一条我想了很久。静态说 pass 运行时说 fail,直觉是信运行时。但也可能是 scenario 写错了、主题选错了、mocks 没给够。程序不应该替人做这个判断,写成 unknown 让人看。
token 是另一条升级通道。静态阶段看到 var(--spacing-md) 这种 css-var 证不了,标 layout:token:<token> unknown。运行时用 extractComputedToken 从 getComputedStyle(body).getPropertyValue('--spacing-md') 读到实际值,proveComputedToken 做字面量比较:空白和大小写归一,px 数值等价,没有容差。布局轴走 skeleton,token 走自己的 id,我不把它们混成一个大的视觉分。
8. visual-diff 只做诊断
运行时仍然会截图,仍然会和 baseline 做 pixelmatch,仍然会写 heatmap。但 artifact 上 diagnosticOnly: true,它永远不参与 reportStatus。
export function visualDiffArtifact(options): VerifyArtifact {
const baselinePath = join(options.baselinesDir, `${options.nodeId}.png`)
// 没有 baseline → unavailable 不影响 status
if (!existsSync(baselinePath))
return { kind: 'visual-diff', path: baselinePath, diagnosticOnly: true, status: 'unavailable',
detail: { reason: `No design baseline at ${baselinePath}.` } }
// baseline 是给旧设计指纹截的 → 同样 unavailable 提醒重截
const meta = readBaselineMetadata(options.baselinesDir, options.nodeId)
if (meta?.designFingerprint && meta.designFingerprint !== options.designFingerprint)
return { kind: 'visual-diff', path: baselinePath, diagnosticOnly: true, status: 'unavailable',
detail: { reason: `Baseline was captured for design fingerprint ${meta.designFingerprint}.` } }
// 有 baseline 且指纹对得上 → 算 diff 写 heatmap
// 尺寸不一致时把 actual 最近邻缩放到 baseline 尺寸 只为让 heatmap 可读
const result = computeVisualDiff({ baselinePath, actualPath: options.actualPath, heatmapPath })
return { kind: 'visual-diff', path: heatmapPath, diagnosticOnly: true, status: 'available',
detail: { ratio: result.ratio, diffPixels: result.diffPixels, totalPixels: result.totalPixels } }
}我为什么这么定。截图回答的是在这一次特定渲染条件下像素像不像。它绑定了字体、抗锯齿、子像素布局、设备像素比、系统主题、浏览器版本。把像不像当成对不对,等于用一张快照担保所有环境。还有一类更隐蔽的问题:绝对定位把右栏画在左栏旁边,截图看是两栏,DOM 和 flex 轴是一栏。像素差很小,布局语义已经错了。
所以我的规则是:没有量测不能发版,没有图可以发版。图是给人看的说明书插图。有人要把 diff ratio 写回 CI 门禁,我在 code review 里拦过,动机通常是测试同学习惯看图。习惯可以保留,把图当附件打开,但不能让图重新拥有否决权或放行权。
排障时我的顺序是:先看 fail / unknown 的 obligation id,再打开 geometry 和 computed-style 的 JSON,最后才看 heatmap。反过来从红绿图猜原因,很容易把字体问题当成布局问题。
9. pass / fail / unknown 与 disposition
运行时义务的 outcome 只有三个值,聚合规则和静态一样:有 fail 整单 fail,没 fail 有 unknown 整单 unknown,全 pass 才 pass。disposition 跟着 status 走:pass → accepted,fail → rejected,unknown → needs-review。
schema 层还有一道校验,保证没人手改 status:
export const VerifyReportSchema = z.object({ /* ... */ }).superRefine((report, ctx) => {
const hasFail = report.obligations.some(o => o.outcome === 'fail')
const hasUnknown = report.obligations.some(o => o.outcome === 'unknown')
const expected = hasFail ? 'fail' : hasUnknown ? 'unknown' : 'pass'
if (report.status !== expected)
ctx.addIssue({ path: ['status'], message: `Report status must be ${expected} for its proof outcomes.` })
// 只有全 pass 的报告才能 accepted
if (report.disposition === 'accepted' && report.status !== 'pass')
ctx.addIssue({ path: ['disposition'], message: 'Only passing proof reports can be accepted.' })
})运行时 unknown 的 reason 有这几种:
stale-fingerprint:DecisionRecord 里缺某条指纹missing-setup:Playwright 起不来、导航抛异常,整段作废missing-scenario:没有 designReference、reference 节点没映射 selector、viewport / theme 和 reference 不一致、containment / order 涉及的节点没有 selectorruntime-required:harness 路径下 selector 找不到元素unknown:双通道冲突
这些我都不编造成 fail 来显得严格,也不编造成 pass。人拿到 needs-review 的时候看 unresolved.reason:该补 scenario 就补,该补 selector 就补,该承认这条只能人眼看就在 review 里写清楚。
10. 踩坑:截图 diff 当验收
这是我改架构之前踩得最疼的一次。
早期我把 CI 门禁绑在截图 diff 上。设计侧导出一张 baseline,运行时截一张,pixelmatch 算差异比例,低于阈值就 pass。本地和 CI 用同一套 headless Chromium、同一 viewport,diff ratio 长期低于 0.5%,报表上一片绿。
灰度的时候出了两件事。一个业务同学的显示器缩放是 125%,Dialog 右栏掉了下去。另一台机器字体回退到系统默认,文字换行把高度顶开,两列间距看着不对。这两个 case 在 CI 的字体环境下都不会出现,截图门禁从来没见过。
还有一次更离谱。baseline 是设计导出的理想态,actual 是组件库默认皮肤,颜色差很大。有人把阈值调松到差不多就行,于是结构错了的版本只要颜色碰巧接近也能过。阈值要靠讨价还价这件事本身就说明它不适合当证明。
绿的是 CI 字体环境下的像素运气,不是布局证明。后来做了三件事:
- 把 visual-diff 降成
diagnosticOnly,缺 baseline 就unavailable - 把 skeleton 档位和 geometry / computed 双通道扶正,当主路径
- 把设计有布局、usage 无布局事实从静默跳过改成
runtime-required(第九篇讲这次收紧)
改完第一周 unknown 变多,有同事觉得验证退步了。其实是把原来藏在绿色截图底下的未知翻到了明面。真机红的那些 case,现在会在运行时报告里留下 fail 或 unknown,而不是一张绿色 heatmap。
11. 走读:Dialog 两栏
把第 1 节那个 Dialog 用第 4 节的 scenario 跑一遍,报告里关键的几条:
{
"id": "verify:decision:settings:12:300:settings-dialog-open",
"status": "fail",
"disposition": "rejected",
"obligations": [
{ "id": "boundary:fingerprint:catalog", "outcome": "pass" },
{ "id": "behavior:navigation", "outcome": "pass" },
{ "id": "behavior:action:click:[data-d2c='open-settings']", "outcome": "pass" },
{ "id": "behavior:action:wait-for:[data-d2c='dialog']", "outcome": "pass" },
{ "id": "structure:present:12:342", "outcome": "pass" },
{
"id": "layout:style:12:340:flex-direction",
"outcome": "fail",
"expected": "row",
"actual": "column"
},
{
"id": "layout:geometry:12:342",
"outcome": "fail",
"differences": [
{ "path": "12:342.x", "expected": 432, "actual": 24, "tolerance": 4, "message": "Geometry delta 408px exceeds 4px." },
{ "path": "12:342.y", "expected": 24, "actual": 318, "tolerance": 4, "message": "Geometry delta 294px exceeds 4px." }
]
},
{ "id": "structure:containment:12:340>12:342", "outcome": "pass" },
{ "id": "structure:order:12:340", "outcome": "pass" },
{ "id": "layout:scenario-reference", "outcome": "pass" }
],
"artifacts": [
{ "kind": "geometry", "path": ".d2c/artifacts/settings-dialog-open.geometry.json", "diagnosticOnly": false },
{ "kind": "screenshot", "path": ".d2c/artifacts/settings-dialog-open.png", "diagnosticOnly": true },
{ "kind": "visual-diff", "path": ".d2c/baselines/12:300.png", "diagnosticOnly": true, "status": "unavailable" }
]
}怎么读这份报告:
- 指纹全 pass,这份 decision 还是现行的,结论有效
- 导航和 action 全 pass,Dialog 确实打开了,后面的量测有意义
layout:style说 computed 是 column,layout:geometry说右栏的 x 从 432 变成 24、y 从 24 变成 318。两个通道指向同一件事:右栏掉到了左栏下面- containment 和 order 都 pass,DOM 结构没变,只是方向变了。这就排除了 wrapper 多包一层的可能
- visual-diff 是
unavailable,没有 baseline,不影响 status
根因就是主题里那段 [data-theme=light] .dialog-body { flex-direction: column; } 盖掉了 utility。源码侧的 usage 图只有 flex flex-row,静态当然绿。没有运行时,这种覆盖链可以躺很久。修法是改主题规则或者给内容区加更高优先级的方向声明,这是实现层的事,引擎只负责把冲突指出来。
小结
本篇完成了:
SkeletonNode类型,以及从LayoutConstraintGraph到骨架的投影:unresolved 不硬填、padding 取最大边- gap / padding 按档位比而不按像素比,边界写死、空洞故意不映射
skeletonDiff逐字段比较,一边缺失算 error,warn 目前也落 fail- 三路投影落到同一套字段;computed 侧只保留设计声称的字段
RuntimeScenario的完整示例:target / designReference / selectors / actions / networkverifyRuntime的流程:指纹、导航、动作、采样、证明、artifacts,每一步留义务- geometry 相对 reference 归一后比容差,computed style 字面量比,scroll-metrics 抓隐藏溢出
- 双通道合并规则,以及 token 的运行时升级通道
- visual-diff 只做诊断:
diagnosticOnly: true,缺 baseline 或指纹过期落unavailable
到这里,一条设计节点从 ingest 到 decide 到 verify static 到 verify runtime 的路走完了。下一篇讲这些 DecisionRecord 和 VerifyReport 落到哪里:decision ledger 按月追加、FingerprintSet 怎么让老记录失效、evolve 为什么只产出 EvolutionProposal 而不自动改 CatalogSnapshot。