ICN-01 · Seoul14.2 ms
NRT-02 · Tokyo28.6 ms
SIN-01 · Singapore34.0 ms
diff --git a/apps/site/public/work/pulsegate/styles/main.css b/apps/site/public/work/pulsegate/styles/main.css
index e972ff1..14529ee 100644
--- a/apps/site/public/work/pulsegate/styles/main.css
+++ b/apps/site/public/work/pulsegate/styles/main.css
@@ -27,6 +27,8 @@ html {
}
body {
+ word-break: keep-all;
+ overflow-wrap: break-word;
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
diff --git a/apps/site/public/work/pulsegate/styles/tokens.css b/apps/site/public/work/pulsegate/styles/tokens.css
index 04500ac..552208a 100644
--- a/apps/site/public/work/pulsegate/styles/tokens.css
+++ b/apps/site/public/work/pulsegate/styles/tokens.css
@@ -21,7 +21,7 @@
--leading-tight: 1.22;
--leading-normal: 1.7;
- --font-body: "IBM Plex Sans", -apple-system, sans-serif;
+ --font-body: "IBM Plex Sans", "IBM Plex Sans KR", Pretendard, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* 색 — 야간 당직실. 위계는 명도차로만. 코발트는 '잉크'다: 글자와 가는 선. */
diff --git a/apps/site/public/work/synapse-bci/index.html b/apps/site/public/work/synapse-bci/index.html
index c14831c..409b748 100644
--- a/apps/site/public/work/synapse-bci/index.html
+++ b/apps/site/public/work/synapse-bci/index.html
@@ -20,7 +20,7 @@
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);
diff --git a/apps/site/tools/showcase-audit.mjs b/apps/site/tools/showcase-audit.mjs
new file mode 100644
index 0000000..284c3be
--- /dev/null
+++ b/apps/site/tools/showcase-audit.mjs
@@ -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);
diff --git a/apps/site/tools/verify.mjs b/apps/site/tools/verify.mjs
index 9020384..53269de 100644
--- a/apps/site/tools/verify.mjs
+++ b/apps/site/tools/verify.mjs
@@ -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" }) },