release: v0.10.0
All checks were successful
ci / build (push) Successful in 42s
release / release (push) Successful in 1m5s

This commit is contained in:
Yun Chan 2026-08-29 21:19:26 +09:00
parent 2b7fa32b5b
commit 6930906583
61 changed files with 15165 additions and 82 deletions

View file

@ -0,0 +1,194 @@
// 한남자리·철판클럽 전용 캡처.
//
// 이 스크립트가 덮어쓰는 파일은 아래 여섯 개뿐이다.
// src/assets/shots/work-hannam-jari.png
// src/assets/shots/work-hannam-jari-2.png
// src/assets/shots/work-griddle-club.png
// src/assets/shots/work-griddle-club-2.png
// public/work/hannam-jari/og.png
// public/work/griddle-club/og.png
//
// 공개 자산과 같은 HTTP 경로로 상대 링크를 검증하고, 폰트·이미지가 준비되지 않으면
// 불완전한 화면을 저장하지 않고 실패한다.
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import url from "node:url";
import puppeteer from "puppeteer-core";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const publicRoot = path.join(root, "public");
const shotsRoot = path.join(root, "src", "assets", "shots");
const chrome = [
process.env.CHROME_PATH,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
].find((candidate) => candidate && fs.existsSync(candidate));
if (!chrome) {
throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium.");
}
const MIME = new Map([
[".avif", "image/avif"],
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".ico", "image/x-icon"],
[".jpeg", "image/jpeg"],
[".jpg", "image/jpeg"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".webmanifest", "application/manifest+json; charset=utf-8"],
[".webp", "image/webp"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
const jobs = [
{
label: "한남자리 공개",
route: "/work/hannam-jari/",
output: path.join(shotsRoot, "work-hannam-jari.png"),
},
{
label: "한남자리 관리자",
route: "/work/hannam-jari/admin/",
output: path.join(shotsRoot, "work-hannam-jari-2.png"),
},
{
label: "철판클럽 공개",
route: "/work/griddle-club/",
output: path.join(shotsRoot, "work-griddle-club.png"),
},
{
label: "철판클럽 가맹",
route: "/work/griddle-club/franchise/",
output: path.join(shotsRoot, "work-griddle-club-2.png"),
},
];
const ogJobs = [
{
label: "한남자리 OG",
route: "/work/hannam-jari/",
output: path.join(publicRoot, "work", "hannam-jari", "og.png"),
},
{
label: "철판클럽 OG",
route: "/work/griddle-club/",
output: path.join(publicRoot, "work", "griddle-club", "og.png"),
},
];
function inside(rootDirectory, candidate) {
const relative = path.relative(rootDirectory, candidate);
return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
}
const server = http.createServer((request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
const candidate = path.resolve(publicRoot, `.${pathname}`);
if (!inside(publicRoot, candidate)) {
response.writeHead(403).end("forbidden");
return;
}
const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()
? path.join(candidate, "index.html")
: candidate;
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
response.writeHead(404).end("not found");
return;
}
response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream");
response.setHeader("Cache-Control", "no-store");
fs.createReadStream(file).pipe(response);
} catch {
response.writeHead(400).end("bad request");
}
});
await fs.promises.mkdir(shotsRoot, { recursive: true });
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
const origin = `http://127.0.0.1:${address.port}`;
let browser;
async function waitForReady(page) {
await Promise.race([
page.evaluate(() => document.fonts?.ready || Promise.resolve()),
new Promise((_, reject) => setTimeout(() => reject(new Error("Font readiness timed out")), 10000)),
]);
await page.evaluate(async () => {
const images = [...document.images];
for (const image of images) image.loading = "eager";
await Promise.allSettled(images.map((image) => image.decode()));
});
await new Promise((resolve) => setTimeout(resolve, 900));
const broken = await page.evaluate(() => [...document.images]
.filter((image) => image.complete && image.naturalWidth === 0)
.map((image) => image.currentSrc || image.src));
if (broken.length) throw new Error(`Broken images: ${broken.join(", ")}`);
await page.addStyleTag({
content: "*{animation:none!important;transition:none!important;caret-color:transparent!important}html{scroll-behavior:auto!important}::-webkit-scrollbar{display:none!important}",
});
await page.evaluate(() => window.scrollTo(0, 0));
}
async function capture(job, viewport) {
const page = await browser.newPage();
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror ${error.message}`));
page.on("response", (response) => {
if (response.url().startsWith(origin) && response.status() >= 400) {
failures.push(`${response.status()} ${response.url()}`);
}
});
try {
await page.setCacheEnabled(false);
await page.setViewport(viewport);
const response = await page.goto(origin + job.route, { waitUntil: "networkidle0", timeout: 30000 });
if (!response?.ok()) throw new Error(`${job.route} returned ${response?.status() ?? "no response"}`);
await waitForReady(page);
if (failures.length) throw new Error(failures.join(" · "));
await fs.promises.mkdir(path.dirname(job.output), { recursive: true });
await page.screenshot({ path: job.output, type: "png", fullPage: false });
const size = Math.round((await fs.promises.stat(job.output)).size / 1024);
console.log(`CAPTURE ${job.label} → ${path.relative(root, job.output)} (${viewport.width}×${viewport.height}, ${size}KB)`);
} finally {
await page.close();
}
}
try {
console.log("OVERWRITE SCOPE");
for (const job of [...jobs, ...ogJobs]) console.log(`- ${path.relative(root, job.output)}`);
browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
args: [
"--no-sandbox",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--force-device-scale-factor=1",
],
});
for (const job of jobs) await capture(job, { width: 1440, height: 900, deviceScaleFactor: 1 });
for (const job of ogJobs) await capture(job, { width: 1200, height: 630, deviceScaleFactor: 1 });
} finally {
if (browser) await browser.close();
await new Promise((resolve) => server.close(resolve));
}
console.log("쇼케이스 듀오 카드 4장과 OG 2장 캡처 완료");

View file

@ -26,6 +26,7 @@ const DEFAULT_CFG = {
viewAttribute: "data-view",
views: ["main"],
h1: "h1",
h1MaxLines: 1,
brand: ".brand",
},
],
@ -103,49 +104,105 @@ const cssFilesOf = (p) => {
return out;
};
// 페이지가 공유 CSS·동적 JS·지역 토큰을 함께 쓰면 HTML 한 장과 파일명 규칙만으로
// 소유권을 추측할 수 없다. staticContract 는 검사 자체를 끄지 않고, 어떤 CSS를
// 어떤 문서 코퍼스로 대조하며 어느 파일이 토큰 권위인지 명시한다.
const contractFilesOf = (p, key, fallback) => {
const specs = p.staticContract?.[key];
if (!Array.isArray(specs)) return fallback;
return specs.map((file) => path.resolve(process.cwd(), file));
};
const scriptFilesOf = (p) => {
const out = [];
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<script[^>]+src=["']([^"']+)["'][^>]*>/g)) {
const src = tag[1];
if (/^(?:https?:|data:)/.test(src)) continue;
const file = path.resolve(pageDir(p), src.split("?")[0]);
if (fs.existsSync(file) && !out.includes(file)) out.push(file);
}
return out;
};
const selectorDocumentsOf = (p) => contractFilesOf(
p,
"selectorDocuments",
[path.resolve(process.cwd(), p.path), ...scriptFilesOf(p)],
);
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
if (CFG.checks.deadCss) {
const ignore = CFG.deadCssIgnore || [];
for (const p of CFG.pages) {
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); }
catch { skip(`${p.name} 죽은 선택자`, "HTML 읽기 실패"); continue; }
const files = cssFilesOf(p);
const files = contractFilesOf(p, "selectorFiles", cssFilesOf(p));
const documents = selectorDocumentsOf(p);
const missing = missingFiles([...files, ...documents]);
if (missing.length) {
fail(`${p.name} 죽은 선택자 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
const corpus = documents.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const dead = new Set();
for (const f of files) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
const cls = m[1];
if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
if (!html.includes(cls)) dead.add(cls);
if (!corpus.includes(cls)) dead.add(cls);
}
}
dead.size
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS 전 클래스 사용 중(무시 ${ignore.length}접두사)`);
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS × ${documents.length}개 HTML·JS 문서(무시 ${ignore.length}접두사)`);
}
} else skip("죽은 선택자", "checks.deadCss=false");
if (CFG.checks.cssHygiene) {
for (const p of CFG.pages) {
const files = cssFilesOf(p);
const files = contractFilesOf(p, "hygieneFiles", cssFilesOf(p));
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
// 토큰 파일 = 파일명에 tokens 가 있는 것. 이 안의 값이 프로젝트의 단일 원본이다
const tokenFiles = files.filter((f) => /tokens/i.test(path.basename(f)));
const others = files.filter((f) => !tokenFiles.includes(f));
const hexes = []; const radii = new Set();
for (const f of others) {
const inferredTokens = cssFilesOf(p).filter((f) => /tokens/i.test(path.basename(f)));
const tokenFiles = contractFilesOf(p, "tokenFiles", inferredTokens);
const missing = missingFiles([...files, ...tokenFiles]);
if (missing.length) {
fail(`${p.name} 토큰 위생 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
const tokenNames = new Set();
for (const f of new Set([...tokenFiles, ...files])) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
for (const m of src.matchAll(/(?<![\w.-])rgba?\(/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
for (const m of src.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) tokenNames.add(m[1]);
}
const colors = []; const colorKinds = new Set(); const radii = new Set();
for (const f of files) {
// 어느 파일에 있든 custom property 선언은 지역 토큰이다. 선언값을 다시
// 하드코딩으로 세면 :root 토큰을 만든 페이지가 오히려 실패한다.
const src = fs.readFileSync(f, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/--[A-Za-z0-9_-]+\s*:\s*([^;{}]+);/g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|lab|lch)\([^)]*\)/g)) {
colors.push(`${path.basename(f)}:${m[0]}`);
colorKinds.add(m[0].toLowerCase().replace(/\s+/g, " "));
}
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
}
const budgets = p.staticContract?.budgets || {};
const hasColorBudget = Number.isFinite(budgets.colorLiterals);
const hasRadiusBudget = Number.isFinite(budgets.radiusLiterals);
const colorLimit = hasColorBudget ? budgets.colorLiterals : (tokenFiles.length ? 0 : null);
const radiusLimit = hasRadiusBudget ? budgets.radiusLiterals : null;
const bad = [];
if (tokenFiles.length && hexes.length) bad.push(`토큰 밖 색 리터럴 ${hexes.length}건(토큰 파일이 있는데 상수색)`);
if (radii.size >= CFG.thresholds.radiusLiterals) bad.push(`radius 리터럴 ${radii.size}종([${[...radii].join(", ")}])`);
if (colorLimit !== null && colors.length > colorLimit) bad.push(`색 리터럴 ${colors.length}건 > 계약 ${colorLimit}건`);
if (radiusLimit !== null ? radii.size > radiusLimit : radii.size >= CFG.thresholds.radiusLiterals) {
bad.push(`radius 리터럴 ${radii.size}종 > 계약 ${radiusLimit ?? `<${CFG.thresholds.radiusLiterals}`}종([${[...radii].join(", ")}])`);
}
bad.length
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (hexes.length ? " — 예: " + hexes.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${hexes.length}·radius ${radii.size} 리터럴(토큰 ${tokenFiles.length}파일)`);
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (colors.length ? " — 예: " + colors.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${colors.length}${colorLimit === null ? "" : `/${colorLimit}`}건(${colorKinds.size}종)·radius ${radii.size}${radiusLimit === null ? "" : `/${radiusLimit}`}종·토큰 ${tokenFiles.length}파일/${tokenNames.size}개`);
}
} else skip("토큰 위생", "checks.cssHygiene=false");
@ -240,7 +297,7 @@ for (const p of CFG.pages) {
if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg))
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
}
if (meta.h1Count > 1) m.push(`h1 ${meta.h1Count}개(유일해야)`);
if (meta.h1Count !== 1) m.push(`h1 ${meta.h1Count}개(정확히 하나여야)`);
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증
@ -269,10 +326,14 @@ for (const p of CFG.pages) {
}
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
// h1 1줄
// h1 줄 수 — 앱 화면은 기본 1줄, 마케팅 헤드라인은 페이지별로 최대 2줄까지 명시할 수 있다.
await page.setViewport({ width: 1440, height: 900 });
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음"));
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
const h1Rule = h1MaxLines === 1 ? "h1 1줄" : `h1 최대 ${h1MaxLines}줄`;
h1l >= 1 && h1l <= h1MaxLines
? pass(`${p.name}/${v} ${h1Rule}`)
: (h1l > h1MaxLines ? fail(`${p.name}/${v} h1 ${h1l}줄(최대 ${h1MaxLines}줄)`) : skip(`${p.name}/${v} h1`, "요소 없음"));
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
if (CFG.checks.stack) {
@ -372,6 +433,7 @@ for (const p of CFG.pages) {
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
if (CFG.checks.scaleMatrix) {
const bad = [];
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
for (const w of [375, 390]) {
for (const s of CFG.fontScales) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
@ -379,10 +441,10 @@ for (const p of CFG.pages) {
await page.evaluate(UTILS);
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
if (l > 1) bad.push(`${w}@${s}x:${l}줄`);
if (l > h1MaxLines) bad.push(`${w}@${s}x:${l}줄(최대 ${h1MaxLines}줄)`);
}
}
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`);
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `최대 ${h1MaxLines}줄 · scales ${CFG.fontScales.join("/")}`);
// 브랜드 1줄
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,23 @@
# 검증 리포트 — 2026-08-26 09:13
# 검증 리포트 — 2026-08-29 12:16
게이트: **전 계층 통과 — 배포 가능** · 총 195.3초
게이트: **전 계층 통과 — 배포 가능** · 총 416.9초
| 계층 | 결과 | 항목 | 소요 |
|---|---|---|---|
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.3s |
| L0-한자 금지/배포 텍스트 | PASS | PASS 1 | 0.1s |
| L0-정적/stylelint | PASS | PASS — | 2.4s |
| L0-정식/html-validate | PASS | PASS — | 1.9s |
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.1s |
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s |
| L2-기능/감사 | PASS | PASS 72 | 10.0s |
| L2-불변식/렌더 | PASS | PASS 10 | 36.9s |
| L2-디자인 게이트/메인 | PASS | PASS 130 | 37.9s |
| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.2s |
| L3-시각회귀/diff | PASS | PASS 30 | 11.8s |
| L4-WebKit | PASS | PASS 8 | 3.8s |
| L5-탐색/키보드 | PASS | PASS 14 | 6.3s |
| L6-시나리오/E2E | PASS | PASS — | 43.6s |
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 10.9s |
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.0s |
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.8s |
| L0-한자 금지/배포 텍스트 | PASS | PASS 1 | 0.2s |
| L0-정적/stylelint | PASS | PASS — | 14.8s |
| L0-정식/html-validate | PASS | PASS — | 9.0s |
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.9s |
| L1-단위/계산로직 | PASS | PASS 40 | 0.8s |
| L2-기능/감사 | PASS | PASS 72 | 16.0s |
| L2-불변식/렌더 | PASS | PASS 10 | 23.8s |
| L2-디자인 게이트/메인 | PASS | PASS 178 | 70.9s |
| L2-디자인 게이트/제품 | PASS | PASS 15 | 8.9s |
| L3-시각회귀/diff | PASS | PASS 30 | 17.9s |
| L4-WebKit | PASS | PASS 8 | 9.0s |
| L5-탐색/키보드 | PASS | PASS 14 | 10.1s |
| L6-시나리오/E2E | PASS | PASS — | 50.5s |
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 29.3s |
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 29.9s |
| L6-시나리오/쇼케이스 듀오 | PASS | PASS — | 124.1s |

View file

@ -21,11 +21,11 @@ const steps = [
},
{
layer: "L0-정적/stylelint",
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }),
cmd: () => spawnSync(npx, ["stylelint", "public/work/**/*.css"], { cwd: root, encoding: "utf8", shell: true }),
},
{
layer: "L0-정식/html-validate",
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html", "public/work/twentyoz-corp/products.html"], { cwd: root, encoding: "utf8", shell: true }),
cmd: () => spawnSync(npx, ["html-validate", "public/work/**/*.html"], { cwd: root, encoding: "utf8", shell: true }),
},
{
// 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도
@ -44,6 +44,7 @@ const steps = [
{ layer: "L6-시나리오/E2E", cmd: () => spawnSync(process.execPath, ["tools/e2e.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L6-시나리오/최근 쇼케이스", cmd: () => spawnSync(process.execPath, ["tools/recent-showcases-e2e.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L6-시나리오/트웬티온스", cmd: () => spawnSync(process.execPath, ["tools/twentyoz-e2e.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L6-시나리오/쇼케이스 듀오", cmd: () => spawnSync(process.execPath, ["tools/showcase-duo-e2e.mjs", "--root=public"], { cwd: root, encoding: "utf8" }) },
];
const report = [];