关于 visual-qa
visual-qa 是面向检查本地 SPA 的无头视觉测试工作流。它结合截图基线对比、页面内设计与无障碍检查,并要求逐页审阅后再复验。
主要功能
- 01截取路由截图并生成清单
- 02与像素基线进行对比
- 03检查页面内设计与无障碍规则
- 04逐路由审阅问题
- 05修复后重新截图验证
适用场景
- 01检测重新设计后的意外视觉变化
- 02验证应用不同主题和状态下的 UI 改动
- 03浏览器扩展无法捕获 localhost 时执行无头视觉检查
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rheinmir/ setup --skill visual-qa安装后在 Claude Code 中输入/visual-qa确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/.gitignorepackage-lock.jsonpackage.jsonroute-shots.mjs详细介绍
技能:visual-qa — 视觉自检工作流(capture → diff → judge → fix → re-verify)
存在的原因: Agent 修改 UI 后自夸是系统性偏差(失败
ui-pass-without-full-visual-review)。拍出的截图没人打开 = 等于没测试。这个工作流 用三层独立检查取代“Agent 自己看一眼就判定通过”,其中两层由机器完成。2026/07/13 决策(来自 /last30days): 对于 AI 生成的 UI,行业只有一项成熟做法—— 基线快照 + 像素差异(
argos-ci/argos:“detects unintended visual changes”; PlaywrighttoHaveScreenshot)。而“LLM-judge 按 rubric 给截图打分”仍处于前沿探索阶段 (Agent-as-a-Judge,论文阶段)——所以我们自己实现这一层,不等别人。
内容
目的和背景
- 目的: 用三层独立检查取代“Agent 自己看一眼就判定通过”,用于本地 SPA——基线像素差异(机器)、设计/a11y 不变量(机器)、按 rubric 完整阅读图片(Agent)——只有机器和人工检查都通过,并且有截图文件作为证据,才能通过。
- 触发条件(何时使用):
- 刚修改了主题 / UI / 品牌重塑,需要确认是否意外破坏了什么。
- Frame 的
ui_role≠none需要验收门禁(参见技能br§ 视觉自检循环)。 - 浏览器扩展无法截取 localhost(很常见)→ 使用 headless CLI 替代。
- 用户说“视觉 QA”、“visual qa”、“route-shots”、“逐个 route 截图”、“检查界面”、“基线差异”、“UI 是否有回归”、“headless 界面测试”、“/visual-qa”。
- 非目标: 不与
computer-use(桌面 AX / Orca 浏览器)重叠;不是 senior 的 4 项评审(那是/qc-uiux,使用此引擎);不放宽阈值来“消除错误”。
心智模型
app 运行中 + 真实 route 列表 + 登录 → route-shots(截图 + MANIFEST,所有主题/状态)→ 第 1 层基线差异(阈值 0)→ 第 2 层页面内不变量(contrast-aa · monochrome-surface · rogue-slab · shadow-clipped · overlap · row-misalign)→ 第 3 层按 rubric 阅读全部 N 张截图 → FINDINGS.md → 修复 → 重新截图 + 重新阅读。
输入和输出约定
| 字段 | 必填? | 含义 | |
|---|---|---|---|
| 输入 | app 正在运行(--base) | 是 | SPA localhost |
| 输入 | 来自真实 router 的 route 列表 + 登录账户 | 是 | 不要猜;登录失败时门禁必须失败 |
| 输入 | 来自 app 本身的主题/状态列表 | 是 | app 支持多个主题时,不要硬编码 light,dark |
| 输入 | 基线目录 | 否 | 首次运行时自动创建 |
| 输出 | <out>/*.png + MANIFEST.json | 是 | 没有文件 = 从未截图 |
| 输出 | 使用 --assert 时的退出码 | 门禁必需 | route 出错 / 缺少截图 / 违反不变量 / 与基线不符时为 1 |
| 输出 | FINDINGS.md | 是 | 每个 route 一行,包括“正常”的 route |
规则与能力
血的教训——不要重蹈覆辙:
- RULE-01 (MUST):凡是能计算的,就必须交给机器计算——禁止靠肉眼判断。文字对比度是数值(WCAG ratio = f(文字颜色,背景颜色))。Rubric 写着“文字达到 AA”,却没有设置不变量 ⇒ evaluator 盲目:模糊的文字会漏过整个登录页,Agent 看截图也无法想象出来。凡是可量化的标准 → 必须变成 assert,而不是 rubric 里的文字。
- RULE-02 (MUST):任何路由都不能成为盲区。Driver 先登录 ⇒
/auth重定向 ⇒ 登录页面永远不会被审计——偏偏有问题的就是这个页面。必须先截取未登录状态,再登录。检查一下:是否还有某些状态(modal、error、empty、loading)从未出现在截图中? - RULE-03 (MUST):主题是一种状态——light 和 dark 都要完整截取。App 通常通过 class(
<html>上的.dark)根据 app 设置 启用 dark,而不是根据prefers-color-scheme⇒ headless 默认永远只会看到 light,dark 则完全成为盲区:用户在 dark 下打开 app,UI 已经损坏,gate 依然显示绿色。启用--themes light,dark(默认)→ 首次运行就发现 dark 下有 172 处未达到 AAA,而 light 则毫无问题。设计上的结论:accent 必须随主题切换(浅色表面 → 深色 accent;深色表面 → 浅色 accent);两个主题共用同一个 accent 常量,从定义上就是错的。并且禁止在 accent 上硬编码文字颜色(color:#fff)——切换到另一个主题就会变成白字白底,只有 1.13:1。 - RULE-04 (MUST):覆盖 app 提供的所有主题——主题列表必须从 app 获取,不能自行猜测。我把
light,dark硬编码了,但 app 有三个主题(default/default-dark/paper)⇒paper无人检查 ⇒ 它和 dark 一样会损坏,发现问题的是用户,不是 gate。规则:运行前,从 app 本身读取主题列表(Settings→Preferences 下拉菜单 / sourceTHEME_OPTIONS),然后全部覆盖。推而广之:所有状态维度(theme、locale、role、empty/full data)都必须从 app 获取,不能靠 Agent 的记忆。 - RULE-05 (MUST):改变状态必须走 app 实际使用的变更路径——然后验证状态确实改变了。接连两层都出错:① 手动添加
.darkclass(app 使用data-theme+ 注入 CSS ⇒ 这种机制根本不存在);② 设置localStorage(app 会根据服务器上的账户设置覆盖它)。两次 gate 都在不存在的状态下显示绿色——比显示红色更糟。唯一可靠的路径是用户实际点击的路径(Settings → Preferences → 选择)。并且始终要有一个不变量:两个不同主题的背景却一模一样 ⇒ 截图是假的 ⇒ ASSERT FAIL(gate 连续截了两次同一个主题,还自我表扬)。 - RULE-06 (MUST):主题是 app 的机制,先读 source。不要假设
.dark/prefers-color-scheme。搜索documentElement.classList/data-theme/localStorage/ 注入的<style>,确认 app 如何切换主题;主题通常是在运行时注入 CSS ⇒ 对常规 token,它会覆盖我自己的静态主题文件;但它会被!important规则覆盖 ⇒ 结果就成了不伦不类的界面(app 的主题背景 + 我自己的主题 card = 页面中间出现一块奇怪的颜色)。正确的修复方式:把我自己的配色写进 app 注入的主题文件本身,不要在 app 根本不会读取的地方写.dark{}块。 - RULE-07 (MUST):**登录失败 = gate 必须失败。**登录失败 ⇒ 所有路由都重定向到
/auth⇒ driver 审计了 8 张一模一样的登录页面,然后打印“design-ok”。这种绿色比红色更糟:它确认了一个自己从未见过的 app。登录失败时,--assert必须立即失败。 - RULE-08 (MUST):用户报告问题 + 机器报告正常 ⇒ 机器错了,不是用户错了。(2026 年 7 月 14 日的教训——用户:“这是开发,不是法庭,你为什么要如此固执地为自己的论点辩护”。)Gate 显示绿色不是挡箭牌:它只能证明它被教会的内容,以及它实际截取到的状态。用户看到问题 = 存在一个 gate 从未进入过的 state/theme/bundle。禁止用猜测反驳用户(“可能是缓存导致的”“可能按错键了”)——每次猜测都是把验证工作推给用户,而我已经连续 3 次在这方面犯错。正确流程:(1) 相信用户的报告;(2) 复现用户的准确状态(route + theme + bundle + 登录/focus 状态);(3) 只有测量之后才能下结论。无法复现就直说“目前无法复现”,不要编造假设再为其辩护。
- RULE-09 (MUST):截图前先消除随机因素,不要放宽阈值。闪烁的光标 + animation 会让每条route 每次截图都偏离 baseline 约 0.04% ⇒ gate 永远显示红色 ⇒ 结果被忽视 ⇒ 真正的 regression 漏过。Driver 在初始化时注入 CSS
animation/transition:none; caret-color:transparent+ 隐藏 CodeMirror 光标 ⇒ diff 降至 0%。放宽--diff-threshold来“让它不再报警”就是让机器失明,不是修复问题。 - RULE-10 (MUST):过于宽泛的 SELECTOR = 自己坑自己。CSS 去噪规则中的
[class*="cursor"]还会匹配 Tailwind 的cursor-pointer/cursor-text⇒ 输入框被隐藏 ⇒ 无法登录 ⇒ gate 把正在检查的 app 弄坏了。Gate 注入的所有 CSS/selector 都必须尽可能精确,添加之后还必须运行完整流程(登录 → 所有 route)。 - RULE-11 (MUST):**Gate 必须能从干净的 repo 运行。**缺少
playwright-core⇒node route-shots.mjs会在 import 行立即报错退出 ⇒ UI frame 的 acceptance_test 永远不会运行 = gate 已死,但无人知晓。依赖项声明在skills/visual-qa/assets/package.json;安装:npm i --prefix skills/visual-qa/assets。 - RULE-12 (MUST):**证据 > 口头声明。**只有存在图片文件时,pass/fixed 才有效。没有文件 = 没做。禁止根据推断声称 pass。
- RULE-13 (MUST):**全部读完,不要挑着读。**读了 3/7 张图片就得出结论,是
ui-pass-without-full-visual-reviewfailure 的根本原因——根因就在尚未打开的图片里。 - RULE-14 (MUST):“机器 OK” ≠ “UI 好看”。不变量只知道它被教会的内容。Gate 显示绿色不是跳过阅读图片这一步的借口。
- RULE-15 (MUST):**阈值是一把双刃剑。**阈值太宽松 → 真实问题漏过(0.5% diff 就漏掉了把整张 card 变平的 regression)。阈值太严格 → 误报警 → 嘈杂的 gate = 死掉的 gate。Baseline 使用阈值 0 + 人工审核;DOM 不变量必须设置能区分真实问题与噪声的阈值。
- RULE-16 (MUST):CSS 要测量,不要猜。仅凭肉眼诊断连续错了 4 次(裁切 → 冲突 → selector 错误 → specificity 错误)。只有查询
getComputedStyle才找到根因。任何 CSS 修复之前:先测量 computed style + rect,再修改。修改之后:先重新测量,再看图片。 - RULE-17 (MUST):**不要为了“消除错误”而让机器失明。**把 blur 降到 detector 阈值以下 = 掩盖问题,而不是修复问题(光晕依然被裁切)。修复缺陷,不要改量尺。
- RULE-18 (MUST):在
overflow:hidden容器中(窄 rail、tab bar):禁止使用外扩阴影。阴影会被裁切或溢入缝隙 → 留下难看的痕迹。使用内阴影(不会溢出)或不加阴影。若另一个轴为auto,则overflow-x:visible不可行(CSS 会将其强制改为 auto)。 - RULE-19 (MUST):使用 playwright-core + 系统 Chrome(不要下载 130MB 的 chromium)。Baseline diff 需要
pixelmatch+pngjs(体积小,缺少它们时会自动跳过 baseline)。 - RULE-20 (MUST):实时 SPA 禁止使用
networkidle——使用domcontentloaded+ 固定等待时间。 - RULE-21 (MUST):不要与
computer-use冲突(桌面 AX / Orca 浏览器)。此 skill 是面向 localhost SPA 的 headless CLI。 - 能力:在 localhost 上运行 headless 浏览器;在页面内测量 computed style + rect;与 baseline 进行像素比较;记录图片/manifest/findings;修复目标项目的 UI。
失败边界
- 登录失败 →
--assert立即失败,不要审核登录页面的副本。 - 缺少
playwright-core→ gate 在 import 行就会终止:blocked,安装npm i --prefix skills/visual-qa/assets。 - 基线差异 ≠ 0 → failed,直到查看并通过
--update-baseline批准。 - 用户报告错误,但机器报告正常 → 机器有误:复现相同状态;如果无法复现,就直说“无法复现”。
- 机器检查正常,但尚未读完 N 张图片 → 尚未完成。
操作方式
主要工作流
W01–W07 分别对应下面的步骤 0–6。
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | deterministic | app、路由、账户 | 确认 app 正在运行,从 app 获取真实路由列表 + 主题列表 | 路由 + 主题列表 | app 未运行 → blocked |
| W02 | effect | 路由列表 | 使用 route-shots.mjs 截图(+ --assert) | 图片 + MANIFEST.json | 登录失败 → failed |
| W03 | deterministic | 图片 + 基线 | 基线差异阈值为 0 | 差异 | ≠ 0 → 查看并批准(B02)或修复 |
| W04 | deterministic | 页面内检查 | 机器检查的不变量 | 违规或正常 | 硬性 FAIL → W06 |
| W05 | judgment | MANIFEST 中的 N 条路由 | Read 全部 N 张图片,按评分标准打分 | 每条路由一行 | 有图片尚未阅读 → 尚未完成 |
| W06 | effect | 发现项 | FINDINGS.md(根本原因)→ 修复 | 已修复代码 | — |
| W07 | deterministic | 已修复代码 | 重新截图 → 再次阅读 | 机器检查正常且人工检查正常 | 仍有问题 → W06 |
各步骤详情(W01–W07 的事实依据):
0. App 必须正在运行。 确认路由列表(从真实路由器获取,不要猜测)+ 登录账户。
1. 截图 + 证据。
node skills/visual-qa/assets/route-shots.mjs \
--base http://localhost:5230 --out <dir>/qa-shots \
--baseline <dir>/qa-baseline \
--user demo --pass demo --assert
生成 <out>/*.png + MANIFEST.json(路由 → 文件 → 字节数)。没有文件 = 从未截图。
2. 基线差异(机器第 1 层)——捕获非预期变更。
首次自动创建基线。之后逐像素比较。默认阈值 = 0:任何差异 ≠ 0 都会
FAIL,且必须查看并通过 --update-baseline 手动批准。这是唯一能捕获以下类型错误的层:
CSS 不变量检查和 Agent 肉眼都看不见的错误(例如:修复过度导致整个 UI 扁平化)。
3. 机器检查的不变量(机器第 2 层)——设计一致性 + 无障碍。 在页面内使用计算样式运行:
contrast-aa— 所有文本节点均符合 WCAG AAA(根据文字颜色 + 有效背景计算对比度;普通文字 7:1,大号文字 4.5:1)。硬性 FAIL。monochrome-surface— 大面积窗格(sidebar/aside/nav)必须使用统一背景色。硬性 FAIL。rogue-slab— 所有面积 ≥ 视口 2%、背景不透明且与 body 背景对比度 > 1.6:1 的元素。硬性 FAIL。 (monochrome-surface只检查 sidebar/aside/nav,因此无法发现页面中任意<div>上的异常色块。)shadow-clipped— 父元素overflow:hidden导致外扩光晕被裁切(带模糊阈值)。警告。overlap— 两个控件(button/a/input/[role=button])不是父子元素,且矩形在两个方向上均重叠 ≥4px。捕获按钮相互覆盖的问题(例如position:fixed搭配 magic-offset,而 badge 宽度会变化)。硬性 FAIL。row-misalign— 同一父元素下、处于近似同一行(top 偏差 ≤6px)的控件,高度差 >3px。捕获相邻按钮高度不一致的问题(例如 toggle 按钮因 padding/font 不同而比 badge 高)。硬性 FAIL。 (这两条规则于 15/07/26 添加,因为/redesign-existing-projects(读取静态 CSS,无法测量矩形)和单元测试(检查内容,不检查坐标)都无法发现渲染时的几何错误。overlap 阈值故意不用面积百分比:真正的 bug 可能只裁切按钮边缘,占 18% 面积,任何百分比阈值都会漏掉——两个独立控件只要重叠几像素就算错误。)
根据项目的设计规范扩展不变量。
4. 阅读图片——严格覆盖率 + 评分标准(人工/Agent 层)。
MANIFEST 有 N 条路由 → 必须 Read 全部 N 张图片。FINDINGS 必须为每条路由提供一行
(包括“正常”)。按评分标准打分,不凭感觉——例如 neumorphism:① 单色表面 ② 阴影未被裁切 ③ CTA 使用扁平强调色 ④ 文字/图标符合 AA ⑤ 空状态符合品牌规范 ⑥ 焦点环。
5. FINDINGS.md → 修复。 表格格式:# · 路由 · 问题 · 严重程度 · **根本原因** · 修复方法。
6. 重新截图 → 再次阅读 → 才算通过。 机器检查正常且人工检查正常。缺少任意一项 = 尚未完成。
分支
| ID | 类型 | 守卫条件 | 行为 | 跳过 / 失败 | 汇合点 |
|---|---|---|---|---|---|
| B01 | conditional_required | frame gate 只需 1 条 route | 运行 --route <r> | — | W03 |
| B02 | user_optional | 相对基线的偏差是已审阅的有意变更 | --update-baseline | 未查看图片 → 不得批准 | W04 |
| B03 | capability_optional | 有 pixelmatch + pngjs | 运行基线差异比对 | 没有 → 自动跳过基线 | W04 |
| B04 | recovery | 用户报告问题,但 gate 显示通过 | 复现完全相同的状态(route + theme + bundle + 登录状态 / 焦点),测量后再反馈 | 无法复现 → 如实说明 | W02 |
验证与停止条件
机器:--assert 的退出码(route 出错 / 缺少图片 / 不变量 / 基线)。人工:FINDINGS 中 MANIFEST 的每条 route 都有对应的一行。只有两者都没有问题且存在图片文件时才算通过;没有文件 = 尚未完成。
示例
- 正向: 更改应用主题
/br后,THEME_OPTIONS生成 7 条 route × 3 种主题 →route-shots.mjs --assert退出码为 0,基线差异为 0%,Agent 用Read查看所有图片,FINDINGS 有 7 行“干净”结果 → 通过并附上图片。 - 边界 / 失败: light 正常,但尚未覆盖 dark → 启用
--themes light,dark后发现 172 处未达到 AAA →--assert退出码为 1;修复随主题切换的 accent 后重新截图,不要放宽--diff-threshold。
参考 — Driver 标志
--base --out --user --pass · --route <r>(1 条 route,用于 frame gate)· --assert
(以下情况退出码为 1:route 出错 / 缺少图片 / 违反不变量 / 基线偏差)· --baseline <dir> ·
--update-baseline(批准有意变更)· --diff-threshold <%>(默认值为 0)。