- qa/function-audit: playwright 行为审计工具(audit.mjs/verify-modals.mjs/pages.json) + 18 页审计产出(*.audit.md/json、summary、运行日志) - qa/visual-parity: 调试/测量辅助脚本(_dbg*.mjs/_measure.mjs/_off.mjs) - core/还原度核对报告.md: 18 页 pixelmatch 核对结果(含 vite 代理陈旧坑记录) - core/还原与接口待办.md: 逐页还原度/真实数据/交互接入待办总表 - .claude/skills/pixel-perfect-react: 像素级还原 React 的 SKILL 文档 - frontend/public/_devlogin.html: 临时本地登录辅助页(可删) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 KiB
name, description
| name | description |
|---|---|
| pixel-perfect-react | 把 HTML 设计稿像素级精准还原成 React/TSX 组件。当任务涉及"还原页面 / 页面还原度 / HTML 转 React / 设计稿转组件 / exact 页面 React 化 / 精确到 px / 像素对齐 / visual parity / 还原度太粗糙 / 对齐设计稿",或要把 public/exact/*.html 转成 src/routes 下真 React 组件、或修一个已 React 化但还原不准的页面时使用。强制"逐字转写而非重新发挥"+ pixelmatch 像素 diff 闭环,把 diffPixels 逼到趋近 0。 |
像素级 HTML → React 还原
路径约定:本文路径相对 AirShelf 仓库根(即本
.claude的上一级)。前端工程在core/frontend/,设计 SSoT 在电商AI平台/design.md,设计镜像在core/frontend/public/exact/。
核心原则:转写,不是重画(Transcribe, don't reinterpret)
React 文件 = 源 HTML 的忠实转写。只有「数据」和「事件」变,结构 / 类名 / 内联样式 / 文案逐字保留。
还原度变糙的 99% 根因:AI 把 HTML"读懂后重写"成自以为更干净 / 更语义化的结构和类名,于是 restraint.css 里靠这些类名和结构生效的精确样式全部失配——间距、字号、圆角、颜色就这么一点点漂走了。禁止重画,只许转写。
十条铁律(每条都对应一种"变糙"的根因)
- 先读设计规范 — 涉及任何样式,先
Read 电商AI平台/design.md(§0 协作铁律、§2 token、§8 Don't List)。这是 CLAUDE.md 的硬性要求。 - 类名逐字保留 — 源 HTML 每个
class原样搬到className。禁止改名 / 合并 / 拆分 / 换成 Tailwind utility / 换成内联样式。 - DOM 结构 1:1 — 嵌套层级、兄弟顺序、看似多余的空
<div>、装饰<span class="corner-tr">/corner-bl全部保留。restraint.css大量用后代选择器和:nth-child,结构一动样式就错位。 - 内联样式精确搬运 —
style="width: 33%"→style={{ width: "33%" }},数值一个字符都不改;kebab-case → camelCase。禁止"约等于"(padding 14 写成 16、宽 33% 写成 35% 都算错)。 - 文案 / Mono 装饰逐字 —
// 05.14、[ 200 OK ]、[ /v1 ]、LIVE、占位文本全部原样保留,禁止改写 / 翻译 / 精简 / 补全。这些是品牌签名。 - 内联 SVG 属性级保留 —
viewBox/path d/fill/stroke-width逐字抄,禁止重画图标或换 lucide 顶替(除非源 HTML 本就用 lucide)。 - 加载同一套 CSS + 字体 — React 入口必须
import同一份restraint.css/styles.css/design-restraint.css;禁止在组件里用内联<style>或新 class 重定义.btn.pill.input.modal.drawer等共享类(design.md 铁律 #3)。要变体回restraint.css加。 - 只换数据,不动壳 — 把写死的示例数据替换成
props/state/.map(),周围 markup 一个标签都不动。循环渲染时,卡片 / 行内部的完整结构要原样保留。 - 核对"作用域 CSS 复刻"是否逐行忠实 — 当源页面有页面级内联
<style>(登录/向导这类),它往往被复刻进共享 CSS 的一个作用域块(如.auth-exact-page .divider {...})。这份复刻本身也可能抄错:实战见过.divider被多加height:1px; background,把 OR 分隔塌成一条线——你组件转写得再准也白搭。逐行 diff 作用域 CSS 块 vs 源<style>,多一行少一行都要揪出来。 尤其当你"补回一个缺失元素"后样式不对,先怀疑这里,而不是怀疑组件。 - 警惕"旧全局 CSS 泄漏" — 同名类(
.balance-banner.pay-row.recharge-card.pane.stage-script.video-thumb等)在共享 styles.css 里常有一份旧的全局版本。你新写的.page-x .foo即使特异性更高,也只覆盖你显式声明的属性;你没声明的属性(如margin-top/max-height/border-top)会回退到旧全局值,造成神秘高差、gridalign-items:stretch失效、元素被顶偏。症状:diff 图里某块以下全部"重影"(垂直错位累积)。定位:量getBoundingClientRect().height对比设计稿镜像,逐元素找多出来的 px;再grepstyles.css 找同名旧全局规则。根治:确认该旧全局类只此页用(grep -rl组件),直接从 styles.css 删掉那段旧全局规则(你的 scoped 版已自洽);删不干净时在 scoped 规则里显式把泄漏属性清零。实战:pipeline 删掉 1245-1342 旧全局块,顺手把 stage-1 卡了很久的 pane-h 换行刀刃残差从 25237px→999px——旧全局.stage-script/.video-thumb max-height一直在暗中泄漏。
标准流程
1. 定位三件套
- 设计源(SSoT):
电商AI平台/<page>.html,或其镜像core/frontend/public/exact/<page>.html - 共享样式:
public/exact/assets/restraint.css+src/styles.css+src/design-restraint.css - 目标文件:
src/routes/<page>.tsx(接主 SPA 的真组件)
2. 截设计稿基线(这是像素目标)
确保前端在跑(cd core/frontend && npm run dev),然后在目标视口打开设计稿截图:视口 1440x900、deviceScaleFactor=1、colorScheme: light、reducedMotion: reduce、等 document.fonts.ready。(下面第 5 步的 compare-page.mjs 已自动做这些。)
3. 逐字转写 HTML → JSX(对照铁律 2-6)
机械转换,别动脑"优化":
class→className,for→htmlFor,空元素自闭合(<br>→<br />)style="a: x; b: y"→style={{ a: "x", b: "y" }},值不变,kebab → camel<!-- 注释 -->→{/* 注释 */}(可删,但别碰可见文案)- 内联
onclick→ React handler,只接交互,不动结构 tabindex→tabIndex,stroke-width→strokeWidth等 SVG 属性驼峰化- CSS 自定义属性内联(如 mock-media 的
--mock-media-url)→style={{ ["--mock-media-url"]: "url(...)" } as CSSProperties}
4. 接数据,保持壳不变
示例数据 → props / state / map。唯一允许变的就是数据出处和事件,DOM 一律不动。
5. 像素 diff 闭环(仓库已有 pixelmatch 工具,别自己造)
cd core/qa/visual-parity
node compare-page.mjs \
--source "http://127.0.0.1:5173/exact/<page>.html" \
--target "http://127.0.0.1:5173/<真 React 路由>" \
--name <page> --viewport 1440x900 --token <登录token>
- 读
output/<page>.report.json的diffPixels/diffRatio。 - 打开
output/<page>.diff.png:红色高亮处就是 drift。逐个定位是哪个元素的间距 / 字号 / 字重 / 颜色 / 圆角错了 → 回组件改 → 再 diff。 diffPixels每轮必须下降,目标趋近 0。 上轮没降就是改错了方向。
⚠️ 登录态陷阱:真 React 业务页(
/dashboard/products等)有登录门禁。compare-page.mjs --token <token>会给 source + target 同时注入登录态。token 取法:curl -s -X POST http://127.0.0.1:8010/api/auth/login/ -H 'Content-Type: application/json' -d '{"username":"<演示账号>","password":"<密码>"}'(注意字段是username不是 email)。源页也要带同一?product_id=/?project_id=等 query,两边同数据才是公平结构 diff。
6. 逐项自检(对照 design.md §8 Don't List)
- 全场 8px 圆角(
>12px直接判错;pill / dot999例外) - 全场只有一个橙色 accent;hover 用 alpha 不换 hue
- 无裸 hex,颜色全部用 design.md §2.1 的 token
- 字重只有 400 / 500 / 600(700 仅 Ctrl K 徽标)
- 用 inside-border(
box-shadow: inset)而非真border(hover 不抖) - Mono 装饰在位(
// xx[ 200 OK ]) - 只有主 CTA 有阴影,其他场景无阴影
- 没动基础 token(
--heat--background-base--border-faint)
实战补遗(2026-06 全站还原沉淀 · 比铁律更具体的坑)
这套页面的"真实感"不是单层数据——镜像视觉 = 三层叠加,要像素对齐必须三层都复刻。诊断时先用 Playwright 把镜像(带
?id+ token)的真实 DOM 抓出来(可见元素数 / 计数 / 每个 thumb 的 computed backgroundImage / 步进器 dot 类名),照着复刻,比盲读 HTML 快得多。
- 三层叠加:①
public/exact/assets/api-bridge.js的renderPageX只 hydrate 少量真字段(且setField仅在值非空时覆盖,否则留 mock 默认值——React 必须照抄"真值‖mock默认"回退);② 页面自带内联<script>跑默认筛选/排序(如 product-detail 素材默认 status filter「通过」永远生效→只显示 pass 卡而非全部);③shell.js加载assets/mock-media.js,对所有.placeholder按"上下文文本"正则塞 mock 图。 - mock-media 映射:
.placeholder命中即加.has-mock-media类(共享 CSS 里该类background-image:var(--mock-media-url)+ 把.ph-frame透明)。React 要给对应元素手动加has-mock-media+ 内联--mock-media-url。映射:面膜/补水/玻尿酸→product-mask;平台/套图→scene-tabletop;办公→scene-office;床头/卧室→scene-bedroom;林夕/主播/女性→person-linxi;视频封面 mask→cover-mask-v3 / final→cover-mask-final。特例:#ed-canvas这类非.placeholder元素,mock-media 只设内联backgroundImage(size 默认 auto 平铺,别加 cover)。 - 运行时会改静态 HTML——别照抄静态:products 静态 result-meta 里有 grid/list 切换器,但镜像 api-bridge 运行时把它删了(只剩计数 span);照静态加上去会让该行变高、把整块网格下推几 px → 全红。以 Playwright 抓到的实时 DOM 为准,不是静态文件。
- 默认 tab/pane 对齐镜像而非"全部":library 镜像默认 active=人物(0 资产→空态),不是"全部";pipeline 默认可见 pane=脚本(stage1),但步进器 active=项目真实阶段(二者解耦)。
- 步进器(pipeline)严格复刻
activateStage:activeDot = 已导航?所看阶段:项目阶段;completed=max(项目阶段-1, activeDot-1);doti===activeDot active / i<=completed done;linen<=completed done。 - chip caret 别自己写宽高:给
.chip .caret加width/margin会让每个 chip 宽 +2px、多个 chip 累计错位文字重影。交给全局 restraint.chip管。(projects 这一删 1494→182px) - 作用域写法:用 CSS 嵌套
.xxx-page { ... },@keyframes提到顶层。.app.xxx-page全屏页用& > main子选择器。共享类(tabs/chip/toolbar/search-inline/result-meta/empty-filter/pill/btn/.stat/.prog)走全局 design-restraint(它是 restraint.css 的端口,在 styles.css 之后加载→覆盖旧全局),页面专属类才 scope。
反模式(出现任一即判"变糙",必须重做)
- ❌ 把 HTML 结构"优化"成更少的
div/ 更语义化的标签 - ❌ 自创 class 名,或用 Tailwind / 内联样式替代
restraint共享类 - ❌ 内联样式凭感觉约等于(
padding 14 → 16、width 33% → 35%) - ❌ 删改 Mono 装饰 / 占位文案 / 标点
- ❌ 重画 SVG 图标,或拿相近图标顶替
- ❌ 在组件里
<style>重定义共享类 - ❌ 不截图不 diff,肉眼"差不多"就提交
- ❌ 改了基础 token,影响全站
- ❌ 照抄静态 HTML 的运行时被改部分(view-tog、默认 tab、注入的 mock 图)
验收门槛
diffPixels 逐轮下降且 diff.png 无业务性红块为通过。字体抗锯齿 + 共享 shell 残差(crumb mock 名 / nav 徽标 / bell 计数)等非业务差异允许残留,但必须在 report 旁单独记一行说明。视频真实生成不在还原阶段触发。
一页话总结
读 design.md → 截设计稿基线 → 逐字转写(类名/结构/内联样式/文案/SVG 全保真)→ 只换数据(三层叠加都复刻)→ pixelmatch diff 到趋近 0 → 对 §8 自检。 任何"我觉得这样更好"的改写,都是还原度变糙的源头。