feat: 한국어 우선 쇼케이스 정리
All checks were successful
ci / build (push) Successful in 51s
release / release (push) Successful in 2m34s

This commit is contained in:
Yun Chan 2026-08-26 18:14:41 +09:00
parent e4087c29f5
commit 2b7fa32b5b
109 changed files with 437 additions and 279 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

@ -13,6 +13,24 @@ const root = path.resolve(here, "..");
const shots = path.join(root, "src", "assets", "shots");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function requestedSlugs(argv) {
const slugs = new Set();
for (let index = 2; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--slug") {
const slug = argv[index + 1];
if (!slug) throw new Error("--slug requires a showcase slug");
slugs.add(slug);
index += 1;
} else if (argument.startsWith("--slug=")) {
slugs.add(argument.slice("--slug=".length));
} else {
throw new Error(`Unknown argument: ${argument}`);
}
}
return slugs;
}
const CHROME = [
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
@ -44,6 +62,12 @@ const JOBS = [
},
];
const requested = requestedSlugs(process.argv);
const known = new Set(JOBS.map((job) => job.slug));
const unknown = [...requested].filter((slug) => !known.has(slug));
if (unknown.length) throw new Error(`Unknown showcase slug(s): ${unknown.join(", ")}`);
const selectedJobs = requested.size ? JOBS.filter((job) => requested.has(job.slug)) : JOBS;
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: "new",
@ -56,7 +80,7 @@ const browser = await puppeteer.launch({
defaultViewport: { width: 1440, height: 900 },
});
for (const job of JOBS) {
for (const job of selectedJobs) {
const page = await browser.newPage();
await page.setCacheEnabled(false);
const file = path.join(root, "public/work", job.slug, "index.html");

View file

@ -8,6 +8,24 @@ const rootDir = path.resolve(__dirname, "..");
const chromePath = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const shotsDir = path.join(rootDir, "src", "assets", "shots");
function requestedSlugs(argv) {
const slugs = new Set();
for (let index = 2; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--slug") {
const slug = argv[index + 1];
if (!slug) throw new Error("--slug requires a showcase slug");
slugs.add(slug);
index += 1;
} else if (argument.startsWith("--slug=")) {
slugs.add(argument.slice("--slug=".length));
} else {
throw new Error(`Unknown argument: ${argument}`);
}
}
return slugs;
}
if (!fs.existsSync(shotsDir)) {
fs.mkdirSync(shotsDir, { recursive: true });
}
@ -94,6 +112,13 @@ const targets = [
},
];
const requested = requestedSlugs(process.argv);
const slugOf = (target) => path.basename(path.dirname(target.url));
const known = new Set(targets.map(slugOf));
const unknown = [...requested].filter((slug) => !known.has(slug));
if (unknown.length) throw new Error(`Unknown showcase slug(s): ${unknown.join(", ")}`);
const selectedTargets = requested.size ? targets.filter((target) => requested.has(slugOf(target))) : targets;
console.log("📸 Capturing high-resolution screenshots...");
const shot = (t, url, out) => {
@ -111,7 +136,7 @@ const shot = (t, url, out) => {
]);
};
for (const t of targets) {
for (const t of selectedTargets) {
console.log(`- Capturing ${path.basename(t.out)}`);
try {
shot(t, t.url, t.out);

View file

@ -10,6 +10,30 @@ const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
const shots = path.join(root, "src", "assets", "shots");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function requestedSlugs(argv) {
const slugs = new Set();
for (let index = 2; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--slug") {
const slug = argv[index + 1];
if (!slug) throw new Error("--slug requires an app slug");
slugs.add(slug);
index += 1;
} else if (argument.startsWith("--slug=")) {
slugs.add(argument.slice("--slug=".length));
} else {
throw new Error(`Unknown argument: ${argument}`);
}
}
return slugs;
}
const requested = requestedSlugs(process.argv);
const known = new Set(["gaon-lms", "dure-enrollment"]);
const unknown = [...requested].filter((slug) => !known.has(slug));
if (unknown.length) throw new Error(`Unknown app slug(s): ${unknown.join(", ")}`);
const selected = requested.size ? requested : known;
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: "new",
@ -24,24 +48,28 @@ const open = async (app) => {
await sleep(250);
};
// 가온: 대시보드 본컷 + 학적 카드(수강생 모달) -2컷
await open("gaon-lms");
await page.screenshot({ path: path.join(shots, "work-gaon-lms.png") });
await page.screenshot({ path: path.join(root, "public/work/gaon-lms/og.png") });
await page.evaluate(() => document.querySelector('[data-view="students"]').click());
await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click());
await page.waitForSelector("#stu-dialog[open]");
await sleep(350); // 모달 등장 모션 200ms + 여유
await page.screenshot({ path: path.join(shots, "work-gaon-lms-2.png") });
if (selected.has("gaon-lms")) {
// 가온: 대시보드 본컷 + 학적 카드(수강생 모달) -2컷
await open("gaon-lms");
await page.screenshot({ path: path.join(shots, "work-gaon-lms.png") });
await page.screenshot({ path: path.join(root, "public/work/gaon-lms/og.png") });
await page.evaluate(() => document.querySelector('[data-view="students"]').click());
await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click());
await page.waitForSelector("#stu-dialog[open]");
await sleep(350); // 모달 등장 모션 200ms + 여유
await page.screenshot({ path: path.join(shots, "work-gaon-lms-2.png") });
}
// 두레: 과목 찾기 본컷 + 강의계획(과목 모달) -2컷
await open("dure-enrollment");
await page.screenshot({ path: path.join(shots, "work-dure-enrollment.png") });
await page.screenshot({ path: path.join(root, "public/work/dure-enrollment/og.png") });
await page.evaluate(() => document.querySelector(".course-row").click());
await page.waitForSelector("#course-dialog[open]");
await sleep(350);
await page.screenshot({ path: path.join(shots, "work-dure-enrollment-2.png") });
if (selected.has("dure-enrollment")) {
// 두레: 과목 찾기 본컷 + 강의계획(과목 모달) -2컷
await open("dure-enrollment");
await page.screenshot({ path: path.join(shots, "work-dure-enrollment.png") });
await page.screenshot({ path: path.join(root, "public/work/dure-enrollment/og.png") });
await page.evaluate(() => document.querySelector(".course-row").click());
await page.waitForSelector("#course-dialog[open]");
await sleep(350);
await page.screenshot({ path: path.join(shots, "work-dure-enrollment-2.png") });
}
await browser.close();
console.log("work shots + og.png 갱신 완료");

