#!/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 '); 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