diff --git a/apps/site/tools/design-gate.mjs b/apps/site/tools/design-gate.mjs index f2a04e7..788a3a4 100644 --- a/apps/site/tools/design-gate.mjs +++ b/apps/site/tools/design-gate.mjs @@ -26,7 +26,6 @@ const DEFAULT_CFG = { viewAttribute: "data-view", views: ["main"], h1: "h1", - h1MaxLines: 1, brand: ".brand", }, ], @@ -233,6 +232,53 @@ const UTILS = ` const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim()); return window.__lines(text || el); }; + window.__heading = (sel) => { + let el; + try { el = document.querySelector(sel); } + catch { return { state: "invalid" }; } + if (!el) return { state: "missing" }; + if (!el.textContent.trim()) return { state: "empty" }; + const box = el.getBoundingClientRect(); + const hiddenAncestor = (() => { + for (let node = el; node && node !== document.documentElement; node = node.parentElement) { + const style = getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden" || parseFloat(style.opacity) <= 0) return true; + } + return false; + })(); + if (hiddenAncestor || box.width <= 0 || box.height <= 0) { + return { state: "hidden" }; + } + const range = document.createRange(); + range.selectNodeContents(el); + const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4); + const tops = rects.map((r) => r.top).sort((a, b) => a - b); + const lines = []; + for (const top of tops) if (!lines.length || top - lines[lines.length - 1] > 5) lines.push(top); + return { state: "visible", lines: lines.length, rects: rects.map((r) => ({ left: r.left, right: r.right, top: r.top, bottom: r.bottom })) }; + }; + window.__headingScaleAudit = (sel) => { + const heading = window.__heading(sel); + if (heading.state !== "visible") return heading; + const el = document.querySelector(sel); + const clipping = []; + const viewportWidth = document.documentElement.clientWidth; + for (const rect of heading.rects) { + if (rect.left < -1 || rect.right > viewportWidth + 1) clipping.push("viewport"); + } + for (let ancestor = el; ancestor && ancestor !== document.documentElement; ancestor = ancestor.parentElement) { + const cs = getComputedStyle(ancestor); + const box = ancestor.getBoundingClientRect(); + const name = ancestor.className ? "." + String(ancestor.className).trim().split(/\\s+/)[0] : ancestor.tagName.toLowerCase(); + if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowX) && heading.rects.some((rect) => rect.left < box.left - 1 || rect.right > box.right + 1)) clipping.push(name + "(x)"); + if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowY) && heading.rects.some((rect) => rect.top < box.top - 1 || rect.bottom > box.bottom + 1)) clipping.push(name + "(y)"); + } + return { + ...heading, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + clipping: [...new Set(clipping)], + }; + }; `; const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] }); @@ -326,14 +372,19 @@ for (const p of CFG.pages) { } overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/")); - // h1 줄 수 — 앱 화면은 기본 1줄, 마케팅 헤드라인은 페이지별로 최대 2줄까지 명시할 수 있다. + // 제목은 항상 존재·가시성을 확인하고, 줄 수는 페이지가 명시한 계약일 때만 검사한다. await page.setViewport({ width: 1440, height: 900 }); - const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "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`, "요소 없음")); + const h1Selector = p.h1 || "h1"; + const h1 = await page.evaluate((sel) => window.__heading(sel), h1Selector); + const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : null; + if (h1.state === "missing") fail(`${p.name}/${v} h1`, `선택자 없음 (${h1Selector})`); + else if (h1.state === "empty") fail(`${p.name}/${v} h1`, "텍스트 없음"); + else if (h1.state === "hidden") fail(`${p.name}/${v} h1`, `선택자가 숨김 (${h1Selector})`); + else if (h1.state === "invalid") fail(`${p.name}/${v} h1`, `잘못된 선택자 (${h1Selector})`); + else if (h1.lines < 1) fail(`${p.name}/${v} h1`, "텍스트 없음"); + else if (h1MaxLines !== null && h1.lines > h1MaxLines) fail(`${p.name}/${v} h1`, `${h1.lines}줄(최대 ${h1MaxLines}줄)`); + else if (h1MaxLines !== null) pass(`${p.name}/${v} h1 최대 ${h1MaxLines}줄`, `${h1.lines}줄`); + else pass(`${p.name}/${v} h1 보임`, `${h1.lines}줄(줄수 계약 없음)`); // 세로 쌓임(수축) — 짧은 라벨 3줄 이상 if (CFG.checks.stack) { @@ -430,21 +481,34 @@ 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; + const measurements = []; for (const w of [375, 390]) { for (const s of CFG.fontScales) { await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true }); await page.goto(href, { waitUntil: "networkidle0" }); 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 > h1MaxLines) bad.push(`${w}@${s}x:${l}줄(최대 ${h1MaxLines}줄)`); + const baseRootSize = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize)); + if (!Number.isFinite(baseRootSize) || baseRootSize <= 0) { + bad.push(`${w}@${s}x:root font-size 측정 실패`); + continue; + } + await page.addStyleTag({ content: `html { font-size: ${baseRootSize * s}px !important; }` }); + const h1 = await page.evaluate((sel) => window.__headingScaleAudit(sel), p.h1 || "h1"); + const point = `${w}@${s}x`; + if (h1.state === "missing") { bad.push(`${point}:h1 선택자 없음 (${p.h1 || "h1"})`); continue; } + if (h1.state === "empty") { bad.push(`${point}:h1 텍스트 없음`); continue; } + if (h1.state === "hidden") { bad.push(`${point}:h1 선택자가 숨김 (${p.h1 || "h1"})`); continue; } + if (h1.state === "invalid") { bad.push(`${point}:h1 잘못된 선택자 (${p.h1 || "h1"})`); continue; } + if (h1.lines < 1) { bad.push(`${point}:h1 텍스트 없음`); continue; } + measurements.push(`${point}:${h1.lines}줄`); + if (s > 1 && h1.documentOverflow > 1) bad.push(`${point}:문서 가로오버플로 +${h1.documentOverflow}px`); + if (s > 1 && h1.clipping.length) bad.push(`${point}:제목 텍스트 클리핑 ${h1.clipping.join(",")}`); } } - bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `최대 ${h1MaxLines}줄 · scales ${CFG.fontScales.join("/")}`); + bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `줄수 정보 ${measurements.join(", ")}`); // 브랜드 1줄 await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await page.goto(href, { waitUntil: "networkidle0" }); diff --git a/packages/skill/tools/design-gate-contract.test.mjs b/packages/skill/tools/design-gate-contract.test.mjs index 9531202..22d9cbc 100644 --- a/packages/skill/tools/design-gate-contract.test.mjs +++ b/packages/skill/tools/design-gate-contract.test.mjs @@ -7,8 +7,16 @@ import url from "node:url"; const repo = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "../../.."); const gate = path.join(repo, "apps/site/tools/design-gate.mjs"); +const packagedGate = path.join(repo, "packages/skill/tools/design-gate.mjs"); const work = fs.mkdtempSync(path.join(os.tmpdir(), "design-gate-contract-")); +const normalizedSource = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n"); +assert.equal( + normalizedSource(gate), + normalizedSource(packagedGate), + "apps/site/tools/design-gate.mjs must match packages/skill/tools/design-gate.mjs", +); + const configFor = (page, extra = {}) => ({ pages: [{ name: "fixture", path: page, h1: "h1", ...extra }], widths: [375, 390],