View file

@ -170,6 +170,12 @@ const UTILS = `
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
window.__textLines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
return window.__lines(text || el);
};
`;
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
@ -386,7 +392,7 @@ for (const p of CFG.pages) {
const bl = await page.evaluate((sel) => {
const els = [...document.querySelectorAll(sel)];
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
return window.__lines(el);
return window.__textLines(el);
}, p.brand);
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
}

View file

@ -1,6 +1,6 @@
# E2E 시나리오 게이트 — 2026-08-24 19:47
# E2E 시나리오 게이트 — 2026-08-26 09:12
단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과**
단언 1799 · 통과 1799 · 실패 0 — **GREEN — 전 단언 통과**
| 스위트 | 통과 | 실패 |
|---|---|---|
@ -10,7 +10,7 @@
| M320 매트릭스 | 90 | 0 |
| M390 매트릭스 | 105 | 0 |
| M1 터치타깃 | 231 | 0 |
| M2 대비 | 1093 | 0 |
| M2 대비 | 1087 | 0 |
| M768 매트릭스 | 90 | 0 |
| S7 뒤로 가기 | 6 | 0 |
| S7 뒤로 가기·모달 | 5 | 0 |

View file

@ -60,6 +60,12 @@ const UTILS = `
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
window.__textLines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
return window.__lines(text || el);
};
window.__tapSweep = () => {
const EXEMPT_STRICT = ".undo-btn, .row-open";
const out = [];
@ -265,7 +271,7 @@ for (const [app, cfg] of Object.entries(APPS)) {
const h1 = await page.evaluate(() => window.__lines(document.querySelector(".view.is-on h2.v-h, h1")));
ok(h1 <= 1, S, `${app}/${v}@${w} 제목 1줄`, h1 + "줄");
if (w === 390) {
const brand = await page.evaluate(() => window.__lines(document.querySelector(".brand, .appbar .brand")));
const brand = await page.evaluate(() => window.__textLines(document.querySelector(".brand, .appbar .brand")));
ok(brand <= 1, S, `${app}/${v}@390 브랜드 1줄`, brand + "줄");
}
const nav = await page.evaluate(() => {

View file

@ -170,7 +170,8 @@ for (const [app, views] of [["gaon-lms", GAON_VIEWS], ["dure-enrollment", DURE_V
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await openApp(page, app);
const brandLines = await page.evaluate("window.__lines(document.querySelector('.brand, .appbar .brand'))");
// 브랜드 마크의 세로 중앙 정렬 차이는 줄바꿈이 아니다. 실제 브랜드 문구만 센다.
const brandLines = await page.evaluate("window.__textLines(document.querySelector('.brand, .appbar .brand'))");
if (brandLines > 1) fail(`${app} 브랜드 세로 쌓임`, brandLines + "줄");
else pass(`${app} 브랜드 1줄`);
}

View file

@ -0,0 +1,59 @@
// 배포 텍스트에는 한자를 두지 않는다. 이미지처럼 텍스트가 아닌 자산은 이 검사의 대상이 아니다.
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 TEXT_EXTENSIONS = new Set([".astro", ".css", ".html", ".js", ".json", ".mjs", ".svg", ".ts", ".tsx", ".txt", ".webmanifest", ".xml"]);
const HANJA = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/u;
const HANJA_GLOBAL = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/gu;
function scanRoots(argv) {
const roots = [];
for (let index = 2; index < argv.length; index += 1) {
const argument = argv[index];
const value = argument === "--root" ? argv[++index] : argument.startsWith("--root=") ? argument.slice("--root=".length) : null;
if (!value) throw new Error(`Unknown or incomplete argument: ${argument}`);
const candidate = path.resolve(root, value);
if (candidate !== root && !candidate.startsWith(root + path.sep)) throw new Error(`Root must stay inside apps/site: ${value}`);
roots.push(candidate);
}
return roots.length ? roots : [path.join(root, "src"), path.join(root, "public")];
}
function textFiles(directory) {
const files = [];
const walk = (current) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isDirectory()) walk(candidate);
else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(candidate);
}
};
walk(directory);
return files;
}
const failures = [];
let scanned = 0;
for (const scanRoot of scanRoots(process.argv)) {
if (!fs.existsSync(scanRoot)) throw new Error(`Scan root does not exist: ${path.relative(root, scanRoot) || "."}`);
for (const file of textFiles(scanRoot)) {
scanned += 1;
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
lines.forEach((line, index) => {
if (!HANJA.test(line)) return;
const characters = [...new Set(line.match(HANJA_GLOBAL) || [])].join("");
failures.push(`${path.relative(root, file).replaceAll("\\", "/")}:${index + 1} contains ${characters}`);
});
}
}
if (failures.length) {
console.error(`FAIL: ${failures.length} Hanja occurrence(s) across ${scanned} shipped text files.`);
for (const failure of failures) console.error(` ${failure}`);
process.exit(1);
}
console.log(`PASS: ${scanned} shipped text files contain no Hanja.`);

View file

@ -257,7 +257,7 @@ try {
// 4. 새벽청과 uses the current Korean market date and turns every price row into a readable lot.
await goto(page, urls.market);
const stamp = expectedKoreanStamp();
const date = await page.$eval(".hero-stamp time", (time) => ({ text: time.textContent.trim(), datetime: time.dateTime }));
const date = await page.$eval(".hero-meta time", (time) => ({ text: time.textContent.trim(), datetime: time.dateTime }));
assert(date.text === stamp.text && date.datetime === stamp.datetime, "새벽청과 date", "market stamp is current in Korea", `${date.text}/${date.datetime}`);
assert((await page.$$eval("[data-market-row]", (items) => items.length)) === 6, "새벽청과 lot board", "six selectable lots");
await page.click('[data-market-row][data-name="무화과"]');

View file

@ -1,4 +1,4 @@
# TWENTYOZ E2E — 2026-08-24 19:48
# TWENTYOZ E2E — 2026-08-26 09:13
**148 assertions passed · GREEN**

View file

@ -1,21 +1,22 @@
# 검증 리포트 — 2026-08-24 19:48
# 검증 리포트 — 2026-08-26 09:13
게이트: **전 계층 통과 — 배포 가능** · 총 167.0초
게이트: **전 계층 통과 — 배포 가능** · 총 195.3초
| 계층 | 결과 | 항목 | 소요 |
|---|---|---|---|
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.2s |
| L0-정적/stylelint | PASS | PASS — | 1.7s |
| L0-정식/html-validate | PASS | PASS — | 1.6s |
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.2s |
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.3s |
| L0-한자 금지/배포 텍스트 | PASS | PASS 1 | 0.1s |
| L0-정적/stylelint | PASS | PASS — | 2.4s |
| L0-정식/html-validate | PASS | PASS — | 1.9s |
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.1s |
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s |
| L2-기능/감사 | PASS | PASS 72 | 9.7s |
| L2-불변식/렌더 | PASS | PASS 10 | 16.6s |
| L2-디자인 게이트/메인 | PASS | PASS 130 | 32.6s |
| L2-디자인 게이트/제품 | PASS | PASS 15 | 6.9s |
| L3-시각회귀/diff | PASS | PASS 30 | 11.5s |
| L4-WebKit | PASS | PASS 8 | 3.6s |
| L5-탐색/키보드 | PASS | PASS 14 | 6.1s |
| L6-시나리오/E2E | PASS | PASS — | 42.6s |
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 11.0s |
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.7s |
| L2-기능/감사 | PASS | PASS 72 | 10.0s |
| L2-불변식/렌더 | PASS | PASS 10 | 36.9s |
| L2-디자인 게이트/메인 | PASS | PASS 130 | 37.9s |
| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.2s |
| L3-시각회귀/diff | PASS | PASS 30 | 11.8s |
| L4-WebKit | PASS | PASS 8 | 3.8s |
| L5-탐색/키보드 | PASS | PASS 14 | 6.3s |
| L6-시나리오/E2E | PASS | PASS — | 43.6s |
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 10.9s |
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.0s |

View file

@ -15,6 +15,10 @@ const steps = [
layer: "L0-자산/트웬티온스 3D",
cmd: () => spawnSync(process.execPath, ["tools/build-showcase-assets.mjs", "--check"], { cwd: root, encoding: "utf8" }),
},
{
layer: "L0-한자 금지/배포 텍스트",
cmd: () => spawnSync(process.execPath, ["tools/no-hanja.mjs"], { cwd: root, encoding: "utf8" }),
},
{
layer: "L0-정적/stylelint",
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }),

View file

@ -27,6 +27,19 @@ const UTILS = `
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
window.__textLines = (sel) => {
const el = document.querySelector(sel);
if (!el) return -1;
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
if (!text) return window.__lines(sel);
const range = document.createRange();
range.selectNodeContents(text);
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 ls = [];
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
`;
const browser = await webkit.launch();
@ -40,7 +53,7 @@ for (const [app, views] of Object.entries(APPS)) {
await page.evaluate(() => document.fonts.ready);
// 브랜드 1줄 + h1 1줄 + 페이지 오버플로
const brand = await page.evaluate(`__lines('.brand')`);
const brand = await page.evaluate(`__textLines('.brand')`);
if (brand > 1) fail(`${app} WebKit 브랜드 세로`, brand + "줄"); else pass(`${app} WebKit 브랜드`);
const h1 = await page.evaluate(`__lines('h1')`);
if (h1 > 1) fail(`${app} WebKit h1 여러 줄`, h1 + "줄"); else pass(`${app} WebKit h1`);