release: v0.11.0
This commit is contained in:
parent
3c39a1731d
commit
45ba92ea82
45 changed files with 1459 additions and 1887 deletions
72
packages/skill/tools/design-gate-contract.test.mjs
Normal file
72
packages/skill/tools/design-gate-contract.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 work = fs.mkdtempSync(path.join(os.tmpdir(), "design-gate-contract-"));
|
||||
|
||||
const configFor = (page, extra = {}) => ({
|
||||
pages: [{ name: "fixture", path: page, h1: "h1", ...extra }],
|
||||
widths: [375, 390],
|
||||
fontScales: [1, 1.3],
|
||||
checks: {
|
||||
meta: false,
|
||||
contrast: false,
|
||||
stack: false,
|
||||
rhythm: false,
|
||||
scaleMatrix: true,
|
||||
tapTargets: false,
|
||||
leftInset: false,
|
||||
deadCss: false,
|
||||
cssHygiene: false,
|
||||
},
|
||||
});
|
||||
|
||||
const run = (name, html, extra = {}) => {
|
||||
const page = path.join(work, `${name}.html`);
|
||||
const config = path.join(work, `${name}.json`);
|
||||
fs.writeFileSync(page, html.replace("<!doctype html>", "<!doctype html><style>html { overflow-x: clip; } body { margin: 0; }</style>"));
|
||||
fs.writeFileSync(config, JSON.stringify(configFor(page, extra)));
|
||||
return spawnSync(process.execPath, [gate, `--config=${config}`], { cwd: work, encoding: "utf8" });
|
||||
};
|
||||
|
||||
const outputOf = (result) => result.stdout + result.stderr;
|
||||
|
||||
try {
|
||||
const flexible = run("flexible", `<!doctype html><style>h1 { width: 9ch; font-size: 2rem; }</style><h1>A flexible display heading</h1>`);
|
||||
assert.equal(flexible.status, 0, outputOf(flexible));
|
||||
assert.match(outputOf(flexible), /h1 보임 — [2-9]줄\(줄수 계약 없음\)/);
|
||||
|
||||
const capped = run("capped", `<!doctype html><style>h1 { width: 100px; font-size: 2rem; }</style><h1>Heading that must wrap</h1>`, { h1MaxLines: 1 });
|
||||
assert.equal(capped.status, 1, outputOf(capped));
|
||||
assert.match(outputOf(capped), /h1 — \d+줄\(최대 1줄\)/);
|
||||
|
||||
const expanded = run("expanded", `<!doctype html><style>h1 { width: 260px; font-size: 1.5rem; }</style><h1>Scale tolerant design</h1>`, { h1MaxLines: 1 });
|
||||
assert.equal(expanded.status, 0, outputOf(expanded));
|
||||
assert.match(outputOf(expanded), /375@1x:1줄/);
|
||||
assert.match(outputOf(expanded), /375@1\.3x:[2-9]줄/);
|
||||
|
||||
const clipped = run("clipped", `<!doctype html><style>.clip { width: 150px; overflow: hidden; } h1 { white-space: nowrap; font-size: 1.5rem; }</style><div class="clip"><h1>Heading that is clipped when enlarged</h1></div>`);
|
||||
assert.equal(clipped.status, 1, outputOf(clipped));
|
||||
assert.match(outputOf(clipped), /제목 텍스트 클리핑/);
|
||||
|
||||
const verticallyClipped = run("vertically-clipped", `<!doctype html><style>.clip { width: 300px; height: 32px; overflow-y: hidden; } h1 { margin: 0; font-size: 1.5rem; }</style><div class="clip"><h1>Title that becomes vertically clipped</h1></div>`);
|
||||
assert.equal(verticallyClipped.status, 1, outputOf(verticallyClipped));
|
||||
assert.match(outputOf(verticallyClipped), /제목 텍스트 클리핑 .+\(y\)/);
|
||||
|
||||
const missing = run("missing", `<!doctype html><h1>Existing heading</h1>`, { h1: ".missing" });
|
||||
assert.equal(missing.status, 1, outputOf(missing));
|
||||
assert.match(outputOf(missing), /h1 선택자 없음 \(\.missing\)/);
|
||||
|
||||
const empty = run("empty", `<!doctype html><h1></h1>`);
|
||||
assert.equal(empty.status, 1, outputOf(empty));
|
||||
assert.match(outputOf(empty), /h1 — 텍스트 없음/);
|
||||
|
||||
console.log("design-gate-contract: 7 passed");
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue