// L0-수렴 — 쇼케이스 간 시각 지문 유사도 게이트. // // 왜 필요한가: 기존 게이트는 전부 "한 페이지 안"을 본다. 넘침·대비·토큰 위생은 // 통과했는데 쇼케이스 21종이 하나의 하우스 룩으로 수렴했다(레드팀 2026-10-01 // 05·06 실측: 본문 Pretendard 21/21, 표제 마루부리 11/21, radius 2px 11곳, // 웜 오프화이트·니어블랙 양극 배경, lumina·pulsegate·synapse 사실상 같은 템플릿). // 스킬이 약속한 "다른 클라이언트의 것과 혼동될 수 없다"는 페이지 간 비교로만 // 검증된다. 이 검사가 그 비교를 한다. // // 지문 축 (각 public/work//styles/*.css 정적 분석, var() 재귀 해석): // 1. 한글 본문 서체 — body/--font-body 스택에서 첫 한글 지원 서체 // 2. 한글 표제 서체 — h1/--font-display 스택에서 첫 한글 지원 서체 // 3. 배경 — body/--surface 계열 색의 CIELAB 명도·채도·색상 // 4. 강조색 hue — --accent 계열(무채색이면 다음 후보) // 5. radius 구간 — --radius 또는 최빈 border-radius // 6. 모노 사용 — 규칙에서 모노 스택을 실제로 쓰는가 // // 판정: // - 쌍별 유사도(0~1)가 threshold 이상이면 수렴 쌍. 기본은 WARN. // config.strict 에 든 쇼케이스(재작업 대상)가 낀 쌍은 FAIL. // - 분포 편중: 한 값이 전체의 maxShare 를 넘으면 WARN (distributionMode: "fail" 이면 FAIL). // // 사용: node tools/similarity.mjs [--config=similarity.config.json] [--json] // 결과: tools/similarity-report.md 에 표를 남긴다. 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 configArg = process.argv.find((a) => a.startsWith("--config=")); const configPath = path.resolve(root, configArg ? configArg.slice("--config=".length) : "similarity.config.json"); const defaults = { threshold: 0.78, maxShare: 0.4, distributionMode: "warn", strict: [], ignore: [], weights: { body: 0.25, display: 0.2, background: 0.2, accent: 0.15, radius: 0.1, mono: 0.1 }, }; const config = fs.existsSync(configPath) ? { ...defaults, ...JSON.parse(fs.readFileSync(configPath, "utf8")) } : defaults; config.weights = { ...defaults.weights, ...(config.weights || {}) }; /* ── 서체 사전 ── */ // 한글 글리프를 가진 서체. 스택의 첫 라틴 전용 서체(IBM Plex Sans 등)를 건너뛰고 // 실제로 한글을 그리는 서체를 찾기 위해 쓴다. 이름은 정규화한 소문자로 비교한다. const KOREAN_FONTS = new Map( [ ["pretendard", "Pretendard"], ["pretendard variable", "Pretendard"], ["pretendard jp", "Pretendard"], ["suit", "SUIT"], ["suit variable", "SUIT"], ["wanted sans", "Wanted Sans"], ["wanted sans variable", "Wanted Sans"], ["ibm plex sans kr", "IBM Plex Sans KR"], ["noto sans kr", "Noto Sans KR"], ["noto serif kr", "Noto Serif KR"], ["spoqa han sans neo", "Spoqa Han Sans Neo"], ["gothic a1", "Gothic A1"], ["nanum gothic", "Nanum Gothic"], ["nanumgothic", "Nanum Gothic"], ["nanum myeongjo", "Nanum Myeongjo"], ["nanum gothic coding", "Nanum Gothic Coding"], ["maruburi", "MaruBuri"], ["maru buri", "MaruBuri"], ["ridibatang", "RIDIBatang"], ["ridi batang", "RIDIBatang"], ["iropke batang", "Iropke Batang"], ["iropkebatang", "Iropke Batang"], ["orbit", "Orbit"], ["gasoek one", "Gasoek One"], ["kopubworld batang", "KoPub Batang"], ["gowun batang", "Gowun Batang"], ["gowun dodum", "Gowun Dodum"], ["hahmlet", "Hahmlet"], ["song myung", "Song Myung"], ["do hyeon", "Do Hyeon"], ["black han sans", "Black Han Sans"], ["jua", "Jua"], ["sunflower", "Sunflower"], ["orbit", "Orbit"], ["gasoek one", "Gasoek One"], ["bagel fat one", "Bagel Fat One"], ["dongle", "Dongle"], ["s-core dream", "S-Core Dream"], ["gmarket sans", "Gmarket Sans"], ["jeju myeongjo", "Jeju Myeongjo"], ["apple sd gothic neo", "Apple SD Gothic Neo"], ["malgun gothic", "Malgun Gothic"], ], ); const MONO_RE = /\b(mono|monospace|consolas|courier|menlo|sfmono|coding)\b/i; /* ── CSS 파싱 ── */ function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, ""); } /** 최상위·미디어 안의 규칙을 평평하게 뽑는다. @font-face 는 지문이 아니므로 버린다. */ function rulesOf(css) { const rules = []; const re = /([^{}]+)\{([^{}]*)\}/g; let m; while ((m = re.exec(css))) { const selector = m[1].trim().replace(/^@media[^{]*$/, "").trim(); if (!selector || selector.startsWith("@font-face")) continue; rules.push({ selector: selector.replace(/^.*\{/, "").trim(), body: m[2], index: m.index }); } return rules; } function declsOf(body) { const out = new Map(); for (const part of body.split(";")) { const i = part.indexOf(":"); if (i < 0) continue; const prop = part.slice(0, i).trim().toLowerCase(); const value = part.slice(i + 1).trim(); if (prop && value && !out.has(prop)) out.set(prop, value); } return out; } function readShowcase(slug) { const stylesDir = path.join(workRoot, slug, "styles"); if (!fs.existsSync(stylesDir)) return null; const order = ["tokens.css", "fonts.css", "main.css", "site.css", "landing.css"]; const files = fs .readdirSync(stylesDir) .filter((f) => f.endsWith(".css")) .sort((a, b) => { const ia = order.indexOf(a); const ib = order.indexOf(b); return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b); }); const css = files.map((f) => stripComments(fs.readFileSync(path.join(stylesDir, f), "utf8"))).join("\n"); return css; } /** :root·html 에서 처음 정의된 값을 쓴다. 다크 모드 재정의는 기본 지문이 아니다. */ function collectVars(rules) { const vars = new Map(); for (const r of rules) { if (!/(^|,)\s*(:root|html)\s*(,|$)/.test(r.selector)) continue; for (const [prop, value] of declsOf(r.body)) { if (prop.startsWith("--") && !vars.has(prop)) vars.set(prop, value); } } // :root 밖에서만 정의된 토큰도 마지막 후보로 받는다. for (const r of rules) { for (const [prop, value] of declsOf(r.body)) { if (prop.startsWith("--") && !vars.has(prop)) vars.set(prop, value); } } return vars; } function resolve(value, vars, depth = 0) { if (value == null || depth > 8) return value; return value.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (_, name, fallback) => { const v = vars.get(name); if (v != null) return resolve(v, vars, depth + 1); return fallback != null ? resolve(fallback.trim(), vars, depth + 1) : ""; }); } function familiesOf(stack) { return String(stack || "") .split(",") .map((f) => f.trim().replace(/^["']|["']$/g, "").trim()) .filter(Boolean); } function koreanFamily(stack) { for (const f of familiesOf(stack)) { const key = f.toLowerCase(); if (KOREAN_FONTS.has(key)) return KOREAN_FONTS.get(key); } return null; } /* ── 색 ── */ const NAMED = { white: "#ffffff", black: "#000000" }; function parseColor(raw) { if (!raw) return null; let s = raw.trim().toLowerCase(); if (NAMED[s]) s = NAMED[s]; let m = s.match(/^#([0-9a-f]{3,8})\b/); if (m) { let h = m[1]; if (h.length === 3 || h.length === 4) h = h.slice(0, 3).split("").map((c) => c + c).join(""); if (h.length < 6) return null; return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; } m = s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/); if (m) return [Number(m[1]), Number(m[2]), Number(m[3])]; m = s.match(/^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/); if (m) { const h = Number(m[1]) / 360; const sat = Number(m[2]) / 100; const l = Number(m[3]) / 100; const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat; const p = 2 * l - q; const f = (t) => { t = (t + 1) % 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255]; } // 여러 값이 섞인 background 단축 속성에서 첫 색을 찾는다. m = s.match(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/); if (m && m[0] !== s) return parseColor(m[0]); return null; } /** sRGB → CIELAB(D65) → LCh. 명도·채도·색상을 사람 눈 기준으로 비교하기 위해 쓴다. */ export function lch(rgb) { const lin = rgb.map((v) => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); const x = (lin[0] * 0.4124 + lin[1] * 0.3576 + lin[2] * 0.1805) / 0.95047; const y = lin[0] * 0.2126 + lin[1] * 0.7152 + lin[2] * 0.0722; const z = (lin[0] * 0.0193 + lin[1] * 0.1192 + lin[2] * 0.9505) / 1.08883; const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116); const L = 116 * f(y) - 16; const a = 500 * (f(x) - f(y)); const b = 200 * (f(y) - f(z)); const C = Math.hypot(a, b); const h = ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360; return { L, C, h, a, b }; } function hueDelta(a, b) { const d = Math.abs(a - b) % 360; return d > 180 ? 360 - d : d; } /* ── 길이 ── */ function toPx(raw) { if (raw == null) return null; const s = String(raw).trim(); if (s === "0") return 0; const m = s.match(/^(-?[\d.]+)(px|rem|em)?/); if (!m) return null; const n = Number(m[1]); return m[2] === "rem" || m[2] === "em" ? n * 16 : n; } function radiusBucket(px) { if (px == null) return "미상"; if (px === 0) return "0"; if (px <= 3) return "1–3px"; if (px <= 8) return "4–8px"; if (px <= 16) return "9–16px"; if (px < 999) return "17px+"; return "pill"; } /* ── 지문 추출 ── */ function firstDecl(rules, selectorRe, prop) { for (const r of rules) { if (!selectorRe.test(r.selector)) continue; const v = declsOf(r.body).get(prop); if (v) return v; } return null; } function firstVar(vars, names) { for (const n of names) if (vars.has(n)) return { name: n, value: vars.get(n) }; return null; } export function fingerprint(slug, css) { const rules = rulesOf(css); const vars = collectVars(rules); const res = (v) => resolve(v, vars); const bodyStack = res( firstDecl(rules, /(^|,)\s*body\s*(,|$)/, "font-family") || firstVar(vars, ["--font-body", "--font-sans", "--font-text", "--font-base"])?.value || firstDecl(rules, /(^|,)\s*html\s*(,|$)/, "font-family") || "", ); const displayStack = res( firstDecl(rules, /(^|[\s,>])h1(\b|$)/, "font-family") || firstVar(vars, ["--font-display", "--font-head", "--font-heading", "--font-serif"])?.value || bodyStack, ); const bgRaw = res( firstDecl(rules, /(^|,)\s*body\s*(,|$)/, "background-color") || firstDecl(rules, /(^|,)\s*body\s*(,|$)/, "background") || firstVar(vars, ["--bg", "--surface", "--paper", "--page", "--background", "--color-bg"])?.value || "", ); const bgRgb = parseColor(bgRaw); let accent = null; for (const name of ["--accent", "--brand", "--signal", "--primary", "--accent-stroke", "--cobalt", "--highlight"]) { if (!vars.has(name)) continue; const rgb = parseColor(res(vars.get(name))); if (!rgb) continue; const c = lch(rgb); if (c.C < 12) continue; // 무채색 primary(#18181b 등)는 강조색이 아니다. accent = { name, rgb, ...c }; break; } if (!accent) { // 이름 규약이 다른 쇼케이스(--blue, --gold 등)는 :root 색 토큰 중 채도가 가장 높은 // 것을 강조색으로 본다. 상태색·옅은 면색은 브랜드 강조가 아니므로 뺀다. for (const [name, value] of vars) { if (/pale|soft|wash|tint|bg|surface|paper|page|ink|line|danger|error|success|warn|focus|review/.test(name)) continue; const rgb = parseColor(res(value)); if (!rgb) continue; const c = lch(rgb); if (c.C < 12) continue; if (!accent || c.C > accent.C) accent = { name, rgb, ...c }; } } let radiusPx = toPx(res(vars.get("--radius") ?? "")); if (radiusPx == null) { const counts = new Map(); for (const m of css.matchAll(/border-radius\s*:\s*([^;}]+)/g)) { const first = res(m[1]).trim().split(/\s+/)[0]; if (/%$/.test(first)) continue; // 원형(50%)은 모서리 언어가 아니라 도형이다. const px = toPx(first); if (px == null || px >= 999) continue; counts.set(px, (counts.get(px) || 0) + 1); } radiusPx = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; } // 모노: 모노 스택이 :root 밖의 실제 규칙에서 font-family 로 쓰이는가. let mono = false; for (const r of rules) { if (/(^|,)\s*(:root|html)\s*(,|$)/.test(r.selector)) continue; const ff = declsOf(r.body).get("font-family") || declsOf(r.body).get("font"); if (!ff) continue; const first = familiesOf(res(ff))[0] || ""; if (MONO_RE.test(first)) { mono = true; break; } } const bg = bgRgb ? lch(bgRgb) : null; return { slug, body: koreanFamily(bodyStack) || familiesOf(bodyStack)[0] || "미상", display: koreanFamily(displayStack) || familiesOf(displayStack)[0] || "미상", bg: bg ? { hex: toHex(bgRgb), ...round(bg) } : null, bgClass: bg ? bgClass(bg) : "미상", accent: accent ? { hex: toHex(accent.rgb), ...round(accent), name: accent.name } : null, radiusPx, radius: radiusBucket(radiusPx), mono, }; } function toHex(rgb) { return "#" + rgb.map((v) => Math.round(v).toString(16).padStart(2, "0")).join(""); } function round(c) { return { L: Math.round(c.L), C: Math.round(c.C), h: Math.round(c.h) }; } function bgClass(c) { const tone = c.L < 22 ? "어두움" : c.L > 88 ? "밝음" : "중간"; const chroma = c.C < 3 ? "무채" : c.C < 15 ? "웜/쿨 기운" : "유채"; return `${tone}·${chroma}`; } /* ── 유사도 ── */ export function similarity(a, b, weights = defaults.weights) { const parts = {}; parts.body = a.body === b.body ? 1 : 0; parts.display = a.display === b.display ? 1 : 0; if (a.bg && b.bg) { const dE = Math.hypot(a.bg.L - b.bg.L, (a.bg.C - b.bg.C) * 1.5, a.bg.C > 6 && b.bg.C > 6 ? hueDelta(a.bg.h, b.bg.h) / 6 : 0); parts.background = Math.max(0, 1 - dE / 30); } else parts.background = 0; if (a.accent && b.accent) parts.accent = Math.max(0, 1 - hueDelta(a.accent.h, b.accent.h) / 50); else parts.accent = !a.accent && !b.accent ? 1 : 0; parts.radius = a.radius === b.radius ? 1 : 0; parts.mono = a.mono === b.mono ? 1 : 0; let score = 0; let total = 0; for (const [k, w] of Object.entries(weights)) { score += (parts[k] ?? 0) * w; total += w; } return { score: total ? score / total : 0, parts }; } /* ── 실행 ── */ function main() { const slugs = fs .readdirSync(workRoot, { withFileTypes: true }) .filter((d) => d.isDirectory() && !config.ignore.includes(d.name)) .map((d) => d.name) .sort(); const prints = []; for (const slug of slugs) { const css = readShowcase(slug); if (css == null) continue; prints.push(fingerprint(slug, css)); } const strict = new Set(config.strict); const pairs = []; for (let i = 0; i < prints.length; i++) { for (let j = i + 1; j < prints.length; j++) { const { score, parts } = similarity(prints[i], prints[j], config.weights); pairs.push({ a: prints[i].slug, b: prints[j].slug, score, parts }); } } pairs.sort((x, y) => y.score - x.score); const failures = []; const warnings = []; let pass = 0; for (const p of pairs) { if (p.score < config.threshold) { pass++; continue; } const same = Object.entries(p.parts) .filter(([, v]) => v >= 0.75) .map(([k]) => k) .join("·"); const line = `${p.a} ↔ ${p.b} 유사도 ${p.score.toFixed(2)} ≥ ${config.threshold} (겹친 축: ${same})`; if (strict.has(p.a) || strict.has(p.b)) failures.push(line); else warnings.push(line); } const axes = [ ["body", "한글 본문 서체", (f) => f.body], ["display", "한글 표제 서체", (f) => f.display], ["bgClass", "배경 명도·채도", (f) => f.bgClass], ["radius", "radius 구간", (f) => f.radius], ["mono", "모노 사용", (f) => (f.mono ? "사용" : "미사용")], ]; const distribution = []; for (const [key, label, pick] of axes) { const counts = new Map(); for (const f of prints) counts.set(pick(f), (counts.get(pick(f)) || 0) + 1); const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]); const [top, n] = sorted[0] || ["", 0]; const share = prints.length ? n / prints.length : 0; // 모노 미사용은 편중이어도 문제가 아니다 — 수렴은 '같은 장치를 다 같이 쓰는 것'이다. const skip = key === "mono" && top === "미사용"; const over = !skip && share > config.maxShare; distribution.push({ key, label, top, n, share, over, sorted }); const line = `${label} 편중 — '${top}' ${n}/${prints.length} (${Math.round(share * 100)}%) > ${Math.round(config.maxShare * 100)}%`; if (over) (config.distributionMode === "fail" ? failures : warnings).push(line); else pass++; } if (process.argv.includes("--json")) { console.log(JSON.stringify({ prints, pairs: pairs.slice(0, 30), distribution }, null, 2)); } writeReport(prints, pairs, distribution, warnings, failures); console.log(`쇼케이스 유사도 — ${prints.length}종 · 쌍 ${pairs.length}개 · 임계 ${config.threshold}`); for (const w of warnings) console.log(" WARN " + w); for (const f of failures) console.log(" FAIL " + f); // verify.mjs 는 'PASS n / FAIL m' 요약을 센다. 통과면 FAIL 낱말을 쓰지 않는다. if (failures.length) { console.log(`✗ PASS ${pass} / FAIL ${failures.length} (경고 ${warnings.length})`); process.exit(1); } console.log(`✓ PASS ${pass}개 검사 통과 (경고 ${warnings.length})`); } function writeReport(prints, pairs, distribution, warnings, failures) { const pct = (x) => `${Math.round(x * 100)}%`; const md = [ `# 쇼케이스 유사도 리포트`, ``, `생성: \`node tools/similarity.mjs\` · 임계 ${config.threshold} · 분포 상한 ${pct(config.maxShare)} · 재작업 엄격 대상: ${config.strict.join(", ") || "없음"}`, ``, `결과: 실패 ${failures.length} · 경고 ${warnings.length}`, ``, `## 지문`, ``, `| 쇼케이스 | 한글 본문 | 한글 표제 | 배경 | 배경 L·C·h | 강조 hue | radius | 모노 |`, `|---|---|---|---|---|---|---|---|`, ...prints.map( (f) => `| ${f.slug} | ${f.body} | ${f.display} | ${f.bg?.hex ?? "—"} ${f.bgClass} | ${f.bg ? `${f.bg.L}·${f.bg.C}·${f.bg.h}` : "—"} | ${f.accent ? `${f.accent.hex} (${f.accent.h}°)` : "—"} | ${f.radiusPx ?? "—"} (${f.radius}) | ${f.mono ? "사용" : "—"} |`, ), ``, `## 분포`, ``, `| 축 | 최빈값 | 점유 | 상한 초과 | 전체 |`, `|---|---|---|---|---|`, ...distribution.map((d) => `| ${d.label} | ${d.top} | ${d.n}/${prints.length} (${pct(d.share)}) | ${d.over ? "**초과**" : "—"} | ${d.sorted.map(([v, n]) => `${v} ${n}`).join(", ")} |`), ``, `## 유사도 상위 15쌍`, ``, `| 쌍 | 점수 | 본문 | 표제 | 배경 | 강조 | radius | 모노 |`, `|---|---|---|---|---|---|---|---|`, ...pairs.slice(0, 15).map( (p) => `| ${p.a} ↔ ${p.b} | ${p.score.toFixed(2)} | ${p.parts.body} | ${p.parts.display} | ${p.parts.background.toFixed(2)} | ${p.parts.accent.toFixed(2)} | ${p.parts.radius} | ${p.parts.mono} |`, ), ``, ...(warnings.length ? [`## 경고`, ``, ...warnings.map((w) => `- ${w}`), ``] : []), ...(failures.length ? [`## 실패`, ``, ...failures.map((w) => `- ${w}`), ``] : []), ].join("\n"); fs.writeFileSync(path.join(here, "similarity-report.md"), md); } if (process.argv[1] && path.resolve(process.argv[1]) === url.fileURLToPath(import.meta.url)) main();