基于 FigmaMCP 的 D2C 工具(七): verify——skeleton 与运行时量测

16 分钟阅读

在前六篇中,我们已经完成:

  • 同一份设计稿为什么会生成出漂移的结果
  • 为什么我把归一化做在工具侧,而不是让设计师再出一份干净稿
  • 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 是布局约束投影出来的语义骨架,里边装的是离散的意图值,不是原始像素。

typescript
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 字段。核心分支:

typescript
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,管线会被这种噪声淹没。

所以我把像素收成档位:

typescript
// 边界写死在这里 改边界等于改证明语义 要走评审
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:逐字段比

两个骨架的比较就是逐字段比,没有加权,没有分数:

typescript
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 投出来,放在一起比:

json
// 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"
  }                                      }
}                                      }

比出来的义务:

json
{
  "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。

比之前还有一步过滤,这一步很重要:

typescript
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 写出来是这样:

json
{
  "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

对应的伪代码,思路在注释里:

typescript
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',
  })
}

命令行这样跑:

bash
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-setup unknown。我不在半吊子环境里假装量过了
  • 报告 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 各有一个结论,最后要合并。规则我写得很死:

typescript
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。

typescript
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:

typescript
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 涉及的节点没有 selector
  • runtime-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 跑一遍,报告里关键的几条:

json
{
  "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 / network
  • verifyRuntime 的流程:指纹、导航、动作、采样、证明、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。

系列 · 基于 FigmaMCP 的 D2C 工具 · 第 7 篇 / 共 9 篇
01基于 FigmaMCP 的 D2C 工具(一): 为什么同一份设计稿的生成结果会漂移02基于 FigmaMCP 的 D2C 工具(二): 为什么我不靠设计师再出一份干净稿03基于 FigmaMCP 的 D2C 工具(三): catalog——机器出事实,人补判断04 基于 FigmaMCP 的 D2C 工具(四): ingest——把 Figma 噪声收成 DesignIntentIR05基于 FigmaMCP 的 D2C 工具(五): decide——reuse / extend / new 的判定06 基于 FigmaMCP 的 D2C 工具(六): verify(上)——静态义务和 ComponentUsageIR
07 基于 FigmaMCP 的 D2C 工具(七): verify——skeleton 与运行时量测本篇
08基于 FigmaMCP 的 D2C 工具(八): 账本与 evolve——只提案,不自动改库09基于 FigmaMCP 的 D2C 工具(九): 我收紧过的规则,以及 Agent/MCP 怎么接进来
评论