zyc 890cb9ab67
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
chore(core/qa): function-audit toolchain + parity/audit reports + pixel-perfect skill
- 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>
2026-06-10 09:41:30 +08:00

116 lines
12 KiB
Markdown

---
name: pixel-perfect-react
description: 把 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` 里靠这些类名和结构生效的精确样式**全部失配**——间距、字号、圆角、颜色就这么一点点漂走了。**禁止重画,只许转写。**
---
## 十条铁律(每条都对应一种"变糙"的根因)
1. **先读设计规范** — 涉及任何样式,先 `Read 电商AI平台/design.md`(§0 协作铁律、§2 token、§8 Don't List)。这是 CLAUDE.md 的硬性要求。
2. **类名逐字保留** — 源 HTML 每个 `class` 原样搬到 `className`。**禁止**改名 / 合并 / 拆分 / 换成 Tailwind utility / 换成内联样式。
3. **DOM 结构 1:1** — 嵌套层级、兄弟顺序、看似多余的空 `<div>`、装饰 `<span class="corner-tr">`/`corner-bl` 全部保留。`restraint.css` 大量用后代选择器和 `:nth-child`,**结构一动样式就错位**。
4. **内联样式精确搬运**`style="width: 33%"``style={{ width: "33%" }}`,数值一个字符都不改;kebab-case → camelCase。**禁止"约等于"**(padding 14 写成 16、宽 33% 写成 35% 都算错)。
5. **文案 / Mono 装饰逐字**`// 05.14``[ 200 OK ]``[ /v1 ]``LIVE`、占位文本全部原样保留,**禁止改写 / 翻译 / 精简 / 补全**。这些是品牌签名。
6. **内联 SVG 属性级保留**`viewBox` / `path d` / `fill` / `stroke-width` 逐字抄,**禁止重画图标或换 lucide 顶替**(除非源 HTML 本就用 lucide)。
7. **加载同一套 CSS + 字体** — React 入口必须 `import` 同一份 `restraint.css` / `styles.css` / `design-restraint.css`;**禁止**在组件里用内联 `<style>` 或新 class 重定义 `.btn` `.pill` `.input` `.modal` `.drawer` 等共享类(design.md 铁律 #3)。要变体回 `restraint.css` 加。
8. **只换数据,不动壳** — 把写死的示例数据替换成 `props` / `state` / `.map()`,**周围 markup 一个标签都不动**。循环渲染时,卡片 / 行内部的完整结构要原样保留。
9. **核对"作用域 CSS 复刻"是否逐行忠实** — 当源页面有页面级内联 `<style>`(登录/向导这类),它往往被复刻进共享 CSS 的一个作用域块(如 `.auth-exact-page .divider {...}`)。**这份复刻本身也可能抄错**:实战见过 `.divider` 被多加 `height:1px; background`,把 OR 分隔塌成一条线——你组件转写得再准也白搭。**逐行 diff 作用域 CSS 块 vs 源 `<style>`,多一行少一行都要揪出来。** 尤其当你"补回一个缺失元素"后样式不对,先怀疑这里,而不是怀疑组件。
10. **警惕"旧全局 CSS 泄漏"** — 同名类(`.balance-banner` `.pay-row` `.recharge-card` `.pane` `.stage-script` `.video-thumb` 等)在共享 styles.css 里常有一份**旧的全局版本**。你新写的 `.page-x .foo` 即使特异性更高,也只覆盖你**显式声明**的属性;你没声明的属性(如 `margin-top`/`max-height`/`border-top`)会**回退到旧全局值**,造成神秘高差、grid `align-items:stretch` 失效、元素被顶偏。**症状**:diff 图里某块以下全部"重影"(垂直错位累积)。**定位**:量 `getBoundingClientRect().height` 对比设计稿镜像,逐元素找多出来的 px;再 `grep` styles.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 工具,别自己造)
```bash
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 / dot `999` 例外)
- [ ] 全场只有**一个**橙色 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 快得多。
1. **三层叠加**:① `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 图。
2. **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**)。
3. **运行时会改静态 HTML——别照抄静态**:products 静态 result-meta 里有 grid/list 切换器,但镜像 api-bridge 运行时把它删了(只剩计数 span);照静态加上去会让该行变高、把整块网格下推几 px → 全红。**以 Playwright 抓到的实时 DOM 为准,不是静态文件。**
4. **默认 tab/pane 对齐镜像而非"全部"**:library 镜像默认 active=人物(0 资产→空态),不是"全部";pipeline 默认可见 pane=脚本(stage1),但步进器 active=项目真实阶段(二者解耦)。
5. **步进器(pipeline)严格复刻 `activateStage`**:`activeDot = 已导航?所看阶段:项目阶段`;`completed=max(项目阶段-1, activeDot-1)`;dot `i===activeDot active / i<=completed done`;line `n<=completed done`
6. **chip caret 别自己写宽高**:给 `.chip .caret``width/margin` 会让每个 chip 宽 +2px、多个 chip 累计错位文字重影。交给全局 restraint `.chip` 管。(projects 这一删 1494→182px)
7. **作用域写法**:用 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 自检。** 任何"我觉得这样更好"的改写,都是还原度变糙的源头。