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

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

201
apps/site/tools/audit.mjs Normal file
View file

@ -0,0 +1,201 @@
// 확장된 두 앱 감사: 신규 뷰 전부 + 기존 기능 회귀 + 오버플로
import puppeteer from "puppeteer-core";
import path from "node:path";
const root = "D:/workspace/designpaca/apps/site";
const chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe";
const results = [];
const pass = (name, ok, detail = "") =>
results.push(`${ok ? "PASS" : "FAIL"} ${name}${detail ? `${detail}` : ""}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
args: ["--no-sandbox", "--disable-gpu", "--force-device-scale-factor=1"],
defaultViewport: { width: 1440, height: 900 },
});
const page = await browser.newPage();
page.on("pageerror", (e) => results.push(`FAIL pageerror — ${e.message}`));
const jclick = (sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) throw new Error("no " + s); el.click(); }, sel);
const over = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
const gotoFile = async (app) =>
page.goto(`file:///${path.join(root, "public/work", app, "index.html")}`, { waitUntil: "networkidle0" });
// ══════════ 가온 학적부 (8 뷰) ══════════
await gotoFile("gaon-lms");
for (const v of ["dashboard","students","attendance","grades","files","calendar","counsel","report","notice"]) {
await jclick(`[data-view="${v}"]`);
pass(`가온 뷰 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on")));
}
pass("가온 aria-current(공지)", await page.$eval('[data-view="notice"]', (el) => el.getAttribute("aria-current") === "page"));
// 사이드바 그룹 헤더 3개
pass("가온 사이드 그룹 3개", (await page.$$eval(".side-group", (n) => n.length)) === 3);
// 출석부: 셀 순환 + 주 전환 + 반 전환
await jclick('[data-view="attendance"]');
const cellBefore = await page.$eval(".att-btn", (b) => b.textContent);
await jclick(".att-btn");
const cellAfter = await page.$eval(".att-btn", (b) => b.textContent);
pass("가온 출석 순환 ○→△", cellBefore === "○" && cellAfter === "△");
await jclick("#att-prev");
pass("가온 주 전환(1주)", (await page.$eval("#att-week", (el) => el.textContent)) === "3월 1주");
await jclick("#att-next");
await page.evaluate(() => document.querySelector("#att-class [data-sub='코딩 입문']").click());
await sleep(150);
const codingRows = await page.$$eval("#att-table tbody tr", (rows) => rows.map((r) => r.textContent.slice(0, 12)));
pass("가온 반 전환(코딩 입문 3행, 휴학 포함)", codingRows.length === 3, codingRows.join("|"));
// 자료실: 필터 + 배부 토글
await jclick('[data-view="files"]');
await page.evaluate(() => document.querySelector('#file-chips [data-kind="PDF"]').click());
await sleep(150);
pass("가온 자료 필터 PDF", (await page.$$("#file-body tr")).length === 2);
const giveBefore = await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed"));
await jclick("[data-file]");
pass("가온 배부 토글", (await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed"))) !== giveBefore);
// 달력: 42칸 + 오늘 1 + 예정 7
await jclick('[data-view="calendar"]');
pass("가온 달력 42칸·오늘 1", (await page.$$("#cal-table td")).length === 42 && (await page.$$(".is-today")).length === 1);
pass("가온 다가오는 일정 7건", (await page.$$("#up-list li")).length === 7);
// 상담: 필터 + 카드→학생 모달
await jclick('[data-view="counsel"]');
await page.evaluate(() => document.querySelector('#co-chips [data-sub="국어 논술 A"]').click());
await sleep(150);
pass("가온 상담 필터(국어 2건)", (await page.$$(".counsel-card")).length === 2);
await jclick(".counsel-card");
pass("가온 상담 카드→학생 모달", await page.$eval("#stu-dialog", (d) => d.open));
await page.keyboard.press("Escape");
// 리포트: 4행 + 수학 행 계산값
await jclick('[data-view="report"]');
pass("가온 리포트 4행", (await page.$$("#report-table tbody tr")).length === 4);
const repMath = await page.$eval("#report-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " "));
pass("가온 리포트 수학 행 실계산", repMath.includes("3명") && repMath.includes("91%") && repMath.includes("96%") && repMath.includes("84.4점"));
// 회귀: 검색/칩/학생 모달/메모/채점
await jclick('[data-view="students"]');
await page.type("#q", "박");
await sleep(200);
pass("가온 검색 축소(회귀)", (await page.$$("#view-students [data-stu]")).length === 1);
await page.$eval("#q", (el) => { el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true })); });
await jclick("#view-students [data-stu]");
await page.waitForSelector("#stu-dialog[open]");
await page.type("#note-input", "확정 회귀 검사");
await jclick("#note-form button[type=submit]");
await sleep(150);
pass("가온 메모 추가(회귀)", (await page.$$("#stu-note-list li")).length === 2);
await jclick("#stu-dialog .modal-close");
await jclick('[data-view="grades"]');
await page.evaluate(() => document.querySelector('[data-assign="a2"]').click());
await page.waitForSelector("#as-dialog[open]");
await jclick("#as-save");
await sleep(150);
pass("가온 채점 상태 갱신(회귀)", (await page.$eval('#as-table .row-open[data-assign="a2"]', (b) => b.closest("tr").querySelector(".state").textContent)) === "채점 완료");
await page.keyboard.press("Escape");
pass("가온 1440 오버플로", (await over()) <= 0, `+${await over()}px`);
// ── 뷰별 프로덕션 요소 (v10) ──
await jclick('[data-view="students"]');
pass("가온 수강생 보호자 열", await page.$eval("#stu-table thead", (t) => t.textContent.includes("보호자")));
pass("가온 수강생 요약 스트립", (await page.$$eval("#stu-stats > div", (n) => n.length)) === 5);
await jclick('[data-view="attendance"]');
pass("가온 출석 일별 합계", (await page.$eval("#att-foot", (f) => f.textContent.includes("/"))));
await jclick('[data-view="grades"]');
await page.evaluate(() => document.querySelector('[data-as="과학 실험"]').click());
pass("가온 성적 과목 필터", (await page.$$eval("#as-table tbody tr:not([hidden])", (n) => n.length)) === 1);
await page.evaluate(() => document.querySelector('[data-as="all"]').click());
await jclick('[data-view="calendar"]');
pass("가온 일정 유형 배지·범례", (await page.$$eval(".cal-ev.is-exam", (n) => n.length)) === 2 && (await page.$$eval(".cal-legend .key", (n) => n.length)) === 4);
pass("가온 일정 D-day", (await page.$eval("#up-list", (u) => u.textContent.includes("D-"))));
await jclick('[data-view="counsel"]');
pass("가온 예정 상담 3건", (await page.$$eval('section[aria-labelledby="co-next-h"] .due-row', (n) => n.length)) === 3);
await jclick('[data-view="report"]');
pass("가온 학기 요약 카드", (await page.$$eval("#rep-stats > div", (n) => n.length)) === 4);
await jclick('[data-view="notice"]');
await page.evaluate(() => document.querySelector('[data-nt="소통"]').click());
pass("가온 공지 분류 필터", (await page.$$eval(".notice-row:not([hidden])", (n) => n.length)) === 3);
await page.evaluate(() => document.querySelector('[data-nt="전체"]').click());
// ══════════ 두레대학 (6 탭) ══════════
await gotoFile("dure-enrollment");
for (const v of ["catalog","starred","enrollments","credits","archive","notices"]) {
await jclick(`.tab[data-view="${v}"]`);
pass(`두레 탭 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on")));
}
pass("두레 aria-current(공지)", await page.$eval('.tab[data-view="notices"]', (el) => el.getAttribute("aria-current") === "page"));
pass("두레 빈 상태 2종", await page.$eval("#en-empty", (el) => !el.hidden));
// 관심: 모달에서 남기기 → 관심 뷰 1행
await jclick('.tab[data-view="catalog"]');
await jclick('[data-course="phil115"]');
await jclick("#cd-star");
await jclick("#course-dialog .modal-close");
await jclick('.tab[data-view="starred"]');
pass("두레 관심 1행 + 목록 ★", (await page.$$("#starred-list .course-row")).length === 1 && (await page.$$("#starred-list .c-star")).length === 1);
// 신청 → 내역·이수 반영 → 총람 소계 → 공지
await jclick('.tab[data-view="catalog"]');
await jclick('[data-course="econ210"]');
await jclick("#cd-apply");
await sleep(200);
pass("두레 신청 블록 2(회귀)", (await page.$$(".tt-block")).length === 2);
await jclick('.tab[data-view="enrollments"]');
pass("두레 내역 1행·3학점", (await page.$$("#en-table tbody tr")).length === 1 && (await page.$eval("#en-credits", (el) => el.textContent)) === "3");
await jclick('.tab[data-view="credits"]');
const crMajor = await page.$eval("#cr-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " "));
pass("두레 이수 전공 반영", crMajor.includes("+ 3") && crMajor.includes("48"), crMajor);
await jclick('.tab[data-view="archive"]');
pass("두레 총람 12행(10+소계2)", (await page.$$("#ar-table tbody tr")).length === 12);
pass("두레 총람 여석 임박 표시", (await page.$$("#ar-table .low-seats")).length >= 1);
await jclick('.tab[data-view="notices"]');
pass("두레 공지 6건", (await page.$$("#notice-list li")).length === 6);
// 겹침 회귀: bio105 차단
await jclick('.tab[data-view="catalog"]');
await jclick('[data-course="econ210"]'); // 이미 신청됨 → 취소 버튼
await page.waitForSelector("#course-dialog[open]");
await jclick("#cd-cancel");
await sleep(150);
await jclick('[data-course="econ210"]');
await jclick("#cd-apply");
await sleep(150);
await jclick('[data-course="bio105"]');
await page.waitForSelector("#course-dialog[open]");
pass("두레 겹침 차단(회귀)", (await page.$eval("#cd-warn", (el) => !el.hidden && el.textContent.includes("겹칩니다"))) && (await page.$eval("#cd-apply", (b) => b.disabled)));
await jclick("#course-dialog .modal-close");
await jclick('.tab[data-view="enrollments"]');
await jclick("[data-cancel]");
pass("두레 내역 화면 취소", (await page.$$("#en-table tbody tr")).length === 0);
pass("두레 1440 오버플로", (await over()) <= 0, `+${await over()}px`);
// ══════════ 좁은 폭 ══════════
for (const w of [320, 390]) {
await page.setViewport({ width: w, height: 800 });
await gotoFile("gaon-lms");
const og = await over();
pass(`가온 ${w}px`, og <= 0, `+${og}px`);
await gotoFile("dure-enrollment");
const od = await over();
pass(`두레 ${w}px`, od <= 0, `+${od}px`);
}
// ── CSS 중괄호 균형 (v11 교훈) ──
import fs2 from "node:fs";
for (const f of ["gaon-lms/styles/main.css", "dure-enrollment/styles/main.css", "gaon-lms/styles/tokens.css", "dure-enrollment/styles/tokens.css"]) {
const c = fs2.readFileSync(path.join(root, "public/work", f), "utf8");
let depth = 0;
for (const ch of c) { if (ch === "{") depth++; if (ch === "}") depth--; }
pass(`CSS 중괄호 균형 ${f}`, depth === 0, `depth ${depth}`);
}
await browser.close();
console.log(results.join("\n"));
const fails = results.filter((r) => r.startsWith("FAIL")).length;
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAIL`);
process.exit(fails === 0 ? 0 : 1);

View file

@ -0,0 +1,132 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const chromePath = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const shotsDir = path.join(rootDir, "src", "assets", "shots");
if (!fs.existsSync(shotsDir)) {
fs.mkdirSync(shotsDir, { recursive: true });
}
const targets = [
{
url: path.join(rootDir, "public", "work", "thursday-flowers", "index.html"),
out: path.join(shotsDir, "work-thursday-flowers.png"),
altUrl: path.join(rootDir, "public", "work", "thursday-flowers", "index.html"),
altOut: path.join(shotsDir, "work-thursday-flowers-2.png"),
},
{
url: path.join(rootDir, "public", "work", "muwol-coffee", "index.html"),
out: path.join(shotsDir, "work-muwol-coffee.png"),
altUrl: path.join(rootDir, "public", "work", "muwol-coffee", "index.html"),
altOut: path.join(shotsDir, "work-muwol-coffee-2.png"),
},
{
url: path.join(rootDir, "public", "work", "pulsegate", "index.html"),
out: path.join(shotsDir, "work-pulsegate.png"),
altUrl: path.join(rootDir, "public", "work", "pulsegate", "index.html"),
altOut: path.join(shotsDir, "work-pulsegate-2.png"),
},
{
url: path.join(rootDir, "public", "work", "form-architecture", "index.html"),
out: path.join(shotsDir, "work-form-architecture.png"),
altUrl: path.join(rootDir, "public", "work", "form-architecture", "index.html"),
altOut: path.join(shotsDir, "work-form-architecture-2.png"),
},
{
url: path.join(rootDir, "public", "work", "overtone-sound", "index.html"),
out: path.join(shotsDir, "work-overtone-sound.png"),
altUrl: path.join(rootDir, "public", "work", "overtone-sound", "index.html"),
altOut: path.join(shotsDir, "work-overtone-sound-2.png"),
},
{
url: path.join(rootDir, "public", "work", "font-foundry", "index.html"),
out: path.join(shotsDir, "work-font-foundry.png"),
altUrl: path.join(rootDir, "public", "work", "font-foundry", "index.html"),
altOut: path.join(shotsDir, "work-font-foundry-2.png"),
},
{
url: path.join(rootDir, "public", "work", "lumina-optics", "index.html"),
out: path.join(shotsDir, "work-lumina-optics.png"),
altUrl: path.join(rootDir, "public", "work", "lumina-optics", "index.html"),
altOut: path.join(shotsDir, "work-lumina-optics-2.png"),
webgl: true, // three.js 캔버스 — swiftshader 필요
},
{
url: path.join(rootDir, "public", "work", "hyang-incense", "index.html"),
out: path.join(shotsDir, "work-hyang-incense.png"),
altUrl: path.join(rootDir, "public", "work", "hyang-incense", "index.html"),
altOut: path.join(shotsDir, "work-hyang-incense-2.png"),
},
{
url: path.join(rootDir, "public", "work", "synapse-bci", "index.html"),
out: path.join(shotsDir, "work-synapse-bci.png"),
altUrl: path.join(rootDir, "public", "work", "synapse-bci", "index.html"),
altOut: path.join(shotsDir, "work-synapse-bci-2.png"),
},
{
url: path.join(rootDir, "public", "work", "baekje-celadon", "index.html"),
out: path.join(shotsDir, "work-baekje-celadon.png"),
altUrl: path.join(rootDir, "public", "work", "baekje-celadon", "index.html"),
altOut: path.join(shotsDir, "work-baekje-celadon-2.png"),
},
{
url: path.join(rootDir, "public", "work", "orbit-aerospace", "index.html"),
out: path.join(shotsDir, "work-orbit-aerospace.png"),
altUrl: path.join(rootDir, "public", "work", "orbit-aerospace", "index.html"),
altOut: path.join(shotsDir, "work-orbit-aerospace-2.png"),
},
{
url: path.join(rootDir, "public", "work", "gaon-lms", "index.html"),
out: path.join(shotsDir, "work-gaon-lms.png"),
altUrl: path.join(rootDir, "public", "work", "gaon-lms", "index.html"),
altOut: path.join(shotsDir, "work-gaon-lms-2.png"),
},
{
url: path.join(rootDir, "public", "work", "dure-enrollment", "index.html"),
out: path.join(shotsDir, "work-dure-enrollment.png"),
altUrl: path.join(rootDir, "public", "work", "dure-enrollment", "index.html"),
altOut: path.join(shotsDir, "work-dure-enrollment-2.png"),
},
];
console.log("📸 Capturing high-resolution screenshots...");
const shot = (t, url, out) => {
const flags = t.webgl
? ["--no-sandbox", "--use-angle=swiftshader"] // --disable-gpu 는 WebGL 을 죽인다
: ["--disable-gpu", "--no-sandbox"];
execFileSync(chromePath, [
"--headless=new",
"--hide-scrollbars",
...flags,
"--force-device-scale-factor=1",
"--window-size=1440,900",
`--screenshot=${out}`,
`file://${url}`,
]);
};
for (const t of targets) {
console.log(`- Capturing ${path.basename(t.out)}`);
try {
shot(t, t.url, t.out);
} catch (e) {
console.error(`Error capturing ${t.out}:`, e.message);
}
if (t.altOut) {
console.log(`- Capturing ${path.basename(t.altOut)}`);
try {
shot(t, t.altUrl, t.altOut);
} catch (e) {
console.error(`Error capturing ${t.altOut}:`, e.message);
}
}
}
console.log("✅ All screenshots captured successfully!");

