feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
Some checks failed
ci / build (push) Failing after 5s

- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다
- 5단계 기계 검사 4번째: design-gate 실행 의무화
- references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙
- tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합)
- 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0)

feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18)

- 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판
- LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png)
- 시간표 자동배치 결함 수리(명시적 격자 좌표)
- QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
This commit is contained in:
Yun Chan 2026-08-22 21:22:05 +09:00
parent 78ddc8b61a
commit 72337b7ee0
226 changed files with 18212 additions and 380 deletions

View file

@ -0,0 +1,182 @@
// L2 — 렌더링 불변식. file:// 로 앱을 열어 검사한다(서버 불필요).
// 줄수(세로 쌓임·갸행) · 수축 탐지 · 대비 · axe 접근성 · 콘텐츠 정합 · 스케일×폭 행렬.
import puppeteer from "puppeteer-core";
import path from "node:path";
import url from "node:url";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, "../public/work");
const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
const results = [];
const fail = (name, detail = "") => results.push({ ok: false, name, detail });
const pass = (name, detail = "") => results.push({ ok: true, name, detail });
const GAON_VIEWS = ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"];
const DURE_VIEWS = ["catalog", "starred", "enrollments", "credits", "archive", "notices"];
const PAGE_UTILS = `
window.__lines = (el) => {
if (!el) return -1;
const range = document.createRange();
range.selectNodeContents(el);
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
const ls = [];
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
window.__textLines = (el) => {
if (!el) return -1;
const tn = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim());
const target = tn || el;
const range = document.createRange();
range.selectNodeContents(target);
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
const ls = [];
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
`;
async function openApp(page, app) {
await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
await page.evaluate(PAGE_UTILS);
}
async function scanInvariants(page) {
return page.evaluate(() => {
const out = { stacked: [], contrast: [], h1: [] };
const lum = ({ r, g, b }) => {
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; };
const blend = (top, bottom) => ({ r: top.r * top.a + bottom.r * (1 - top.a), g: top.g * top.a + bottom.g * (1 - top.a), b: top.b * top.a + bottom.b * (1 - top.a), a: 1 });
const bgOf = (el) => {
let node = el; const stack = [];
while (node && node !== document.documentElement) {
const c = rgb(getComputedStyle(node).backgroundColor);
if (c && c.a >= 0.95) return c;
if (c && c.a > 0) stack.push(c);
node = node.parentElement;
}
let base = { r: 255, g: 255, b: 255, a: 1 };
for (let i = stack.length - 1; i >= 0; i--) base = blend(stack[i], base);
return base;
};
const h1 = document.querySelector(".view.is-on h2.v-h") || document.querySelector("h1");
if (h1 && window.__lines(h1) > 1) out.h1.push(h1.textContent.trim().slice(0, 16));
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent) return;
const txt = el.textContent.trim();
const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
if (!txt || !ownText) return;
const cs = getComputedStyle(el);
if (txt.length <= 14 && !el.querySelector("*") && window.__textLines(el) >= 3) {
out.stacked.push(txt.slice(0, 12) + " (" + window.__textLines(el) + "줄)");
}
const fg = rgb(cs.color);
if (fg) {
const bg = bgOf(el);
const fgb = blend(fg, bg);
const l1 = lum(fgb), l2 = lum(bg);
const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
const size = parseFloat(cs.fontSize);
const w = +cs.fontWeight || 400;
const need = size >= 24 || (size >= 18.66 && w >= 600) ? 3 : 4.5;
if (ratio < need) {
const key = cs.color + Math.round(size);
if (!out.contrast.some((c) => c.key === key)) {
out.contrast.push({ key, txt: txt.slice(0, 12), ratio, need });
}
}
}
});
return out;
});
}
async function runAxe(page) {
const axePath = path.resolve(here, "../node_modules/axe-core/axe.min.js");
await page.addScriptTag({ path: axePath });
const res = await page.evaluate(async () => {
const r = await window.axe.run(document, { resultTypes: ["violations"] });
return r.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length }));
});
return res;
}
async function contentConsistency(page) {
return page.evaluate(() => {
const out = [];
if (window.GAON && document.getElementById("rep-stats")) {
const stats = GAON.rosterStats(STUDENTS);
const txt = document.getElementById("rep-stats").textContent;
if (!txt.includes(stats.active + "명")) out.push("리포트 재학생 " + stats.active + "명 표시 안 됨");
if (!txt.includes(stats.avgAtt + "%")) out.push("리포트 평균출석 " + stats.avgAtt + "% 표시 안 됨");
if (!txt.includes(stats.hwRate + "%")) out.push("리포트 제출률 " + stats.hwRate + "% 표시 안 됨");
}
if (window.DURE && document.getElementById("en-credits")) {
const list = [...enrolled].map((id) => COURSES.find((c) => c.id === id));
const want = DURE.credits(list);
const got = document.getElementById("en-credits").textContent.trim();
if (got !== String(want)) out.push("신청 학점 표시 " + got + " ≠ 재계산 " + want);
}
return out;
});
}
const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
const page = await browser.newPage();
await page.setCacheEnabled(false);
for (const [app, views] of [["gaon-lms", GAON_VIEWS], ["dure-enrollment", DURE_VIEWS]]) {
await page.setViewport({ width: 1440, height: 900 });
await openApp(page, app);
for (const v of views) {
await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), v);
await new Promise((r) => setTimeout(r, 80));
const inv = await scanInvariants(page);
if (inv.h1.length) fail(`${app}/${v} 뷰 제목 여러 줄`, inv.h1.join(", "));
if (inv.stacked.length) fail(`${app}/${v} 세로 쌓임(수축)`, inv.stacked.slice(0, 4).join(", "));
if (inv.contrast.length) fail(`${app}/${v} 대비 미달`, inv.contrast.slice(0, 3).map((c) => `"${c.txt}" ${c.ratio}:1`).join(", "));
}
pass(`${app} 불변식(전 뷰)`, "제목·수축·대비");
await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), views[0]);
const violations = await runAxe(page);
const serious = violations.filter((v) => v.impact === "serious" || v.impact === "critical");
if (serious.length) fail(`${app} axe 심각 위반`, serious.map((v) => `${v.id}×${v.nodes}`).join(", "));
else pass(`${app} axe`, violations.length ? `minor ${violations.length}종 무시` : "위반 0");
const cons = await contentConsistency(page);
if (cons.length) fail(`${app} 콘텐츠 정합`, cons.join(" / "));
else pass(`${app} 콘텐츠 정합`);
let matrixBad = [];
for (const w of [320, 375, 390]) {
for (const scale of [1.0, 1.3]) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
await openApp(page, app);
await page.addStyleTag({ content: `html { font-size: ${16 * scale}px !important; }` });
const lines = await page.evaluate("window.__lines(document.querySelector('.view.is-on h2.v-h') || document.querySelector('h1'))");
if (lines > 1) matrixBad.push(`${w}@${scale}x:${lines}`);
}
}
if (matrixBad.length) fail(`${app} 스케일×폭 제목`, matrixBad.join(", "));
else pass(`${app} 스케일×폭 제목 행렬`);
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp(page, app);
const brandLines = await page.evaluate("window.__lines(document.querySelector('.brand, .appbar .brand'))");
if (brandLines > 1) fail(`${app} 브랜드 세로 쌓임`, brandLines + "줄");
else pass(`${app} 브랜드 1줄`);
}
await browser.close();
const totalFail = results.filter((r) => !r.ok).length;
for (const r of results) console.log((r.ok ? "PASS" : "FAIL") + " " + r.name + (r.detail ? `${r.detail}` : ""));
console.log(totalFail ? `\nINVARIANTS: ${totalFail} FAIL` : "\nINVARIANTS ALL PASS");
process.exit(totalFail ? 1 : 0);