update skill ba
This commit is contained in:
195
.claude/skills/ba-design/scripts/design-check.mjs
Executable file
195
.claude/skills/ba-design/scripts/design-check.mjs
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/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)
|
||||
27
.claude/skills/ba-design/scripts/render-figma.sh
Executable file
27
.claude/skills/ba-design/scripts/render-figma.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# render-figma.sh — chụp PNG từng màn hình × khung nhìn × trạng thái của HIFI bằng Chrome/Edge headless sẵn có (không cài thêm).
|
||||
# render-figma.sh <HIFI.html> <out-dir> [--scr SCR-01,SCR-03] [--views desktop,tablet,mobile] [--states default,loading,...]
|
||||
# Mặc định: mọi SCR có trong file · desktop+mobile · mọi trạng thái xuất hiện trong file (data-state). Ảnh: <out-dir>/SCR-xx.<view>.<state>.png
|
||||
set -euo pipefail
|
||||
HIFI="${1:?Thiếu đường dẫn HIFI.html}"; OUT="${2:?Thiếu thư mục output}"; shift 2
|
||||
SCRS=""; VIEWS="desktop,mobile"; STATES=""
|
||||
while [ $# -gt 0 ]; do case "$1" in --scr) SCRS="$2"; shift 2;; --views) VIEWS="$2"; shift 2;; --states) STATES="$2"; shift 2;; *) echo "Tham số lạ: $1" >&2; exit 2;; esac; done
|
||||
BROWSER=""
|
||||
for b in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge microsoft-edge-stable msedge; do command -v "$b" >/dev/null 2>&1 && { BROWSER="$b"; break; }; done
|
||||
for p in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" "/c/Program Files/Google/Chrome/Application/chrome.exe" "/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; do [ -z "$BROWSER" ] && [ -x "$p" ] && BROWSER="$p"; done
|
||||
[ -z "$BROWSER" ] && { echo "blocked: không tìm thấy Chrome/Edge — chụp tay: mở HIFI với ?scr=&view=&state= rồi lưu ảnh vào $OUT" >&2; exit 3; }
|
||||
[ -z "$SCRS" ] && SCRS=$(grep -oE 'class="hf-frame"[^>]*id="SCR-[0-9]+"' "$HIFI" | grep -oE 'SCR-[0-9]+' | paste -sd, -)
|
||||
[ -z "$STATES" ] && STATES=$(grep -oE 'data-state="[a-z-]+"' "$HIFI" | grep -oE '"[a-z-]+"' | tr -d '"' | sort -u | paste -sd, -)
|
||||
mkdir -p "$OUT"
|
||||
ABS=$(cd "$(dirname "$HIFI")" && pwd)/$(basename "$HIFI")
|
||||
n=0
|
||||
for scr in ${SCRS//,/ }; do for view in ${VIEWS//,/ }; do
|
||||
case "$view" in desktop) W=1440; H=900;; tablet) W=834; H=1112;; mobile) W=390; H=844;; *) echo "view lạ: $view" >&2; exit 2;; esac
|
||||
for st in ${STATES//,/ }; do
|
||||
f="$OUT/$scr.$view.$st.png"
|
||||
"$BROWSER" --headless=new --disable-gpu --no-first-run --no-default-browser-check --disable-extensions --hide-scrollbars \
|
||||
--allow-file-access-from-files --virtual-time-budget=3000 --window-size="$W,$H" --screenshot="$f" \
|
||||
"file://$ABS?scr=$scr&view=$view&state=$st&mode=mockup" >/dev/null 2>&1 && n=$((n+1)) || echo "lỗi: $scr $view $st" >&2
|
||||
done
|
||||
done; done
|
||||
echo "ok: $n ảnh → $OUT (khung: desktop 1440×900 · tablet 834×1112 · mobile 390×844; nội dung dài hơn khung bị cắt — ghi chú trong DSPEC §1)"
|
||||
Reference in New Issue
Block a user