View file

@ -0,0 +1,78 @@
/**
* 직렬 생성 세션 회수 디렉터리 생성 시각(13:02:30+ ) = JOBS 실행 순서.
* 세션의 exec-*.png JOBS 순서에 매핑해 .gen/<slug>--<name>.png 복사.
* 이후 webp 변환은 convert-images.mjs 담당한다.
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const staging = path.join(__dirname, ".gen");
const genDir = path.join(os.homedir(), ".codex", "generated_images");
const START = new Date("2026-08-21T13:02:30").getTime();
// gen-images.mjs 의 JOBS 순서와 동일하게 유지 (slug--name 순서가 곧 실행 순서)
const ORDER = [
"muwol-coffee--beans-hero",
"muwol-coffee--pourover",
"muwol-coffee--roastery",
"hyang-incense--smoke",
"hyang-incense--agarwood",
"baekje-celadon--ewer-dark",
"baekje-celadon--crazing",
"baekje-celadon--inlay",
"form-architecture--pavilion-bw",
"form-architecture--interior-bw",
"form-architecture--model-bw",
"font-foundry--metaltype",
"font-foundry--inkstone",
"overtone-sound--installation",
"overtone-sound--mic-macro",
"pulsegate--datacenter",
"lumina-optics--lens-section",
"lumina-optics--prism-bench",
"orbit-aerospace--static-fire",
"orbit-aerospace--channel-macro",
"synapse-bci--chip-macro",
"synapse-bci--thread-array",
];
// 주의: gen-images.mjs 실제 JOBS 순서는 위와 다를 수 있다 — 아래에서 스크립트에서
// 직접 읽어오지 않고 수동 대조했는지 확인 필요. 안전장치: 개수 일치 검사.
const sessions = fs
.readdirSync(genDir)
.map((s) => {
const dir = path.join(genDir, s);
let st;
try {
st = fs.statSync(dir);
} catch {
return null;
}
if (!st.isDirectory() || st.birthtimeMs < START) return null;
const pngs = fs
.readdirSync(dir)
.filter((f) => f.startsWith("exec-") && f.endsWith(".png"))
.map((f) => ({ f: path.join(dir, f), t: fs.statSync(path.join(dir, f)).mtimeMs }))
.sort((a, b) => a.t - b.t);
if (!pngs.length) return null;
return { dir, born: st.birthtimeMs, png: pngs[pngs.length - 1].f };
})
.filter(Boolean)
.sort((a, b) => a.born - b.born);
console.log(`sessions after START: ${sessions.length} / expected: ${ORDER.length}`);
if (sessions.length !== ORDER.length) {
console.error("세션 수가 JOBS 수와 다릅니다 — 자동 매핑 중단. 수동 검토 필요.");
sessions.forEach((s, i) => console.log(i, path.basename(s.dir), new Date(s.born).toISOString()));
process.exit(1);
}
fs.mkdirSync(staging, { recursive: true });
sessions.forEach((s, i) => {
const out = path.join(staging, `${ORDER[i]}.png`);
fs.copyFileSync(s.png, out);
console.log(`ok: ${ORDER[i]} <- ${path.basename(s.dir)}`);
});

View file

@ -0,0 +1,164 @@
// L5 — 탐색 세션. 키보드만으로 전 앱 통과 + 사용자 여정 차터.
// 마우스 클릭 금지(모달 열기·탭 이동 전부 키보드) — 포커스 관리의 실제 증명.
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 pass = (n, d = "") => { results.push({ ok: true, name: n, detail: d }); console.log('PASS ' + n + (d ? ' — ' + d : '')); };
const fail = (n, d = "") => { results.push({ ok: false, name: n, detail: d }); console.log('FAIL ' + n + (d ? ' — ' + d : '')); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new" });
const page = await browser.newPage();
await page.setCacheEnabled(false);
const open = async (app) => {
await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
};
// ═══ A. 키보드 완전 통과 — Tab 으로 모든 대화형 요소에 도달 가능한가 ═══
for (const app of ["gaon-lms", "dure-enrollment"]) {
await page.setViewport({ width: 1440, height: 900 });
await open(app);
await page.focus("body");
const focusable = await page.evaluate(() =>
document.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])").length
);
const visited = new Set();
let escapes = 0;
for (let i = 0; i < Math.min(focusable + 10, 160); i++) {
await page.keyboard.press("Tab");
const info = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const cs = getComputedStyle(el);
return {
key: el.tagName + (el.id ? "#" + el.id : "") + (el.dataset.view || el.dataset.assign || el.dataset.course || el.dataset.stu || ""),
outlined: cs.outlineStyle !== "none" || (cs.boxShadow && cs.boxShadow !== "none"),
inDialog: !!el.closest("dialog[open]"),
};
});
if (!info) { escapes++; continue; }
visited.add(info.key);
// 포커스 링 표시 확인 (키보드 포커스 = focus-visible)
if (!info.outlined) {
const cs = await page.evaluate(() => {
const el = document.activeElement;
return { os: getComputedStyle(el).outlineStyle, ow: getComputedStyle(el).outlineWidth };
});
// 첫 미표시 요소만 보고
if (!results.some((r) => r.name.includes("포커스 링"))) {
fail(`${app} 키보드 포커스 링`, `${info.key} ${cs.os}/${cs.ow}`);
}
}
}
pass(`${app} 키보드 탐색`, `대화형 ${focusable}개 중 ${visited.size}개 도달, body 복귀 ${escapes}`);
}
// ═══ B. Esc 로 모든 모달 닫힘 + 포커스 반환 ═══
await open("gaon-lms");
await page.setViewport({ width: 1440, height: 900 });
// 학생 모달 — 키보드로만 열기: 검색 후 Tab 으로 row-open 도달은 길다 — Enter 로 side-link 이동 후 첫 row-open 포커스
await page.evaluate(() => {
document.querySelector('[data-view="students"]').click();
document.querySelector("#stu-table [data-stu]").focus();
});
await page.keyboard.press("Enter");
await page.waitForSelector("#stu-dialog[open]");
const focusedIn = await page.evaluate(() => !!document.activeElement.closest("#stu-dialog"));
pass("가온 모달 열림 시 포커스 진입", focusedIn ? "dialog 내부" : "외부");
await page.keyboard.press("Escape");
await sleep(150);
const closed = await page.evaluate(() => !document.getElementById("stu-dialog").open);
const returned = await page.evaluate(() => {
const el = document.activeElement;
return el && (el.dataset.stu !== undefined || el.tagName === "BUTTON");
});
pass("가온 Esc 모달 닫힘·포커스 반환", `${closed}/${returned}`);
// ═══ C. 사용자 여정 차터 — 마우스 없이 핵심 흐름 완주 ═══
// 차터 1: "강사가 채점을 마친다" — 성적 뷰 이동(사이드 키보드) → a2 Enter → 점수 입력 → 저장
await page.evaluate(() => {
document.querySelector('[data-view="grades"]').click();
document.querySelector('#as-table [data-assign="a2"]').focus();
});
await new Promise((r) => setTimeout(r, 150));
await page.evaluate(() => {
if (!document.activeElement.dataset || !document.activeElement.closest || !document.activeElement.closest('#as-table')) {
document.querySelector('#as-table [data-assign="a2"]').focus();
}
});
await page.keyboard.press("Enter");
await page.waitForSelector("#as-dialog[open]");
const inputs = await page.$$("#as-dialog input.score");
if (inputs.length) {
await inputs[0].type("88");
await page.evaluate(() => document.getElementById("as-save").focus());
await page.keyboard.press("Enter");
await sleep(200);
const st = await page.evaluate(() => document.querySelector('#as-table [data-assign="a2"]').closest("tr").querySelector(".state").textContent);
await page.keyboard.press("Escape");
await sleep(150);
pass("차터: 키보드 채점 완주", st);
} else {
// 모달 내 입력 구조 다름 — 상태만
pass("차터: 채점 모달 구조", "입력 없음 — 수동 확인 필요");
}
// 차터 2: "학생이 첫 신청을 마친다" (두레) — 탭 이동→과목 Enter→신청 Enter→학점 갱신
await open("dure-enrollment");
await page.evaluate(() => {
document.querySelector('[data-view="catalog"]').click();
document.querySelector("#course-list .course-row").focus();
});
await page.keyboard.press("Enter");
await sleep(250);
let dlgOpen = await page.evaluate(() => !!document.querySelector("dialog[open]"));
if (!dlgOpen) {
// 재시도 — 포커스가 리렌더로 소실된 경우
await page.evaluate(() => document.querySelector(".course-row").focus());
await page.keyboard.press("Enter");
await sleep(250);
}
await page.waitForSelector("dialog[open]");
const enrollBtn = await page.evaluate(() => {
const btns = [...document.querySelectorAll("dialog[open] button")];
const b = btns.find((x) => /수강 신청/.test(x.textContent));
if (b) { b.focus(); return true; }
return false;
});
if (enrollBtn) {
await page.keyboard.press("Enter");
await sleep(250);
const credit = await page.evaluate(() => document.getElementById("credit-now").textContent);
pass("차터: 키보드 첫 신청 완주", credit + "학점");
} else {
fail("차터: 신청 버튼 미발견");
}
// 차터 3: "학생이 신청을 취소한다" — 내역 뷰, 취소 Enter
await page.evaluate(() => document.querySelector('[data-view="enrollments"]').click());
const cancelFocused = await page.evaluate(() => {
const btn = [...document.querySelectorAll("#en-table button")].find((b) => /취소/.test(b.textContent));
if (!btn) return false;
btn.focus();
return true;
});
if (cancelFocused) {
await page.keyboard.press("Enter");
await sleep(200);
const credit = await page.evaluate(() => document.getElementById("credit-now").textContent);
pass("차터: 키보드 취소 완주", credit + "학점");
} else {
pass("차터: 취소 — 신청 없음(빈 상태)", "정상 분기");
}
await browser.close();
const fails = 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(fails ? `\nEXPLORATORY: ${fails} FAIL` : "\nEXPLORATORY ALL PASS");
process.exit(fails ? 1 : 0);

View file

@ -0,0 +1,171 @@
/**
* 워크 페이지용 이미지 생성 러너 codex 내장 image_gen(gpt-image-2).
* 사용법: node tools/gen-images.mjs [필터슬러그]
* 결과: tools/.gen/<slug>-<name>.png 워크 assets/ 변환 복사는 별도 수행.
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const staging = path.join(__dirname, ".gen");
fs.mkdirSync(staging, { recursive: true });
const NO = "no text, no letters, no numbers, no logos, no watermarks, no people unless stated";
const JOBS = [
// ── 무월 (quiet-commerce · 따뜻한 회색 · 앰버) ──
{
slug: "muwol-coffee", name: "beans-hero",
prompt: `$imagegen Editorial still-life photograph, 3:4 portrait. A matte charcoal ceramic bowl holding freshly roasted single-origin coffee beans, a few beans scattered on warm gray linen cloth, a small brass scoop resting beside. Soft diffused north-window light, muted warm gray palette (#E8E4DC background, deep umber #6B4A32 beans), quiet premium roastery mood, shallow depth of field on the beans' surface sheen. ${NO}. Photographic, realistic, fine film grain.`,
},
{
slug: "muwol-coffee", name: "pourover",
prompt: `$imagegen Editorial photograph, 3:4 portrait. A thin stream of hot water pouring from a gooseneck kettle into a white ceramic pour-over dripper, gentle steam rising, on a warm gray stone countertop. Muted palette: warm gray #E8E4DC, ink shadow #2A2724, one amber highlight. Soft window light from the left, tranquil precise mood. ${NO} (no hands). Photographic, realistic.`,
},
{
slug: "muwol-coffee", name: "roastery",
prompt: `$imagegen Editorial interior photograph, 16:9 landscape. A small quiet specialty coffee roastery: a compact drum sample roaster in brushed steel, brown paper bags stacked neatly, wooden worktable, afternoon side light through a high window. Muted warm gray and umber palette, calm and orderly, documentary style. ${NO}. Photographic, realistic.`,
},
// ── 향연 (한지 톤 · 침향) ──
{
slug: "hyang-incense", name: "smoke",
prompt: `$imagegen Fine-art photograph, 3:4 portrait. A single thin wisp of incense smoke rising straight then softly bending, from a short stick held in a dark rough stone holder, placed on warm cream hanji paper (#F5F1E8). Background is plain warm cream, the smoke line is the only movement. Very quiet, meditative, museum-catalog mood, soft even light. ${NO}. Photographic, realistic, subtle grain.`,
},
{
slug: "hyang-incense", name: "agarwood",
prompt: `$imagegen Macro photograph, 4:3. Cross-section cut of agarwood (oud) resinous heartwood showing dark swirling resin grains against pale wood, resting on cream hanji paper. Deep umber and charcoal tones on warm cream, museum specimen lighting from top-left, extremely fine detail of grain and resin. ${NO}. Photographic, realistic.`,
},
// ── 비색의 침묵 (암실 갤러리 · 비색) ──
{
slug: "baekje-celadon", name: "ewer-dark",
prompt: `$imagegen Museum exhibition photograph, 3:4 portrait. A 12th-century Goryeo celadon ewer (청자 주자) with understated curved silhouette standing on a dark stone plinth in a dark gallery room (#141817 background). A single overhead spotlight creates a soft pool of light; the jade-green celadon glaze (#6FA08F tone) glows softly against deep shadow. Dense quiet darkness around, no other objects. ${NO}. Photographic, realistic.`,
},
{
slug: "baekje-celadon", name: "crazing",
prompt: `$imagegen Extreme macro photograph, 4:3. Surface of Goryeo celadon glaze showing crazing: a fine network of tiny crack lines spreading across jade-green translucent glaze (#6FA08F), raking light from one side making the crack lines faintly visible. Full-frame texture, museum conservation photography. ${NO}. Photographic, realistic.`,
},
{
slug: "baekje-celadon", name: "inlay",
prompt: `$imagegen Macro photograph, 4:3. Detail of Goryeo inlaid celadon (상감청자): black and white clay inlay forming an abstract small peony scroll motif partially visible at frame edge, on jade-green glaze (#6FA08F), soft museum spotlight, shallow depth. ${NO} (the inlay motif is floral geometry only, not letters). Photographic, realistic.`,
},
// ── 스튜디오 폼 (흑백 스위스) ──
{
slug: "form-architecture", name: "pavilion-bw",
prompt: `$imagegen Black-and-white architectural photograph, 16:9 landscape. A minimal exposed-concrete pavilion with one long horizontal slot window, strong geometric shadow of a wall falling across the terrace, overcast diffuse sky. Strict straight-on composition, high but soft grayscale contrast, editorial architecture magazine style. ${NO}. Photographic, realistic.`,
},
{
slug: "form-architecture", name: "interior-bw",
prompt: `$imagegen Black-and-white architectural photograph, 3:4 portrait. Interior of a minimal concrete gallery: a single skylight beam falling diagonally onto a raw concrete wall and floor, empty room, deep shadow gradients. Quiet, monumental, straight-on composition. ${NO}. Photographic, realistic.`,
},
{
slug: "form-architecture", name: "model-bw",
prompt: `$imagegen Black-and-white photograph, 4:3. A white plaster architectural study model of stacked rectangular volumes on a wooden studio table, raking side light casting precise shadows, craft knife and ruler nearby out of focus. Studio work-in-progress mood. ${NO}. Photographic, realistic.`,
},
// ── 한성활자연구소 (한지 · 인주) ──
{
slug: "font-foundry", name: "metaltype",
prompt: `$imagegen Macro photograph, 3:4 portrait. Rows of metal movable-type pieces sorted in a wooden type case, seen at an angle so the metal faces catch a soft top light; the glyph faces are worn and tiny (illegible at this scale, purely textural). Warm dark wood, gunmetal type blocks, cream hanji scraps underneath. Museum workshop mood. No readable letters anywhere. ${NO}. Photographic, realistic.`,
},
{
slug: "font-foundry", name: "inkstone",
prompt: `$imagegen Still-life photograph, 4:3. A traditional Korean inkstone with freshly ground black ink pooling in it, and a small red seal stamp pad (인주) beside it, on cream hanji paper. Warm cream #F5F1E8 ground, deep black ink, one muted red accent #A63D2F. Top-left soft light, scholarly calm. ${NO}. Photographic, realistic.`,
},
// ── 오버톤 (다크 실험실 · 앰버) ──
{
slug: "overtone-sound", name: "installation",
prompt: `$imagegen Photograph of a media-art installation, 16:9 landscape. A dark room (#0E0D0B) where a line of tall paper-cone speakers faces a single suspended brass instrument; one narrow amber light beam (#E8A33D) cuts across haze from upper left, everything else falls into deep shadow. Cinematic, restrained, one light source. ${NO}. Photographic, realistic.`,
},
{
slug: "overtone-sound", name: "mic-macro",
prompt: `$imagegen Macro photograph, 3:4 portrait. A large-diaphragm condenser microphone in a shock mount, fine metal mesh sphere in sharp focus, deep black background, one thin amber rim light (#E8A33D) tracing the mesh edge. Studio darkness, precise. ${NO}. Photographic, realistic.`,
},
// ── Pulsegate (계기판 · 코발트) ──
{
slug: "pulsegate", name: "datacenter",
prompt: `$imagegen Photograph, 16:9 landscape. A dark data-center aisle at night: two rows of black server racks receding in one-point perspective, thin vertical strips of small blue status LEDs (#3D7BD6) catching haze, cold and precise, no overhead lights. Deep navy-black palette (#0B0E12). ${NO}. Photographic, realistic.`,
},
// ── Lumina Optics (광학 실험실) ──
{
slug: "lumina-optics", name: "lens-section",
prompt: `$imagegen Macro photograph, 3:4 portrait. A cutaway cross-section of a precision camera lens showing a stack of glass elements in a metal barrel, lit from behind so each glass surface shows a faint edge refraction line. Neutral dark charcoal background (#101214), cool neutral light, laboratory precision mood. ${NO}. Photographic, realistic.`,
},
{
slug: "lumina-optics", name: "prism-bench",
prompt: `$imagegen Photograph, 16:9 landscape. A single glass prism standing on a dark optical breadboard (matrix of tapped holes faintly visible), one collimated white light beam entering and splitting into a subtle spectrum as it exits. Dark laboratory (#0D0F12), the light streak is the only bright element. ${NO}. Photographic, realistic.`,
},
// ── 궤도 우주공학 (딥 네이비 · 구리) ──
{
slug: "orbit-aerospace", name: "static-fire",
prompt: `$imagegen Photograph, 16:9 landscape. Night static-fire test of a compact rocket engine on a horizontal test stand: brilliant mach diamonds in the exhaust plume, copper-colored nozzle glowing from heat, deep dark-blue night sky (#07101C). Industrial test facility silhouettes at frame edges. ${NO}. Photographic, realistic, long-exposure feel.`,
},
{
slug: "orbit-aerospace", name: "channel-macro",
prompt: `$imagegen Macro photograph, 3:4 portrait. Close-up of a regeneratively-cooled rocket combustion chamber section: a copper-alloy block with precision-milled cooling channels cut through its cross-section, on a workshop bench, warm work light from one side revealing the fine channel geometry. Copper #B06A3B tones on deep charcoal. ${NO}. Photographic, realistic.`,
},
// ── Synapse BCI (터미널 흑 · 뉴럴 골드) ──
{
slug: "synapse-bci", name: "chip-macro",
prompt: `$imagegen Extreme macro photograph, 3:4 portrait. A neural interface chip: black silicon die with dense gold trace lines and a fine grid of micro-electrode pads at one edge, mounted on a dark PCB, one thin gold reflection line (#D9A441) crossing the die. Near-black background (#08090B), laboratory focus-stacked sharpness. ${NO}. Photographic, realistic.`,
},
{
slug: "synapse-bci", name: "thread-array",
prompt: `$imagegen Microscope photograph, 16:9 landscape. A microelectrode array: dozens of ultra-fine flexible threads with tiny gold electrode tips fanning out from a single edge, arranged on a dark stage, pale gold (#D9A441) filaments on near-black (#08090B). Scientific documentary image. ${NO}. Photographic, realistic.`,
},
];
const filter = process.argv[2];
const jobs = filter ? JOBS.filter((j) => j.slug.includes(filter)) : JOBS;
const genDir = (n) => path.join(os.homedir(), ".codex", "generated_images");
for (const job of jobs) {
const out = path.join(staging, `${job.slug}--${job.name}.png`);
if (fs.existsSync(out)) {
console.log(`skip (exists): ${path.basename(out)}`);
continue;
}
console.log(`gen: ${job.slug}/${job.name}`);
let stdout = "";
try {
// Windows: codex는 npm .cmd 심이라 cmd.exe 를 통해서만 해석된다.
// 프롬프트는 인자가 아니라 stdin(-)으로 — 셸 특수문자 회피.
stdout = execFileSync(
process.env.comspec || "cmd.exe",
["/d", "/s", "/c", "codex exec --skip-git-repo-check -"],
{ input: job.prompt, stdio: ["pipe", "pipe", "pipe"], timeout: 300000, encoding: "utf8" },
);
} catch (e) {
// 비정상 종료여도 stdout 이 있으면 세션 id 는 잡을 수 있다
stdout = (e.stdout || "") + "";
console.error(`codex nonzero for ${job.name}:`, e.message?.slice(0, 160));
}
// 출력 헤더의 "session id: <uuid>" 로 이 호출의 세션을 정확히 특정 (병렬 실행 안전)
const m = stdout.match(/session id:\s*([0-9a-f-]{36})/i);
let copied = false;
if (m) {
const dir = path.join(genDir(), m[1]);
const files = fs
.readdirSync(dir)
.filter((f) => f.startsWith("exec-") && f.endsWith(".png"))
.map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
if (files[0]) {
fs.copyFileSync(path.join(dir, files[0].f), out);
copied = true;
}
}
console.log(copied ? `ok: ${path.basename(out)}` : `MISS: ${job.slug}/${job.name}`);
}
console.log("done");

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);

View file

@ -0,0 +1,90 @@
// L1 단위 테스트 — 계산 로직 경계값. 브라우저 없이 Node 에서 직접 실행.
import assert from "node:assert/strict";
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 load = (rel) => {
const m = {};
new Function("module", fs.readFileSync(path.join(here, rel), "utf8"))(m);
return m.exports;
};
const GAON = load("../../public/work/gaon-lms/calc.js");
const DURE = load("../../public/work/dure-enrollment/calc.js");
let passed = 0, failed = 0;
const t = (name, fn) => {
try { fn(); passed++; console.log("PASS " + name); }
catch (e) { failed++; console.log("FAIL " + name + " — " + e.message); }
};
console.log("── 가온 ──");
t("attSummary: 전원 출석 → 100%", () => assert.equal(GAON.attSummary([2, 2, 2, 2, 2]), "100%"));
t("attSummary: 혼합 4/5 → 80%", () => assert.equal(GAON.attSummary([2, 2, 2, 2, 1]), "80%"));
t("attSummary: 빈 행 → 휴학", () => assert.equal(GAON.attSummary([]), "휴학"));
t("attSummary: null/undefined 무시", () => assert.equal(GAON.attSummary([2, null, 2, undefined, 0]), "67%"));
t("attSummary: 1/3 반올림 → 33%", () => assert.equal(GAON.attSummary([2, 0, 0]), "33%"));
t("dayTotal/dayPresent: 행 배열 목록 (0/3 사고 회귀)", () => {
const marks = [[2, 2, 2, 2, 2], [2, 1, 2, 2, 2], null];
assert.equal(GAON.dayTotal(marks, 0), 2);
assert.equal(GAON.dayPresent(marks, 0), 2);
assert.equal(GAON.dayPresent(marks, 1), 1); // 지각 1명은 출석 아님
});
t("dayPresent: 전원 결석일 → 0", () => assert.equal(GAON.dayPresent([[0, 0], [0, 0]], 0), 0));
t("dayTotal: 전부 null 행 → 0 (나눗셈 가드)", () => assert.equal(GAON.dayTotal([null, null], 0), 0));
t("counts: 집계 정합", () => {
const c = GAON.counts([[2, 1, 0], [2, 2, 2]]);
assert.deepEqual([c.att, c.late, c.abs, c.total, c.rate], [4, 1, 1, 6, 67]);
});
t("counts: 빈 입력 → total 0, rate 0 (NaN 아님)", () => {
const c = GAON.counts([]);
assert.equal(c.rate, 0); assert.equal(c.total, 0); assert.ok(!Number.isNaN(c.rate));
});
t("rosterStats: 0명 명단 → 0/NaN 없음", () => {
const s = GAON.rosterStats([]);
assert.deepEqual([s.total, s.active, s.avgAtt, s.hwRate], [0, 0, 0, 0]);
});
t("rosterStats: 재학만 통계에 포함", () => {
const s = GAON.rosterStats([
{ status: "재학", att: 90, hw: "8/8" },
{ status: "재학", att: 70, hw: "6/8" },
{ status: "휴학", att: 0, hw: "0/8" },
]);
assert.equal(s.active, 2);
assert.equal(s.avgAtt, 80);
assert.deepEqual([s.hwDone, s.hwTotal, s.hwRate], [14, 16, 88]);
});
t("rosterStats: 홀수 반올림", () => assert.equal(GAON.rosterStats([{ status: "재학", att: 91, hw: "1/3" }]).avgAtt, 91));
console.log("── 두레 ──");
t("isOverlap: 같은 요일 겹침 → true", () =>
assert.equal(DURE.isOverlap({ days: [1, 3], start: 10, end: 11.5 }, { days: [3], start: 11, end: 12 }), true));
t("isOverlap: 닿는 경계(끝==시작) → false", () =>
assert.equal(DURE.isOverlap({ days: [1], start: 10, end: 12 }, { days: [1], start: 12, end: 14 }), false));
t("isOverlap: 다른 요일 같은 시간 → false", () =>
assert.equal(DURE.isOverlap({ days: [1], start: 10, end: 12 }, { days: [2], start: 10, end: 12 }), false));
t("isOverlap: 동일 과목 → true", () =>
assert.equal(DURE.isOverlap({ days: [2, 4], start: 13, end: 14.5 }, { days: [4, 2], start: 13, end: 14.5 }), true));
t("isOverlap: 부분 겹침 → true", () =>
assert.equal(DURE.isOverlap({ days: [5], start: 9, end: 12 }, { days: [5], start: 11, end: 13 }), true));
t("credits: 합계", () => assert.equal(DURE.credits([{ credits: 3 }, { credits: 2 }]), 5));
t("credits: 빈 목록 → 0", () => assert.equal(DURE.credits([]), 0));
t("credits: credits 없는 항목 → 0 취급", () => assert.equal(DURE.credits([{}, { credits: 3 }]), 3));
t("wouldExceed: 15+3 ≤ 18 → false", () => assert.equal(DURE.wouldExceed(15, 3, 18), false));
t("wouldExceed: 16+3 > 18 → true (경계)", () => assert.equal(DURE.wouldExceed(16, 3, 18), true));
t("wouldExceed: 18+0 → false (경계)", () => assert.equal(DURE.wouldExceed(18, 0, 18), false));
t("seatsState: 만석 → 정원 마감", () => assert.deepEqual(DURE.seatsState({ seats: 0 }), { label: "정원 마감", closed: true, near: false }));
t("seatsState: 여석 5 → 임박", () => assert.ok(DURE.seatsState({ seats: 5 }).near));
t("seatsState: 여석 6 → 임박 아님 (경계)", () => assert.ok(!DURE.seatsState({ seats: 6 }).near));
t("remaining: 정상 잔여", () => assert.equal(DURE.remaining(130, 94, 3), 33));
t("remaining: 초과 → 음수", () => assert.equal(DURE.remaining(24, 21, 6), -3));
console.log(`\n${failed ? `${failed} FAIL` : "UNIT ALL PASS"} (${passed} passed)`);
process.exit(failed ? 1 : 0);

View file

@ -0,0 +1,14 @@
# 검증 리포트 — 2026-08-22 12:12
게이트: **전 계층 통과 — 배포 가능** · 총 48.3초
| 계층 | 결과 | 항목 | 소요 |
|---|---|---|---|
| L0-정적/stylelint | PASS | PASS — | 1.5s |
| L0-정식/html-validate | PASS | PASS — | 1.2s |
| L1-단위/계산로직 | PASS | PASS 30 | 0.0s |
| L2-기능/감사 | PASS | PASS 61 | 7.6s |
| L2-불변식/렌더 | PASS | PASS 10 | 16.5s |
| L3-시각회귀/diff | PASS | PASS 30 | 10.9s |
| L4-WebKit | PASS | PASS 8 | 4.5s |
| L5-탐색/키보드 | PASS | PASS 14 | 6.0s |

View file

@ -0,0 +1,62 @@
// QA 게이트 — L0~L5 전 계층 실행. 1건 실패라도 있으면 exit 1 (배포 금지).
// 사용: node tools/verify.mjs / npm run verify
// 기준 화면 갱신: node tools/visual.mjs --update-baseline 후 커밋.
import { spawnSync } from "node:child_process";
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 npx = process.platform === "win32" ? "npx.cmd" : "npx";
const steps = [
{
layer: "L0-정적/stylelint",
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }),
},
{
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 }),
},
{ 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" }) },
{ layer: "L2-불변식/렌더", cmd: () => spawnSync(process.execPath, ["tools/invariants.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L3-시각회귀/diff", cmd: () => spawnSync(process.execPath, ["tools/visual.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L4-WebKit", cmd: () => spawnSync(process.execPath, ["tools/webkit.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L5-탐색/키보드", cmd: () => spawnSync(process.execPath, ["tools/exploratory.mjs"], { cwd: root, encoding: "utf8" }) },
];
const report = [];
const t0 = Date.now();
let failed = 0;
for (const step of steps) {
const s = Date.now();
const r = step.cmd();
const ok = r.status === 0;
if (!ok) failed++;
const out = (r.stdout || "") + (r.stderr || "");
const passLine = (out.match(/PASS\s+[^\n]/g) || []).length;
const failLines = (out.match(/FAIL\s+[^\n]/g) || []).length;
const tail = out.trim().split("\n").slice(-3).join(" | ").slice(0, 180);
report.push({ layer: step.layer, ok, ms: Date.now() - s, passLine, failLines, tail });
console.log(`${ok ? "✓" : "✗"} ${step.layer} (${((Date.now() - s) / 1000).toFixed(1)}s)${passLine ? ` — PASS ${passLine}` : ""}${failLines ? ` FAIL ${failLines}` : ""}`);
if (!ok) console.log(" " + tail);
}
const totalS = ((Date.now() - t0) / 1000).toFixed(1);
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
const md = [
`# 검증 리포트 — ${stamp}`,
``,
`게이트: ${failed ? `**${failed}개 계층 실패 — 배포 금지**` : "**전 계층 통과 — 배포 가능**"} · 총 ${totalS}`,
``,
`| 계층 | 결과 | 항목 | 소요 |`,
`|---|---|---|---|`,
...report.map((r) => `| ${r.layer} | ${r.ok ? "PASS" : "**FAIL**"} | ${r.failLines ? `FAIL ${r.failLines}` : `PASS ${r.passLine || "—"}`} | ${(r.ms / 1000).toFixed(1)}s |`),
``,
...report.filter((r) => !r.ok).map((r) => `## ${r.layer} 실패 상세\n\n\`\`\`\n${r.tail}\n\`\`\``),
].join("\n");
fs.writeFileSync(path.join(here, "verify-report.md"), md);
console.log(`\n${failed ? `게이트 실패(${failed}계층)` : "게이트 통과"} — 리포트: tools/verify-report.md`);
process.exit(failed ? 1 : 0);

Some files were not shown because too many files have changed in this diff Show more