fix(showcase): 미정의 CSS 변수·한글 조판·접근성 결함 수리 및 무결성 게이트 두 겹 신설

stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 없다"를 잡지
못한다. 미정의 변수는 선언 전체를 무효로 만드는데 콘솔 에러가 없어 조용히 남는다.
검사기를 심자마자 v0.9.1 의 실제 결함이 나왔다.

게이트 신설
- tools/integrity.mjs — L0 무결성. CSS 커스텀 프로퍼티 참조, 상대경로,
  한글 조판 필수 규칙(keep-all·한글 폰트 지정), 슬롭 지문, 스크롤 리스너를 검사.
  verify.mjs 체인에 편입해 실패 시 배포를 막는다.
  폴백이 있는 var(--x, 기본값) 은 선택적 주입이므로 미정의로 세지 않는다.
- tools/showcase-audit.mjs — 320/390/768/1440 실렌더 감사. 가로 스크롤·오버플로·
  컨트롤 줄바꿈·대비 AA·왼쪽 인셋·h1 개수·히어로 줄 수·깨진 이미지·콘솔 에러.
  반투명 배경을 합성해 대비를 재고, WebGL 페이지를 위해 swiftshader 로 렌더한다.
- html-validate 대상을 두 페이지에서 public/work 전체로 넓혔다.

수리한 결함
- 미정의 변수 64건: --dur-fast·--radius-xs 가 정의된 적이 없어 cask-archive,
  cheongon-care, materia-interior 의 transition 선언이 통째로 무효였다.
  새 토큰을 만들지 않고 기존 --dur-quick·--radius 로 매핑했다 — 토큰을 늘리면
  radius 어휘가 4종이 되어 하드 게이트 3에 걸린다.
- 한글 조판: lumina-optics·pulsegate·synapse-bci 가 word-break: keep-all 없이
  IBM Plex Sans 만 지정해 한글이 시스템 기본으로 떨어지고 있었다.
  같은 계열의 IBM Plex Sans KR 을 스택에 넣고 keep-all 을 추가했다.
- 슬롭 지문 19건: text-transform: uppercase 로 만든 영문 대문자 eyebrow 를
  한글 문장으로 바꾸고, 대문자용으로 잡아둔 넓은 자간과 모노 서체도 되돌렸다.
- 하드 게이트 9: materia-interior/hannam-duplex.html 의 100vh → 100dvh.
  이 파일은 어디서도 참조되지 않으면서 인라인 hex 까지 갖고 있어 토큰 기반으로 다시 썼다.
- 접근성 11건: label 하나가 output 과 input 둘에 걸리던 것 5건(font-foundry,
  lumina-optics), dl > div > div > dt 4건(muwol-coffee), div 에 붙은 aria-label
  2건(overtone-sound, pulsegate).

측정기 자체의 오류 둘도 고쳤다 — 눈에 보이지 않는 .sr-only 를 대비 실패로 잡던 것과,
overflow-x: auto 상자 안의 넓은 표를 오버플로로 잡던 것. 재는 도구가 틀리면
통과도 실패도 의미가 없다.
This commit is contained in:
Yun Chan 2026-08-24 12:50:57 +09:00
parent 225c1d9f42
commit 123d9fc5a8
28 changed files with 658 additions and 133 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

