release: v0.11.0

This commit is contained in:
Yun Chan 2026-09-12 15:28:46 +09:00
parent 3c39a1731d
commit 45ba92ea82
45 changed files with 1459 additions and 1887 deletions

View file

@ -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" });