Files
sys-analysis-design/.claude/skills/ba-design/scripts/design-check.mjs
2026-09-11 23:01:18 +07:00

196 lines
12 KiB
JavaScript
Executable File

#!/usr/bin/env node
// design-check.mjs — phép kiểm máy cho artifact của skill ba-design (DS / HIFI / FIGMA).
// Chỉ dùng Node ≥ 18, không phụ thuộc gói ngoài. Không sửa file. Exit 1 khi có 🔴.
//
// node design-check.mjs --tokens 00-index/ds/tokens.json [--css 00-index/ds/tokens.css]
// [--components 00-index/ds/components.html] [--html 03-specification/design/HIFI_US-002_v1.0.html]
// [--srs 03-specification/SRS_US-002_v1.0.md] [--wf 03-specification/WF_US-002_v1.0.md]
// [--svg-dir 03-specification/design/FIGMA_US-002_v1.0] [--emit-css] [--json]
//
// Phép kiểm: T1 tokens.json ↔ tokens.css T2 tương phản WCAG theo $extensions.ba.contrastPairs
// H1 :root + khối ds-components trong HTML trùng nguồn H2 mã màu thô trong markup
// H3 C-id/F-id SRS ↔ HIFI hai chiều H4 text data-key ↔ SRS §4.2 H5 SCR SRS §2.1 ↔ HIFI
// S1 SVG: màu ⊆ token · mỗi SCR có desktop+mobile · id Cnn ⊆ HIFI
import fs from 'node:fs'
import path from 'node:path'
const args = process.argv.slice(2)
const opt = { html: [] }
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--html') opt.html.push(args[++i])
else if (a.startsWith('--')) { const k = a.slice(2); const nxt = args[i + 1]; if (nxt && !nxt.startsWith('--')) { opt[k] = nxt; i++ } else opt[k] = true }
}
if (!opt.tokens) { console.error('Thiếu --tokens <tokens.json>'); process.exit(2) }
const out = []
let red = 0
const ok = (id, msg, details) => out.push({ id, status: '✅', msg, details })
const warn = (id, msg, details) => out.push({ id, status: '🟠', msg, details })
const bad = (id, msg, details) => { red++; out.push({ id, status: '🔴', msg, details }) }
const read = (p) => fs.readFileSync(p, 'utf8')
const norm = (s) => s.replace(/\s+/g, ' ').trim()
// ---------- Token ----------
const tokens = JSON.parse(read(opt.tokens))
const flat = {} // 'color.bg.canvas' -> {type, value}
;(function walk(node, pth) {
for (const [k, v] of Object.entries(node)) {
if (k.startsWith('$')) continue
if (v && typeof v === 'object' && '$value' in v) flat[[...pth, k].join('.')] = { type: v.$type, value: v.$value }
else if (v && typeof v === 'object') walk(v, [...pth, k])
}
})(tokens, [])
const cssName = (p) => '--ds-' + p.replace(/\./g, '-')
const cssVal = (t) => {
const v = t.value
if (Array.isArray(v)) return v.map((f) => (/\s/.test(f) || /[A-Z]/.test(f) ? `"${f}"` : f)).join(',')
if (t.type === 'shadow' && v && typeof v === 'object') return `${v.offsetX} ${v.offsetY} ${v.blur} ${v.spread} ${v.color}`
return String(v)
}
const expectedRoot = ':root{\n' + Object.entries(flat).map(([p, t]) => ` ${cssName(p)}:${cssVal(t)};`).join('\n') + '\n}'
if (opt['emit-css']) { console.log(expectedRoot); process.exit(0) }
const tokenHex = new Set(Object.values(flat).filter((t) => t.type === 'color').map((t) => String(t.value).toUpperCase().slice(0, 7)))
ok('T0', `tokens.json: ${Object.keys(flat).length} token, ${tokenHex.size} màu`)
// T1
if (opt.css) {
const css = read(opt.css)
const m = css.match(/:root\{[\s\S]*?\n\}/)
if (!m) bad('T1', 'tokens.css không có khối :root')
else {
const have = Object.fromEntries([...m[0].matchAll(/(--ds-[\w-]+):([^;]+);/g)].map((x) => [x[1], norm(x[2])]))
const want = Object.fromEntries(Object.entries(flat).map(([p, t]) => [cssName(p), norm(cssVal(t))]))
const missing = Object.keys(want).filter((k) => !(k in have))
const extra = Object.keys(have).filter((k) => !(k in want))
const diff = Object.keys(want).filter((k) => k in have && have[k] !== want[k]).map((k) => `${k}: css=${have[k]} json=${want[k]}`)
if (missing.length || extra.length || diff.length) bad('T1', 'tokens.css lệch tokens.json', { missing, extra, diff })
else ok('T1', 'tokens.css khớp tokens.json')
}
}
// T2 contrast
function lum(hex) {
const h = hex.replace('#', '').slice(0, 6)
const c = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((x) => (x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4))
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
const ratio = (a, b) => { const [l1, l2] = [lum(a), lum(b)].sort((x, y) => y - x); return (l1 + 0.05) / (l2 + 0.05) }
const pairs = tokens.$extensions?.ba?.contrastPairs || []
const contrast = []
for (const p of pairs) {
const fg = flat[p.fg]?.value, bg = flat[p.bg]?.value
if (!fg || !bg) { bad('T2', `Cặp tương phản tham chiếu token không tồn tại: ${p.fg} / ${p.bg}`); continue }
const r = Math.round(ratio(fg, bg) * 100) / 100
const pass = r >= (p.min || 4.5)
contrast.push({ fg: p.fg, bg: p.bg, ratio: r, min: p.min || 4.5, pass, use: p.use })
if (!pass) bad('T2', `Tương phản ${p.fg} / ${p.bg} = ${r} < ${p.min || 4.5} (${p.use || ''})`)
}
if (pairs.length && contrast.every((c) => c.pass)) ok('T2', `Tương phản: ${contrast.length}/${contrast.length} cặp đạt`, contrast.map((c) => `${c.fg} / ${c.bg} = ${c.ratio} (≥${c.min})`))
if (!pairs.length) warn('T2', 'tokens.json không khai $extensions.ba.contrastPairs — không kiểm được tương phản')
// ---------- HTML ----------
const idsOf = (s) => new Set([...s.matchAll(/\b([CF][0-9]{2})\b/g)].map((m) => m[1]))
const attrIds = (html) => new Set([...html.matchAll(/data-[cf]="([CF][0-9]{2})"/g)].map((m) => m[1]))
const srcComponents = opt.components ? read(opt.components) : null
const compBlock = (s) => { const m = s.match(/\/\* ds-components v[\d.]+ — BEGIN[\s\S]*?\/\* ds-components v[\d.]+ — END \*\//); return m ? norm(m[0]) : null }
let srs = opt.srs ? read(opt.srs) : null
let srsIds = srs ? idsOf(srs.replace(/```[\s\S]*?```/g, '')) : null
let srsScr = srs ? new Set([...srs.matchAll(/^\|\s*(SCR-\d{2})\s*\|/gm)].map((m) => m[1])) : null
let srsText = null
if (srs) {
const sec = srs.split(/^### 4\.2[^\n]*\n/m)[1]?.split(/^##+ /m)[0] || ''
const rows = sec.split('\n').filter((l) => /^\|/.test(l) && !/^\|\s*-+/.test(l))
if (rows.length > 1) {
const hdr = rows[0].split('|').map((c) => c.trim())
const ki = hdr.findIndex((h) => /^Khoá|^Khóa|^Key/i.test(h)), ti = hdr.findIndex((h) => /^(Text \(VI\)|Text VI|Text|VI)$/i.test(h))
if (ki >= 0 && ti >= 0) { srsText = {}; for (const r of rows.slice(1)) { const c = r.split('|').map((x) => x.trim()); if (c[ki + 0]) srsText[c[ki].replace(/`/g, '')] = c[ti].replace(/^"|"$/g, '') } }
}
}
let hifiIds = new Set(), hifiScr = new Set()
for (const f of opt.html) {
const html = read(f), tag = path.basename(f)
// H1
const m = html.match(/:root\{[\s\S]*?\n\}/)
if (!m || norm(m[0]) !== norm(expectedRoot)) bad('H1', `${tag}: khối :root không trùng tokens.json (chạy --emit-css để lấy khối chuẩn)`)
else ok('H1', `${tag}: khối :root khớp tokens.json`)
if (srcComponents && path.resolve(f) !== path.resolve(opt.components)) {
const a = compBlock(html), b = compBlock(srcComponents)
if (!a || !b) bad('H1', `${tag}: không tìm thấy khối "ds-components … BEGIN/END" trong HTML hoặc components.html`)
else if (a !== b) bad('H1', `${tag}: khối ds-components lệch components.html — chép lại nguyên văn`)
else ok('H1', `${tag}: khối ds-components khớp components.html`)
}
// H2 raw colors in markup (outside <style>)
const markup = html.replace(/<style[\s\S]*?<\/style>/g, '').replace(/<!--[\s\S]*?-->/g, '')
const raw = [...markup.matchAll(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g)].map((x) => x[0].toUpperCase())
const nonToken = [...new Set(raw.filter((h) => h.length === 7 && !tokenHex.has(h)))]
const inStyleAttr = [...markup.matchAll(/style="[^"]*#[0-9a-fA-F]{3,6}[^"]*"/g)].length
if (nonToken.length) bad('H2', `${tag}: ${nonToken.length} mã màu thô ngoài tập token trong markup`, nonToken)
else if (inStyleAttr) warn('H2', `${tag}: ${inStyleAttr} style="" có mã màu — nên dùng var(--ds-*)`)
else ok('H2', `${tag}: không có mã màu thô trong markup`)
// ids / scr
const isHifi = /class="hf-frame"/.test(html)
if (isHifi) {
attrIds(html).forEach((x) => hifiIds.add(x))
;[...html.matchAll(/class="hf-frame"[^>]*\bid="(SCR-\d{2})"/g)].forEach((x) => hifiScr.add(x[1]))
// H4 text
if (srsText) {
const mism = [], unknown = []
for (const m2 of html.matchAll(/<(\w+)([^>]*?)\sdata-key="([^"]+)"([^>]*)>/g)) {
const [, tagName, pre, key, post] = m2
const attrs = pre + post
let text
if (/^(input|select|textarea)$/i.test(tagName)) text = (attrs.match(/placeholder="([^"]*)"/) || attrs.match(/value="([^"]*)"/) || [])[1]
else { const rest = html.slice(m2.index + m2[0].length); const close = rest.indexOf(`</${tagName}>`); text = rest.slice(0, close < 0 ? 0 : close).replace(/<[^>]+>/g, '') }
text = norm(text || '').replace(/\s*\*$/, '')
if (!(key in srsText)) { unknown.push(key); continue }
if (norm(srsText[key]) !== text) mism.push(`${key}: HIFI="${text}" SRS="${srsText[key]}"`)
}
if (mism.length) bad('H4', `${tag}: ${mism.length} data-key có text khác SRS §4.2 (W5)`, mism); else ok('H4', `${tag}: text data-key khớp SRS §4.2`)
if (unknown.length) warn('H4', `${tag}: ${unknown.length} data-key không có trong SRS §4.2`, [...new Set(unknown)])
} else if (srs) warn('H4', `${tag}: không đọc được bảng SRS §4.2 (cần cột "Khoá" và "Text (VI)") — bỏ phép kiểm H4`)
}
}
if (srsIds && hifiIds.size) {
const onlySrs = [...srsIds].filter((x) => !hifiIds.has(x)), onlyHifi = [...hifiIds].filter((x) => !srsIds.has(x))
if (onlySrs.length || onlyHifi.length) bad('H3', 'C-id/F-id lệch hai chiều SRS ↔ HIFI', { chiCoTrongSRS: onlySrs, chiCoTrongHIFI: onlyHifi })
else ok('H3', `C-id/F-id khớp hai chiều (${hifiIds.size} ID)`)
if (opt.wf) { const wfIds = idsOf(read(opt.wf)); const d = [...hifiIds].filter((x) => !wfIds.has(x)); if (d.length) warn('H3', 'ID có trong HIFI nhưng không có trong WF', d) }
}
if (srsScr && hifiScr.size) {
const miss = [...srsScr].filter((s) => !hifiScr.has(s)), extra = [...hifiScr].filter((s) => !srsScr.has(s))
if (miss.length) bad('H5', `HIFI thiếu màn hình của SRS §2.1`, miss); else ok('H5', `HIFI phủ ${hifiScr.size}/${srsScr.size} SCR của SRS §2.1`)
if (extra.length) warn('H5', 'HIFI có SCR không có trong SRS §2.1', extra)
}
// ---------- SVG ----------
if (opt['svg-dir']) {
const dir = opt['svg-dir']
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.svg')) : []
if (!files.length) bad('S1', `${dir}: không có file .svg`)
const views = {}, badColor = {}, badIds = {}
for (const f of files) {
const s = read(path.join(dir, f))
const m = f.match(/^(SCR-\d{2})\.(desktop|tablet|mobile)(?:\.([\w-]+))?\.svg$/)
if (!m) { warn('S1', `${f}: tên không theo mẫu SCR-xx.<view>[.<state>].svg`); continue }
;(views[m[1]] ||= new Set()).add(m[2])
const cols = [...new Set([...s.matchAll(/(?:fill|stroke)="(#[0-9a-fA-F]{6})/g)].map((x) => x[1].toUpperCase()))].filter((h) => !tokenHex.has(h))
if (cols.length) badColor[f] = cols
if (hifiIds.size) { const ids = [...s.matchAll(/\bid="([CF][0-9]{2})"/g)].map((x) => x[1]).filter((x) => !hifiIds.has(x)); if (ids.length) badIds[f] = ids }
}
if (Object.keys(badColor).length) bad('S1', 'SVG dùng mã màu ngoài tập token', badColor); else if (files.length) ok('S1', `SVG: ${files.length} file, màu ⊆ token`)
if (Object.keys(badIds).length) bad('S1', 'SVG có id Cnn/Fnn không có trong HIFI', badIds)
const need = srsScr ? [...srsScr] : Object.keys(views)
const lack = need.filter((s) => !(views[s]?.has('desktop') && views[s]?.has('mobile')))
if (lack.length) bad('S1', 'SCR chưa đủ desktop + mobile trong SVG', lack); else if (need.length) ok('S1', `SVG phủ desktop+mobile cho ${need.length} SCR`)
}
// ---------- Report ----------
if (opt.json) console.log(JSON.stringify({ red, results: out, contrast }, null, 2))
else {
for (const r of out) { console.log(`${r.status} ${r.id} ${r.msg}`); if (r.details) console.log(' ' + (Array.isArray(r.details) ? r.details.join('\n ') : JSON.stringify(r.details))) }
console.log(red ? `\n🔴 ${red} lỗi — chưa đạt` : '\nok — không có lỗi 🔴')
}
process.exit(red ? 1 : 0)