@ -0,0 +1,236 @@
// L0-무결성 — CSS 커스텀 프로퍼티와 정적 경로의 참조 무결성 검사.
//
// 왜 필요한가: stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도
// 없다"를 잡지 못한다. 미정의 변수는 선언 전체를 무효로 만들지만 콘솔 에러가
// 없어서 조용히 살아남는다. 실측 사고 — cheongon-care/main.css 가 var(--dur-fast)
// 를 11회 쓰는데 tokens.css 에는 --dur-quick 만 있었다. 그 결과 transition 선언
// 11개가 통째로 무효였고, 호버 전환이 전부 즉시 점프였다.
//
// 경로도 같은 이유로 검사한다. href/src 오타는 빌드가 통과하고 배포도 되지만
// 사용자에게만 404 로 나타난다.
//
// 사용: node tools/integrity.mjs
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const workRoot = path.join(root, "public", "work");
let pass = 0;
const failures = [];
function ok(label) {
pass++;
void label;
}
function fail(label, detail) {
failures.push(`FAIL ${label}${detail ? ` — ${detail}` : ""}`);
}
/**
* CSS 주석을 제거한다. 주석 안의 var() 나 url() 은 참조가 아니다.
* 주석이 먹은 줄바꿈은 그대로 남겨 보고하는 행 번호가 원본과 어긋나지 않게 한다.
*/
function stripComments(css) {
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ""));
}
/** :root 등에서 정의된 커스텀 프로퍼티 이름을 모은다. */
function collectDefined(css) {
const names = new Set();
// 선언 위치에서만 잡는다: `--name:` 형태이되 var( 안이 아닌 것.
for (const m of css.matchAll(/(^|[;{]\s*)(--[A-Za-z0-9_-]+)\s*:/g)) {
names.add(m[2]);
}
return names;
}
/**
* 폴백이 없는 var(--name) 사용처만 모은다.
* `var(--delay, 0ms)` 처럼 폴백을 둔 것은 요소별로 인라인 주입하는 선택적 변수이므로
* 정의가 없어도 선언이 무효가 되지 않는다 — 결함이 아니다.
*/
function collectUsed(css) {
const uses = [];
for (const m of css.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)\s*([,)])/g)) {
if (m[2] === ",") continue;
uses.push({ name: m[1], index: m.index });
}
return uses;
}
function lineOf(text, index) {
return text.slice(0, index).split("\n").length;
}
const showcases = fs
.readdirSync(workRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
if (showcases.length === 0) fail("쇼케이스 탐색", "public/work 아래에 디렉터리가 없다");
/* ── 1. CSS 커스텀 프로퍼티 참조 무결성 ── */
for (const slug of showcases) {
const stylesDir = path.join(workRoot, slug, "styles");
if (!fs.existsSync(stylesDir)) continue;
const cssFiles = fs
.readdirSync(stylesDir)
.filter((f) => f.endsWith(".css"))
.map((f) => path.join(stylesDir, f));
// 한 쇼케이스의 CSS 는 @import 로 묶여 한 문서가 되므로 정의를 합집합으로 본다.
const merged = cssFiles.map((f) => stripComments(fs.readFileSync(f, "utf8")));
const defined = new Set();
for (const css of merged) for (const n of collectDefined(css)) defined.add(n);
// 미디어쿼리·@supports 안에서만 정의되는 변수도 정의로 친다(위 정규식이 이미 포함).
const undefinedUses = [];
cssFiles.forEach((file, i) => {
for (const use of collectUsed(merged[i])) {
if (!defined.has(use.name)) {
undefinedUses.push(`${path.relative(root, file)}:${lineOf(merged[i], use.index)} ${use.name}`);
}
}
});
if (undefinedUses.length) {
const shown = undefinedUses.slice(0, 6).join(", ");
fail(
`${slug} CSS 변수 정의`,
`미정의 ${undefinedUses.length}건 — ${shown}${undefinedUses.length > 6 ? " …" : ""}`,
);
} else {
ok(`${slug} CSS 변수 정의`);
}
}
/* ── 2. 정적 경로 무결성 (로컬 상대 경로만) ── */
const localRef = /(?:href|src)\s*=\s*"(\.\/[^"#?]+|\.\.\/[^"#?]+)"/g;
const cssUrlRef = /url\(\s*["']?(\.{1,2}\/[^"')?#]+)/g;
for (const slug of showcases) {
const dir = path.join(workRoot, slug);
const broken = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) {
walk(p);
continue;
}
const isHtml = entry.name.endsWith(".html");
const isCss = entry.name.endsWith(".css");
if (!isHtml && !isCss) continue;
const text = fs.readFileSync(p, "utf8");
const body = isCss ? stripComments(text) : text;
const pattern = isHtml ? localRef : cssUrlRef;
pattern.lastIndex = 0;
for (const m of body.matchAll(pattern)) {
const target = path.resolve(path.dirname(p), m[1]);
if (!fs.existsSync(target)) {
broken.push(`${path.relative(root, p)}:${lineOf(body, m.index)} → ${m[1]}`);
}
}
}
};
walk(dir);
if (broken.length) {
fail(`${slug} 경로 참조`, `끊긴 링크 ${broken.length}건 — ${broken.slice(0, 5).join(", ")}`);
} else {
ok(`${slug} 경로 참조`);
}
}
/* ── 3. 한글 프로젝트 필수 조판 규칙 ── */
for (const slug of showcases) {
const stylesDir = path.join(workRoot, slug, "styles");
if (!fs.existsSync(stylesDir)) continue;
const all = fs
.readdirSync(stylesDir)
.filter((f) => f.endsWith(".css"))
.map((f) => fs.readFileSync(path.join(stylesDir, f), "utf8"))
.join("\n");
if (/word-break:\s*keep-all/.test(all)) ok(`${slug} keep-all`);
else fail(`${slug} keep-all`, "한글 조판 필수 규칙 word-break: keep-all 이 없다");
if (/Pretendard|Noto Sans KR|본고딕|MaruBuri|마루부리/.test(all)) ok(`${slug} 한글 폰트 지정`);
else fail(`${slug} 한글 폰트 지정`, "한글 폰트를 명시하지 않아 시스템 기본으로 떨어진다");
}
/* ── 4. 슬롭 지문 정적 grep ── */
const slopPatterns = [
[/#6366f1|#818cf8|#a855f7|#8b5cf6|#c4b5fd|#a78bfa|#22d3ee/i, "보라·인디고·네온 시안 계열 하드코딩"],
[/text-transform:\s*uppercase/i, "전체 대문자 라벨"],
[/font-family:[^;]*\bInter\b/i, "Inter 를 이유 없이"],
[/100vh/i, "100vh (모바일 주소창 — 100dvh 여야 한다)"],
];
for (const slug of showcases) {
const dir = path.join(workRoot, slug);
const hits = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) {
walk(p);
continue;
}
if (!/\.(html|css|js)$/.test(entry.name)) continue;
const text = stripComments(fs.readFileSync(p, "utf8"));
for (const [re, label] of slopPatterns) {
const m = text.match(re);
if (m) hits.push(`${path.relative(root, p)} ${label}`);
}
}
};
walk(dir);
if (hits.length) fail(`${slug} 슬롭 지문`, hits.slice(0, 5).join(", "));
else ok(`${slug} 슬롭 지문`);
}
/* ── 5. JS 스크롤 리스너 금지 (하드 게이트 10) ── */
for (const slug of showcases) {
const dir = path.join(workRoot, slug);
const hits = [];
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) {
walk(p);
continue;
}
if (!/\.(html|js)$/.test(entry.name)) continue;
const text = fs.readFileSync(p, "utf8");
if (/addEventListener\(\s*["']scroll["']/.test(text)) {
hits.push(path.relative(root, p));
}
}
};
walk(dir);
if (hits.length) fail(`${slug} 스크롤 리스너`, hits.join(", "));
else ok(`${slug} 스크롤 리스너`);
}
console.log(`무결성 검사 — 쇼케이스 ${showcases.length}종`);
for (const f of failures) console.log(" " + f);
// verify.mjs 는 출력에서 /PASS\s./ 와 /FAIL\s./ 를 세어 요약을 만든다.
// 요약 줄에 'FAIL 0' 이라고 적으면 그것까지 실패로 집계돼 리포트가 거짓말을 한다.
// 그래서 통과일 때는 FAIL 이라는 낱말을 쓰지 않는다.
if (failures.length) {
console.log(`✗ 무결성 ${failures.length}건 실패 (검사 ${pass + failures.length}개)`);
} else {
console.log(`✓ PASS ${pass}개 검사 전부 통과`);
}
process.exit(failures.length ? 1 : 0);

