feat(site): rework 19 marketing showcases for composition diversity
Some checks are pending
ci / build (push) Waiting to run

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.
This commit is contained in:
Yun Chan 2026-10-06 16:55:50 +09:00
parent 88c11682b1
commit 8e3cdb0d3b
296 changed files with 19131 additions and 17538 deletions

View file

@ -0,0 +1,527 @@
// 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();