designpaca/apps/site/tools/similarity.mjs
Yun Chan 8e3cdb0d3b
Some checks are pending
ci / build (push) Waiting to run
feat(site): rework 19 marketing showcases for composition diversity
Measured convergence before: split hero 13/19, top-bar nav 19/19,
one-line notice footer 14/19, body Pretendard 17/21, 10 similarity
pairs above 0.78. Each showcase got a pre-assigned hero family, nav
form, footer form, type pair, background, accent hue, radius, motion
grammar and material from outputs/rework-2026-10-06/plan.md; direction
cards and the chosen reason are recorded in every design.md.

After: 0 similarity warnings, 19 distinct display faces, 7 hero
families, 5 nav forms, 5 footer forms, no scroll reveals, 5 showcases
with SVG filter materials. Example contact values replace bracket
placeholders under the existing fictional-brand notices. Thumbnails,
OG images, card copy and three e2e assertions updated; dead CSS in
dure-enrollment and hannam-jari removed; similarity.mjs learns the new
font families.
2026-10-06 16:55:50 +09:00

527 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// L0-수렴 — 쇼케이스 간 시각 지문 유사도 게이트.
//
// 왜 필요한가: 기존 게이트는 전부 "한 페이지 안"을 본다. 넘침·대비·토큰 위생은
// 통과했는데 쇼케이스 21종이 하나의 하우스 룩으로 수렴했다(레드팀 2026-10-01
// 05·06 실측: 본문 Pretendard 21/21, 표제 마루부리 11/21, radius 2px 11곳,
// 웜 오프화이트·니어블랙 양극 배경, lumina·pulsegate·synapse 사실상 같은 템플릿).
// 스킬이 약속한 "다른 클라이언트의 것과 혼동될 수 없다"는 페이지 간 비교로만
// 검증된다. 이 검사가 그 비교를 한다.
//
// 지문 축 (각 public/work/<slug>/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();