View file

@ -0,0 +1,241 @@
// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다.
//
// 검사 항목
// 1. 가로 스크롤 (하드 게이트 5)
// 2. 뷰포트를 넘는 요소 (오버플로)
// 3. 버튼·내비 라벨 2줄 접힘 (하드 게이트 6)
// 4. 텍스트 대비 AA (하드 게이트 7) — 조상 배경을 걷어 올려 실제 배경으로 잰다
// 5. 왼쪽 인셋 최소값 (가장자리에 붙는 제목)
// 6. h1 개수 · 히어로 헤드라인 줄 수
// 7. 콘솔 에러 · 깨진 이미지
//
// 사용: node tools/showcase-audit.mjs [slug ...]
// 인자가 없으면 public/work 아래 전부를 돈다.
import puppeteer from "puppeteer-core";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const workRoot = path.join(root, "public", "work");
const CHROME_CANDIDATES = [
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
process.env.CHROME_PATH,
].filter(Boolean);
const chrome = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
if (!chrome) {
console.error("크롬을 찾지 못했다. CHROME_PATH 환경변수로 지정해라.");
process.exit(2);
}
const WIDTHS = [320, 390, 768, 1440];
const args = process.argv.slice(2);
const slugs = args.length
? args
: fs
.readdirSync(workRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
/** 한 쇼케이스가 가진 모든 index.html (하위 상세 페이지 포함) */
function pagesOf(slug) {
const out = [];
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name === "index.html") out.push(p);
}
};
walk(path.join(workRoot, slug));
return out;
}
/* 페이지 안에서 도는 판정기. 여기서 나온 수치가 판정의 근거다. */
const PROBE = `() => {
const lum = (c) => {
const m = (c || '').match(/[\\d.]+/g);
if (!m) return 1;
const [r, g, b] = m.slice(0, 3).map(Number).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
// 반투명 배경을 만나면 뒤 배경과 합성한다. 불투명으로 취급하면
// 대비가 실제보다 나쁘게(혹은 좋게) 나온다 — 측정기가 틀리면 판정이 무의미하다
const bgOf = (el) => {
let n = el, acc = null;
while (n && n.nodeType === 1) {
const c = getComputedStyle(n).backgroundColor;
const m = (c || '').match(/[\\d.]+/g);
if (m) {
const a = m.length > 3 ? Number(m[3]) : 1;
if (a > 0.001) {
const rgb = m.slice(0, 3).map(Number);
acc = acc === null ? { rgb, a } : { rgb: acc.rgb.map((v, i) => v * acc.a + rgb[i] * (1 - acc.a)), a: 1 };
if (acc.a >= 0.999) return 'rgb(' + acc.rgb.map(Math.round).join(',') + ')';
}
}
n = n.parentElement;
}
return acc ? 'rgb(' + acc.rgb.map(Math.round).join(',') + ')' : 'rgb(255,255,255)';
};
// 스크린리더 전용 텍스트는 눈에 보이지 않으므로 대비 판정 대상이 아니다.
// 1px 상자 + clip 은 sr-only 의 표준 구현이고, 실제 본문이 이 모양일 수는 없다.
const srOnly = (el) => {
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
const clipped = (cs.clipPath && cs.clipPath !== 'none') || (cs.clip && cs.clip !== 'auto');
return clipped && r.width <= 2 && r.height <= 2;
};
const visible = (el) => {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
if (srOnly(el)) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
// 가로로 스크롤되는 상자 안의 넓은 표는 결함이 아니라 처방이다.
// 그 상자가 실제로 스크롤될 때만 면제한다 — 그냥 넘치는 것과 구별해야 한다
const inScroller = (el) => {
let n = el.parentElement;
while (n && n !== document.body) {
const ox = getComputedStyle(n).overflowX;
if ((ox === 'auto' || ox === 'scroll') && n.scrollWidth > n.clientWidth + 1) return true;
n = n.parentElement;
}
return false;
};
const contrast = [];
document.querySelectorAll('p,span,a,li,dt,dd,h1,h2,h3,h4,td,th,label,output,figcaption,kbd,caption,button,strong,em,summary').forEach((el) => {
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
if (!own || !visible(el)) return;
const cs = getComputedStyle(el);
const size = parseFloat(cs.fontSize);
const bold = Number(cs.fontWeight) >= 700;
const large = size >= 24 || (size >= 18.66 && bold);
const a = lum(cs.color), b = lum(bgOf(el));
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const need = large ? 3 : 4.5;
if (ratio < need - 0.005) {
contrast.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), size: +size.toFixed(1), ratio: +ratio.toFixed(2), need, text: el.textContent.trim().slice(0, 30) });
}
});
const overflow = [];
document.querySelectorAll('body *').forEach((el) => {
if (!visible(el) || inScroller(el)) return;
const r = el.getBoundingClientRect();
if (r.right > window.innerWidth + 1.5) overflow.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), right: Math.round(r.right) });
});
const wrapped = [];
document.querySelectorAll('nav a, .btn, button, [role=button]').forEach((el) => {
if (!visible(el) || !el.textContent.trim()) return;
const cs = getComputedStyle(el);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25;
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
if (el.getBoundingClientRect().height > lh * 1.6 + pad) wrapped.push(el.textContent.trim().slice(0, 26));
});
const insets = [...document.querySelectorAll('h1,h2,h3,p,li')]
.filter((e) => e.textContent.trim() && visible(e))
.map((e) => Math.round(e.getBoundingClientRect().left));
const h1 = document.querySelectorAll('h1');
const firstH1 = h1[0];
let h1Lines = 0;
if (firstH1) {
const cs = getComputedStyle(firstH1);
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2;
h1Lines = Math.round(firstH1.getBoundingClientRect().height / lh);
}
const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.currentSrc || i.src);
return {
docWidth: document.documentElement.scrollWidth,
winWidth: window.innerWidth,
horizScroll: document.documentElement.scrollWidth > window.innerWidth + 1,
contrast, overflow, wrapped,
minInset: insets.length ? Math.min(...insets) : null,
h1Count: h1.length,
h1Lines,
brokenImages,
};
}`;
const browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
// WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다.
// swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
});
let totalFail = 0;
const lines = [];
for (const slug of slugs) {
for (const file of pagesOf(slug)) {
const rel = path.relative(workRoot, file).replace(/\\/g, "/");
for (const width of WIDTHS) {
const page = await browser.newPage();
const consoleErrors = [];
page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120)));
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push(m.text().slice(0, 120));
});
await page.setViewport({ width, height: 900 });
await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
await new Promise((r) => setTimeout(r, 260));
// 문자열을 넘기면 puppeteer 는 그것을 '식'으로 평가한다.
// 화살표 함수만 넘기면 함수 객체가 그대로 직렬화돼 빈 객체가 돌아온다 — 즉시호출로 감싼다
const r = await page.evaluate(`(${PROBE})()`);
await page.close();
const problems = [];
if (r.horizScroll) problems.push(`가로스크롤 ${r.docWidth}>${r.winWidth}`);
if (r.overflow.length) problems.push(`오버플로 ${r.overflow.length} (${r.overflow.slice(0, 2).map((o) => o.tag + "." + o.cls).join(", ")})`);
if (r.wrapped.length) problems.push(`컨트롤 줄바꿈 ${r.wrapped.length} (${r.wrapped.slice(0, 2).join(", ")})`);
if (r.contrast.length) problems.push(`대비미달 ${r.contrast.length} (최악 ${Math.min(...r.contrast.map((c) => c.ratio))}:1 — ${r.contrast[0].cls || r.contrast[0].tag})`);
if (r.minInset !== null && r.minInset < 12) problems.push(`왼쪽 인셋 ${r.minInset}px`);
if (r.h1Count !== 1) problems.push(`h1 ${r.h1Count}개`);
if (r.h1Lines > 2) problems.push(`히어로 헤드라인 ${r.h1Lines}줄`);
if (r.brokenImages.length) problems.push(`깨진 이미지 ${r.brokenImages.length}`);
if (consoleErrors.length) problems.push(`콘솔에러 ${consoleErrors.length} (${consoleErrors[0]})`);
if (problems.length) {
totalFail++;
lines.push(`FAIL ${rel} @${width} — ${problems.join(" · ")}`);
if (r.contrast.length) {
for (const c of r.contrast.slice(0, 4)) {
lines.push(` ↳ ${c.ratio}:1 (${c.need} 필요) ${c.size}px ${c.tag}.${c.cls} "${c.text}"`);
}
}
} else {
lines.push(`PASS ${rel} @${width}`);
}
}
}
}
await browser.close();
for (const l of lines) console.log(l);
const passCount = lines.filter((l) => l.startsWith("PASS")).length;
console.log(`\n${totalFail ? "✗" : "✓"} 실렌더 감사 — PASS ${passCount} / FAIL ${totalFail}`);
process.exit(totalFail ? 1 : 0);

View file

@ -17,7 +17,13 @@ const steps = [
},
{
layer: "L0-정식/html-validate",
cmd: () => spawnSync(npx, ["html-validate", "public/work/gaon-lms/index.html", "public/work/dure-enrollment/index.html"], { cwd: root, encoding: "utf8", shell: true }),
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html"], { cwd: root, encoding: "utf8", shell: true }),
},
{
// 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도
// 잡지 못한다. 실측 사고: --dur-fast 미정의로 transition 선언 11개가 통째로 무효였다.
layer: "L0-무결성/변수·경로·조판",
cmd: () => spawnSync(process.execPath, ["tools/integrity.mjs"], { cwd: root, encoding: "utf8" }),
},
{ layer: "L1-단위/계산로직", cmd: () => spawnSync(process.execPath, ["tools/unit/calc.test.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L2-기능/감사", cmd: () => spawnSync(process.execPath, ["tools/audit.mjs"], { cwd: root, encoding: "utf8" }) },