@double-coding/pixel-print
Advanced tools
| #!/usr/bin/env node | ||
| // check-rules.mjs — pp-d2c 硬防线脚本 (v1.2.1) | ||
| // 覆盖 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21 | ||
| // v1.2.0 对账升级:loadCache 标注 _inBakedSubtree / _hidden / _templateDup; | ||
| // R02/R06 跳过 baked·隐藏·模板副本 + SCSS &__ 嵌套匹配(lib/cssMatch.mjs)消除假阳性; | ||
| // R17 禁 baked 子孙出 DOM(双重渲染);R18 flex-direction 忠实度;R19 padding 忠实度;R20 绝对定位坐标忠实度。 | ||
| // v1.2.1:_inBakedSubtree 移除 bgc-(bgc- 非 baked,子孙走正常规则暴露误放); | ||
| // 新增 R21 node-id-coverage(应渲染节点必挂 data-node-id,机械强制 §5.1.1 铁律,堵 R18/R19/R20 空 classMap 逃逸)。 | ||
| // | ||
| // 用法: | ||
| // node check-rules.mjs --block <blockDir> --cache-key <fileKey> | ||
| // node check-rules.mjs --merge <pageDir> --cache-key <fileKey> | ||
| // node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06 | ||
| // | ||
| // exit code: | ||
| // 0 — ok=true, 全通过 (可能有 warnings) | ||
| // 1 — ok=false, 有 violations | ||
| // 2 — 环境错误 (cache/产物/config 缺失) | ||
| import path from 'node:path'; | ||
| import { findProjectRoot, loadConfig, loadCache } from './lib/loadCache.mjs'; | ||
| import { loadProduct } from './lib/loadProduct.mjs'; | ||
| import { buildNodeIdToClassName } from './lib/nodeIdToClassName.mjs'; | ||
| import { makeReport, printReport } from './lib/report.mjs'; | ||
| import * as R01 from './rules/R01-fixed-position.mjs'; | ||
| import * as R02 from './rules/R02-fills-image.mjs'; | ||
| import * as R05 from './rules/R05-space-between.mjs'; | ||
| import * as R06 from './rules/R06-text-solid-last.mjs'; | ||
| import * as R08 from './rules/R08-bg-landing-form.mjs'; | ||
| import * as R16 from './rules/R16-no-flatten-text.mjs'; | ||
| import * as R17 from './rules/R17-no-baked-dom.mjs'; | ||
| import * as R18 from './rules/R18-flex-direction.mjs'; | ||
| import * as R19 from './rules/R19-padding.mjs'; | ||
| import * as R20 from './rules/R20-absolute-position.mjs'; | ||
| import * as R21 from './rules/R21-node-id-coverage.mjs'; | ||
| const ALL_RULES = [R01, R02, R05, R06, R08, R16, R17, R18, R19, R20, R21]; | ||
| function parseArgv(argv) { | ||
| const args = { mode: null, dir: null, cacheKey: null, forceSkip: [] }; | ||
| for (let i = 2; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a === '--block') { args.mode = 'block'; args.dir = argv[++i]; } | ||
| else if (a === '--merge') { args.mode = 'merge'; args.dir = argv[++i]; } | ||
| else if (a === '--cache-key') { args.cacheKey = argv[++i]; } | ||
| else if (a === '--force-skip') { | ||
| args.forceSkip = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); | ||
| } else if (a === '-h' || a === '--help') { | ||
| printHelp(); | ||
| process.exit(0); | ||
| } | ||
| } | ||
| return args; | ||
| } | ||
| function printHelp() { | ||
| process.stdout.write(`check-rules.mjs (pp-d2c v1.2.1) | ||
| Usage: | ||
| node check-rules.mjs --block <blockDir> --cache-key <fileKey> | ||
| node check-rules.mjs --merge <pageDir> --cache-key <fileKey> | ||
| node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06 | ||
| Rules covered: R01 R02 R05 R06 R08 R16 R17 R18 R19 R20 R21 | ||
| Exit: 0=ok, 1=violations, 2=env-error | ||
| `); | ||
| } | ||
| function fatal(msg) { | ||
| process.stderr.write(`[check-rules] ERROR: ${msg}\n`); | ||
| process.exit(2); | ||
| } | ||
| function main() { | ||
| const args = parseArgv(process.argv); | ||
| if (!args.mode || !args.dir) fatal('missing --block <dir> or --merge <dir>'); | ||
| if (!args.cacheKey) fatal('missing --cache-key <fileKey>'); | ||
| const productDir = path.resolve(args.dir); | ||
| const product = loadProduct(productDir); | ||
| if (product.error) fatal(product.error); | ||
| if (product.jsx.length === 0 && product.style.length === 0) { | ||
| fatal(`no jsx/style found under ${productDir}`); | ||
| } | ||
| const projectRoot = findProjectRoot(productDir); | ||
| if (!projectRoot) fatal('pp-d2c.config.json not found in ancestors of ' + productDir); | ||
| const config = loadConfig(projectRoot); | ||
| if (!config) fatal('failed to load pp-d2c.config.json at ' + projectRoot); | ||
| const cache = loadCache(projectRoot, args.cacheKey); | ||
| if (cache.error) fatal(cache.error); | ||
| const classMap = buildNodeIdToClassName(product.jsx); | ||
| const checked = []; | ||
| const skipped = []; | ||
| const violations = []; | ||
| const warnings = []; | ||
| for (const rule of ALL_RULES) { | ||
| checked.push(rule.id); | ||
| if (args.forceSkip.includes(rule.id)) { | ||
| skipped.push(rule.id); | ||
| warnings.push({ rule: rule.id, reason: 'skipped via --force-skip' }); | ||
| continue; | ||
| } | ||
| try { | ||
| const hits = rule.check({ cache, product, config, classMap }); | ||
| for (const h of hits) violations.push(h); | ||
| } catch (e) { | ||
| warnings.push({ rule: rule.id, reason: `rule crashed: ${e.message}` }); | ||
| } | ||
| } | ||
| const report = makeReport({ checked, skipped, violations, warnings }); | ||
| printReport(report); | ||
| process.exit(report.ok ? 0 : 1); | ||
| } | ||
| main(); |
| #!/usr/bin/env node | ||
| // figma.mjs — Figma REST API 封装脚本 | ||
| // 用途:把 SKILL.md 里"每次都让 LLM 手拼 curl / 手管缓存"的机械动作固化下来。 | ||
| // 调用约定: | ||
| // node figma.mjs <command> [args...] [--flag=value] | ||
| // 输出: | ||
| // stdout 一行 JSON: {ok: true, data: {...}} 或 {ok: false, error: "..."} | ||
| // 退出码: 0 成功,非 0 失败 | ||
| // 依赖: Node 18+ 内置 fetch,无 npm install | ||
| import fs from 'node:fs' | ||
| import path from 'node:path' | ||
| import { setTimeout as sleep } from 'node:timers/promises' | ||
| const FIGMA_API = 'https://api.figma.com' | ||
| const MAX_RETRIES = 3 | ||
| // ─── 工具 ──────────────────────────────────────────────────────── | ||
| function output(obj) { | ||
| console.log(JSON.stringify(obj)) | ||
| process.exit(obj.ok ? 0 : 1) | ||
| } | ||
| function fail(error, extra = {}) { | ||
| output({ ok: false, error, ...extra }) | ||
| } | ||
| function nodeIdSafe(nodeId) { | ||
| return nodeId.replace(/:/g, '_') | ||
| } | ||
| function findProjectRoot(startDir = process.cwd()) { | ||
| let dir = path.resolve(startDir) | ||
| while (true) { | ||
| if (fs.existsSync(path.join(dir, 'pp-d2c.config.json'))) return dir | ||
| const parent = path.dirname(dir) | ||
| if (parent === dir) return null | ||
| dir = parent | ||
| } | ||
| } | ||
| function loadConfig() { | ||
| const projectRoot = findProjectRoot() | ||
| if (!projectRoot) throw new Error('pp-d2c.config.json not found in cwd or ancestors') | ||
| const cfg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'pp-d2c.config.json'), 'utf8')) | ||
| return { config: cfg, projectRoot } | ||
| } | ||
| // 极简 .env 解析:只处理 KEY=VALUE 行,支持引号包裹和 # 注释,不做变量插值 | ||
| // 之所以自己写而不引入 dotenv:pp 仓库承诺零 npm 依赖,figma.mjs 现在只用 Node 18+ 内置能力 | ||
| function parseEnvFile(text) { | ||
| const out = {} | ||
| for (const rawLine of text.split(/\r?\n/)) { | ||
| const line = rawLine.trim() | ||
| if (!line || line.startsWith('#')) continue | ||
| const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/) | ||
| if (!m) continue | ||
| let val = m[2] | ||
| // 剥单/双引号(常见于 KEY="xxx" 写法) | ||
| if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { | ||
| val = val.slice(1, -1) | ||
| } | ||
| out[m[1]] = val | ||
| } | ||
| return out | ||
| } | ||
| // token 读取优先级:process.env > 项目根 .env FIGMA_TOKEN > config.figma.token(向后兼容) | ||
| // v1.0.2 起主推 .env,老项目 config 里的 token 由 install.js 迁移;此处保留兼容读取避免旧项目立即断 | ||
| function loadFigmaToken(config, projectRoot) { | ||
| if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN | ||
| const envPath = path.join(projectRoot, '.env') | ||
| if (fs.existsSync(envPath)) { | ||
| try { | ||
| const parsed = parseEnvFile(fs.readFileSync(envPath, 'utf8')) | ||
| if (parsed.FIGMA_TOKEN) return parsed.FIGMA_TOKEN | ||
| } catch (_) { | ||
| // .env 读失败不阻塞,继续走 config 兜底 | ||
| } | ||
| } | ||
| return config?.figma?.token || '' | ||
| } | ||
| function parseFlags(argv) { | ||
| const positional = [] | ||
| const flags = {} | ||
| for (const a of argv) { | ||
| const m = a.match(/^--([^=]+)(?:=(.*))?$/) | ||
| if (m) flags[m[1]] = m[2] === undefined ? true : m[2] | ||
| else positional.push(a) | ||
| } | ||
| return { positional, flags } | ||
| } | ||
| function ensureDir(p) { | ||
| fs.mkdirSync(p, { recursive: true }) | ||
| } | ||
| function cachePaths(projectRoot, fileKey) { | ||
| const base = path.join(projectRoot, '.d2c-cache', fileKey) | ||
| return { | ||
| base, | ||
| meta: path.join(base, 'meta.json'), | ||
| nodesDir: path.join(base, 'nodes'), | ||
| images: path.join(base, 'images.json'), | ||
| } | ||
| } | ||
| function tmpScreenshotsDir(projectRoot) { | ||
| return path.join(projectRoot, '.d2c-tmp', 'screenshots') | ||
| } | ||
| async function figmaFetch(pathAndQuery, token) { | ||
| let lastErr | ||
| for (let i = 0; i < MAX_RETRIES; i++) { | ||
| try { | ||
| const res = await fetch(`${FIGMA_API}${pathAndQuery}`, { | ||
| headers: { 'X-Figma-Token': token }, | ||
| }) | ||
| if (res.status === 401 || res.status === 403) { | ||
| throw new Error(`Figma API auth failed (HTTP ${res.status}); token invalid, expired, or lacks permission`) | ||
| } | ||
| if (!res.ok) { | ||
| const body = await res.text() | ||
| throw new Error(`Figma API error HTTP ${res.status}: ${body.slice(0, 200)}`) | ||
| } | ||
| const json = await res.json() | ||
| if (json.err) throw new Error(`Figma API returned err: ${json.err}`) | ||
| return json | ||
| } catch (e) { | ||
| lastErr = e | ||
| if (i < MAX_RETRIES - 1) await sleep(Math.pow(2, i) * 1000) | ||
| } | ||
| } | ||
| throw lastErr | ||
| } | ||
| async function downloadToFile(url, destPath) { | ||
| let lastErr | ||
| for (let i = 0; i < MAX_RETRIES; i++) { | ||
| try { | ||
| const res = await fetch(url) | ||
| if (!res.ok) throw new Error(`download HTTP ${res.status}`) | ||
| const buf = Buffer.from(await res.arrayBuffer()) | ||
| ensureDir(path.dirname(destPath)) | ||
| fs.writeFileSync(destPath, buf) | ||
| return | ||
| } catch (e) { | ||
| lastErr = e | ||
| if (i < MAX_RETRIES - 1) await sleep(Math.pow(2, i) * 1000) | ||
| } | ||
| } | ||
| throw lastErr | ||
| } | ||
| // ─── 命令: verify-token ───────────────────────────────────────── | ||
| async function cmdVerifyToken() { | ||
| const { config, projectRoot } = loadConfig() | ||
| const token = loadFigmaToken(config, projectRoot) | ||
| if (!token) return fail('FIGMA_TOKEN 未配置 (请在项目根 .env 写 FIGMA_TOKEN=xxx)') | ||
| try { | ||
| const me = await figmaFetch('/v1/me', token) | ||
| output({ ok: true, data: { email: me.email, handle: me.handle } }) | ||
| } catch (e) { | ||
| fail(e.message) | ||
| } | ||
| } | ||
| // ─── 命令: cache-check <fileKey> ──────────────────────────────── | ||
| async function cmdCacheCheck(positional) { | ||
| const [fileKey] = positional | ||
| if (!fileKey) return fail('用法: figma cache-check <fileKey>') | ||
| const { config, projectRoot } = loadConfig() | ||
| const token = loadFigmaToken(config, projectRoot) | ||
| if (!token) return fail('FIGMA_TOKEN 未配置 (请在项目根 .env 写 FIGMA_TOKEN=xxx)') | ||
| const paths = cachePaths(projectRoot, fileKey) | ||
| let remoteLastModified | ||
| try { | ||
| const meta = await figmaFetch(`/v1/files/${fileKey}?depth=1`, token) | ||
| remoteLastModified = meta.lastModified | ||
| } catch (e) { | ||
| return fail(`拉取文件 metadata 失败: ${e.message}`) | ||
| } | ||
| let localLastModified = null | ||
| if (fs.existsSync(paths.meta)) { | ||
| try { | ||
| localLastModified = JSON.parse(fs.readFileSync(paths.meta, 'utf8')).lastModified | ||
| } catch { /* meta 损坏,视为无缓存 */ } | ||
| } | ||
| if (localLastModified === remoteLastModified) { | ||
| return output({ ok: true, data: { status: 'hit', lastModified: remoteLastModified } }) | ||
| } | ||
| // miss: 清空整份缓存,重建目录 + meta.json | ||
| if (fs.existsSync(paths.base)) fs.rmSync(paths.base, { recursive: true, force: true }) | ||
| ensureDir(paths.nodesDir) | ||
| fs.writeFileSync(paths.meta, JSON.stringify({ | ||
| lastModified: remoteLastModified, | ||
| cachedAt: new Date().toISOString(), | ||
| }, null, 2)) | ||
| fs.writeFileSync(paths.images, JSON.stringify({}, null, 2)) | ||
| output({ | ||
| ok: true, | ||
| data: { | ||
| status: 'miss', | ||
| lastModified: remoteLastModified, | ||
| previousLastModified: localLastModified, | ||
| }, | ||
| }) | ||
| } | ||
| // ─── 命令: fetch-node <fileKey> <nodeId> [--depth=N] ──────────── | ||
| async function cmdFetchNode(positional, flags) { | ||
| const [fileKey, nodeId] = positional | ||
| if (!fileKey || !nodeId) return fail('用法: figma fetch-node <fileKey> <nodeId> [--depth=N]') | ||
| const depth = flags.depth ? parseInt(flags.depth, 10) : undefined | ||
| const { config, projectRoot } = loadConfig() | ||
| const token = loadFigmaToken(config, projectRoot) | ||
| if (!token) return fail('FIGMA_TOKEN 未配置 (请在项目根 .env 写 FIGMA_TOKEN=xxx)') | ||
| const paths = cachePaths(projectRoot, fileKey) | ||
| ensureDir(paths.nodesDir) | ||
| const cacheFile = path.join(paths.nodesDir, `${nodeIdSafe(nodeId)}.json`) | ||
| if (fs.existsSync(cacheFile)) { | ||
| try { | ||
| const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) | ||
| if (!depth || (cached._depth && cached._depth >= depth)) { | ||
| return output({ ok: true, data: { cached: true, node: cached.node } }) | ||
| } | ||
| } catch { /* 缓存损坏,重拉 */ } | ||
| } | ||
| try { | ||
| const q = new URLSearchParams({ ids: nodeId }) | ||
| if (depth) q.set('depth', String(depth)) | ||
| const resp = await figmaFetch(`/v1/files/${fileKey}/nodes?${q.toString()}`, token) | ||
| const doc = resp.nodes?.[nodeId]?.document | ||
| if (!doc) return fail(`节点 ${nodeId} 在文件 ${fileKey} 中未找到`) | ||
| fs.writeFileSync(cacheFile, JSON.stringify({ _depth: depth || null, node: doc }, null, 2)) | ||
| output({ ok: true, data: { cached: false, node: doc } }) | ||
| } catch (e) { | ||
| fail(e.message) | ||
| } | ||
| } | ||
| // ─── 命令: export-image <fileKey> <nodeId> [--format=png] ─────── | ||
| async function cmdExportImage(positional, flags) { | ||
| const [fileKey, nodeId] = positional | ||
| if (!fileKey || !nodeId) return fail('用法: figma export-image <fileKey> <nodeId> --filename=<name> [--format=png|svg] [--scale=1|2] [--preserve-effect]') | ||
| const format = flags.format || 'png' | ||
| const scale = flags.scale ? parseInt(flags.scale, 10) : 2 | ||
| const useAbsoluteBounds = !flags['preserve-effect'] | ||
| const filename = flags.filename | ||
| if (!filename) return fail('缺少 --filename=<name>') | ||
| const { config, projectRoot } = loadConfig() | ||
| const token = loadFigmaToken(config, projectRoot) | ||
| if (!token) return fail('FIGMA_TOKEN 未配置 (请在项目根 .env 写 FIGMA_TOKEN=xxx)') | ||
| const assetsDir = config.images?.assetsDir || 'static/' | ||
| const paths = cachePaths(projectRoot, fileKey) | ||
| // 处理 projectRoot 与 assetsDir 之间的路径拼接(允许 assetsDir 以 / 开头) | ||
| const localDir = path.join(projectRoot, assetsDir.replace(/^\//, '')) | ||
| const ext = format === 'svg' ? 'svg' : format | ||
| const localPath = path.join(localDir, `${filename}.${ext}`) | ||
| // 存在即跳过: images.json 有记录 + 磁盘文件真实存在 | ||
| let imagesIndex = {} | ||
| if (fs.existsSync(paths.images)) { | ||
| try { imagesIndex = JSON.parse(fs.readFileSync(paths.images, 'utf8')) } catch {} | ||
| } | ||
| const cached = imagesIndex[nodeId] | ||
| if (cached && cached.path && fs.existsSync(cached.path) && cached.format === format) { | ||
| return output({ ok: true, data: { path: cached.path, reused: true, format } }) | ||
| } | ||
| try { | ||
| const q = new URLSearchParams({ ids: nodeId, format, scale: String(scale) }) | ||
| if (useAbsoluteBounds) q.set('use_absolute_bounds', 'true') | ||
| if (format === 'svg') q.delete('scale') | ||
| const resp = await figmaFetch(`/v1/images/${fileKey}?${q.toString()}`, token) | ||
| const url = resp.images?.[nodeId] | ||
| if (!url) return fail(`Figma /v1/images 未返回 ${nodeId} 的 URL`) | ||
| ensureDir(localDir) | ||
| await downloadToFile(url, localPath) | ||
| imagesIndex[nodeId] = { path: localPath, format, filename: `${filename}.${ext}` } | ||
| if (!fs.existsSync(paths.base)) ensureDir(paths.base) | ||
| fs.writeFileSync(paths.images, JSON.stringify(imagesIndex, null, 2)) | ||
| output({ ok: true, data: { path: localPath, reused: false, format } }) | ||
| } catch (e) { | ||
| fail(e.message) | ||
| } | ||
| } | ||
| // ─── 命令: screenshot <fileKey> <nodeId> [--tag=leaf|whole|block] ─ | ||
| async function cmdScreenshot(positional, flags) { | ||
| const [fileKey, nodeId] = positional | ||
| if (!fileKey || !nodeId) return fail('用法: figma screenshot <fileKey> <nodeId> [--tag=leaf|whole|block] [--scale=2]') | ||
| const tag = flags.tag || 'block' | ||
| const scale = flags.scale ? parseInt(flags.scale, 10) : 2 | ||
| const { config, projectRoot } = loadConfig() | ||
| const token = loadFigmaToken(config, projectRoot) | ||
| if (!token) return fail('FIGMA_TOKEN 未配置 (请在项目根 .env 写 FIGMA_TOKEN=xxx)') | ||
| const dir = tmpScreenshotsDir(projectRoot) | ||
| const destPath = path.join(dir, `${tag}-${nodeIdSafe(nodeId)}.png`) | ||
| try { | ||
| const q = new URLSearchParams({ ids: nodeId, format: 'png', scale: String(scale) }) | ||
| const resp = await figmaFetch(`/v1/images/${fileKey}?${q.toString()}`, token) | ||
| const url = resp.images?.[nodeId] | ||
| if (!url) return fail(`Figma /v1/images 未返回 ${nodeId} 的截图 URL`) | ||
| await downloadToFile(url, destPath) | ||
| output({ ok: true, data: { path: destPath, tag } }) | ||
| } catch (e) { | ||
| fail(e.message) | ||
| } | ||
| } | ||
| // ─── 命令: cleanup-tmp ────────────────────────────────────────── | ||
| function cmdCleanupTmp() { | ||
| try { | ||
| const { projectRoot } = loadConfig() | ||
| const dir = tmpScreenshotsDir(projectRoot) | ||
| const existed = fs.existsSync(dir) | ||
| if (existed) fs.rmSync(dir, { recursive: true, force: true }) | ||
| output({ ok: true, data: { removed: existed, path: dir } }) | ||
| } catch (e) { | ||
| fail(e.message) | ||
| } | ||
| } | ||
| // ─── 分发 ────────────────────────────────────────────────────── | ||
| const [, , cmd, ...rest] = process.argv | ||
| const { positional, flags } = parseFlags(rest) | ||
| const commands = { | ||
| 'verify-token': () => cmdVerifyToken(), | ||
| 'cache-check': () => cmdCacheCheck(positional), | ||
| 'fetch-node': () => cmdFetchNode(positional, flags), | ||
| 'export-image': () => cmdExportImage(positional, flags), | ||
| 'screenshot': () => cmdScreenshot(positional, flags), | ||
| 'cleanup-tmp': () => cmdCleanupTmp(), | ||
| } | ||
| if (!cmd || cmd === '--help' || cmd === '-h' || !commands[cmd]) { | ||
| console.error(` | ||
| figma.mjs — Figma REST API helper | ||
| 用法: | ||
| node figma.mjs verify-token | ||
| node figma.mjs cache-check <fileKey> | ||
| node figma.mjs fetch-node <fileKey> <nodeId> [--depth=N] | ||
| node figma.mjs export-image <fileKey> <nodeId> --filename=<name> [--format=png|svg] [--scale=1|2] [--preserve-effect] | ||
| node figma.mjs screenshot <fileKey> <nodeId> [--tag=leaf|whole|block] [--scale=2] | ||
| node figma.mjs cleanup-tmp | ||
| 所有命令都从 cwd 向上查找 pp-d2c.config.json 拿 assetsDir; | ||
| FIGMA_TOKEN 从项目根 .env 读(优先级 process.env > .env > config.figma.token 兜底)。 | ||
| 输出统一为 stdout 一行 JSON: {ok: true, data: {...}} 或 {ok: false, error: "..."}。 | ||
| 退出码 0 表示成功,非零表示失败。 | ||
| `) | ||
| process.exit(2) | ||
| } | ||
| commands[cmd]().catch(e => fail(e.message || String(e))) |
| // cssMatch — 共享 CSS/SCSS 选择器匹配(v1.2.0) | ||
| // | ||
| // 背景:D2C 产物按 config.styleFormat 可能是平铺 CSS(`.page__foo { }`) | ||
| // 或 SCSS 嵌套(`.page { &__foo { } }`)。若规则只用 `\.classname` 平铺正则匹配, | ||
| // 遇到 SCSS `&__foo` / `&-foo` 嵌套写法会整体匹配不到 → 大量假阳性 | ||
| // (test13 事故:R06 47 条假阳性全因产物是 `&__cd-num` 嵌套、正则找的是 `.page__cd-num`)。 | ||
| // | ||
| // 本 lib 提炼自 R01 v1.1.0 补丁,统一供 R01/R02/R06/R18/R19 复用,杜绝各规则各自实现导致的嵌套盲区。 | ||
| // | ||
| // 已知边界:正则按 `{...}` 最近闭合截取规则体,对"同块内既有声明又有嵌套子选择器"的场景 | ||
| // 只截到第一个 `}`。D2C 产物的叶子选择器(承载具体声明)通常无内层嵌套,此边界可接受; | ||
| // 需要更强解析时再升级为词法栈解析。 | ||
| const RE_ESC = /[.*+?^${}()|[\]\\]/g; | ||
| function esc(s) { | ||
| return s.replace(RE_ESC, '\\$&'); | ||
| } | ||
| // className "test13-page__cd-num" → ["__cd-num", "-num"] | ||
| // className "page-fixed-bar" → ["-bar"](无 __) | ||
| // className "foo" → [](无分隔符,无 scss 嵌套形态可推) | ||
| export function deriveScssSuffixes(className) { | ||
| const out = new Set(); | ||
| const idxDouble = className.lastIndexOf('__'); | ||
| if (idxDouble >= 0) out.add(className.slice(idxDouble)); // "__cd-num" | ||
| const idxDash = className.lastIndexOf('-'); | ||
| if (idxDash > 0) out.add('-' + className.slice(idxDash + 1)); // "-num" | ||
| return Array.from(out); | ||
| } | ||
| // 收集某 className 在一段 css 里的所有规则体(平铺 + SCSS &__ / &- 嵌套)。 | ||
| // 返回 [{ body, line }](body = `{ }` 内文本,line = 选择器所在行)。 | ||
| export function collectRuleBodies(css, className) { | ||
| const cn = (className || '').trim(); | ||
| if (!cn) return []; | ||
| const out = []; | ||
| // 1) 平铺完整选择器: .foo { ... } | .parent .foo { ... } | ||
| const directRe = new RegExp(`\\.${esc(cn)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g'); | ||
| let m; | ||
| while ((m = directRe.exec(css)) !== null) { | ||
| out.push({ body: m[1], line: css.slice(0, m.index).split('\n').length }); | ||
| } | ||
| // 2) SCSS 嵌套 &__xxx / &-xxx | ||
| for (const suffix of deriveScssSuffixes(cn)) { | ||
| const nestedRe = new RegExp(`&${esc(suffix)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g'); | ||
| while ((m = nestedRe.exec(css)) !== null) { | ||
| out.push({ body: m[1], line: css.slice(0, m.index).split('\n').length }); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| // 在多个 style 文件里,某 nodeId 的任一 className 是否有规则体命中 propRe。 | ||
| // classes = classMap[nodeId](可能多个);styleFiles = product.style([{ content, rel }])。 | ||
| // 命中返回 { hit:true, rel, line, body };否则 { hit:false, firstRel, firstLine, firstSnippet } | ||
| // (firstX 给出该 className 找到的第一个规则体,便于报错定位)。 | ||
| export function findProperty(styleFiles, classes, propRe) { | ||
| let firstRel = null, firstLine = 0, firstSnippet = ''; | ||
| for (const cls of classes || []) { | ||
| for (const s of styleFiles) { | ||
| const bodies = collectRuleBodies(s.content, cls); | ||
| for (const r of bodies) { | ||
| if (propRe.test(r.body)) { | ||
| return { hit: true, rel: s.rel, line: r.line, body: r.body }; | ||
| } | ||
| if (!firstRel) { | ||
| firstRel = s.rel; | ||
| firstLine = r.line; | ||
| firstSnippet = r.body.slice(0, 200); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return { hit: false, firstRel, firstLine, firstSnippet }; | ||
| } | ||
| // 取某 nodeId 第一个 className 的规则体(用于需要读取声明值的规则,如 R19 padding)。 | ||
| // 返回 { body, rel, line } 或 null。 | ||
| export function firstRuleBody(styleFiles, classes) { | ||
| for (const cls of classes || []) { | ||
| for (const s of styleFiles) { | ||
| const bodies = collectRuleBodies(s.content, cls); | ||
| if (bodies.length) return { body: bodies[0].body, rel: s.rel, line: bodies[0].line }; | ||
| } | ||
| } | ||
| return null; | ||
| } |
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| export function findProjectRoot(startDir) { | ||
| let dir = path.resolve(startDir); | ||
| while (true) { | ||
| if (fs.existsSync(path.join(dir, 'pp-d2c.config.json'))) return dir; | ||
| const parent = path.dirname(dir); | ||
| if (parent === dir) return null; | ||
| dir = parent; | ||
| } | ||
| } | ||
| export function loadConfig(projectRoot) { | ||
| const p = path.join(projectRoot, 'pp-d2c.config.json'); | ||
| if (!fs.existsSync(p)) return null; | ||
| return JSON.parse(fs.readFileSync(p, 'utf8')); | ||
| } | ||
| // "子孙不生成独立 DOM" 的前缀:命中即整体导出 / 忽略,不再向内递归,其子孙不应作为 | ||
| // 独立 DOM 出现,也不应被 R02/R06 逐个溯源。**仅两类**: | ||
| // - bg- / img-(含裸词 bg/img):整体切图,子孙像素**已烤进父层 PNG**(baked,可见) | ||
| // - x-:整体忽略,子孙**被丢弃**(ignored,不可见) | ||
| // **不含 bgc-**:bgc- 是"把盒级 CSS(fills/描边/圆角/阴影) 写到父元素",子孙**没有烤进任何位图**。 | ||
| // 若把 bgc- 也算作 baked,会让误放在 bgc- 下的 TEXT 被静默跳过 R06 + R17 禁 DOM → 静默丢内容。 | ||
| // 故 bgc- 子孙走正常规则:R06 会报"无 className"、R21 会报"不可追溯",把结构错误暴露出来而非吞掉。 | ||
| const NO_RENDER_PREFIXES = ['bg-', 'img-', 'x-']; | ||
| const NO_RENDER_BARE = ['bg', 'img', 'x']; | ||
| export function isNonRecursivePrefix(name) { | ||
| if (!name || typeof name !== 'string') return false; | ||
| const n = name.trim(); | ||
| if (NO_RENDER_PREFIXES.some((p) => n.startsWith(p))) return true; | ||
| if (NO_RENDER_BARE.includes(n)) return true; // 裸词 bg / img / x(与 R16 白名单口径一致) | ||
| return false; | ||
| } | ||
| // 结构签名:捕捉"同构"——同 type + 同层级子结构(深度 3,不含具体文案)。 | ||
| // 用于识别 `.map()` 列表项:≥2 个同签名的容器兄弟 = 列表,非首项是数据副本。 | ||
| function structureSig(node, depth) { | ||
| if (depth <= 0 || !Array.isArray(node.children) || node.children.length === 0) { | ||
| return node.type || '?'; | ||
| } | ||
| return (node.type || '?') + '(' + node.children.map((c) => structureSig(c, depth - 1)).join(',') + ')'; | ||
| } | ||
| // 标记某节点 children 中的"模板重复项":同构容器兄弟里的非首个 → __isDup=true。 | ||
| // 仅对"有自身子结构的容器"生效(叶子如并列 TEXT "20"/"元" 不算列表项,不误标)。 | ||
| function markTemplateDups(node) { | ||
| if (!Array.isArray(node.children) || node.children.length < 2) return; | ||
| const seen = new Map(); // sig -> 已出现 | ||
| for (const c of node.children) { | ||
| if (!c || typeof c !== 'object' || !c.id) continue; | ||
| if (!Array.isArray(c.children) || c.children.length === 0) continue; // 叶子不参与列表判定 | ||
| const sig = structureSig(c, 3); | ||
| if (seen.has(sig)) c.__isDup = true; // 非首个同构兄弟 = 数据副本 | ||
| else seen.set(sig, true); | ||
| } | ||
| } | ||
| export function loadCache(projectRoot, cacheKey) { | ||
| const nodesDir = path.join(projectRoot, '.d2c-cache', cacheKey, 'nodes'); | ||
| if (!fs.existsSync(nodesDir)) { | ||
| return { error: `cache dir not found: ${nodesDir}`, nodes: {} }; | ||
| } | ||
| const nodes = {}; | ||
| const files = fs.readdirSync(nodesDir).filter((f) => f.endsWith('.json')); | ||
| for (const f of files) { | ||
| const raw = fs.readFileSync(path.join(nodesDir, f), 'utf8'); | ||
| let json; | ||
| try { | ||
| json = JSON.parse(raw); | ||
| } catch (e) { | ||
| continue; | ||
| } | ||
| // 自上而下遍历:跟踪 parent / "是否处于整体切图子树" / "是否隐藏" 状态,直接标注节点对象 | ||
| // (nodes[id] 存的是同一对象引用,标注即对全局生效)。 | ||
| walk(json, nodes, null, false, null, false, false); | ||
| } | ||
| return { nodes }; | ||
| } | ||
| function walk(node, acc, parentRealId, inBaked, bakedBy, hidden, templateDup) { | ||
| if (!node || typeof node !== 'object') return; | ||
| let childParentId = parentRealId; | ||
| let childInBaked = inBaked; | ||
| let childBakedBy = bakedBy; | ||
| let childHidden = hidden; | ||
| let childTemplateDup = templateDup; | ||
| if (node.id && node.type) { | ||
| // 节点自身:继承祖先传下来的 baked 状态(前缀节点自身不算 baked,它是切图/忽略目标) | ||
| node._parentId = parentRealId; | ||
| node._inBakedSubtree = inBaked; | ||
| node._bakedBy = inBaked ? bakedBy : null; | ||
| // 隐藏传播:自身 visible===false 或任一祖先隐藏 → 该节点不渲染,对账应整体跳过 | ||
| node._hidden = hidden || node.visible === false; | ||
| // 模板重复项:自身被父标为 __isDup(非首个同构兄弟),或祖先已是副本 → 整棵子树是数据副本 | ||
| node._templateDup = templateDup || node.__isDup === true; | ||
| acc[node.id] = node; | ||
| childParentId = node.id; | ||
| childHidden = node._hidden; | ||
| childTemplateDup = node._templateDup; | ||
| // 自身若是非递归前缀,则其"子孙"进入 baked 子树(自身不进) | ||
| if (!inBaked && isNonRecursivePrefix(node.name)) { | ||
| childInBaked = true; | ||
| childBakedBy = node.id; | ||
| } | ||
| } | ||
| // 进入 children 前,标记本层的模板重复项(同构容器兄弟的非首个) | ||
| markTemplateDups(node); | ||
| if (Array.isArray(node.children)) { | ||
| for (const c of node.children) walk(c, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| } | ||
| if (node.document) walk(node.document, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| if (node.node) walk(node.node, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| if (node.nodes && typeof node.nodes === 'object' && !Array.isArray(node.nodes)) { | ||
| for (const k of Object.keys(node.nodes)) walk(node.nodes[k], acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup); | ||
| } | ||
| } |
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| const STYLE_EXTS = ['.scss', '.less', '.css', '.module.scss', '.module.less', '.module.css']; | ||
| const JSX_EXTS = ['.jsx', '.tsx']; | ||
| export function loadProduct(dir) { | ||
| const absDir = path.resolve(dir); | ||
| if (!fs.existsSync(absDir)) { | ||
| return { error: `product dir not found: ${absDir}`, jsx: [], style: [] }; | ||
| } | ||
| const jsx = []; | ||
| const style = []; | ||
| walk(absDir, absDir, jsx, style); | ||
| return { root: absDir, jsx, style }; | ||
| } | ||
| function walk(root, dir, jsx, style) { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| for (const e of entries) { | ||
| const full = path.join(dir, e.name); | ||
| if (e.isDirectory()) { | ||
| if (e.name === 'node_modules' || e.name.startsWith('.')) continue; | ||
| walk(root, full, jsx, style); | ||
| continue; | ||
| } | ||
| const ext = getExt(e.name); | ||
| const rel = path.relative(root, full); | ||
| if (JSX_EXTS.includes(ext)) { | ||
| jsx.push({ file: full, rel, content: fs.readFileSync(full, 'utf8') }); | ||
| } else if (STYLE_EXTS.includes(ext)) { | ||
| style.push({ file: full, rel, content: fs.readFileSync(full, 'utf8') }); | ||
| } | ||
| } | ||
| } | ||
| function getExt(name) { | ||
| if (name.endsWith('.module.scss')) return '.module.scss'; | ||
| if (name.endsWith('.module.less')) return '.module.less'; | ||
| if (name.endsWith('.module.css')) return '.module.css'; | ||
| const i = name.lastIndexOf('.'); | ||
| return i < 0 ? '' : name.slice(i); | ||
| } |
| // 从 jsx 里 grep 出 data-node-id={"X:Y"} 或 data-node-id="X:Y" 对应的 className={styles.foo} / className="foo" | ||
| // 建立 nodeId -> [className, ...] 的 map | ||
| export function buildNodeIdToClassName(jsxFiles) { | ||
| const map = new Map(); // nodeId -> Set<className> | ||
| for (const { content } of jsxFiles) { | ||
| scanFile(content, map); | ||
| } | ||
| const out = {}; | ||
| for (const [k, v] of map) out[k] = Array.from(v); | ||
| return out; | ||
| } | ||
| function scanFile(src, map) { | ||
| // 简易 JSX 标签匹配 (足够 v1.0.0): 逐行 or 逐标签扫 | ||
| // 用 <XXX ... data-node-id="..." ... /> 或 <XXX ... />...</XXX> | ||
| // 允许 attrs 之间任意换行 (className 和 data-node-id 顺序不定) | ||
| const tagRe = /<[A-Za-z][A-Za-z0-9-]*\b([^<>]*?)\/?>/gs; | ||
| let m; | ||
| while ((m = tagRe.exec(src)) !== null) { | ||
| const attrs = m[1]; | ||
| if (!attrs) continue; | ||
| const nodeId = pickAttr(attrs, 'data-node-id'); | ||
| if (!nodeId) continue; | ||
| const cls = pickClassName(attrs); | ||
| if (!cls || cls.length === 0) continue; | ||
| if (!map.has(nodeId)) map.set(nodeId, new Set()); | ||
| for (const c of cls) map.get(nodeId).add(c); | ||
| } | ||
| } | ||
| function pickAttr(attrs, name) { | ||
| // name="value" 或 name={"value"} 或 name={'value'} | ||
| const re1 = new RegExp(`\\b${name}="([^"]+)"`); | ||
| const m1 = attrs.match(re1); | ||
| if (m1) return m1[1]; | ||
| const re2 = new RegExp(`\\b${name}=\\{['"]([^'"]+)['"]\\}`); | ||
| const m2 = attrs.match(re2); | ||
| if (m2) return m2[1]; | ||
| return null; | ||
| } | ||
| function pickClassName(attrs) { | ||
| // className="a b c" | ||
| const m1 = attrs.match(/\bclassName="([^"]+)"/); | ||
| if (m1) return m1[1].split(/\s+/).filter(Boolean); | ||
| // className={styles.foo} | ||
| const m2 = attrs.match(/\bclassName=\{styles\.([A-Za-z_][A-Za-z0-9_]*)\}/); | ||
| if (m2) return [m2[1]]; | ||
| // className={`${styles.foo} ${styles.bar}`} — 简易匹配 | ||
| const m3 = attrs.match(/\bclassName=\{`([^`]+)`\}/); | ||
| if (m3) { | ||
| const names = []; | ||
| const inner = m3[1]; | ||
| const partRe = /styles\.([A-Za-z_][A-Za-z0-9_]*)/g; | ||
| let mm; | ||
| while ((mm = partRe.exec(inner)) !== null) names.push(mm[1]); | ||
| return names; | ||
| } | ||
| // className={clsx(styles.foo, styles.bar)} / className={classNames(...)} | ||
| const m4 = attrs.match(/\bclassName=\{(?:clsx|classNames|cx)\(([^)]+)\)\}/); | ||
| if (m4) { | ||
| const names = []; | ||
| const partRe = /styles\.([A-Za-z_][A-Za-z0-9_]*)/g; | ||
| let mm; | ||
| while ((mm = partRe.exec(m4[1])) !== null) names.push(mm[1]); | ||
| return names; | ||
| } | ||
| return null; | ||
| } |
| export function makeReport({ checked, skipped, violations, warnings }) { | ||
| const failed = Array.from(new Set(violations.map((v) => v.rule))); | ||
| const passed = checked.filter((r) => !failed.includes(r) && !skipped.includes(r)); | ||
| return { | ||
| ok: violations.length === 0, | ||
| checked, | ||
| skipped, | ||
| passed, | ||
| failed, | ||
| violations, | ||
| warnings, | ||
| }; | ||
| } | ||
| export function printReport(report) { | ||
| process.stdout.write(JSON.stringify(report, null, 2) + '\n'); | ||
| } |
| // R01 fixed-position | ||
| // 触发: node.name.startsWith('fixed-') | ||
| // 期望: 产物 CSS 中该 node 对应类名规则内含 position: fixed | ||
| // | ||
| // v1.1.0 修复 (test12 事故): | ||
| // - 只走 classMap[nodeId] 反查, 禁止再走 name 派生兜底 | ||
| // - SCSS 支持 &__foo / &-foo 嵌套语法, 通过 selector 后缀匹配 | ||
| // - classMap 空 → 真报 R01 (jsx 缺 data-node-id 或未绑 className) | ||
| // v1.2.0: 嵌套匹配逻辑提炼到 lib/cssMatch.mjs 共享;跳过隐藏节点。 | ||
| import { findProperty } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R01'; | ||
| export const name = 'fixed-position'; | ||
| export function check({ cache, product, classMap }) { | ||
| const violations = []; | ||
| const prefix = 'fixed-'; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node.name || !node.name.startsWith(prefix)) continue; | ||
| if (node._hidden) continue; // 隐藏节点不渲染,不校验 | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name, | ||
| type: node.type, | ||
| expected: `jsx 里 data-node-id="${nodeId}" 元素绑定 className, 才能在 CSS 中反查 position: fixed`, | ||
| actual: '产物 jsx 未找到 data-node-id + className 映射; 若产物无该 nodeId 视为漏画', | ||
| file: '(missing in jsx)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| continue; | ||
| } | ||
| const found = findProperty(product.style, classes, /position\s*:\s*fixed/i); | ||
| if (!found.hit) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name, | ||
| type: node.type, | ||
| expected: 'css 含 position: fixed', | ||
| actual: 'css 未含 position: fixed (可能只有 relative / static / 无规则)', | ||
| file: found.firstRel || '(missing in style)', | ||
| line: found.firstLine || 0, | ||
| snippet: found.firstSnippet || '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } |
| // R02 fills-image | ||
| // 触发: node.fills[].some(f => f.type === 'IMAGE' && f.visible !== false) | ||
| // 期望: | ||
| // - assets.txt 中有该 nodeId 的切图记录 (fileName) | ||
| // - 产物 CSS (或 jsx <img>) 引用该切图 | ||
| // 排斥: 节点前缀是 x- → 忽略 | ||
| // v1.2.0: 跳过 baked 子树与隐藏节点;CSS url 匹配走 lib/cssMatch.mjs(修 &__ 嵌套盲区)。 | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R02'; | ||
| export const name = 'fills-image'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const ignorePrefix = 'x-'; | ||
| // 读 assets.txt (在 product root) | ||
| const assetsPath = path.join(product.root, 'assets.txt'); | ||
| const assetsText = fs.existsSync(assetsPath) ? fs.readFileSync(assetsPath, 'utf8') : ''; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!Array.isArray(node.fills) || node.fills.length === 0) continue; | ||
| const hasImage = node.fills.some((f) => f && f.type === 'IMAGE' && f.visible !== false); | ||
| if (!hasImage) continue; | ||
| if (node.name && node.name.startsWith(ignorePrefix)) continue; | ||
| // 处于 bg-/img- 整体切图子树(像素已烤进父层 PNG)或 x- 忽略子树内 → 不应逐个溯源。 | ||
| // 跳过,消除对账假阳性(v1.2.0;v1.2.1 起不含 bgc-)。这类子孙的"禁 DOM"约束交由 R17。 | ||
| if (node._inBakedSubtree) continue; | ||
| if (node._hidden) continue; // 隐藏节点不渲染,不校验 | ||
| if (node._templateDup) continue; // .map() 列表数据副本,只校验代表项 | ||
| // 检查 assets.txt 里是否提到此 nodeId | ||
| const inAssets = assetsText.includes(nodeId); | ||
| // 检查产物是否 (a) jsx 有 <img src=... nodeId 相关> 或 (b) style 有 url(...对应文件) | ||
| const productMention = mentionsNodeIdAsset(product, nodeId, classMap); | ||
| if (!inAssets && !productMention.hit) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: 'assets.txt 有此 nodeId 切图记录 且 产物引用该切图', | ||
| actual: 'assets.txt 未记录 且 产物中未找到该 nodeId 相关 <img> / background url', | ||
| file: '(missing)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| continue; | ||
| } | ||
| if (!productMention.hit) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: '产物 jsx 或 CSS 引用该 nodeId 切图', | ||
| actual: 'assets.txt 已记录但产物未引用', | ||
| file: '(missing in product)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function mentionsNodeIdAsset(product, nodeId, classMap) { | ||
| // 简易: nodeId 直接串在产物 (jsx / style) 里就算 hit; | ||
| // 或该 nodeId 对应 className 的 css 规则里含 url( | ||
| const idNorm = nodeId.replace(/:/g, '-'); | ||
| for (const j of product.jsx) { | ||
| if (j.content.includes(nodeId) || j.content.includes(idNorm)) return { hit: true }; | ||
| } | ||
| const classes = classMap[nodeId] || []; | ||
| for (const cls of classes) { | ||
| for (const s of product.style) { | ||
| for (const r of collectRuleBodies(s.content, cls)) { | ||
| if (/url\(/i.test(r.body) || /background-image\s*:/i.test(r.body)) return { hit: true }; | ||
| } | ||
| } | ||
| } | ||
| for (const s of product.style) { | ||
| if (s.content.includes(nodeId) || s.content.includes(idNorm)) return { hit: true }; | ||
| } | ||
| return { hit: false }; | ||
| } |
| // R05 space-between | ||
| // 触发: primaryAxisAlignItems === 'SPACE_BETWEEN' (Figma AutoLayout) | ||
| // 期望: 对应 CSS 类含 justify-content: space-between | ||
| // 反向 warning: 类内含 margin-*:auto / justify-content:flex-* / gap:auto 之类模拟法 | ||
| export const id = 'R05'; | ||
| export const name = 'space-between'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node.primaryAxisAlignItems !== 'SPACE_BETWEEN') continue; | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: '产物 jsx 里 data-node-id 缺失或无 className', | ||
| actual: '产物中无对应 className', | ||
| file: '(missing in jsx)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| continue; | ||
| } | ||
| let ok = false; | ||
| let hitFile = null; | ||
| let hitLine = 0; | ||
| let hitSnippet = ''; | ||
| for (const cls of classes) { | ||
| for (const s of product.style) { | ||
| const rules = collectRules(s.content, cls); | ||
| for (const r of rules) { | ||
| if (/justify-content\s*:\s*space-between/i.test(r.body)) { | ||
| ok = true; | ||
| break; | ||
| } | ||
| if (!hitFile) { | ||
| hitFile = s.rel; | ||
| hitLine = r.line; | ||
| hitSnippet = r.snippet; | ||
| } | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (!ok) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: 'css 含 justify-content: space-between', | ||
| actual: 'css 未含 justify-content: space-between (可能用 margin/flex-end 模拟)', | ||
| file: hitFile || '(missing in style)', | ||
| line: hitLine, | ||
| snippet: hitSnippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function collectRules(css, className) { | ||
| const re = new RegExp(`\\.${escapeRegex(className)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g'); | ||
| const out = []; | ||
| let m; | ||
| while ((m = re.exec(css)) !== null) { | ||
| const line = css.slice(0, m.index).split('\n').length; | ||
| out.push({ line, body: m[1], snippet: m[0].slice(0, 200) }); | ||
| } | ||
| return out; | ||
| } | ||
| function escapeRegex(s) { | ||
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| } |
| // R06 text-solid-last | ||
| // 触发: TEXT 节点,fills 数组非空,末位可见 fill 是 SOLID | ||
| // 期望: 对应 CSS 类含 color: #HEX (与 SOLID.color 匹配) | ||
| // 排斥: 末位可见 fill 是 GRADIENT/IMAGE → 归 R04 判定,不在此处 | ||
| // v1.2.0: 跳过 baked 子树 TEXT 与隐藏节点;SCSS 嵌套匹配走 lib/cssMatch.mjs(修 &__ 盲区假阳性)。 | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R06'; | ||
| export const name = 'text-solid-last'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node.type !== 'TEXT') continue; | ||
| if (!Array.isArray(node.fills) || node.fills.length === 0) continue; | ||
| // 处于 bg-/img- 整体切图子树(文字像素已烤进父层 PNG)或 x- 忽略子树内的 TEXT → | ||
| // 不作为独立 DOM 渲染,无需校验字色。跳过(v1.2.0;v1.2.1 起不含 bgc-)。禁 DOM 交由 R17。 | ||
| if (node._inBakedSubtree) continue; | ||
| if (node._hidden) continue; // 隐藏 TEXT 不渲染,不校验 | ||
| if (node._templateDup) continue; // .map() 列表数据副本,只校验代表项 | ||
| const lastVisible = pickLastVisibleFill(node.fills); | ||
| if (!lastVisible) continue; // 全 invisible → 走默认 (略过) | ||
| if (lastVisible.type !== 'SOLID') continue; // 走 R04 | ||
| const expectedHex = rgbaToHex(lastVisible.color); | ||
| if (!expectedHex) continue; | ||
| const classes = classMap[nodeId] || []; | ||
| // 不可追溯(无 className/data-node-id)→ 交由 R21 node-id-coverage 统一报"应挂 id", | ||
| // R06 只负责"可追溯 TEXT 的字色是否取对",避免与 R21 双报同一节点(v1.2.1)。 | ||
| if (classes.length === 0) continue; | ||
| let ok = false; | ||
| let hitFile = null; | ||
| let hitLine = 0; | ||
| let hitSnippet = ''; | ||
| let actualColor = null; | ||
| for (const cls of classes) { | ||
| for (const s of product.style) { | ||
| const rules = collectRuleBodies(s.content, cls); | ||
| for (const r of rules) { | ||
| const colorMatch = r.body.match(/(?:^|[^-\w])color\s*:\s*(#[0-9a-fA-F]{3,8})/); | ||
| if (colorMatch) { | ||
| const found = normalizeHex(colorMatch[1]); | ||
| if (found === expectedHex) { ok = true; break; } | ||
| if (!actualColor) actualColor = found; | ||
| } | ||
| if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); } | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (ok) break; | ||
| } | ||
| if (!ok) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `css 含 color: ${expectedHex} (源自 fills 末位可见 SOLID)`, | ||
| actual: actualColor ? `css color: ${actualColor} (与 SOLID 不符)` : 'css 未含 color', | ||
| file: hitFile || '(missing in style)', | ||
| line: hitLine, | ||
| snippet: hitSnippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function pickLastVisibleFill(fills) { | ||
| for (let i = fills.length - 1; i >= 0; i--) { | ||
| const f = fills[i]; | ||
| if (f && f.visible !== false) return f; | ||
| } | ||
| return null; | ||
| } | ||
| function rgbaToHex(color) { | ||
| if (!color) return null; | ||
| const r = Math.round((color.r || 0) * 255); | ||
| const g = Math.round((color.g || 0) * 255); | ||
| const b = Math.round((color.b || 0) * 255); | ||
| return ('#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')).toLowerCase(); | ||
| } | ||
| function normalizeHex(hex) { | ||
| let h = hex.toLowerCase(); | ||
| if (h.length === 4) { | ||
| h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]; | ||
| } else if (h.length === 9) { | ||
| h = h.slice(0, 7); // 忽略 alpha | ||
| } | ||
| return h; | ||
| } | ||
| // R08 bg-landing-form | ||
| // 触发: node.name.startsWith('bg-') 或 name === 'bg' | ||
| // 反向扫产物,禁止: | ||
| // - jsx: <img ... src=".../bg-..." ... /> 或 <img ... src=".../bg.<ext>" ... /> | ||
| // - jsx: style={{ ... background... }} | ||
| // - jsx: className="...-bg..." 里空 div (无 children) 且挂 bg | ||
| // - scss: ::before / ::after { ... background-image ... } | ||
| export const id = 'R08'; | ||
| export const name = 'bg-landing-form'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const bgPrefix = 'bg-'; | ||
| // 先看 cache 有没有 bg- 节点 — 用于生成 nodeId 提示 | ||
| const bgNodes = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node.name) continue; | ||
| if (node.name.startsWith(bgPrefix) || node.name === 'bg') { | ||
| bgNodes.push({ nodeId, name: node.name }); | ||
| } | ||
| } | ||
| if (bgNodes.length === 0) return violations; | ||
| // jsx 反向扫 | ||
| for (const j of product.jsx) { | ||
| const lines = j.content.split('\n'); | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const line = lines[i]; | ||
| // <img src=".../bg-XXX" or .../bg.<ext> | ||
| if (/<img\b[^>]*src=[^>]*bg-/i.test(line)) { | ||
| violations.push(mkV(id, j.rel, i + 1, line.trim(), '<img src="...bg-..."> 应用父容器 background-image 代替', bgNodes[0])); | ||
| } else if (/<img\b[^>]*src=[^>]*\/bg\.[a-z]{2,5}["'`)]/i.test(line)) { | ||
| violations.push(mkV(id, j.rel, i + 1, line.trim(), '<img src="...bg.<ext>"> 应用父容器 background-image 代替', bgNodes[0])); | ||
| } | ||
| // inline style background | ||
| if (/style=\{\{[^}]*background/i.test(line)) { | ||
| violations.push(mkV(id, j.rel, i + 1, line.trim(), 'inline style 挂 background 应用 className + scss background', bgNodes[0])); | ||
| } | ||
| } | ||
| // 空 div 挂 bg-* (整个 <div /> 或空标签,自闭合 or 只有空白 children) | ||
| // 简易匹配: <div className={styles.bg...} data-node-id=... /> | ||
| const emptyBgDivRe = /<div\b[^>]*className=\{styles\.(bg[A-Za-z0-9_]*)\}[^>]*\/>/g; | ||
| let m; | ||
| while ((m = emptyBgDivRe.exec(j.content)) !== null) { | ||
| const idx = j.content.slice(0, m.index).split('\n').length; | ||
| violations.push(mkV(id, j.rel, idx, m[0], '空 <div className={styles.bg...} /> 应作为父容器 background 或用父节点直接挂', bgNodes[0])); | ||
| } | ||
| } | ||
| // scss 反向扫 ::before / ::after 挂 background-image | ||
| for (const s of product.style) { | ||
| // 简易: 检索 ::before { ... background-image ... } 块 | ||
| const pseudoRe = /::(before|after)\s*\{([\s\S]*?)\}/g; | ||
| let m; | ||
| while ((m = pseudoRe.exec(s.content)) !== null) { | ||
| if (/background-image\s*:/i.test(m[2]) || /background\s*:[^;]*url\(/i.test(m[2])) { | ||
| const idx = s.content.slice(0, m.index).split('\n').length; | ||
| violations.push(mkV(id, s.rel, idx, m[0].slice(0, 200), `::${m[1]} 挂 background-image 应改为父节点直接 background`, bgNodes[0])); | ||
| } | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function mkV(rule, file, line, snippet, expected, bgNode) { | ||
| return { | ||
| rule, | ||
| nodeId: bgNode ? bgNode.nodeId : '(bg node in cache)', | ||
| name: bgNode ? bgNode.name : '(bg node)', | ||
| type: 'JSX/SCSS', | ||
| expected, | ||
| actual: snippet, | ||
| file, | ||
| line, | ||
| snippet, | ||
| }; | ||
| } |
| // R16 no-flatten-text | ||
| // 触发: GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT,且节点 name 前缀不在白名单 | ||
| // 白名单: img- / bg- (含裸词 img / bg,全等或以 xxx- 开头) | ||
| // 反查: 产物 jsx 中出现 `<img ... data-node-id="<该节点>" ... />` → 违规 | ||
| // | ||
| // 语义: 禁止用整体切图替代含 TEXT 的容器;否则 TEXT 无障碍缺失、无法本地化、按钮不可点击 | ||
| // 排斥: img-/bg- 节点自身就是拿来切图/挂 background 的,天然免疫 | ||
| export const id = 'R16'; | ||
| export const name = 'no-flatten-text'; | ||
| const WHITELIST_PREFIXES = ['img-', 'bg-']; | ||
| const WHITELIST_BARE = ['img', 'bg']; | ||
| const CONTAINER_TYPES = new Set(['GROUP', 'FRAME', 'COMPONENT', 'INSTANCE']); | ||
| export function check({ cache, product }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node.type || !CONTAINER_TYPES.has(node.type)) continue; | ||
| if (!node.name) continue; | ||
| if (isWhitelisted(node.name)) continue; | ||
| if (!subtreeHasText(node, cache.nodes)) continue; | ||
| const hits = findImgReferencingNode(product.jsx, nodeId); | ||
| for (const hit of hits) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name, | ||
| type: node.type, | ||
| expected: `不得对含 TEXT 的 ${node.type}(前缀非 img-/bg-)整体切图;应按 §4.3 前缀规则拆解 TEXT / btn / img / bg 子节点`, | ||
| actual: `产物 jsx 出现 <img data-node-id="${nodeId}">,意味着该容器被整体烤成位图`, | ||
| file: hit.file, | ||
| line: hit.line, | ||
| snippet: hit.snippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function isWhitelisted(nodeName) { | ||
| const name = nodeName.trim(); | ||
| if (WHITELIST_BARE.includes(name)) return true; | ||
| for (const p of WHITELIST_PREFIXES) { | ||
| if (name.startsWith(p) && name.length > p.length) return true; | ||
| } | ||
| return false; | ||
| } | ||
| // 递归判定:子树内是否存在 TEXT 节点 | ||
| // 通过 cache.nodes 反查 children(避免 node 对象子引用不全) | ||
| function subtreeHasText(root, allNodes) { | ||
| const stack = [root]; | ||
| const visited = new Set(); | ||
| while (stack.length) { | ||
| const cur = stack.pop(); | ||
| if (!cur || !cur.id || visited.has(cur.id)) continue; | ||
| visited.add(cur.id); | ||
| if (cur.type === 'TEXT') return true; | ||
| if (Array.isArray(cur.children)) { | ||
| for (const child of cur.children) { | ||
| if (child && child.id && allNodes[child.id]) { | ||
| stack.push(allNodes[child.id]); | ||
| } else { | ||
| stack.push(child); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| // 在 jsx 里搜 <img ... data-node-id="<nodeId>" ... /> | ||
| // 允许属性顺序任意;跨行也扫(松散匹配到 </img> 或 />) | ||
| function findImgReferencingNode(jsxFiles, nodeId) { | ||
| const hits = []; | ||
| const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| // 匹配 <img ...(可跨行)... data-node-id="<nodeId>" ...(可跨行)... /> | ||
| // 或反向:data-node-id 在前、闭合在后 | ||
| const re = new RegExp(`<img\\b[^>]*?data-node-id=["']${escaped}["'][^>]*?/?>`, 'gs'); | ||
| for (const j of jsxFiles) { | ||
| let m; | ||
| while ((m = re.exec(j.content)) !== null) { | ||
| const before = j.content.slice(0, m.index); | ||
| const line = before.split('\n').length; | ||
| hits.push({ | ||
| file: j.rel, | ||
| line, | ||
| snippet: m[0].length > 200 ? m[0].slice(0, 200) + '...' : m[0], | ||
| }); | ||
| } | ||
| } | ||
| return hits; | ||
| } |
| // R17 no-baked-dom(v1.2.0 对账新增) | ||
| // 触发: 节点处于 bg-/bgc-/img-/x- 整体切图子树内(_inBakedSubtree=true) | ||
| // 期望: 该节点的像素已烤进父层切图(或被 x- 整体忽略),产物中【不得】再有其 data-node-id 元素 | ||
| // 违反: 产物 JSX 出现 data-node-id="<nodeId>" → 双重渲染(文字/图叠一遍,典型 test13 title-text/subtitle 既进 main.png 又出 DOM) | ||
| // | ||
| // 与 R02/R06 的分工: R02/R06 跳过 baked 子孙(不逐个溯源),"禁 DOM" 由本条正向兜底。 | ||
| export const id = 'R17'; | ||
| export const name = 'no-baked-dom'; | ||
| export function check({ cache, product }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (!node._inBakedSubtree) continue; | ||
| if (node._templateDup) continue; // 数据副本,代表项报过即可,避免重复 | ||
| // x- 忽略子树里本就不该出现,bg-/bgc-/img- 烤进切图更不该;隐藏与否都不应出 DOM | ||
| const hit = findDomNode(product, nodeId); | ||
| if (hit) { | ||
| const bakedByNode = node._bakedBy ? cache.nodes[node._bakedBy] : null; | ||
| const bakedByName = (bakedByNode && bakedByNode.name) || node._bakedBy || '?'; | ||
| const isIgnored = /^x[-]?/.test(String(bakedByName).trim()) || String(bakedByName).trim() === 'x'; | ||
| const kind = isIgnored | ||
| ? `处于 x- 忽略子树内(bakedBy=${bakedByName}),该内容被整体忽略,不该渲染` | ||
| : `处于整体切图子树内(bakedBy=${bakedByName}),像素已烤进父层 PNG`; | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `节点${kind},产物不应有其 data-node-id 元素`, | ||
| actual: `产物 ${hit.rel} 出现 data-node-id="${nodeId}"(${isIgnored ? '被忽略内容却出 DOM' : '双重渲染:切图一份 + DOM 一份'})`, | ||
| file: hit.rel, | ||
| line: hit.line, | ||
| snippet: hit.snippet, | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function findDomNode(product, nodeId) { | ||
| const idNorm = nodeId.replace(/:/g, '-'); | ||
| const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`); | ||
| for (const j of product.jsx) { | ||
| const m = j.content.match(re); | ||
| if (m) { | ||
| const line = j.content.slice(0, m.index).split('\n').length; | ||
| const lineText = j.content.split('\n')[line - 1] || ''; | ||
| return { rel: j.rel, line, snippet: lineText.trim().slice(0, 200) }; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function escapeRegex(s) { | ||
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| } |
| // R18 flex-direction(v1.2.0 对账新增) | ||
| // 触发: autolayout 容器(layoutMode === 'HORIZONTAL' | 'VERTICAL') | ||
| // 期望: VERTICAL → CSS 含 flex-direction: column;HORIZONTAL → 不得写 flex-direction: column(row 为 flex 默认,可省) | ||
| // 违反: 方向写反(典型 test13 small-card-top:Figma VERTICAL 却写 flex-direction: row) | ||
| // 跳过: baked / hidden / templateData 副本 / 无 className(不可追溯,交由 §5.1 data-node-id 铁律在生成侧兜底) | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R18'; | ||
| export const name = 'flex-direction'; | ||
| export function check({ cache, product, classMap }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| const lm = node.layoutMode; | ||
| if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue; | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) continue; // 不可追溯:本条不报,由 §5.1 生成铁律保证挂 id | ||
| const body = firstBodyWithFlex(product.style, classes); | ||
| if (!body) continue; // 无 display:flex 规则体(可能非 flex 实现),不在本条判定 | ||
| const dir = extractFlexDirection(body); | ||
| if (lm === 'VERTICAL') { | ||
| if (dir !== 'column') { | ||
| violations.push(mk(nodeId, node, 'flex-direction: column(Figma layoutMode=VERTICAL)', dir ? `flex-direction: ${dir}` : '未写 flex-direction(默认 row,纵向布局会横排)')); | ||
| } | ||
| } else { | ||
| // HORIZONTAL | ||
| if (dir === 'column') { | ||
| violations.push(mk(nodeId, node, 'flex-direction: row 或省略(Figma layoutMode=HORIZONTAL)', 'flex-direction: column(方向写反)')); | ||
| } | ||
| } | ||
| } | ||
| return violations; | ||
| function mk(nodeId, node, expected, actual) { | ||
| return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' }; | ||
| } | ||
| } | ||
| // 找该 nodeId 第一个含 display:flex 的规则体 | ||
| function firstBodyWithFlex(styleFiles, classes) { | ||
| for (const cls of classes) { | ||
| for (const s of styleFiles) { | ||
| for (const r of collectRuleBodies(s.content, cls)) { | ||
| if (/display\s*:\s*flex/i.test(r.body)) return r.body; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| function extractFlexDirection(body) { | ||
| const m = body.match(/flex-direction\s*:\s*([a-z-]+)/i); | ||
| return m ? m[1].toLowerCase() : null; | ||
| } |
| // R19 padding(v1.2.0 对账新增) | ||
| // 触发: autolayout 容器且 Figma 声明了 padding(paddingTop/Right/Bottom/Left 任一非 0),或产物写了 padding | ||
| // 期望: CSS padding 四值 ≈ Figma paddingT/R/B/L × scale(容差 2px) | ||
| // 违反: | ||
| // - Figma pad0 但产物写了非 0 padding(凭空捏造,典型 test13 small-card-top: Figma 四边 0 却写 padding:0 12px) | ||
| // - Figma 有 padding 但产物缺失或数值对不上 | ||
| // 跳过: baked / hidden / templateDup 副本 / 无 className | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R19'; | ||
| export const name = 'padding'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const scale = (config && config.unit && config.unit.scale) || 2; | ||
| const TOL = 2; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| const lm = node.layoutMode; | ||
| if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue; // padding 仅在 autolayout 容器有意义 | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) continue; | ||
| const body = firstBody(product.style, classes); | ||
| if (!body) continue; | ||
| // Figma 期望值(未声明视为 0) | ||
| const fig = [ | ||
| Math.round((node.paddingTop || 0) * scale), | ||
| Math.round((node.paddingRight || 0) * scale), | ||
| Math.round((node.paddingBottom || 0) * scale), | ||
| Math.round((node.paddingLeft || 0) * scale), | ||
| ]; | ||
| const figAllZero = fig.every((v) => v === 0); | ||
| const css = extractPadding(body); // null | [t,r,b,l] | ||
| if (!css) { | ||
| // 产物未写 padding:Figma 也 0 → OK;Figma 有 padding → 缺失违规 | ||
| if (!figAllZero) { | ||
| violations.push(mk(nodeId, node, `padding: ${fig.join('px ')}px(Figma ×${scale})`, '产物未写 padding')); | ||
| } | ||
| continue; | ||
| } | ||
| // 逐边比对 | ||
| const bad = css.some((v, i) => Math.abs(v - fig[i]) > TOL); | ||
| if (bad) { | ||
| const reason = figAllZero ? '(Figma 四边 padding 均为 0,产物凭空加了 padding)' : ''; | ||
| violations.push(mk(nodeId, node, `padding ≈ [${fig.join(', ')}]px(Figma ×${scale})`, `产物 padding = [${css.join(', ')}]px${reason}`)); | ||
| } | ||
| } | ||
| return violations; | ||
| function mk(nodeId, node, expected, actual) { | ||
| return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' }; | ||
| } | ||
| } | ||
| function firstBody(styleFiles, classes) { | ||
| for (const cls of classes) { | ||
| for (const s of styleFiles) { | ||
| const b = collectRuleBodies(s.content, cls); | ||
| if (b.length) return b[0].body; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| // 解析 CSS padding 简写 / 拆分为 [top,right,bottom,left](px)。取规则体里"最后一次" padding 声明。 | ||
| // 只认 px 值;含非 px(%/auto/var)→ 放弃比对返回 null(不误报)。 | ||
| function extractPadding(body) { | ||
| // longhand 优先覆盖 shorthand:先取 shorthand,再用 longhand 覆盖对应边 | ||
| let vals = null; | ||
| const sh = lastMatch(body, /(?:^|[^-\w])padding\s*:\s*([^;}]+)/gi); | ||
| if (sh) { | ||
| const parts = sh.trim().split(/\s+/); | ||
| const nums = []; | ||
| for (const p of parts) { | ||
| const mm = p.match(/^(-?\d+(?:\.\d+)?)(px)?$/); | ||
| if (!mm) return null; // 含 %/auto/var/calc 等非 px → 放弃比对,不误报 | ||
| if (!mm[2] && parseFloat(mm[1]) !== 0) return null; // 无单位非 0(如 rem 缺写)→ 放弃 | ||
| nums.push(parseFloat(mm[1])); // 无单位 0 或带 px → 取数值 | ||
| } | ||
| if (nums.length === 1) vals = [nums[0], nums[0], nums[0], nums[0]]; | ||
| else if (nums.length === 2) vals = [nums[0], nums[1], nums[0], nums[1]]; | ||
| else if (nums.length === 3) vals = [nums[0], nums[1], nums[2], nums[1]]; | ||
| else if (nums.length >= 4) vals = [nums[0], nums[1], nums[2], nums[3]]; | ||
| } | ||
| const lh = { | ||
| 0: lastMatch(body, /padding-top\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| 1: lastMatch(body, /padding-right\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| 2: lastMatch(body, /padding-bottom\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| 3: lastMatch(body, /padding-left\s*:\s*(-?\d+(?:\.\d+)?)px/gi), | ||
| }; | ||
| const hasLh = Object.values(lh).some((x) => x != null); | ||
| if (!vals && !hasLh) return null; | ||
| if (!vals) vals = [0, 0, 0, 0]; | ||
| for (const i of [0, 1, 2, 3]) if (lh[i] != null) vals[i] = parseFloat(lh[i]); | ||
| return vals.map((v) => Math.round(v)); | ||
| } | ||
| function lastMatch(body, re) { | ||
| let m, last = null; | ||
| while ((m = re.exec(body)) !== null) last = m[1]; | ||
| return last; | ||
| } |
| // R20 absolute-position(v1.2.0 对账新增) | ||
| // 触发: node.layoutPositioning === 'ABSOLUTE'(脱离父 autolayout 顺流,绝对定位) | ||
| // 期望: CSS top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4px) | ||
| // 违反: 坐标靠猜(典型 test13 img-huochepiao:真值 left≈-10/top≈-13 溢出到背景上,产物却写 top:40/left:40) | ||
| // 跳过: baked / hidden / templateDup / 无 className / 父无 bbox | ||
| // | ||
| // 核心哲学: 能从 bbox 精确算出的坐标,禁止靠猜 + "需人工核对" 兜底(§6.0.2 已封该逃逸口)。 | ||
| import { collectRuleBodies } from '../lib/cssMatch.mjs'; | ||
| export const id = 'R20'; | ||
| export const name = 'absolute-position'; | ||
| export function check({ cache, product, config, classMap }) { | ||
| const violations = []; | ||
| const scale = (config && config.unit && config.unit.scale) || 2; | ||
| const TOL = 4; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node.layoutPositioning !== 'ABSOLUTE') continue; | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| // fixed- 前缀走 constraints 视口定位(R01 域),不是 (子bbox−父bbox) 相对定位,跳过 | ||
| if (node.name && node.name.startsWith('fixed-')) continue; | ||
| const parent = node._parentId ? cache.nodes[node._parentId] : null; | ||
| const nb = node.absoluteBoundingBox; | ||
| const pb = parent && parent.absoluteBoundingBox; | ||
| if (!nb || !pb) continue; // 缺 bbox 无法精确计算,不误报 | ||
| const classes = classMap[nodeId] || []; | ||
| if (classes.length === 0) continue; // 不可追溯,交由 §5.1 生成铁律 | ||
| const body = firstBody(product.style, classes); | ||
| if (!body) continue; | ||
| const expLeft = Math.round((nb.x - pb.x) * scale); | ||
| const expTop = Math.round((nb.y - pb.y) * scale); | ||
| // inset 简写兜底:inset: <top> <right> <bottom> <left> | inset: 0(四边) | ||
| const inset = extractInset(body); | ||
| const cssTop = extractPos(body, 'top', inset ? inset[0] : null); | ||
| const cssLeft = extractPos(body, 'left', inset ? inset[3] : null); | ||
| // 期望值≈0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,容忍不报(避免噪声)。 | ||
| // 期望非 0 却缺失(丢了真实偏移)、或写了值但对不上(如 huochepiao 40 vs -13)→ 报。 | ||
| const problems = []; | ||
| if (cssTop == null) { | ||
| if (Math.abs(expTop) > TOL) problems.push(`缺 top(应 ${expTop}px,丢了真实偏移)`); | ||
| } else if (Math.abs(cssTop - expTop) > TOL) problems.push(`top=${cssTop}px 应 ${expTop}px`); | ||
| if (cssLeft == null) { | ||
| if (Math.abs(expLeft) > TOL) problems.push(`缺 left(应 ${expLeft}px,丢了真实偏移)`); | ||
| } else if (Math.abs(cssLeft - expLeft) > TOL) problems.push(`left=${cssLeft}px 应 ${expLeft}px`); | ||
| if (problems.length) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `top≈${expTop}px left≈${expLeft}px((子bbox−父bbox)×${scale},父=${node._parentId})`, | ||
| actual: problems.join(';'), | ||
| file: '(style)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function firstBody(styleFiles, classes) { | ||
| for (const cls of classes) { | ||
| for (const s of styleFiles) { | ||
| const b = collectRuleBodies(s.content, cls); | ||
| if (b.length) return b[0].body; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| // 取 top/left 值:兼容带 px 与无单位 0(`top: 0`);无显式声明时回落 inset 值。 | ||
| function extractPos(body, prop, insetVal) { | ||
| const re = new RegExp(`(?:^|[^-\\w])${prop}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)(px)?\\b`, 'i'); | ||
| const m = body.match(re); | ||
| if (m) { | ||
| const num = parseFloat(m[1]); | ||
| if (m[2] || num === 0) return num; // 有 px,或无单位的 0 | ||
| return null; // 无单位的非 0(%/未知)→ 不比对 | ||
| } | ||
| return insetVal; // 回落 inset | ||
| } | ||
| // 解析 inset 简写为 [top,right,bottom,left](px 或无单位 0);含非纯数值 → null | ||
| function extractInset(body) { | ||
| const m = body.match(/(?:^|[^-\w])inset\s*:\s*([^;}]+)/i); | ||
| if (!m) return null; | ||
| const parts = m[1].trim().split(/\s+/); | ||
| const nums = []; | ||
| for (const p of parts) { | ||
| const mm = p.match(/^(-?\d+(?:\.\d+)?)(px)?$/); | ||
| if (!mm) return null; | ||
| if (!mm[2] && parseFloat(mm[1]) !== 0) return null; // 无单位非 0 放弃 | ||
| nums.push(parseFloat(mm[1])); | ||
| } | ||
| if (nums.length === 1) return [nums[0], nums[0], nums[0], nums[0]]; | ||
| if (nums.length === 2) return [nums[0], nums[1], nums[0], nums[1]]; | ||
| if (nums.length === 3) return [nums[0], nums[1], nums[2], nums[1]]; | ||
| if (nums.length >= 4) return [nums[0], nums[1], nums[2], nums[3]]; | ||
| return null; | ||
| } |
| // R21 node-id-coverage(v1.2.1 对账新增) | ||
| // 触发: "应生成独立 DOM"的节点,产物 JSX 里找不到其 data-node-id | ||
| // 目的: 让 §5.1.1「data-node-id 全覆盖铁律」机械强制。没有 node-id,R06/R18/R19/R20 全都 | ||
| // 绑定不到产物 → 遇空 classMap 只能 continue,bug 静默逃逸(典型 test13 small-card-top)。 | ||
| // R21 正是"不可追溯"本身的硬拦截:应渲染却无 node-id = 违规。 | ||
| // | ||
| // "应生成独立 DOM"的节点(满足任一): | ||
| // - TEXT 节点 | ||
| // - autolayout 容器(layoutMode ∈ {HORIZONTAL, VERTICAL}) | ||
| // - layoutPositioning === 'ABSOLUTE'(需 R20 校验坐标) | ||
| // - name 前缀 img- / btn- / input-(生成 <img>/<button>/<input>) | ||
| // 排斥: | ||
| // - _inBakedSubtree(bg-/img- 整体切图 或 x- 忽略子树,本就不出 DOM) | ||
| // - _hidden(不渲染) | ||
| // - _templateDup(.map() 数据副本,只需代表项挂 id) | ||
| // - name 前缀 bg- / bgc- / x-(自身不生成独立 DOM:bg/bgc 挂父,x 忽略) | ||
| // | ||
| // .map() 模板项:产物用代表项(variant a)nodeId 挂 data-node-id;R21 对代表项校验, | ||
| // 副本已被 _templateDup 跳过。 | ||
| export const id = 'R21'; | ||
| export const name = 'node-id-coverage'; | ||
| const NO_OWN_DOM_PREFIXES = ['bg-', 'bgc-', 'x-']; | ||
| export function check({ cache, product }) { | ||
| const violations = []; | ||
| for (const [nodeId, node] of Object.entries(cache.nodes)) { | ||
| if (node._inBakedSubtree || node._hidden || node._templateDup) continue; | ||
| const nm = (node.name || '').trim(); | ||
| if (NO_OWN_DOM_PREFIXES.some((p) => nm.startsWith(p)) || nm === 'bg' || nm === 'bgc' || nm === 'x') continue; | ||
| if (!shouldRender(node, nm)) continue; | ||
| if (!hasDomNode(product, nodeId)) { | ||
| violations.push({ | ||
| rule: id, | ||
| nodeId, | ||
| name: node.name || '(no name)', | ||
| type: node.type, | ||
| expected: `应生成 DOM 的节点必须挂 data-node-id="${nodeId}"(§5.1.1 铁律;.map() 模板挂代表项 id),否则 R06/R18/R19/R20 无法绑定校验`, | ||
| actual: '产物 JSX 中找不到该 nodeId 的 data-node-id(不可追溯,可能漏画或漏挂 id)', | ||
| file: '(missing in jsx)', | ||
| line: 0, | ||
| snippet: '', | ||
| }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
| function shouldRender(node, nm) { | ||
| if (node.type === 'TEXT') return true; | ||
| if (node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL') return true; | ||
| if (node.layoutPositioning === 'ABSOLUTE') return true; | ||
| if (nm.startsWith('img-') || nm.startsWith('btn-') || nm.startsWith('input-')) return true; | ||
| return false; | ||
| } | ||
| function hasDomNode(product, nodeId) { | ||
| const idNorm = nodeId.replace(/:/g, '-'); | ||
| const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`); | ||
| for (const j of product.jsx) { | ||
| if (re.test(j.content)) return true; | ||
| } | ||
| return false; | ||
| } | ||
| function escapeRegex(s) { | ||
| return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| } |
| #!/usr/bin/env node | ||
| // slugify.mjs — pp-d2c v1.1.0 内置 slug 工具 | ||
| // | ||
| // 用法: | ||
| // node slugify.mjs "<name>" → 输出 slug | ||
| // node slugify.mjs "<name>" --fallback "<nodeId>" → 失败时用 page-<nodeId-safe> 兜底 | ||
| // | ||
| // slug 规则: | ||
| // 1. ASCII: 保留 [a-z0-9], 其余 → '-', trim, lowercase, 连续 '-' 压成一个 | ||
| // 2. 中文 → pinyin 简易转换 (内置常用字表, 缺则跳过) | ||
| // 3. 结果为空 or 仅含 '-' → 返回 fallback 或空串 | ||
| // | ||
| // 中文覆盖: 前 500 常用字 + Figma 常见图层词(活动、页面、按钮、导航等) | ||
| // 不追求 100% 覆盖,只作 v1.1.0 默认 slug 兜底;用户可显式指定 slug 覆盖脚本结果 | ||
| const CJK_TO_PINYIN = { | ||
| // 数字 / 单位 | ||
| '零': 'ling', '一': 'yi', '二': 'er', '三': 'san', '四': 'si', '五': 'wu', | ||
| '六': 'liu', '七': 'qi', '八': 'ba', '九': 'jiu', '十': 'shi', | ||
| '百': 'bai', '千': 'qian', '万': 'wan', '亿': 'yi', | ||
| // 常用字 (Figma 图层高频词) | ||
| '完': 'wan', '整': 'zheng', '版': 'ban', '页': 'ye', '面': 'mian', | ||
| '中': 'zhong', '国': 'guo', '秋': 'qiu', '春': 'chun', '夏': 'xia', '冬': 'dong', | ||
| '节': 'jie', '日': 'ri', '月': 'yue', '年': 'nian', '季': 'ji', | ||
| '亚': 'ya', '洲': 'zhou', '欧': 'ou', '美': 'mei', '非': 'fei', '大': 'da', | ||
| '洋': 'yang', '海': 'hai', '陆': 'lu', '岛': 'dao', | ||
| '首': 'shou', '尾': 'wei', '前': 'qian', '后': 'hou', '左': 'zuo', '右': 'you', | ||
| '上': 'shang', '下': 'xia', '内': 'nei', '外': 'wai', | ||
| '状': 'zhuang', '态': 'tai', '栏': 'lan', '条': 'tiao', '框': 'kuang', | ||
| '按': 'an', '钮': 'niu', '键': 'jian', '标': 'biao', '题': 'ti', '副': 'fu', | ||
| '图': 'tu', '片': 'pian', '像': 'xiang', '标': 'biao', '识': 'shi', | ||
| '文': 'wen', '字': 'zi', '本': 'ben', '段': 'duan', | ||
| '导': 'dao', '航': 'hang', '菜': 'cai', '单': 'dan', '侧': 'ce', '边': 'bian', | ||
| '底': 'di', '部': 'bu', '顶': 'ding', | ||
| '活': 'huo', '动': 'dong', '优': 'you', '惠': 'hui', '券': 'quan', '卡': 'ka', | ||
| '福': 'fu', '利': 'li', '专': 'zhuan', '享': 'xiang', | ||
| '领': 'ling', '取': 'qu', '立': 'li', '即': 'ji', '抢': 'qiang', | ||
| '预': 'yu', '约': 'yue', '订': 'ding', '购': 'gou', '买': 'mai', | ||
| '火': 'huo', '车': 'che', '票': 'piao', '飞': 'fei', '机': 'ji', '船': 'chuan', | ||
| '酒': 'jiu', '店': 'dian', '住': 'zhu', '宿': 'su', | ||
| '开': 'kai', '售': 'shou', '关': 'guan', '闭': 'bi', '结': 'jie', '束': 'shu', | ||
| '时': 'shi', '间': 'jian', '倒': 'dao', '计': 'ji', | ||
| '新': 'xin', '旧': 'jiu', '老': 'lao', '客': 'ke', '户': 'hu', | ||
| '会': 'hui', '员': 'yuan', | ||
| '首': 'shou', '页': 'ye', '主': 'zhu', '要': 'yao', | ||
| '服': 'fu', '务': 'wu', '中': 'zhong', '心': 'xin', | ||
| '登': 'deng', '录': 'lu', '注': 'zhu', '册': 'ce', '账': 'zhang', '号': 'hao', | ||
| '密': 'mi', '码': 'ma', '手': 'shou', '机': 'ji', | ||
| '搜': 'sou', '索': 'suo', '查': 'cha', '询': 'xun', | ||
| '折': 'zhe', '扣': 'kou', '价': 'jia', '元': 'yuan', '角': 'jiao', '分': 'fen', | ||
| '免': 'mian', '费': 'fei', | ||
| '收': 'shou', '藏': 'cang', '分': 'fen', '享': 'xiang', | ||
| '设': 'she', '置': 'zhi', '编': 'bian', '辑': 'ji', '删': 'shan', '除': 'chu', | ||
| '保': 'bao', '存': 'cun', '确': 'que', '认': 'ren', | ||
| '成': 'cheng', '功': 'gong', '失': 'shi', '败': 'bai', '错': 'cuo', '误': 'wu', | ||
| '返': 'fan', '回': 'hui', '进': 'jin', '入': 'ru', '出': 'chu', | ||
| '快': 'kuai', '慢': 'man', '高': 'gao', '低': 'di', | ||
| '弹': 'tan', '窗': 'chuang', '浮': 'fu', '动': 'dong', | ||
| '空': 'kong', '白': 'bai', '黑': 'hei', '红': 'hong', '橙': 'cheng', '黄': 'huang', | ||
| '绿': 'lv', '蓝': 'lan', '紫': 'zi', '灰': 'hui', | ||
| '预': 'yu', '警': 'jing', '提': 'ti', '示': 'shi', | ||
| }; | ||
| export function slugify(input, fallbackNodeId = null) { | ||
| if (!input || typeof input !== 'string') { | ||
| return fallbackNodeId ? nodeIdFallback(fallbackNodeId) : ''; | ||
| } | ||
| // 1. 中文转 pinyin (逐字, 缺字直接丢弃) | ||
| let s = ''; | ||
| for (const ch of input) { | ||
| if (CJK_TO_PINYIN[ch]) { | ||
| s += CJK_TO_PINYIN[ch]; | ||
| } else { | ||
| s += ch; | ||
| } | ||
| } | ||
| // 2. ASCII 化: 保留 [A-Za-z0-9], 其余变 '-' | ||
| s = s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); | ||
| // 3. 结果空 or 只有 '-' → fallback | ||
| if (!s || /^-+$/.test(s)) { | ||
| return fallbackNodeId ? nodeIdFallback(fallbackNodeId) : ''; | ||
| } | ||
| return s; | ||
| } | ||
| function nodeIdFallback(nodeId) { | ||
| const safe = String(nodeId).replace(/[^A-Za-z0-9]+/g, '_'); | ||
| return `page-${safe}`; | ||
| } | ||
| // CLI | ||
| import { fileURLToPath } from 'node:url'; | ||
| import path from 'node:path'; | ||
| const __filename = fileURLToPath(import.meta.url); | ||
| if (path.resolve(process.argv[1] || '') === __filename) { | ||
| const argv = process.argv.slice(2); | ||
| let name = null; | ||
| let fallback = null; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| if (argv[i] === '--fallback') fallback = argv[++i]; | ||
| else if (!name) name = argv[i]; | ||
| } | ||
| if (!name) { | ||
| process.stderr.write('Usage: slugify.mjs "<name>" [--fallback "<nodeId>"]\n'); | ||
| process.exit(2); | ||
| } | ||
| const out = slugify(name, fallback); | ||
| process.stdout.write(out + '\n'); | ||
| } |
| # R01 - fixed-position | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: 无(与 R14 fixed-z-index 是"父规则-补充规则"关系,不排斥) | ||
| ## 触发条件 | ||
| - **cache**: `node.name.startsWith('fixed-')` | ||
| - **命中信号**: 图层名以 `fixed-` 开头(如 `fixed-状态栏`、`fixed-topbar`、`fixed-底部bar`) | ||
| ## 期望产物 | ||
| **JSX 端**: | ||
| - 该节点对应的 `<div>` / `<section>` 必须有 `className` 且带 `data-node-id` | ||
| **SCSS 端**: | ||
| - 对应 className 规则内 **必须含** `position: fixed;` | ||
| - 同时按 `constraints` 推 `top` / `left` / `right` / `bottom`: | ||
| - `constraints.vertical === 'TOP'` → `top: 0;` | ||
| - `constraints.vertical === 'BOTTOM'` → `bottom: 0;` | ||
| - `constraints.horizontal === 'LEFT'` → `left: 0;` | ||
| - `constraints.horizontal === 'RIGHT'` → `right: 0;` | ||
| **反例扫描**: | ||
| - `.<className> { position: relative/static/absolute; ... }` 而不是 `fixed` | ||
| - 缺 `position` 属性 | ||
| - 只在父容器上写 `position: relative`,自身没写 `fixed` | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* ❌ 错法 */ | ||
| .topbar { | ||
| position: relative; | ||
| width: 750px; | ||
| height: 236px; | ||
| } | ||
| /* ❌ 错法 (缺 position) */ | ||
| .topbar { | ||
| width: 750px; | ||
| height: 236px; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <div className={styles.topbar} data-node-id="211:32"> | ||
| {/* ... */} | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .topbar { | ||
| position: fixed; | ||
| top: 0; | ||
| left: 0; | ||
| width: 750px; | ||
| height: 236px; | ||
| z-index: 100; // 见 R14 | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 页面滚动时该组件跟随滚动,不再"钉"在视口顶/底/左/右 | ||
| - **典型事故**: v0.3.20 test1 事故 — `fixed-状态栏` 只写了 `position: relative`,滚动后状态栏消失 | ||
| ## 相关 | ||
| - SKILL.md §4.3 切图四条硬规则 | ||
| - rules/R14-fixed-z-index.md |
| # R02 - fills-image | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: | ||
| - 节点前缀是 `x-` → 忽略 | ||
| - 节点是 `btn-` 子层的 `bgc-` → 归 R09 | ||
| - 节点已被 R11 判 mask-vector-css-able → R02 优先切图 | ||
| ## 触发条件 | ||
| - **cache**: `Array.isArray(node.fills) && node.fills.some(f => f.type === 'IMAGE' && f.visible !== false)` | ||
| - **命中信号**: 该节点有可见的 IMAGE 填充 | ||
| ## 期望产物 | ||
| **assets.txt 端**: | ||
| - 必须记录该 `nodeId` 的切图文件名(如 `bg-body.png`) | ||
| **JSX 端** (二选一): | ||
| - (a) `<img src="${ASSET_PREFIX}xxx.png" data-node-id="{id}" />` (前景图) | ||
| - (b) 父容器 `<div className={styles.foo} data-node-id="{id}"></div>`(后台图,配合 SCSS background) | ||
| **SCSS 端** (方式 b 时): | ||
| - `.foo { background-image: url("...xxx.png"); background-size: cover|100% 100%; }` | ||
| **反例扫描**: | ||
| - assets.txt 缺记录 → violation | ||
| - assets.txt 有记录但产物没引用 → violation | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ 错法 1: 该切图不切,凭空 gradient */} | ||
| <div style={{ background: "linear-gradient(180deg, #fee 0%, #fdb 100%)" }} /> | ||
| {/* ❌ 错法 2: 该切图不切,写 solid color */} | ||
| <div className={styles.bgBody} /> | ||
| ``` | ||
| ```scss | ||
| /* ❌ 错法 2 对应 */ | ||
| .bgBody { | ||
| background-color: #f0e0d0; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| **前景图**: | ||
| ```jsx | ||
| <img className={styles.heroImg} | ||
| src={`${ASSET_PREFIX}hero.png`} | ||
| data-node-id="211:126" | ||
| alt="" /> | ||
| ``` | ||
| **背景图(推荐用父容器)**: | ||
| ```jsx | ||
| <div className={styles.bgBody} data-node-id="211:37"> | ||
| {/* children */} | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .bgBody { | ||
| width: 750px; | ||
| height: 1200px; | ||
| background-image: url("../../static/test1/bg-body.png"); | ||
| background-size: 100% 100%; | ||
| background-repeat: no-repeat; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: agent 凭空搓 gradient/solid color 代替真图,视觉严重跑偏 | ||
| - **典型事故**: | ||
| - v0.3.20 test1 事故 — `bg-body` (211:37) 没切图,agent 编造 `background-color` | ||
| - v0.3.21 test8 事故 — 多张 `image XX` 未切,agent 用文字堆叠模拟 | ||
| ## 相关 | ||
| - SKILL.md §4.3 切图四条硬规则 | ||
| - rules/R08-bg-landing-form.md (bg- 前缀的落地形态) | ||
| - rules/R09-btn-bgc-取值.md (btn 内 bgc 的取值) |
| # R03 - implicit-image | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ (语义判断,脚本难) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 节点有 `img-` / `bg-` / `bgc-` / `x-` / `input-` / `sub-` / `block-` / `btn-` / `fixed-` / `end-` / `scrollx-` / `scrolly-` 前缀 → 不适用 | ||
| - 子树中有 TEXT / INSTANCE / COMPONENT 节点 → 不适用 (需交互或文字,不该切) | ||
| - 子树中有以 btn- / input- / sub- / block- 命名的子节点 → 不适用 | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. `node.name` **无**上述任何前缀 | ||
| 2. 整棵子树的节点 `type` 全在 `['VECTOR', 'BOOLEAN_OPERATION', 'RECTANGLE', 'ELLIPSE', 'STAR', 'REGULAR_POLYGON', 'LINE']` | ||
| 3. 子树中**无** `TEXT` / `INSTANCE` / `COMPONENT` 类型 | ||
| 4. 子树中**无** `btn-` / `input-` / `sub-` / `block-` 前缀命名的子节点 | ||
| ## 期望产物 | ||
| **assets.txt 端**: | ||
| - 必须为该节点整体切一张图,记录 `nodeId → filename` | ||
| **JSX 端**: | ||
| - `<img className={styles.foo} src="${ASSET_PREFIX}foo.png" data-node-id="{id}" alt="" />` | ||
| **SCSS 端**: | ||
| - 只写尺寸和定位,**不要**展开成一堆 vector 元素 | ||
| **反例扫描**: | ||
| - agent 不切图,展开成 20+ 个 `<div>` + `<svg>` + CSS 渐变叠加 | ||
| - SCSS 里出现大量 `.icon-part-1 { ... } .icon-part-2 { ... }` 类的重复子元素规则 | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ 错法: 该整体切图但没识别,展开成 vector CSS 堆 */} | ||
| <div className={styles.iconWrap} data-node-id="123:45"> | ||
| <div className={styles.iconLayer1} /> | ||
| <div className={styles.iconLayer2} /> | ||
| <svg><path d="M..." /></svg> | ||
| <div className={styles.iconLayer3} /> | ||
| </div> | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <img className={styles.iconWrap} | ||
| src={`${ASSET_PREFIX}icon-wrap.png`} | ||
| data-node-id="123:45" | ||
| alt="" /> | ||
| ``` | ||
| ```scss | ||
| .iconWrap { | ||
| width: 120px; | ||
| height: 120px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 该切图没切,变成一堆 vector/CSS 堆叠,几何形状不对、无法还原设计 | ||
| - **典型事故**: | ||
| - v0.3.20 test2 事故 — `renwuInviteIcon` (装饰性 icon)没切,agent 用 4 层 div 叠 shadow 模拟 | ||
| ## Rule-Scan 识别提示 | ||
| - 看 cache 里 `node.children` 递归,统计子树的 `type` 分布 | ||
| - 只要出现 `TEXT` 就绝对不是 R03 | ||
| - 只要出现 `btn-` / `input-` / `sub-` / `block-` 前缀的子节点(说明是复合结构,不是纯装饰)也不是 R03 | ||
| - 常见误判:装饰性圆点、装饰花纹被误识别为需要展开的元素 | ||
| ## 相关 | ||
| - SKILL.md §4.3 切图四条硬规则 (R3 隐式切图) | ||
| - rules/R11-mask-vector-css-able.md (相似判断,更严格) |
| # R04 - text-gradient | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 末位可见 fill 是 SOLID → 归 R06 | ||
| - 末位可见 fill 是 IMAGE 且节点不是 TEXT → 归 R02 | ||
| ## 触发条件 | ||
| - **cache**: `node.type === 'TEXT'` | ||
| - **fills 末位可见** (从 `fills.length-1` 倒序找第一个 `visible !== false`) 的类型是: | ||
| - `GRADIENT_LINEAR` / `GRADIENT_RADIAL` / `GRADIENT_ANGULAR` / `GRADIENT_DIAMOND` | ||
| - `IMAGE` | ||
| ## 期望产物 | ||
| **JSX 端** (强制): | ||
| ```jsx | ||
| <div className={styles.title} data-node-id="{id}"> | ||
| <span>2026</span> | ||
| </div> | ||
| ``` | ||
| **SCSS 端** (强制): | ||
| ```scss | ||
| .title { | ||
| span { | ||
| background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%); | ||
| background-clip: text; | ||
| -webkit-background-clip: text; | ||
| color: transparent; | ||
| } | ||
| } | ||
| ``` | ||
| **关键点**: | ||
| - `<span>` 是**必须的**(background-clip 需要行内元素承载) | ||
| - `background` 后必须紧跟 `background-clip: text` + `color: transparent` | ||
| - 缺任何一项都会失效(要么整块背景、要么无渐变) | ||
| ## Handle → CSS angle 转换表 | ||
| Figma `gradientHandlePositions` 有 3 个点:`P0=起点, P1=终点, P2=侧向控制点`。 | ||
| | 常见 handle (P0→P1) | CSS angle | 视觉方向 | | ||
| |---|---|---| | ||
| | `[0.5, 0] → [0.5, 1]` | `180deg` | 从上到下 | | ||
| | `[0.5, 1] → [0.5, 0]` | `0deg` | 从下到上 | | ||
| | `[0, 0.5] → [1, 0.5]` | `90deg` | 从左到右 | | ||
| | `[1, 0.5] → [0, 0.5]` | `270deg` | 从右到左 | | ||
| | `[0, 0] → [1, 1]` | `135deg` | 左上到右下 | | ||
| | `[1, 0] → [0, 1]` | `225deg` | 右上到左下 | | ||
| **通用公式**: | ||
| ``` | ||
| angle_rad = atan2(P1.x - P0.x, P0.y - P1.y) // 注意 y 反向 | ||
| angle_deg = angle_rad * 180 / π | ||
| // 结果映射到 CSS 0..360 | ||
| ``` | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* ❌ 错法 1: 凭空搓 solid color 代替 gradient */ | ||
| .title { color: #FFDBAA; } | ||
| /* ❌ 错法 2: 写 background 但没 background-clip */ | ||
| .title { | ||
| background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%); | ||
| /* 缺 background-clip: text 和 color: transparent → 整个块被染色,文字看不见 */ | ||
| } | ||
| /* ❌ 错法 3: 没包 <span>,直接给 .title 挂 background-clip → Safari 部分场景失效 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| **GRADIENT_LINEAR**: | ||
| ```jsx | ||
| <div className={styles.title2026} data-node-id="211:411 > 211:91"> | ||
| <span>2026</span> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .title2026 { | ||
| font-size: 48px; | ||
| font-weight: bold; | ||
| span { | ||
| background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%); | ||
| background-clip: text; | ||
| -webkit-background-clip: text; | ||
| color: transparent; | ||
| } | ||
| } | ||
| ``` | ||
| **IMAGE fill**: | ||
| ```jsx | ||
| <div className={styles.brandText} data-node-id="{id}"> | ||
| <span>品牌名</span> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .brandText span { | ||
| background: url("../../static/xxx/brand-fill.png") center / cover; | ||
| background-clip: text; | ||
| -webkit-background-clip: text; | ||
| color: transparent; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 文字渐变/图案填充丢失,变成纯色或整块背景 | ||
| - **典型事故**: | ||
| - v0.3.21 test8 事故 — `2026 (TEXT)` 应该白到金渐变,agent 写成 `color: white` | ||
| ## Rule-Scan 识别提示 | ||
| - 只判 TEXT 节点 | ||
| - fills 数组末位可见类型判断: | ||
| ```js | ||
| const lastVisible = fills.slice().reverse().find(f => f && f.visible !== false); | ||
| if (lastVisible && (lastVisible.type.startsWith('GRADIENT') || lastVisible.type === 'IMAGE')) → 命中 R04 | ||
| ``` | ||
| - 输出 context 里必须带 `fills_last_type` / `fills_last_stops` / `fills_last_handles`,UI sub-agent 直接照做 | ||
| ## 相关 | ||
| - SKILL.md §4.1.1 TEXT 多层 fills 处理 | ||
| - rules/R06-text-solid-last.md (末位 SOLID 的对应规则) |
| # R05 - space-between | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: 无 | ||
| ## 触发条件 | ||
| - **cache**: `node.primaryAxisAlignItems === 'SPACE_BETWEEN'` | ||
| - **命中信号**: Figma AutoLayout 主轴对齐 = SPACE_BETWEEN (常见于 topbar 左右两端布局、卡片 header 主题+图标) | ||
| ## 期望产物 | ||
| **JSX 端**: | ||
| - 父容器 flex 布局(结构不用变,子元素两个及以上) | ||
| **SCSS 端** (强制): | ||
| ```scss | ||
| .container { | ||
| display: flex; | ||
| flex-direction: row; // 或 column,看 layoutMode | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| } | ||
| ``` | ||
| **反例扫描** (warning 而非 violation): | ||
| - `margin-left: auto` / `margin-right: auto` 模拟推开 | ||
| - `justify-content: flex-end` + 手动 padding | ||
| - `gap: auto`(不合法但 agent 常用) | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* ❌ 错法 1: 用 margin auto */ | ||
| .topbar { | ||
| display: flex; | ||
| .navBack { margin-right: auto; } | ||
| .navShare { } | ||
| } | ||
| /* ❌ 错法 2: 用 flex-end 单侧对齐 */ | ||
| .topbar { | ||
| display: flex; | ||
| justify-content: flex-end; | ||
| padding-left: 500px; /* 硬 padding 顶开左边元素 */ | ||
| } | ||
| /* ❌ 错法 3: 用 gap auto */ | ||
| .topbar { | ||
| display: flex; | ||
| gap: auto; /* 不合法 */ | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| .topbar { | ||
| display: flex; | ||
| flex-direction: row; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| padding: 0 20px; | ||
| width: 750px; | ||
| height: 118px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 布局在不同宽度设备上错位(margin auto 只在 flex 里工作,固定 padding 无响应) | ||
| - **典型事故**: | ||
| - test8 topbar — 左右两端图标应 SPACE_BETWEEN,agent 写成 margin-right: auto,设备变宽后错位 | ||
| ## Rule-Scan 识别提示 | ||
| - `node.primaryAxisAlignItems === 'SPACE_BETWEEN'` 是**唯一触发** | ||
| - 有些 AutoLayout 主轴是 vertical (`node.layoutMode === 'VERTICAL'`),需 `flex-direction: column` | ||
| ## 相关 | ||
| - SKILL.md §4.3 硬规则 |
| # R06 - text-solid-last | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: | ||
| - 末位可见 fill 是 GRADIENT/IMAGE → 归 R04 | ||
| - fills 全部 invisible → 用默认 `#000000` | ||
| ## 触发条件 | ||
| - **cache**: `node.type === 'TEXT' && Array.isArray(node.fills) && fills.length > 0` | ||
| - **fills 末位可见** (`fills.slice().reverse().find(f => f && f.visible !== false)`) 类型是 `SOLID` | ||
| ## 期望产物 | ||
| **SCSS 端**: | ||
| - 对应 CSS 类含 `color: #RRGGBB;`,值来自末位可见 SOLID | ||
| - HEX 大小写不敏感;alpha 通道另写 `opacity` | ||
| **取色算法**: | ||
| ```js | ||
| const solid = pickLastVisibleFill(node.fills); | ||
| const { r, g, b } = solid.color; // 0..1 | ||
| const hex = '#' + [r,g,b].map(n => Math.round(n*255).toString(16).padStart(2,'0')).join(''); | ||
| // 例: { r: 0, g: 0.4, b: 0.6 } → #006699 | ||
| ``` | ||
| **反例扫描**: | ||
| - 取到 fills[0] 或中间层的色(不是末位可见) | ||
| - 编造的色(cache 里找不到 SOLID.color 对应值) | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* 若 cache 里 fills = [{SOLID, #FFFFFF, visible:true}, {SOLID, #00679D, visible:true}] | ||
| 末位可见 = #00679D,应写: | ||
| ✅ color: #00679D | ||
| ❌ color: #FFFFFF (取到 fills[0]) */ | ||
| /* 若 cache 里 fills = [{SOLID, #00679D, visible:false}, {SOLID, #FF0000, visible:true}] | ||
| 末位可见 = #FF0000,应写: | ||
| ✅ color: #FF0000 | ||
| ❌ color: #00679D (取到 fills[0] 忽略 visible) */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <span className={styles.priceLabel} data-node-id="211:50"> | ||
| 提前下预约单开售自动抢 | ||
| </span> | ||
| ``` | ||
| ```scss | ||
| .priceLabel { | ||
| color: #00679D; // 来自 cache node.fills 末位可见 SOLID | ||
| font-size: 28px; | ||
| line-height: 1.4; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 文字颜色错误,与设计稿不符 | ||
| - **典型事故**: | ||
| - v0.3.20 test1 事故 — TEXT 节点 fills 有两层可见,agent 取到 fills[0] (#FFF),漏了末位 (#00679D) | ||
| ## Rule-Scan 识别提示 | ||
| - 优先看末位 (`fills.length - 1`)倒序遍历 | ||
| - 跳过 `visible === false` 的层 | ||
| - 若 fills 全部 invisible → 走默认 `#000000` (不常见,但要覆盖) | ||
| ## 相关 | ||
| - SKILL.md §4.1.1 TEXT 多层 fills 处理 | ||
| - rules/R04-text-gradient.md (末位是 GRADIENT/IMAGE 的对应规则) | ||
| - rules/R10-no-fake-solid-color.md (核对色源,避免幻觉色) |
| # R07 - multi-fills | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - fills 只有 1 层可见 → 不适用 | ||
| - fills 全部同类型 SOLID → 属 R06 单层判定 | ||
| - 节点是 TEXT + 末位 GRADIENT/IMAGE → 归 R04 | ||
| ## 触发条件 | ||
| - **cache**: `Array.isArray(node.fills) && fills.filter(f => f && f.visible !== false).length >= 2` | ||
| - **且**: 多层 fills 类型混合 (`SOLID + IMAGE`、`SOLID + GRADIENT`、多个 IMAGE 叠加等) | ||
| ## 期望产物 | ||
| **核心原则**: 每层 fills 都要落地,不能只取其一。 | ||
| **JSX/SCSS 组合方式**: | ||
| 1. **SOLID + IMAGE 叠加** (底色 + 图案): | ||
| ```scss | ||
| .box { | ||
| background-color: #FF6600; // SOLID 底色 | ||
| background-image: url("...pattern.png"); // IMAGE 上层 | ||
| background-blend-mode: normal; // 或 multiply / overlay | ||
| } | ||
| ``` | ||
| 2. **SOLID + GRADIENT** (底色 + 渐变): | ||
| ```scss | ||
| .box { | ||
| background: | ||
| linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 100%), // GRADIENT 上层 | ||
| #FF6600; // SOLID 底色 | ||
| } | ||
| ``` | ||
| 3. **多层 GRADIENT / IMAGE**: | ||
| - CSS `background` 简写多层,后写的在下方 | ||
| - **Figma fills 数组顺序**: 索引小的在下,索引大的在上;转 CSS 时**颠倒** | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma fills = [{SOLID, #FF6600, visible:true}, {IMAGE, pattern.png, visible:true}] | ||
| agent 常错法: */ | ||
| /* ❌ 只写 SOLID 忽略 IMAGE */ | ||
| .box { background-color: #FF6600; } | ||
| /* ❌ 只写 IMAGE 忽略 SOLID */ | ||
| .box { background-image: url("...pattern.png"); } | ||
| /* ❌ 层次颠倒 (SOLID 在上盖住 IMAGE) */ | ||
| .box { | ||
| background-image: url("...pattern.png"); | ||
| background-color: #FF6600; | ||
| } | ||
| /* 上例其实 CSS 顺序不重要,但要用 background 简写才可控叠加 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| .orangeCard { | ||
| background: | ||
| url("../../static/xxx/pattern.png") center / cover no-repeat, | ||
| #FF6600; | ||
| width: 750px; | ||
| height: 200px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 底色或图案丢失,视觉与设计不符 | ||
| - **典型事故**: | ||
| - v0.3.19 test8 事故 — `quanItem__btn` fills = [SOLID 金色, IMAGE 光斑],agent 只写 SOLID,光斑丢失 | ||
| ## Rule-Scan 识别提示 | ||
| - 统计 `visible !== false` 的 fills 数量,≥2 才触发 | ||
| - 输出 context 里必须列 **每一层** 的 type + 主要参数(color/imageRef/gradientStops),UI sub-agent 照做 | ||
| ## 相关 | ||
| - SKILL.md §4.3 CSS 翻译表 | ||
| - rules/R09-btn-bgc-取值.md (btn 内 bgc 层的特殊处理) |
| # R08 - bg-landing-form | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底) | ||
| - **排斥条件**: 无(反向匹配,自成一体) | ||
| ## 触发条件 | ||
| - **cache**: `node.name.startsWith('bg-')` 或 `node.name === 'bg'` | ||
| - **命中信号**: 图层名以 `bg-` 开头(如 `bg-body`、`bg-header`、`bg-card`)或裸 `bg` | ||
| ## 期望产物 | ||
| **核心原则**: `bg-` 前缀节点表示"该节点是父容器的背景",**必须以父容器的 `background-image` 方式落地**。 | ||
| **JSX 端** (强制): | ||
| - **不**为 bg 节点单独渲染 `<div>` 或 `<img>` | ||
| - **父容器** 直接挂 `className` 并有 `data-node-id="{bgNodeId}"` (代表这层背景由 bg 节点提供) | ||
| **SCSS 端**: | ||
| - **父容器类**含 `background-image: url("...bg-xxx.png")` + `background-size: cover|100% 100%` | ||
| **禁止的落地形态** (反向扫描): | ||
| | 错法 | 表现 | 检测正则 | | ||
| |---|---|---| | ||
| | `<img src="bg-xxx.png">` | 把 bg 当前景图 | `/<img[^>]*src=[^>]*bg-/` | | ||
| | `<img src=".../bg.png">` (裸 bg) | 同上 | `/<img[^>]*src=[^>]*\/bg\.[a-z]+/` | | ||
| | `style={{ background: ... }}` | inline style | `/style=\{\{[^}]*background/` | | ||
| | `.foo::before { background-image }` | 伪元素挂 bg | `/::(before\|after)\s*\{[^}]*background-image/` | | ||
| | `<div className={styles.bg} />` (空 div) | 空 div 挂 bg | `/<div[^>]*className=\{styles\.bg[^}]*\}[^>]*\/>/` | | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ 错法 1: bg 用 <img> */} | ||
| <img src={`${ASSET_PREFIX}bg-body.png`} className={styles.bgBody} /> | ||
| {/* ❌ 错法 2: inline style */} | ||
| <div style={{ backgroundImage: `url(${ASSET_PREFIX}bg-body.png)` }} /> | ||
| {/* ❌ 错法 3: 空 div 挂 bg */} | ||
| <div className={styles.bgBody} data-node-id="211:37" /> | ||
| {/* 然后在其他 div 里塞内容,分开的父子关系 */} | ||
| ``` | ||
| ```scss | ||
| /* ❌ 错法 4: 伪元素 */ | ||
| .page::before { | ||
| content: ""; | ||
| background-image: url("...bg-body.png"); | ||
| position: absolute; | ||
| inset: 0; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <div className={styles.page} data-node-id="211:31"> | ||
| {/* 父容器 page 承担 bg-body 的背景, 无需为 bg-body 单开元素 */} | ||
| <div className={styles.topbar} data-node-id="211:32">...</div> | ||
| <div className={styles.content}>...</div> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .page { | ||
| background-image: url("../../static/test1/bg-body.png"); | ||
| background-size: 100% 100%; | ||
| background-repeat: no-repeat; | ||
| width: 750px; | ||
| min-height: 100vh; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - `<img>` 挂 bg → 图片和内容 z-index 冲突,内容被压在图后 | ||
| - 伪元素挂 bg → 父容器 `position: relative` 忘配时定位错 | ||
| - inline style → 无缓存、无响应式,后期难维护 | ||
| - **典型事故**: | ||
| - v0.3.9 test8 事故 — `bg-body` 用 `<img>` + `position: absolute`,内容层被盖住 | ||
| ## Rule-Scan 识别提示 | ||
| - 触发只看图层名(`bg-` 前缀 or 裸 `bg`) | ||
| - 判定产物形态需 Read jsx/scss,对照上表 5 种禁止形态 | ||
| - **警惕**: 有些 agent 会把 bg 分层到子块内(不是 page 而是 sub- 或 block-),这时候 bg 应挂到那个子块的父容器 | ||
| ## 相关 | ||
| - SKILL.md §4.3 硬规则第 4 条 | ||
| - rules/R02-fills-image.md (fills IMAGE 的通用落地) |
| # R09 - btn-bgc-取值 | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - `bgc-` 层的 fills 只有单层 SOLID → 直接按 CSS `background-color` 取,不算 R09 | ||
| - `bgc-` 层的 fills 是 IMAGE → 归 R02 | ||
| - btn 前缀节点无 `bgc-` 子层 → 不适用 | ||
| ## 触发条件 | ||
| - **cache**: 存在 `node.name.startsWith('btn-')` 节点 | ||
| - **且**: 该节点 children 里有 `child.name.startsWith('bgc-')` | ||
| - **且**: `bgc-` 层的 `fills` 是 `GRADIENT_*`(或多层含 GRADIENT) | ||
| ## 期望产物 | ||
| **核心原则**: `btn-` 的**父容器** CSS `background` 应取自 `bgc-` 子层的**真实 fills**,不是编造。 | ||
| **JSX 端**: | ||
| - 按分层结构 `<div className={styles.btn}>` 承担 bgc 背景,内部再放文字/图标 | ||
| **SCSS 端**: | ||
| - `.btn { background: linear-gradient(...); }`(gradient 参数按 bgc-*.fills) | ||
| **反例扫描**: | ||
| - agent 编造渐变色(cache 里 bgc- 明明是 A→B,产物写成 C→D) | ||
| - agent 编造 solid color 代替 gradient | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma: | ||
| btn-primary | ||
| └─ bgc-primary fills = [ GRADIENT: #864500 → #6D3600 ] | ||
| └─ text "购买" | ||
| agent 常错法: */ | ||
| /* ❌ 错法 1: 凭空搓 solid color */ | ||
| .btnPrimary { background-color: #864500; } | ||
| /* ❌ 错法 2: 颜色对了但类型错(SOLID 代 GRADIENT) */ | ||
| .btnPrimary { background: #864500; } | ||
| /* ❌ 错法 3: 编造与 bgc 无关的渐变 */ | ||
| .btnPrimary { background: linear-gradient(180deg, #FF0 0%, #F00 100%); } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <button className={styles.btnPrimary} data-node-id="211:446"> | ||
| <span>购买</span> | ||
| </button> | ||
| ``` | ||
| ```scss | ||
| .btnPrimary { | ||
| background: linear-gradient(180deg, #864500 0%, #6D3600 100%); // 取自 bgc-primary.fills | ||
| border: none; | ||
| border-radius: 44px; | ||
| padding: 22px 60px; | ||
| color: #FFFFFF; | ||
| font-size: 32px; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 按钮颜色错、无渐变 | ||
| - **典型事故**: | ||
| - v0.3.20 test1 事故 — `btn-购买` 内 `bgc-` 渐变 #864500→#6D3600,agent 只写 SOLID #864500 | ||
| - v0.3.19 test8 事故 — `quanItem__btn` 内 `bgc-` 光效渐变,agent 编造成完全不同的黄色 | ||
| ## Rule-Scan 识别提示 | ||
| - 先按 `btn-` 前缀找到按钮节点 | ||
| - 递归 children 找 `bgc-` 前缀节点(通常是第 1 或第 2 层) | ||
| - 读该 bgc 节点的 `fills` (**不是** btn 节点自身的 fills) | ||
| - 输出 context 里必须含: | ||
| ```json | ||
| { | ||
| "btn_nodeId": "211:446", | ||
| "bgc_nodeId": "211:447", | ||
| "bgc_fills": [ { "type": "GRADIENT_LINEAR", "gradientStops": [...], "gradientHandlePositions": [...] } ] | ||
| } | ||
| ``` | ||
| ## 相关 | ||
| - SKILL.md §4.3 CSS 翻译表 (fills GRADIENT) | ||
| - rules/R07-multi-fills.md | ||
| - rules/R10-no-fake-solid-color.md |
| # R10 - no-fake-solid-color | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ (需交叉核对 cache 与产物) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - R06 已判定过的 TEXT color 值 → 不重复扫 | ||
| - R07/R09 已判定过的 background 色 → 不重复扫 | ||
| ## 触发条件 | ||
| - **产物**: SCSS 中出现 `color: #XXX` / `background-color: #XXX` / `background: #XXX` 之类的 SOLID 色 | ||
| - **cache 侧**: 该 CSS 规则对应的 `nodeId` 的所有 fills 中,**找不到**任何 SOLID 色的 HEX 与该产物色匹配 | ||
| - **意味着**: agent 幻觉搓色 | ||
| ## 期望产物 | ||
| **核心原则**: 产物里出现的每一个 `#RRGGBB` 都必须在 cache 里能找到 fills 源头。 | ||
| **判定算法**: | ||
| 1. Read 产物所有 .scss / .css 文件 | ||
| 2. 提取所有 `#RRGGBB` (或 `rgba(...)` 已知色) | ||
| 3. 对每个色,反查:该规则挂在哪个 `nodeId` 的 className 下 | ||
| 4. Read 该 nodeId 的 cache,遍历 fills: | ||
| - 有匹配 SOLID.color → OK | ||
| - 全部 GRADIENT/IMAGE → 走 R04/R07/R09,不属 R10 | ||
| - 找不到匹配 → **R10 命中(幻觉色)** | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma nodeId=211:32 fills = [] (无填充,靠父容器) | ||
| agent 幻觉搓: */ | ||
| .topbar { background-color: #F5F5F5; } /* ❌ cache 里找不到 #F5F5F5 */ | ||
| /* Figma nodeId=211:411 fills = [{SOLID, #003366, visible:true}] | ||
| agent 幻觉搓: */ | ||
| .title { color: #0066CC; } /* ❌ #0066CC ≠ #003366 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| **从 cache 精确取色**: | ||
| ```js | ||
| // UI sub-agent 生 SCSS 前,遍历本 block 每个 TEXT/RECT 节点: | ||
| const solid = pickSolidColor(node.fills); // 取第一个可见 SOLID | ||
| if (!solid) return; // 无 SOLID 就不写色 | ||
| const hex = rgbaToHex(solid.color); | ||
| // 输出: color: {hex}; | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 颜色与设计稿不符,agent 常"猜"一个相近色 | ||
| - **典型事故**: | ||
| - v0.3.15 test8 事故 — 卡片背景 agent 写 #FFF7E5,cache 里 fills=[] 也没父背景,纯幻觉 | ||
| - 多起 v0.3.x 事故 — 按钮 hover / disabled 状态色被 agent 编造 | ||
| ## Rule-Scan 识别提示 | ||
| - 只对产物已 Read 后判定;主要靠"反向核对" | ||
| - 检出 `#RRGGBB` 后,反查对应 className → nodeId → cache fills | ||
| - 若 cache 里 fills=[] 但产物有色 → 强命中 | ||
| - 若 cache 里 fills 全 IMAGE/GRADIENT 但产物有 SOLID 色 → 强命中 | ||
| - **豁免**: 系统默认色如 `color: inherit` / `color: transparent` / `background: none`,不算 R10 | ||
| ## 相关 | ||
| - rules/R06-text-solid-last.md (TEXT SOLID 精确取色) | ||
| - rules/R07-multi-fills.md (多层 fills 取色) | ||
| - rules/R09-btn-bgc-取值.md (btn 背景取色) |
| # R11 - mask-vector-css-able | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 节点已按 R02 (fills=IMAGE) 切图 → 不重复判 | ||
| - 节点已按 R03 (implicit-image) 切图 → 不重复判 | ||
| - 简单矩形 / 圆角矩形 / 圆形 / 椭圆 → CSS 可表达,不切 | ||
| ## 触发条件 | ||
| - **cache**: 节点或其子树含以下之一: | ||
| - `type === 'BOOLEAN_OPERATION'`(布尔运算,如 UNION/INTERSECT/SUBTRACT) | ||
| - 多层 `type === 'VECTOR'` 叠加 | ||
| - `node.isMask === true` 与其他节点组合 | ||
| - 复杂 SVG path(非矩形/圆形) | ||
| - **且**: 该结构**不能仅用 CSS** 表达(如 `border-radius` + `background` 组合) | ||
| ## 期望产物 | ||
| **核心原则**: CSS 表达不了的复合几何 → 必须切图。 | ||
| **JSX 端**: | ||
| - `<img className={styles.foo} src="${ASSET_PREFIX}foo.png" data-node-id="{id}" alt="" />` | ||
| **SCSS 端**: | ||
| - 只写尺寸,**不**尝试用 CSS `mask` / 多层 `clip-path` / SVG path 还原 | ||
| **反例扫描**: | ||
| - SCSS 里出现 `mask-image` / `-webkit-mask` (虽然合法,但兼容性差) | ||
| - SCSS 里出现多层 `clip-path` 试图组合 | ||
| - JSX 里 inline `<svg>` 复杂 path | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* Figma 结构: BOOLEAN_OPERATION SUBTRACT (圆环减去中间空心) */} | ||
| {/* ❌ 错法 1: agent 用 mask */} | ||
| <div className={styles.ring} /> | ||
| ``` | ||
| ```scss | ||
| .ring { | ||
| width: 200px; | ||
| height: 200px; | ||
| background: #FF6600; | ||
| mask-image: radial-gradient(circle, transparent 60px, black 61px); | ||
| -webkit-mask-image: radial-gradient(circle, transparent 60px, black 61px); | ||
| } | ||
| ``` | ||
| ```jsx | ||
| {/* ❌ 错法 2: agent 用 clip-path */} | ||
| <div className={styles.ring} style={{ clipPath: "..." }} /> | ||
| {/* ❌ 错法 3: agent 用内联 SVG (性能差, 无缓存) */} | ||
| <svg> | ||
| <path d="M100,100 L200,200 ..." fill="#FF6600" /> | ||
| </svg> | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| <img className={styles.ring} | ||
| src={`${ASSET_PREFIX}ring.png`} | ||
| data-node-id="{id}" | ||
| alt="" /> | ||
| ``` | ||
| ```scss | ||
| .ring { | ||
| width: 200px; | ||
| height: 200px; | ||
| } | ||
| ``` | ||
| ## CSS 可表达 vs 不可表达速查 | ||
| **可 CSS 表达(不切图)**: | ||
| - 纯色矩形 / 圆角矩形 → `background-color` + `border-radius` | ||
| - 单色圆形 / 椭圆 → `border-radius: 50%` | ||
| - 单向阴影 → `box-shadow` | ||
| - 单一渐变(线性/径向) → `background: linear-gradient(...)` | ||
| - 单层 border → `border` | ||
| **不可 CSS 表达(必须切图)**: | ||
| - 布尔运算(subtract/intersect/exclude) | ||
| - 多层 vector 叠加(如 icon 组合) | ||
| - 复杂 SVG path(非规则几何) | ||
| - mask 与其他 fills 组合 | ||
| - 特殊纹理 / 光效 | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - `mask-image` 在旧浏览器失效 | ||
| - 内联 `<svg>` 破坏组件树、无缓存 | ||
| - 复杂 `clip-path` 兼容性差 | ||
| - **典型事故**: | ||
| - v0.3.9 test8 事故 — 圆环装饰用 `mask-image`,某些手机不显示 | ||
| ## Rule-Scan 识别提示 | ||
| - 首先看 cache 里的 `type` 分布:出现 `BOOLEAN_OPERATION` → 强命中 | ||
| - 出现多个 `VECTOR` 叠加 → 命中 | ||
| - 判断"CSS 可表达"的临界:如果 shape 是圆/椭圆/圆角矩形,不命中 | ||
| - 输出 context 里列出复合几何的形态描述 | ||
| ## 相关 | ||
| - SKILL.md §4.4.pre.b 子树结构禁切规则 (v0.3.9) | ||
| - rules/R02-fills-image.md | ||
| - rules/R03-implicit-image.md |
| # R12 - flat-mode-naming | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - `pp-d2c.config.json` 的 `merge.mode !== 'flat'` → 不适用 | ||
| - 项目使用 CSS Modules 且每个 block 独立 scss 文件 → 天然隔离,不适用 | ||
| ## 触发条件 | ||
| - **config**: `merge.mode === 'flat'`(所有 block 产物合并到一个文件) | ||
| - **命中信号**: 多个 block 产出相同 className(如 `.title` / `.button` / `.card`),合并后互相覆盖 | ||
| ## 期望产物 | ||
| **核心原则**: flat 模式下,className 必须带 **block 语义前缀** 才不冲突。 | ||
| **命名规范**: | ||
| | 场景 | 好类名 | 坏类名 | | ||
| |---|---|---| | ||
| | topbar 的 title | `.topbarTitle` | `.title` | | ||
| | card 的 title | `.cardTitle` | `.title` | | ||
| | footer 的 button | `.footerButton` | `.button` | | ||
| | 通用容器 | `.<block>Container` | `.container` | | ||
| **JSX 端**: | ||
| - `<div className={styles.topbarTitle}>`,不是 `<div className={styles.title}>` | ||
| **SCSS 端**: | ||
| - 类选择器全都含 block 前缀 | ||
| - **不用** BEM 双下划线(`.topbar__title`)反而增加复杂度;直接驼峰 camelCase 拼 | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* Block 1: topbar */} | ||
| <div className={styles.title}>标题</div> | ||
| {/* Block 2: card */} | ||
| <div className={styles.title}>卡片标题</div> | ||
| {/* 合并后 `.title` 只保留最后一个规则,前面的样式被覆盖 */} | ||
| ``` | ||
| ```scss | ||
| /* Block 1 生成 */ | ||
| .title { font-size: 32px; color: #003366; } | ||
| /* Block 2 生成 */ | ||
| .title { font-size: 24px; color: #666; } | ||
| /* flat 合并:第二个 .title 覆盖第一个,topbar 的样式丢失 */ | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* Block 1: topbar */} | ||
| <div className={styles.topbarTitle}>标题</div> | ||
| {/* Block 2: card */} | ||
| <div className={styles.cardTitle}>卡片标题</div> | ||
| ``` | ||
| ```scss | ||
| .topbarTitle { font-size: 32px; color: #003366; } | ||
| .cardTitle { font-size: 24px; color: #666; } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: 类名冲突,后 block 的样式盖住前 block,视觉错乱 | ||
| - **典型事故**: | ||
| - v0.3.16 test8 事故 — 3 个 block 都用 `.title`,合并后只剩 footer 样式 | ||
| ## Rule-Scan 识别提示 | ||
| - Read `pp-d2c.config.json`,若 `merge.mode !== 'flat'` → 跳过 R12 判定 | ||
| - 遍历产物 SCSS,检索**同名 selector**是否出现多次 | ||
| - 或提前扫每个 block 的类名列表,交叉比对 | ||
| ## 相关 | ||
| - SKILL.md §5 合并策略 | ||
| - pp-d2c.config.json `merge.mode` |
| # R13 - unit-scale | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ (未来可升,现阶段需 config 解析) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: `pp-d2c.config.json` 的 `unit.figmaBase === unit.outputBase` → scale=1,不适用 | ||
| ## 触发条件 | ||
| - **config**: `unit.scale !== 1`(常见 Figma 375 base 输出到 750 px,scale=2) | ||
| - **命中信号**: 产物 CSS 中的 `px` 数字与 Figma 原始尺寸相同(意味着没换算) | ||
| ## 期望产物 | ||
| **换算公式**: | ||
| ``` | ||
| outputPx = figmaPx * (unit.outputBase / unit.figmaBase) | ||
| ``` | ||
| **举例**(figmaBase=375, outputBase=750, scale=2): | ||
| | Figma 尺寸 | 输出尺寸 | | ||
| |---|---| | ||
| | `x=20, y=44` | `20 * 2 = 40px, 44 * 2 = 88px` | | ||
| | `width=335, height=118` | `670px × 236px` | | ||
| | `fontSize=16` | `32px` | | ||
| | `borderRadius=8` | `16px` | | ||
| | `padding: 12 20` | `24px 40px` | | ||
| **SCSS 端**: | ||
| - 所有 `px` 值都是换算后的输出值,不含 Figma 原值 | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma cache: node.width = 335, node.absoluteBoundingBox.height = 118 | ||
| config: figmaBase=375, outputBase=750, scale=2 | ||
| 期望输出: width=670px, height=236px | ||
| agent 错法: */ | ||
| .card { | ||
| width: 335px; /* ❌ Figma 原值,未换算 */ | ||
| height: 118px; /* ❌ */ | ||
| padding: 12px; /* ❌ */ | ||
| font-size: 16px; /* ❌ */ | ||
| border-radius: 8px; /* ❌ */ | ||
| } | ||
| /* 期望 */ | ||
| .card { | ||
| width: 670px; | ||
| height: 236px; | ||
| padding: 24px; | ||
| font-size: 32px; | ||
| border-radius: 16px; | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| **UI sub-agent 生 SCSS 前应用换算**: | ||
| ```js | ||
| const config = readConfig(); | ||
| const scale = config.unit.outputBase / config.unit.figmaBase; | ||
| function toPx(figmaPx) { | ||
| return `${Math.round(figmaPx * scale)}px`; | ||
| } | ||
| // 使用 | ||
| .card { | ||
| width: ${toPx(node.width)}; | ||
| height: ${toPx(node.height)}; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - 375 base 输出到 750 屏幕 → 元素显示只有一半大小 | ||
| - 或应用 `rem` 换算时,基础字号错乱 | ||
| - **典型事故**: | ||
| - v0.3.8 test1 事故 — agent 忘换算,整个页面在真机上缩小一半 | ||
| ## Rule-Scan 识别提示 | ||
| - Read `pp-d2c.config.json`,取 `unit.figmaBase` / `unit.outputBase` | ||
| - scale = outputBase / figmaBase | ||
| - 若 scale === 1 → 不适用 | ||
| - 命中判定: | ||
| - Read 产物 SCSS,提取所有 `\d+px` | ||
| - 反查每个 px 值对应的 nodeId,读 cache 的 `absoluteBoundingBox.width/height` / `x/y` | ||
| - 若产物 px === Figma px 且 scale != 1 → 命中 R13 | ||
| ## 相关 | ||
| - SKILL.md §4.5 单位换算 | ||
| - pp-d2c.config.json `unit.figmaBase` / `unit.outputBase` | ||
| - rules/R10-no-fake-solid-color.md |
| # R14 - fixed-z-index | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: 页面只有 1 个 `fixed-` 节点 → 无冲突,不判 z-index | ||
| ## 触发条件 | ||
| - **cache**: 存在**多个** `node.name.startsWith('fixed-')` 节点(通常是状态栏 + 底部 bar + 悬浮按钮) | ||
| - **命中信号**: 多层 fixed 节点在 Figma 里有明确 z 顺序(通常按图层顺序),产物 z-index 缺失或未递增 | ||
| ## 期望产物 | ||
| **核心原则**: 多个 fixed 元素必须有递增 z-index,视觉层级依 Figma 图层顺序。 | ||
| **SCSS 端**: | ||
| ```scss | ||
| .fixedStatusBar { position: fixed; top: 0; z-index: 100; } | ||
| .fixedTopbar { position: fixed; top: 118px; z-index: 90; } | ||
| .fixedFloatingBtn { position: fixed; bottom: 40px; right: 40px; z-index: 200; } | ||
| ``` | ||
| **约定**: | ||
| - 底部 bar / 悬浮按钮 → z-index ≥ 100 | ||
| - 顶部固定 bar → z-index 递减(status > topbar > sub-nav) | ||
| - **不要**都用 z-index: 1 或都不写 | ||
| ## 反例 (agent 常见错法) | ||
| ```scss | ||
| /* Figma 有 3 个 fixed 节点: | ||
| - fixed-状态栏 | ||
| - fixed-顶部bar | ||
| - fixed-悬浮按钮 | ||
| 期望层级: 状态栏 > 悬浮按钮 > 顶部bar | ||
| agent 错法 1: 全部不写 z-index */ | ||
| .fixedStatusBar { position: fixed; top: 0; } | ||
| .fixedTopbar { position: fixed; top: 118px; } | ||
| .fixedFloatingBtn { position: fixed; bottom: 40px; } | ||
| /* → z-index 默认为 auto,层级不确定,可能互相覆盖 */ | ||
| /* agent 错法 2: 全部 z-index 相同 */ | ||
| .fixedStatusBar { z-index: 1; } | ||
| .fixedTopbar { z-index: 1; } | ||
| .fixedFloatingBtn { z-index: 1; } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| .fixedStatusBar { | ||
| position: fixed; | ||
| top: 0; | ||
| z-index: 100; | ||
| } | ||
| .fixedTopbar { | ||
| position: fixed; | ||
| top: 118px; | ||
| z-index: 90; | ||
| } | ||
| .fixedFloatingBtn { | ||
| position: fixed; | ||
| bottom: 40px; | ||
| right: 40px; | ||
| z-index: 200; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: fixed 元素互相覆盖、位置错乱 | ||
| - **典型事故**: | ||
| - v0.3.19 test8 事故 — 底部 fixed 悬浮按钮被上滑的内容盖住(z-index 未设) | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 收集 `fixed-` 前缀节点 | ||
| - ≥2 → 判定 R14 | ||
| - Figma 图层顺序(`children` 数组索引)对应视觉 z:索引大的在上 | ||
| - 输出 context 里列出各 fixed 节点及推荐 z-index 值 | ||
| ## 相关 | ||
| - rules/R01-fixed-position.md | ||
| - SKILL.md §4.3 硬规则第 1 条 (fixed-) |
| # R15 - 同构 map 渲染 | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ❌ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**) | ||
| - **排斥条件**: | ||
| - 同层 <3 个同构节点 → 不强制 map | ||
| - 每个节点有明显不同的交互 / 命名 / 结构差异 → 保留独立元素 | ||
| ## 触发条件 | ||
| - **cache**: 同一父节点下有 **≥3 个** 结构同构的子节点 | ||
| - **同构判定**: | ||
| - 相同 `type` | ||
| - 相同 `children` 结构(层级 + 类型分布相同) | ||
| - 相同 `name` 前缀(如 `item-1`, `item-2`, `item-3` 或 `card_1`, `card_2`) | ||
| - **命中信号**: agent 展开成 3+ 份重复 JSX + SCSS | ||
| ## 期望产物 | ||
| **JSX 端** (强制): | ||
| ```jsx | ||
| {[ | ||
| { title: '...', desc: '...' }, | ||
| { title: '...', desc: '...' }, | ||
| { title: '...', desc: '...' }, | ||
| ].map((item, i) => ( | ||
| <div key={i} className={styles.card}> | ||
| <div className={styles.cardTitle}>{item.title}</div> | ||
| <div className={styles.cardDesc}>{item.desc}</div> | ||
| </div> | ||
| ))} | ||
| ``` | ||
| **SCSS 端** (强制): | ||
| - 同构节点**只写一份** `.card { ... }` 规则 | ||
| - 通过 `:nth-child(n)` 处理个别差异(通常无差异,不需要) | ||
| ## 反例 (agent 常见错法) | ||
| ```jsx | ||
| {/* Figma: 同层 3 个 item-* 节点 */} | ||
| {/* ❌ 错法: 展开 3 份重复 JSX */} | ||
| <div className={styles.item1}> | ||
| <div className={styles.item1Title}>标题 1</div> | ||
| <div className={styles.item1Desc}>描述 1</div> | ||
| </div> | ||
| <div className={styles.item2}> | ||
| <div className={styles.item2Title}>标题 2</div> | ||
| <div className={styles.item2Desc}>描述 2</div> | ||
| </div> | ||
| <div className={styles.item3}> | ||
| <div className={styles.item3Title}>标题 3</div> | ||
| <div className={styles.item3Desc}>描述 3</div> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| /* ❌ 错法: 展开 3 份重复 SCSS */ | ||
| .item1 { ... } | ||
| .item1Title { ... } | ||
| .item2 { ... } | ||
| .item2Title { ... } | ||
| .item3 { ... } | ||
| .item3Title { ... } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| const CARDS = [ | ||
| { title: '预约票', desc: '开售自动抢' }, | ||
| { title: '优惠券', desc: '限时领取' }, | ||
| { title: '会员权益', desc: '专享特惠' }, | ||
| ]; | ||
| <div className={styles.cardList}> | ||
| {CARDS.map((card, i) => ( | ||
| <div key={i} className={styles.card}> | ||
| <div className={styles.cardTitle}>{card.title}</div> | ||
| <div className={styles.cardDesc}>{card.desc}</div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .cardList { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 20px; | ||
| } | ||
| .card { | ||
| padding: 30px; | ||
| background: #FFFFFF; | ||
| border-radius: 16px; | ||
| } | ||
| .cardTitle { font-size: 32px; font-weight: bold; } | ||
| .cardDesc { font-size: 24px; color: #666; } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**: | ||
| - 代码冗长 3 倍以上,可维护性差 | ||
| - 后续加/删一项需要多处改 | ||
| - SCSS 类名膨胀,增大产物体积 | ||
| - **典型事故**: | ||
| - v0.3.14 test8 事故 — 6 个卡片各 15 行 JSX + 30 行 SCSS,合计 270 行,合并成 map 后仅 40 行 | ||
| ## Rule-Scan 识别提示 | ||
| - 找同层 ≥3 个子节点 | ||
| - 判断"同构": | ||
| 1. `type` 相同 | ||
| 2. 子结构签名相同(如 `[TEXT, TEXT, VECTOR]`) | ||
| 3. 名字前缀相同(可选) | ||
| - 输出 context 里列出: | ||
| - 同构节点 nodeId 列表 | ||
| - 每个节点的可提取内容差异(如 title / imageUrl) | ||
| ## 相关 | ||
| - rules/R12-flat-mode-naming.md |
| # R16 - no-flatten-text | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底,建议 sub-agent 生成前也自查一遍) | ||
| - **排斥条件**: | ||
| - 节点 name **裸词** `img` / `bg` → 免疫 | ||
| - 节点 name 以 **`img-`** / **`bg-`** 开头(且后有字符)→ 免疫 | ||
| - 节点 `type` 不在 `{GROUP, FRAME, COMPONENT, INSTANCE}` → 不适用 | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. `node.type` ∈ `{GROUP, FRAME, COMPONENT, INSTANCE}` | ||
| 2. `node.name` 前缀 **非** `img-` / `bg-` / 裸词 `img` / `bg` | ||
| 3. 该节点子树(递归 children)中 **存在** 至少一个 `TEXT` 类型节点 | ||
| 4. 产物 jsx 中出现 `<img ... data-node-id="<该节点 nodeId>" ... />`(无论跨行/属性顺序) | ||
| 命中 → 违规。 | ||
| ## 期望产物 | ||
| **JSX 端**: | ||
| - 不允许 `<img data-node-id="<该 nodeId>">` | ||
| - 应按 §4.3 前缀规则递归拆解子树: | ||
| - TEXT 子节点 → `<span>` / `<p>` 展开 | ||
| - `btn-` 子节点 → `<button>` + CSS 化 | ||
| - `img-` 子节点 → `<img>` 引用切图 | ||
| - `bg-` 子节点 → 挂父容器 `background-image` | ||
| - 无前缀装饰性 vector 子树 → 走 R03 隐式切图 | ||
| **assets.txt 端**: | ||
| - 不允许出现该 nodeId 的"整体切图"行 | ||
| ## 反例(agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ Frame 745 含 TEXT "北京时间18:00开抢" + btn,被整体烤成 png */} | ||
| <img | ||
| className={styles.couponBig} | ||
| src={`${ASSET}frame-745.png`} | ||
| data-node-id="211:171" | ||
| alt="" | ||
| /> | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { | ||
| "id": "211:171", | ||
| "name": "Frame 745", | ||
| "type": "FRAME", | ||
| "children": [ | ||
| { "id": "211:174", "name": "coupon-big-bg", "type": "RECTANGLE" }, | ||
| { "id": "211:198", "name": "折扣数字", "type": "TEXT" }, // ← 触发 R16 | ||
| { "id": "211:212", "name": "btn-q", "type": "GROUP" } | ||
| ] | ||
| } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* ✅ 拆解子树;bg-* 挂父 background;TEXT 用 <span>;btn-* 用 <button> */} | ||
| <div className={styles.couponBig} data-node-id="211:171"> | ||
| {/* 211:174 bg-* → 父容器 background-image,此处不生成 DOM */} | ||
| <span className={styles.couponBigNum} data-node-id="211:198">1折</span> | ||
| <button className={styles.couponBigBtn} type="button" data-node-id="211:212"> | ||
| 立即抢 | ||
| </button> | ||
| </div> | ||
| ``` | ||
| ```scss | ||
| .couponBig { | ||
| position: relative; | ||
| width: 718px; | ||
| height: 300px; | ||
| background: url('#{$asset-prefix}coupon-big-bg.png') no-repeat center / 100% 100%; | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:TEXT 无障碍缺失(屏幕阅读器读不到)、无法本地化(换语言换不了)、按钮不可点击、体验不可修改 | ||
| - **典型事故**:v1.0.0 test12 事故 — Frame 745/744/762/img-title-quan + sub-MAIN 五处被整体烤成 png,91 条 R02/R06 违规被 agent 用 `[整体切图兜底]` 标签自签豁免 | ||
| ## 与其他规则的关系 | ||
| - **R03 implicit-image**:R03 自身已排斥子树含 TEXT 的情况;R16 是"哪怕 agent 无视 R03 的排斥条件,最终也拦得住"的兜底 | ||
| - **R02 fills-image**:R02 只管 fills 含 IMAGE 的节点必须落切图;R16 管 fills 不含 IMAGE 但被违规整体切图的情况 | ||
| - **§6.0.2 兜底防线 N=0**:R16 输出的 violation 一律不许被 `[整体切图兜底]` 豁免(该标签已废除) | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 `type ∈ {GROUP, FRAME, COMPONENT, INSTANCE}` 且 `name` 非白名单的节点 | ||
| - 递归 `children` 判断子树是否含 `TEXT` | ||
| - 是 → 在 `rule-hits.json` 里给该节点标记 `rule: 'R16'` + `expected: '禁止整体切图;按前缀拆解子树'` | ||
| - UI sub-agent 生成 jsx 时若准备对该节点写 `<img>` → 强制回退到子树递归 | ||
| ## 相关 | ||
| - SKILL.md §4.3 图层解析规则(前缀白名单来源) | ||
| - SKILL.md §6.0.2 兜底防线(配合本规则彻底废除"整体切图兜底"路径) | ||
| - rules/R02-fills-image.md | ||
| - rules/R03-implicit-image.md | ||
| - rules/R06-text-solid-last.md |
| # R17 - no-baked-dom(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - 节点 `_inBakedSubtree !== true`(不在整体切图子树内)→ 不适用 | ||
| - 节点 `_templateDup === true`(`.map()` 数据副本)→ 跳过(代表项报过即可) | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. 节点处于 `bg-` / `img-`(整体切图,含裸词 `bg`/`img`)或 `x-`(整体忽略)前缀节点的**子树内**(`loadCache.mjs` 标注 `_inBakedSubtree === true`,`_bakedBy` 指向那个前缀节点)。**不含 `bgc-`**:bgc- 是盒级 CSS 写父、非切图,其子孙走正常规则(见 R21) | ||
| 2. 产物 JSX 中出现该节点的 `data-node-id="<nodeId>"` 元素(跨行 / 属性顺序任意) | ||
| 命中 → 违规(双重渲染)。 | ||
| ## 期望产物 | ||
| - 整体切图子树内的节点,其像素**已经在父层切图 PNG 里**(`bg-`/`bgc-`/`img-`),或被 `x-` 整体忽略 | ||
| - 产物中**不得**再有其 `data-node-id` 元素——它不该作为独立 DOM 出现 | ||
| - 与 R02/R06 分工:R02/R06 **跳过** baked 子孙(不逐个溯源),"禁 DOM" 由本条正向兜底 | ||
| ## 反例(agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ bg-main(211:39) 整体切成 main.png,title/subtitle 文字像素已在 PNG 里 */} | ||
| <div className="page__main-bg" data-node-id="211:39" /> | ||
| {/* ↓ 但又把 bg-main 子孙的 title-text/subtitle 生成成 DOM → 文字渲染两遍、叠字 */} | ||
| <div className="page__title-row" data-node-id="211:410"> | ||
| <span className="page__title-text" data-node-id="211:83">中秋火车票开售预测</span> | ||
| </div> | ||
| <p className="page__subtitle" data-node-id="211:84">官方尚未给出明确开售时间...</p> | ||
| ``` | ||
| 对应 cache(211:83 / 211:84 是 bg-main 211:39 的子孙): | ||
| ```json | ||
| { "id": "211:39", "name": "bg-main", "type": "GROUP", | ||
| "children": [ { "id": "211:410", "children": [ { "id": "211:83", "type": "TEXT" } ] }, | ||
| { "id": "211:82", "children": [ { "id": "211:84", "type": "TEXT" } ] } ] } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* ✅ bg-main 整体切图 → 只保留背景层,子孙文字不再出 DOM(已在 main.png 里) */} | ||
| <div className="page__main-bg" data-node-id="211:39" /> | ||
| {/* title-text / subtitle 不生成 —— 它们的像素在 main.png 中 */} | ||
| ``` | ||
| 若文案需要动态替换 → 见 §4.3「含 TEXT 容器 压平 vs 拆」裁决树:改走**拆结构**(去掉 bg- 前缀,文字出 DOM,背景单独切成不含文字的图),而非"既烤又留"。 | ||
| ## 违反后果 | ||
| - **产物表现**:文字/图叠一遍(切图里一份 + DOM 一份),视觉重影、错位 | ||
| - **典型事故**:v1.1.0 test13 — bg-main 的 title-text/subtitle/韩国贴纸既进 main.png 又出 DOM,用户手工删除 DOM 才修正 | ||
| ## 与其他规则的关系 | ||
| - **R16 no-flatten-text**:R16 管"不该压平的容器被整体切图";R17 管"该压平的容器压平后子孙又出 DOM"。二者是同一矛盾(压平 vs 拆)的两面 | ||
| - **R02/R06**:跳过 `_inBakedSubtree` 节点(不误报"缺 url/缺 color"),"禁 DOM" 交由 R17 | ||
| - **§6.0.2 兜底防线 N=0**:R17 违规一律不许豁免,回滚 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache,找 `_inBakedSubtree === true` 的节点 | ||
| - 检查产物 JSX 是否有其 `data-node-id` | ||
| - 有 → 标记 `rule: 'R17'` + `expected: '整体切图子树内节点禁止出 DOM(像素已在父层切图)'` | ||
| ## 相关 | ||
| - SKILL.md §4.3「含 TEXT 容器 压平 vs 拆」裁决树 | ||
| - SKILL.md §6.0.2 兜底防线 | ||
| - rules/R16-no-flatten-text.md | ||
| - bin/lib/loadCache.mjs(`_inBakedSubtree` 标注) |
| # R18 - flex-direction(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `layoutMode` 非 `HORIZONTAL`/`VERTICAL`(非 autolayout 容器)→ 不适用 | ||
| - `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过 | ||
| - `classMap[nodeId]` 为空(不可追溯)→ 不报,由 §5.1.1 data-node-id 铁律在生成侧兜底 | ||
| - 该节点 CSS 规则体无 `display: flex` → 不判定(可能非 flex 实现) | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. `node.layoutMode` ∈ `{HORIZONTAL, VERTICAL}` | ||
| 2. 节点有 `data-node-id` + className 映射,且 CSS 规则体含 `display: flex` | ||
| 3. CSS 的 `flex-direction` 与 Figma `layoutMode` **不符**: | ||
| - `layoutMode === 'VERTICAL'` 但 CSS **非** `flex-direction: column`(含缺省,默认 row)→ 违规 | ||
| - `layoutMode === 'HORIZONTAL'` 但 CSS **是** `flex-direction: column`(方向写反)→ 违规 | ||
| 命中 → 违规。 | ||
| ## 期望产物 | ||
| | Figma `layoutMode` | CSS `flex-direction` | | ||
| |--------------------|----------------------| | ||
| | `VERTICAL` | `column`(**必须显式写**,否则默认 row 会横排) | | ||
| | `HORIZONTAL` | `row` 或省略(row 是 flex 默认,可不写) | | ||
| ## 反例(agent 常见错法) | ||
| ```scss | ||
| /* ❌ small-card-top 对应 Figma Frame 764 layoutMode=VERTICAL,却写成 row */ | ||
| .page { | ||
| &__small-card-top { | ||
| display: flex; | ||
| flex-direction: row; /* ← 应为 column,价格与描述该竖排却横排 */ | ||
| align-items: center; | ||
| } | ||
| } | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { "id": "211:221", "name": "Frame 764", "type": "FRAME", "layoutMode": "VERTICAL" } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| /* ✅ VERTICAL → 显式 column */ | ||
| .page { | ||
| &__small-card-top { | ||
| display: flex; | ||
| flex-direction: column; | ||
| align-items: center; | ||
| } | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:子元素排列方向反了(竖排变横排 / 横排变竖排),整块布局错乱 | ||
| - **典型事故**:v1.1.0 test13 — small-card-top(Figma VERTICAL)写成 `flex-direction: row`,价格与描述横排;且该节点在 `.map()` 模板里没挂 data-node-id,逃过校验直接上线 | ||
| ## 与其他规则的关系 | ||
| - **§5.1.1 data-node-id 全覆盖铁律**:R18 靠 data-node-id 绑定;`.map()` 模板必须挂代表项(variant a)id,否则 R18 无法校验(正是 test13 逃逸根因) | ||
| - **lib/cssMatch.mjs**:R18 用它匹配 SCSS `&__foo` 嵌套写法,避免"产物嵌套、正则找平铺"的假阴性 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 `layoutMode ∈ {HORIZONTAL, VERTICAL}` 的容器 | ||
| - 提示 UI sub-agent:VERTICAL 必写 `flex-direction: column`;勿凭视觉猜方向 | ||
| - 标记 `rule: 'R18'` + `expected: 'VERTICAL→column / HORIZONTAL→row'` | ||
| ## 相关 | ||
| - SKILL.md §4.3 判定优先级(子视角 layoutMode↔flex-direction) | ||
| - SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| - bin/lib/cssMatch.mjs | ||
| - rules/R19-padding.md(同为 autolayout 容器忠实度) |
| # R19 - padding(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `layoutMode` 非 `HORIZONTAL`/`VERTICAL`(padding 仅在 autolayout 容器有意义)→ 不适用 | ||
| - `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过 | ||
| - `classMap[nodeId]` 为空 → 不报,由 §5.1.1 兜底 | ||
| - CSS padding 含非 px 值(%/auto/var/calc)→ 放弃比对(不误报) | ||
| ## 触发条件 | ||
| 设 Figma 期望 padding = `[paddingTop, paddingRight, paddingBottom, paddingLeft] × scale`,容差 2px。 | ||
| **任一命中**: | ||
| 1. Figma 四边 padding 均为 0,但产物写了非 0 padding → **凭空捏造** | ||
| 2. Figma 有 padding,但产物**未写** padding → 漏写 | ||
| 3. 产物 padding 某边与 Figma×scale **相差 > 2px** → 数值错 | ||
| 命中 → 违规。 | ||
| ## 期望产物 | ||
| - CSS padding 四值 ≈ `Figma paddingT/R/B/L × scale` | ||
| - Figma pad0 → 产物**不写** padding(或显式 0) | ||
| - 支持 shorthand(`padding: 0 82px 10px 82px`)与 longhand(`padding-left` 等),longhand 覆盖 shorthand 对应边 | ||
| - **无单位 0 合法**(`padding: 0 12px` 里的 `0` 按 0px 处理) | ||
| ## 反例(agent 常见错法) | ||
| ```scss | ||
| /* ❌ small-card-top 对应 Figma Frame 764 四边 padding 全 0,却凭空加了 0 12px */ | ||
| .page { | ||
| &__small-card-top { | ||
| display: flex; | ||
| padding: 0 12px; /* ← Figma pad0,凭空捏造 */ | ||
| } | ||
| } | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { "id": "211:221", "layoutMode": "VERTICAL", | ||
| "paddingTop": 0, "paddingRight": 0, "paddingBottom": 0, "paddingLeft": 0 } | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| /* ✅ Figma pad0 → 不写 padding */ | ||
| .page { | ||
| &__small-card-top { display: flex; flex-direction: column; } | ||
| } | ||
| /* ✅ Figma pad=[0,41,5,41],scale=2 → padding: 0 82px 10px 82px */ | ||
| .page { | ||
| &__btn-q { display: flex; padding: 0 82px 10px 82px; } | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:内容框内边距多/少,元素错位、挤压或留白异常 | ||
| - **典型事故**:v1.1.0 test13 — small-card-top(Figma pad0)凭空加 `padding: 0 12px`;btn-q(Figma pad=[0,41,5,41])漏写 padding | ||
| ## 与其他规则的关系 | ||
| - **R18 flex-direction**:同为 autolayout 容器忠实度,成对使用 | ||
| - **R13 unit-scale**:R19 的期望值走 `Figma padding × scale`,与 R13 换算口径一致 | ||
| - **§5.1.1**:靠 data-node-id 绑定,模板项挂代表项 id | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 autolayout 容器,读 `paddingTop/Right/Bottom/Left` | ||
| - 提示 UI sub-agent:padding 从 Figma 读,勿凭视觉估;Figma 0 就别写 | ||
| - 标记 `rule: 'R19'` + `expected: 'padding = Figma四边×scale'` | ||
| ## 相关 | ||
| - SKILL.md §4.3 判定优先级(父视角 padding) | ||
| - SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| - bin/lib/cssMatch.mjs | ||
| - rules/R18-flex-direction.md |
| # R20 - absolute-position(v1.2.0 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅ | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `layoutPositioning !== 'ABSOLUTE'`(未脱离父顺流)→ 不适用 | ||
| - `name` 以 `fixed-` 开头 → 跳过(走 constraints 视口定位,R01 域,非 bbox 相对定位) | ||
| - `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过 | ||
| - `classMap[nodeId]` 为空 → 不报,由 §5.1.1 兜底 | ||
| - 节点或父节点缺 `absoluteBoundingBox` → 无法精确计算,不误报 | ||
| ## 触发条件 | ||
| 设 `scale` = `config.unit.scale`(默认 2),容差 4px,父 = `cache.nodes[node._parentId]`: | ||
| - 期望 `left = (node.bbox.x − parent.bbox.x) × scale` | ||
| - 期望 `top = (node.bbox.y − parent.bbox.y) × scale` | ||
| **任一命中**: | ||
| 1. 产物写了 `top`/`left` 但与期望**相差 > 4px** → 坐标错(典型:靠猜) | ||
| 2. 期望值**非 0**(|exp| > 4)但产物**缺** `top`/`left` → 丢了真实偏移 | ||
| 命中 → 违规。 | ||
| > **容忍**:期望值 ≈ 0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,不报(避免噪声)。支持 `top: 0` 无单位零与 `inset` 简写。 | ||
| ## 期望产物 | ||
| - ABSOLUTE 子节点:`position: absolute` + `top`/`left` = `(子bbox − 父bbox) × scale` | ||
| - 父容器加 `position: relative` | ||
| - **能从 bbox 精确算出的坐标,禁止靠猜 + 「需人工核对」兜底**(§6.0.2 已封该逃逸口) | ||
| ## 反例(agent 常见错法) | ||
| ```scss | ||
| /* ❌ img-huochepiao 相对父 Frame 760:真值 left=-5/top=-6.5(×2=-10/-13,溢出到背景上) | ||
| 产物却写正值 40/40,方向完全反 */ | ||
| .page { | ||
| &__screen-piao { | ||
| position: absolute; | ||
| top: 40px; /* ← 应 -13px */ | ||
| left: 40px; /* ← 应 -10px */ | ||
| } | ||
| } | ||
| ``` | ||
| 对应 cache: | ||
| ```json | ||
| { "id": "211:440", "name": "img-huochepiao", "layoutPositioning": "ABSOLUTE", | ||
| "absoluteBoundingBox": { "x": -4016, "y": 248 }, | ||
| "_parentId": "211:102" } // 父 Frame 760 bbox.x=-4011, y=254.5 | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```scss | ||
| /* ✅ left=(-4016−(-4011))×2=-10;top=(248−254.5)×2=-13 */ | ||
| .page { | ||
| &__screen { position: relative; } /* 父加 relative */ | ||
| &__screen-piao { | ||
| position: absolute; | ||
| top: -13px; | ||
| left: -10px; | ||
| } | ||
| } | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:绝对定位元素位置错乱(本该溢出到背景上的贴纸缩进容器内 / 偏移丢失贴边错位) | ||
| - **典型事故**:v1.1.0 test13 — img-huochepiao 坐标靠猜写 40/40(应 -10/-13),agent 用「需人工核对」兜底交付 | ||
| ## 与其他规则的关系 | ||
| - **R01 fixed-position**:`fixed-` 前缀走 constraints 视口定位,由 R01 管;R20 只管非 fixed 的 `layoutPositioning: ABSOLUTE` | ||
| - **§6.0.2**:R20 覆盖的坐标是"可机械计算量",禁用「需人工核对」豁免 | ||
| - **§5.1.1**:靠 data-node-id 绑定 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里 `layoutPositioning === 'ABSOLUTE'` 且非 `fixed-` 的节点 | ||
| - 提示 UI sub-agent:top/left 用 `(子bbox − 父bbox) × scale` 算,勿目测;父加 `position: relative` | ||
| - 标记 `rule: 'R20'` + `expected: 'top/left=(子bbox−父bbox)×scale'` | ||
| ## 相关 | ||
| - SKILL.md §4.3 判定优先级第 0 条(`layoutPositioning: ABSOLUTE`) | ||
| - SKILL.md §6.0.2(禁「需人工核对」用于可计算量) | ||
| - rules/R01-fixed-position.md | ||
| - bin/lib/loadCache.mjs(`_parentId` 供父 bbox 查询) |
| # R21 - node-id-coverage(v1.2.1 对账新增) | ||
| ## 判定归属 | ||
| - **硬防线** (check-rules.mjs 自动拦截): ✅(优先级最高——节点无 node-id 则其余绑定类规则全失效) | ||
| - **软防线** (Rule-Scan sub-agent 识别): ✅(兜底) | ||
| - **排斥条件**: | ||
| - `_inBakedSubtree`(bg-/img- 整体切图 或 x- 忽略子树,本就不出独立 DOM)→ 跳过 | ||
| - `_hidden`(不渲染)→ 跳过 | ||
| - `_templateDup`(`.map()` 数据副本,只需代表项挂 id)→ 跳过 | ||
| - `name` 前缀 `bg-` / `bgc-` / `x-`(自身不生成独立 DOM:bg/bgc 挂父,x 忽略)→ 跳过 | ||
| ## 触发条件 | ||
| **同时满足**: | ||
| 1. 节点"应生成独立 DOM"(满足任一): | ||
| - `type === 'TEXT'` | ||
| - autolayout 容器:`layoutMode ∈ {HORIZONTAL, VERTICAL}` | ||
| - `layoutPositioning === 'ABSOLUTE'`(需 R20 校验坐标) | ||
| - `name` 前缀 `img-` / `btn-` / `input-`(生成 `<img>`/`<button>`/`<input>`) | ||
| 2. 产物 JSX 中**找不到**该节点的 `data-node-id="<nodeId>"`(跨行/属性顺序任意) | ||
| 命中 → 违规(不可追溯)。 | ||
| ## 期望产物 | ||
| - 凡承载 Figma 语义、会渲染成 DOM 的节点,产物元素必须带 `data-node-id="<nodeId>"` | ||
| - `.map()` 模板项:用**代表项(列表第一个同构兄弟 = variant a)**的 nodeId 挂在模板元素上;副本(variant b/c)已被 `_templateDup` 跳过,只校验代表项 | ||
| - 详见 SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| ## 为什么必须机械强制 | ||
| 没有 node-id,R06(字色)/R18(flex 方向)/R19(padding)/R20(绝对坐标)在 `classMap[nodeId]` 为空时只能 `continue` → **对账规则集体失灵**。若只把"挂 id"写成文档铁律,agent 仍可漏挂 → bug 静默逃逸(典型 test13:`.small-card-top` 无 data-node-id,flex 方向反 + 幻觉 padding 直接上线,R18/R19 绑定不上没拦住)。R21 把"应渲染却无 id"本身变成硬违规,从机制上堵死这条逃逸。 | ||
| ## 反例(agent 常见错法) | ||
| ```jsx | ||
| {/* ❌ .map() 模板:容器与文字都没挂 data-node-id → 逃出 R18/R19/R06 校验 */} | ||
| {CARDS.map((c) => ( | ||
| <div className="page__small-card" key={c.key}> | ||
| <div className="page__small-card-top"> {/* 无 data-node-id */} | ||
| <span className="page__small-card-title">{c.title}</span> {/* 无 data-node-id */} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| ``` | ||
| ## 落地代码模板 | ||
| ```jsx | ||
| {/* ✅ 模板挂代表项(variant a)的 nodeId;副本由 _templateDup 跳过 */} | ||
| {CARDS.map((c) => ( | ||
| <div className="page__small-card" data-node-id="211:218" key={c.key}> | ||
| <div className="page__small-card-top" data-node-id="211:221"> | ||
| <span className="page__small-card-title" data-node-id="211:227">{c.title}</span> | ||
| </div> | ||
| </div> | ||
| ))} | ||
| ``` | ||
| ## 违反后果 | ||
| - **产物表现**:节点不可追溯 → 对账规则绑定不上 → 布局/字色/坐标错误无人拦截,静默上线 | ||
| - **典型事故**:v1.1.0 test13 — `.map()` 模板的 small-card-top / 文字节点全无 data-node-id,R18/R19 无法校验,flex 方向反 + 幻觉 padding 逃逸 | ||
| ## 与其他规则的关系 | ||
| - **§5.1.1 data-node-id 全覆盖铁律**:R21 是该铁律的机械执行体 | ||
| - **R06**:R06 只校验"可追溯 TEXT 的字色";"TEXT 不可追溯"由 R21 统一报,二者不双报同一节点 | ||
| - **R18/R19/R20**:都靠 data-node-id 绑定;R21 保证绑定前提成立 | ||
| - **_templateDup**:R21 只校验代表项,副本跳过——与"模板挂代表项 id"策略配套 | ||
| ## Rule-Scan 识别提示 | ||
| - 遍历 cache 里"应渲染"节点(TEXT / autolayout 容器 / ABSOLUTE / img-·btn-·input-) | ||
| - 排除 baked/hidden/templateDup 与 bg-/bgc-/x- 前缀 | ||
| - 提示 UI sub-agent:每个这类节点必挂 data-node-id,模板挂代表项 id | ||
| - 标记 `rule: 'R21'` + `expected: '应渲染节点必挂 data-node-id'` | ||
| ## 相关 | ||
| - SKILL.md §5.1.1 data-node-id 全覆盖铁律 | ||
| - bin/lib/loadCache.mjs(`_inBakedSubtree`/`_hidden`/`_templateDup` 标注) | ||
| - rules/R18-flex-direction.md / R19-padding.md / R20-absolute-position.md(依赖 R21 保证的可追溯前提) |
| # pp-d2c 规则库 | ||
| > pp-d2c skill 硬性规则的原始定义。当 rules/*.md 内容与 SKILL.md 冲突时以 rules/ 为准。 | ||
| ## 内置前缀常量表(硬编码,不可配置) | ||
| Figma 图层名前缀是**内置常量**,写死在 skill 里,不再从 config 读取。所有规则(SKILL / rules/ / check-rules.mjs / Rule-Scan sub-agent / UI sub-agent)一律用下表值: | ||
| | 前缀 | 语义 | | ||
| |---|---| | ||
| | `sub-` | 分块边界(sub-agent 派发单元) | | ||
| | `block-` | 独立布局块(命名空间隔离) | | ||
| | `img-` | 图片内容(生成 `<img>`,不递归) | | ||
| | `bg-` | 背景图(挂父容器 background-image,自身不生成 DOM) | | ||
| | `bgc-` | 背景纯色(全套盒级 CSS 写父,自身不生成 DOM) | | ||
| | `btn-` | 可点击区域(永远 CSS 化) | | ||
| | `scrollx-` | 横向滚动容器 | | ||
| | `scrolly-` | 纵向滚动容器 | | ||
| | `fixed-` | 视口固定定位(修饰前缀) | | ||
| | `end-` | 逆向布局(贴父末端,修饰前缀) | | ||
| | `input-` | 输入框(生成 `<input type="text">`,不递归) | | ||
| | `x-` | 忽略(跳过整层,优先级最高) | | ||
| **pp-d2c.config.json 里不再有 `layers` 段**——之前的 `layers.sub` / `layers.bg` / `layers.but` 等映射都已删除。 | ||
| ## 索引表 | ||
| | ID | 名称 | 判定归属 | 一句话触发条件 | | ||
| |---|---|---|---| | ||
| | R01 | fixed-position | 硬防线 | `name.startsWith('fixed-')` | | ||
| | R02 | fills-image | 硬防线 | `fills[].some(f => f.type === 'IMAGE' && f.visible !== false)` | | ||
| | R03 | implicit-image | 软防线 | 无前缀 + 整棵子树 VECTOR/BOOL/几何 + 无 TEXT/INSTANCE + 无 btn-/input-/sub-/block- 子层 | | ||
| | R04 | text-gradient | 软防线 | `TEXT` 节点,fills 末位可见 = `GRADIENT_*` 或 `IMAGE` | | ||
| | R05 | space-between | 硬防线 | `primaryAxisAlignItems === 'SPACE_BETWEEN'` | | ||
| | R06 | text-solid-last | 硬防线 | `TEXT` 节点,fills 末位可见 = `SOLID` | | ||
| | R07 | multi-fills | 软防线 | fills 数组多层可见(≥2),且不全是 SOLID | | ||
| | R08 | bg-landing-form | 硬防线 | `name.startsWith('bg-')` 或 `name === 'bg'`,产物落地形态错 | | ||
| | R09 | btn-bgc-取值 | 软防线 | `btn-` 前缀内含 `bgc-` 子层,bgc 的真 fills 是 GRADIENT/IMAGE | | ||
| | R10 | no-fake-solid-color | 软防线 | 产物 CSS 出现 `color: #XXX`,但 cache 里对应节点找不到源头 | | ||
| | R11 | mask-vector-css-able | 软防线 | 复合 mask / 多层 vector,CSS 表达不了 → 应切图 | | ||
| | R12 | flat-mode-naming | 软防线 | `merge.mode === 'flat'` 下类名跨 block 冲突 | | ||
| | R13 | unit-scale | 软防线 | Figma px → 产物 px 未换算(应 `outputBase / figmaBase`) | | ||
| | R14 | fixed-z-index | 软防线 | 多个 `fixed-` 节点,z-index 未递增 | | ||
| | R15 | 同构 map 渲染 | 软防线 | 同层 ≥3 同构子节点,展开成重复代码而非 `.map()` | | ||
| | R16 | no-flatten-text | 硬防线 | GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT 且前缀非 `img-`/`bg-`,产物 jsx 出现 `<img data-node-id="该节点">` | | ||
| | R17 | no-baked-dom | 硬防线 | 节点 `_inBakedSubtree`(处于 bg-/bgc-/img-/x- 整体切图子树内),产物却有其 `data-node-id`(双重渲染) | | ||
| | R18 | flex-direction | 硬防线 | autolayout 容器 `layoutMode` 与产物 `flex-direction` 不符(VERTICAL 却非 column / HORIZONTAL 却 column) | | ||
| | R19 | padding | 硬防线 | autolayout 容器 padding 与 `Figma paddingT/R/B/L × scale` 不符(凭空加 / 漏写 / 数值错) | | ||
| | R20 | absolute-position | 硬防线 | `layoutPositioning === 'ABSOLUTE'`(非 fixed-),top/left ≠ (子bbox−父bbox)×scale | | ||
| | R21 | node-id-coverage | 硬防线 | 应渲染节点(TEXT/autolayout 容器/ABSOLUTE/img-·btn-·input-)在产物 JSX 里找不到 data-node-id | | ||
| ## 判定归属说明 | ||
| **硬防线** (`check-rules.mjs` 自动拦截): 用代码 grep + JSON scan 精确判定,exit 1 拦截 → R01 / R02 / R05 / R06 / R08 / R16 / R17 / R18 / R19 / R20 / R21。 | ||
| **软防线** (Rule-Scan sub-agent 识别): 需 LLM 语义判断,输出 `rule-hits.json` 给 UI sub-agent 参考 → R03 / R04 / R07 / R09 / R10 / R11 / R12 / R13 / R14 / R15。 | ||
| **v1.2.0 对账基座**: R02 / R06 / R17 / R18 / R19 / R20 依赖 `bin/lib/loadCache.mjs` 标注的 `_inBakedSubtree`(整体切图子树)/`_hidden`(隐藏)/`_templateDup`(`.map()` 数据副本),以及 `bin/lib/cssMatch.mjs` 的 SCSS `&__foo` 嵌套匹配。这些标注把"整体切图子树 / 隐藏 / 列表副本 / 嵌套写法"四类假阳性从根源清除,使硬防线报数即真值,校验从"黑名单抽查"升级为"以 cache 为真值逐节点对账"。 | ||
| ## 使用方式 | ||
| ### Rule-Scan sub-agent | ||
| 派发时的完整 prompt: | ||
| ``` | ||
| 你是 Rule-Scan sub-agent, 只做规则识别, 不写 UI 代码. | ||
| 任务: | ||
| 1. Read templates/skills/pp-d2c/rules/*.md (全部 19 条) | ||
| 2. Read .d2c-cache/<cache-key>/nodes/ 下与本 block nodeIds 相关的 JSON | ||
| 3. 对本 block 的每个节点, 判断命中了哪些规则 | ||
| 4. 输出 rule-hits.json (schema 见附) | ||
| 规则命中判定原则: | ||
| - 硬防线规则 (R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21): 你也扫,即使 check-rules.mjs 会兜底 | ||
| - 软防线规则 (R03/R04/R07/R09-R15): 你是唯一识别方 | ||
| - 排斥条件: 若节点命中高优先级规则, 低优先级规则不再重复列 | ||
| - 优先级 (由高到低): R21 > R16 > R17 > R02 > R01 > R05 > R11 > R03 > R04 > R07 > R06 > R09 > R08 > R20 > R18 > R19 > R14 > R15 > R13 > R12 > R10(R21 最高:节点不可追溯则其余绑定类规则无从谈起) | ||
| 输出要求: | ||
| - 每个 hit 包含 nodeId / rule / trigger 描述 / expected 描述 / context (关键 JSON 字段抽样) | ||
| - 输出 JSON, 不带 markdown 代码块围栏, 不加解释文字 | ||
| - 落盘到 blocks/{sub}/rule-hits.json | ||
| 禁止: | ||
| - 不允许写 JSX / SCSS | ||
| - 不允许改 cache 文件 | ||
| - 不允许基于"设计意图猜测"命中规则; 只按 rules/*.md "触发条件" 字面判定 | ||
| ``` | ||
| ### UI sub-agent | ||
| - Read `blocks/{sub}/rule-hits.json` 里涉及的 R0X.md,按"期望产物"落地 | ||
| - 生完 JSX + SCSS 后跑: | ||
| ```bash | ||
| node .claude/skills/pp-d2c/bin/check-rules.mjs \ | ||
| --block blocks/{sub}/ \ | ||
| --cache-key <fileKey> | ||
| ``` | ||
| - exit 0 继续 / exit 1 按 violations 回滚重做 / exit 2 报环境错 | ||
| - `assets.txt` 追加"rule-hits 消费证明"块(格式见下) | ||
| **rule-hits 消费证明格式**: | ||
| ``` | ||
| ## rule-hits 消费证明 (v1.0.0) | ||
| - 输入 rule-hits 条数: N | ||
| - 处理到位条数: M (M == N 时 ✅) | ||
| - 处理列表: | ||
| - { nodeId, rule: "R0X", 落地类型: "css 属性" | "span 包裹" | "切图挂父" | ... } | ||
| - 遗漏补捕: K 条 | ||
| - [遗漏补捕] R0X {nodeId} "{name}": Rule-Scan 未识别, 自动补齐落地 = {做了什么} | ||
| - check-rules.mjs 通过: ✅ / ❌ + violations 列表 | ||
| ``` | ||
| ### check-rules.mjs | ||
| - **硬编码 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20 逻辑**,rules/*.md 是设计文档,不是执行文档 | ||
| - 假阳性时用 `--force-skip R0X,R0Y` 跳过,但 UI sub-agent 必须在 `assets.txt` 备注 `[脚本误判] R0X {nodeId} 理由: ...` | ||
| - 详细 CLI 见 `templates/skills/pp-d2c/bin/check-rules.mjs --help` | ||
| ## 排斥关系图 | ||
| ``` | ||
| R02 (fills-image) ─┬─► R11 (mask-vector-css-able): 已切图不再判 CSS-able | ||
| └─► R09 (btn-bgc): btn 内 fills IMAGE 走 R09 优先 | ||
| R01 (fixed-position) ── R14 (fixed-z-index): 多个 fixed 才判 R14 | ||
| R06 (text-solid-last) ─┬─► R04 (text-gradient): 末位是 GRADIENT/IMAGE 归 R04 | ||
| └─► R10 (no-fake-solid-color): R06 命中即已核对色源 | ||
| R05 (space-between) ── (无排斥) | ||
| R08 (bg-landing-form) ── (无排斥,反向匹配所以自成一体) | ||
| R03 (implicit-image) ── R11 (mask-vector-css-able): R03 覆盖后者的常见形态 | ||
| R12 (flat-mode-naming) ── (无排斥,只影响 merge.mode='flat') | ||
| R13 (unit-scale) ── (无排斥,单位) | ||
| R15 (同构 map) ── (无排斥,结构) | ||
| R16 (no-flatten-text) ── 最高优先级; 命中 R16 时 R02/R06 若源自同一"整体切图"违规则视为衍生, 不重复报 | ||
| R17 (no-baked-dom) ── 与 R16 配套(压平 vs 拆两面); R02/R06 跳过 _inBakedSubtree 节点(不报"缺 url/color"), "禁 DOM" 交 R17 正向兜底 | ||
| R18 (flex-direction) ─┬─ 与 R19 成对(autolayout 容器忠实度); 靠 data-node-id 绑定, 模板项挂代表项 id | ||
| R19 (padding) ────────┘ | ||
| R20 (absolute-position) ── 排斥 fixed-(那走 R01/constraints); 只管非 fixed 的 layoutPositioning:ABSOLUTE | ||
| R21 (node-id-coverage) ── 最高优先级; 节点无 data-node-id 则 R06/R18/R19/R20 全绑定不上, 先补 id 再谈其余; 排斥 baked/hidden/templateDup 与 bg-/bgc-/x-(不生成独立 DOM) | ||
| ``` | ||
| ## 版本 | ||
| - **v1.2.1** `_inBakedSubtree` 移除 bgc-(bgc- 盒级 CSS 写父、非切图,子孙误放 TEXT 应被 R06/R21 暴露而非静默吞); 新增 **R21 node-id-coverage**(应渲染节点漏挂 data-node-id 即 exit 1,机械强制 §5.1.1 铁律,堵 R18/R19/R20 遇空 classMap 静默 continue 的逃逸); §6.0.2 禁生成流程用 `--force-skip` | ||
| - **v1.2.0** 校验范式从"黑名单抽查"→"以 cache 为真值逐节点对账": loadCache 标注 `_inBakedSubtree`/`_hidden`/`_templateDup` + cssMatch 共享 SCSS 嵌套匹配(R02/R06 假阳性根源清除); 新增 R17 no-baked-dom / R18 flex-direction / R19 padding / R20 absolute-position 四条对账规则 | ||
| - v1.1.0 新增 R16 no-flatten-text 硬防线 | ||
| - v1.0.0 首次引入 rules/ 目录 + check-rules.mjs | ||
| - v0.3.21 之前:硬规则文字散落在 SKILL.md §4.3 等章节 | ||
| ## 相关 | ||
| - `templates/skills/pp-d2c/SKILL.md` — 主流程 | ||
| - `templates/skills/pp-d2c/bin/check-rules.mjs` — 硬防线脚本 | ||
| - `.Knowledge/req-docs/pp-d2c-rule-scan_技术方案.md` — 本轮技术方案 | ||
| - `.Knowledge/req-docs/pp-d2c-rule-scan_需求澄清.md` — 需求澄清 |
Sorry, the diff of this file is too big to display
+23
-15
@@ -207,3 +207,8 @@ #!/usr/bin/env node | ||
| const skillsSrc = path.join(TEMPLATES_DIR, 'skills') | ||
| const skillsDst = path.join(CWD, '.claude/skills') | ||
| // 无条件双写:同一 skill 同时落到 .claude/skills(Claude Code) 与 .codex/skills(Codex)。 | ||
| // .codex/skills 约定由 flow2spec 建立,结构与 .claude/skills 一致;两处一视同仁。 | ||
| const skillsDsts = [ | ||
| path.join(CWD, '.claude/skills'), | ||
| path.join(CWD, '.codex/skills'), | ||
| ] | ||
| // pp-style 是 pp-d2c 的规则速查手册,pp-doctor 是静态体检 skill;两者当前无独立触发入口、 | ||
@@ -220,5 +225,7 @@ // 没有工具调用能力、内容与主 SKILL 重复,**后期准备丢弃**,默认不落到用户项目。 | ||
| if (skipRn && entry.name === 'pp-d2c-rn') continue | ||
| if (skipH5 && entry.name === 'pp-d2c') continue | ||
| if (skipH5 && (entry.name === 'pp-d2c' || entry.name === 'pp-d2c-fast')) continue | ||
| if (OPT_IN_ONLY.has(entry.name)) continue | ||
| copyDir(path.join(skillsSrc, entry.name), path.join(skillsDst, entry.name), forceSkills, silent) | ||
| for (const skillsDst of skillsDsts) { | ||
| copyDir(path.join(skillsSrc, entry.name), path.join(skillsDst, entry.name), forceSkills, silent) | ||
| } | ||
| installedSkills.push(entry.name) | ||
@@ -233,3 +240,3 @@ } | ||
| const verb = _copyStats.overwrite > 0 ? '刷新' : _copyStats.copy > 0 ? '安装' : '跳过' | ||
| console.log(` ${verb} ${installedSkills.length} 个 skill(${installedSkills.join(' / ')}),共 ${total} 个文件到 .claude/skills/`) | ||
| console.log(` ${verb} ${installedSkills.length} 个 skill(${installedSkills.join(' / ')}),共 ${total} 个文件到 .claude/skills/ 与 .codex/skills/`) | ||
| } | ||
@@ -761,14 +768,15 @@ if (!silent) console.log('') | ||
| // 将单位规则注入主 Skill(按 framework 分叉:react → h5 SKILL;rn → rn SKILL) | ||
| const skillPath = framework === 'rn' | ||
| ? path.join(CWD, '.claude/skills/pp-d2c-rn/SKILL.md') | ||
| : path.join(CWD, '.claude/skills/pp-d2c/SKILL.md') | ||
| if (fs.existsSync(skillPath)) { | ||
| // 双写:.claude/skills 与 .codex/skills 两处的主 SKILL 都注入(与 installFiles 分发一致) | ||
| const skillRel = framework === 'rn' ? 'pp-d2c-rn/SKILL.md' : 'pp-d2c/SKILL.md' | ||
| const unitExample = outputUnit === 'px' | ||
| ? `Figma \`16px\` → 代码写 \`${16 * scale}px\`` | ||
| : outputUnit === 'vw' | ||
| ? `Figma \`${outputBase}px\` → 代码写 \`${(outputBase * scale / outputBase * 100).toFixed(3)}vw\`` | ||
| : `Figma \`${outputBase}px\` → 代码写 \`1rem\`` | ||
| const section = `\n## 项目个性化规则\n> 由 npx @double-coding/pixel-print init 生成,重新执行可更新。\n\n### 单位换算\n- 设计稿基准:${figmaBase}px\n- 代码单位:${outputUnit},基准:${outputBase}\n- 换算倍数:×${scale}\n- 示例:${unitExample}\n` | ||
| const marker = '\n## 项目个性化规则' | ||
| for (const agentSkillsDir of ['.claude/skills', '.codex/skills']) { | ||
| const skillPath = path.join(CWD, agentSkillsDir, skillRel) | ||
| if (!fs.existsSync(skillPath)) continue | ||
| let skillContent = fs.readFileSync(skillPath, 'utf8') | ||
| const unitExample = outputUnit === 'px' | ||
| ? `Figma \`16px\` → 代码写 \`${16 * scale}px\`` | ||
| : outputUnit === 'vw' | ||
| ? `Figma \`${outputBase}px\` → 代码写 \`${(outputBase * scale / outputBase * 100).toFixed(3)}vw\`` | ||
| : `Figma \`${outputBase}px\` → 代码写 \`1rem\`` | ||
| const section = `\n## 项目个性化规则\n> 由 npx @double-coding/pixel-print init 生成,重新执行可更新。\n\n### 单位换算\n- 设计稿基准:${figmaBase}px\n- 代码单位:${outputUnit},基准:${outputBase}\n- 换算倍数:×${scale}\n- 示例:${unitExample}\n` | ||
| const marker = '\n## 项目个性化规则' | ||
| skillContent = skillContent.includes(marker) | ||
@@ -775,0 +783,0 @@ ? skillContent.slice(0, skillContent.indexOf(marker)) + section |
+1
-1
| { | ||
| "name": "@double-coding/pixel-print", | ||
| "version": "1.3.0", | ||
| "version": "1.3.1", | ||
| "description": "PixelPrint(像素打印)—— Figma D2C 工具,一键安装 Claude Code Skill,像素级还原设计稿为前端代码(H5 / React Native / xtaro)", | ||
@@ -5,0 +5,0 @@ "bin": { |
+3
-1
@@ -7,3 +7,3 @@ # PixelPrint(像素打印) | ||
| **PixelPrint 是一套让 Claude Code 学会「把 Figma 稿子还原成代码」的知识包**。装到项目里,把设计稿链接发给 Claude,它自己拆图层、切图、出代码、逐块视觉对比。 | ||
| **PixelPrint 是一套让 AI 编程 agent 学会「把 Figma 稿子还原成代码」的知识包**。装到项目里(skill 双写 `.claude/skills/` 与 `.codex/skills/`,Claude Code 和 Codex 都能用),把设计稿链接发给 agent,它自己拆图层、切图、出代码、逐块视觉对比。 | ||
@@ -63,2 +63,4 @@ **H5(React)** / **React Native** / **RN 系跨端(xtaro / taro / 自定义)** 三端产物一套 SKILL 全覆盖。走 Figma 原生 REST API,不装 MCP 插件、不走 OAuth。 | ||
| 🔬 **原理文档:[`docs/pp-d2c-principles.md`](./docs/pp-d2c-principles.md)** — pp-d2c 内部如何运转:四层架构、前缀协议、sub-agent 分块、软/硬双防线、以 cache 为真值的逐节点对账。想理解或改造 skill 的看这篇。 | ||
| 🎨 **给设计师看:[`docs/design-guide.md`](./docs/design-guide.md)** — 图层命名规范速查(sub- / img- / bg- / fixed- / …)。 | ||
@@ -65,0 +67,0 @@ |
@@ -0,1 +1,6 @@ | ||
| --- | ||
| name: pp-d2c-reskin | ||
| description: 按 img/bg 前缀扫描 Figma 图层树批量切图并归入 theme 子目录,用于多套换肤稿对齐同一套代码;触发:pp-d2c-reskin、换肤、reskin、批量切图 | ||
| --- | ||
| # pp-d2c-reskin Skill | ||
@@ -2,0 +7,0 @@ |
@@ -0,1 +1,6 @@ | ||
| --- | ||
| name: pp-fix-partial | ||
| description: D2C 出码后的局部 UI 修复,仅重跑指定区块不重刷整页,复用 .d2c-cache 元数据与资产;触发:pp-fix-partial、修复这块、这一小块不对、重新生成某区域 | ||
| --- | ||
| # pp-fix-partial Skill | ||
@@ -2,0 +7,0 @@ |
@@ -0,1 +1,6 @@ | ||
| --- | ||
| name: pp-image-compress | ||
| description: 无损压缩指定文件夹下的 PNG/JPEG 图片到 compressed/ 子目录(Python + Pillow);触发:pp-image-compress、压缩图片、无损压缩、优化图片体积 | ||
| --- | ||
| # pp-image-compress Skill | ||
@@ -2,0 +7,0 @@ |
@@ -0,1 +1,6 @@ | ||
| --- | ||
| name: pp-strip-nodeid | ||
| description: 清理 D2C 产物中的 data-node-id 调试锚点,并保存 nodeId→代码位置映射供局部修复定位;触发:pp-strip-nodeid、清理 data-node-id、去掉 nodeId、上线前清理调试属性 | ||
| --- | ||
| # pp-strip-nodeid Skill | ||
@@ -2,0 +7,0 @@ |
@@ -0,1 +1,6 @@ | ||
| --- | ||
| name: pp-style | ||
| description: pp-d2c 命名与实现规则速查手册;触发:pp-style、命名规则、实现规则速查 | ||
| --- | ||
| # pp-d2c 命名与实现规则 | ||
@@ -2,0 +7,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
1085335
35.9%112
60%5758
41.06%82
2.5%24
33.33%8
33.33%