diff --git a/bin/design-audit.mjs b/bin/design-audit.mjs index 87fae7ed..505758c1 100644 --- a/bin/design-audit.mjs +++ b/bin/design-audit.mjs @@ -8,30 +8,52 @@ * arrives one page at a time, each one defensible on its own, and it is only * visible side by side. Run it after adding a page or touching a stylesheet: * - * pnpm run preview # or any server for the built site + * pnpm run preview # or any server for the built site * node bin/design-audit.mjs [baseUrl] + * node bin/design-audit.mjs [baseUrl] --all # every page in every language * * The reference scale is in docs/design-system.md. Nothing here fails a build: * a number outside the range may be right for that page, and the audit exists * to make the choice deliberate, not to forbid it. */ import { chromium } from '@playwright/test'; +import { LOCALES } from './build-pages.mjs'; const base = process.argv[2] ?? 'http://localhost:4173'; +/** `--all` measures every locale; by default only English, which is the scale. */ +const allLocales = process.argv.includes('--all'); -const PAGES = [ - ['/', 'home EN'], - ['/zh-CN/', 'home ZH'], +/** + * One row per page shape, in English -- the scale the design system is written + * against. With --all, each shape is measured in every language the site ships: + * a measure that is 66 characters in English can be something else entirely in + * German (long compounds) or Japanese (no spaces, wider glyphs), and until this + * existed nobody was looking. + */ +const SHAPES = [ + ['/', 'home'], ['/help', 'help'], ['/changelog', 'changelog'], ['/private-document-editor', 'landing (satellite)'], ['/open/docx', 'open/'], ['/convert/docx-to-pdf', 'convert/'], +]; +const UNTRANSLATED = [ ['/history', 'history'], ['/404.html', '404'], ['/embed-demo.html', 'embed-demo'], ]; +const localePath = (locale, path) => (locale === 'en' ? path : `${LOCALES[locale].prefix}${path === '/' ? '/' : path}`); +const PAGES = allLocales + ? [ + ...Object.keys(LOCALES).flatMap((locale) => + SHAPES.map(([path, label]) => [localePath(locale, path), `${label} [${locale}]`]), + ), + ...UNTRANSLATED, + ] + : [...SHAPES, ...UNTRANSLATED]; + /** Everything measured inside the page, in one pass. */ function probe() { const cs = (el) => (el ? getComputedStyle(el) : null); @@ -49,13 +71,23 @@ function probe() { .filter((p) => (p.textContent || '').trim().length > 80 && p.offsetParent) .sort((a, b) => (b.textContent || '').length - (a.textContent || '').length)[0]; + // Measure with the script the page is actually set in. A Latin probe on a + // Japanese page just restates the container width in Latin units, which is + // how the audit read the same 72 for all seven languages while the CJK pages + // were really running ~33 characters. The bands differ too: 45-75 for Latin, + // 30-40 for CJK (JLReq / CLReq). + const script = /^(zh|ja|ko)/.test(document.documentElement.lang || 'en') ? 'cjk' : 'latin'; + const PROBE = { + latin: 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ', + cjk: '\u6587\u5b57\u6392\u7248\u884c\u9577\u6e2c\u5b9a\u7528\u4f8b\u6a19\u672c\u6c49\u5b57\u5bbd\u5ea6', + }[script]; let chars = null; if (para) { const probeEl = document.createElement('span'); probeEl.style.cssText = `position:absolute;visibility:hidden;white-space:pre;font:${cs(para).font}`; - probeEl.textContent = 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + probeEl.textContent = PROBE; document.body.appendChild(probeEl); - const avg = probeEl.getBoundingClientRect().width / probeEl.textContent.length; + const avg = probeEl.getBoundingClientRect().width / PROBE.length; probeEl.remove(); chars = Math.round(para.getBoundingClientRect().width / avg); } @@ -64,7 +96,7 @@ function probe() { const bar = document.querySelector('header.bar'); return { container: Math.round(container.getBoundingClientRect().width), - chars, + chars: chars === null ? null : `${chars}${script === 'cjk' ? ' cjk' : ''}`, body: px(cs(document.body).fontSize), lh: px(cs(document.body).lineHeight), h1: h1 ? `${px(cs(h1).fontSize)}/${cs(h1).fontWeight}` : '-', @@ -77,7 +109,7 @@ const browser = await chromium.launch(); const page = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); const COLS = [ - ['page', 22], + ['page', 26], ['container', 11], ['chars/line', 12], ['body', 6], @@ -96,7 +128,7 @@ for (const [url, label] of PAGES) { await page.waitForTimeout(250); m = await page.evaluate(probe); } catch { - console.log(label.padEnd(22) + '(unreachable)'); + console.log(label.padEnd(26) + '(unreachable)'); continue; } const cells = [label, `${m.container}px`, m.chars ?? '-', m.body, m.lh, m.h1, m.bar, m.footer]; diff --git a/docs/design-system.md b/docs/design-system.md index b8fa8dfa..45edbc1f 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -4,7 +4,8 @@ 适用:所有用户可见页面(落地页、生成页、`/history`、demo、404)。编辑器本身(`/editor`) 是全屏应用,不在此列。 -审计脚本:`node bin/design-audit.mjs `——渲染每个页面,打印下面这些数字。 +审计脚本:`node bin/design-audit.mjs `——渲染每个页面,打印下面这些数字; +加 `--all` 则把每种页型在**七种语言**下各量一遍(默认只量英文,那是本规范写作时的基准)。 一致性应该是可核对的,而不是靠记忆:漂移都是一页一页来的,每一页单看都合理, 只有并排列出来才看得见。 @@ -31,6 +32,19 @@ Notion 正文 708)。**不存在"一个正确的宽度",只存在按内容 当前实测(1440 视口):首页 71、`/help` 72、落地页 72、`/history` 66、404 59。 全部在区间内。 +### 中日韩另有一档:30–40 全角字符 + +拉丁字母的 45–75 换算成中日韩就是 22–37 全角字(一个汉字约等于两个字母宽), +业界惯例(JLReq / CLReq、以及知乎/少数派一类中文长文站)给的是 **30–40**。 +**审计脚本按 `` 选探针**:中日韩页面用汉字量、拉丁页面用字母量, +输出带 `cjk` 后缀。这一条不是多余的讲究——探针写死成字母时,七种语言全部报 72, +那个数字其实只是"容器宽度换算成字母",中日韩页面真实跑在 41,审计等于没审。 + +当前实测:中日韩内容页 **40–41 全角字**(660 ÷ 16px 字号 = 41.25),首页同值。 +比 40 高出 1,**刻意不改**:要压到 40 以内只能单独收窄段落,而整站的标题、发丝线、 +表格都对齐在 660 上,段落缩进去会让右边缘参差,代价大于收益。这个数字记在这里, +是为了下次有人动字号或列宽时知道它已经贴着上沿。 + ### 内容页在宽屏上是两栏,不是一根柱子 `≥1081px` 时内容页(`/open/*`、`/convert/*`、特性页、`/help`、`/changelog`)用 diff --git a/docs/explorations/2026-08-23-design-audit-all-locales.md b/docs/explorations/2026-08-23-design-audit-all-locales.md new file mode 100644 index 00000000..9bfc9006 --- /dev/null +++ b/docs/explorations/2026-08-23-design-audit-all-locales.md @@ -0,0 +1,50 @@ +# 版式审计补上另外六种语言,并按语言换探针 + +日期:2026-08-23 +起因:[七语言审计](2026-08-23-seven-languages-audit.md)留下的第一条"还没做的"—— +`bin/design-audit.mjs` 只量英文页面,另外六种语言的行宽从来没被量过。 + +## 做了什么 + +1. **页型 × 语言**。页面清单不再手写路由,改成从 `bin/build-pages.mjs` 的 `LOCALES` + 派生:六种页型(首页 / help / changelog / 特性页 / open / convert)在七种语言下各量一遍, + 加上三张不翻译的页(`/history`、`/404`、`/embed-demo`)。默认仍只量英文(那是规范 + 写作时的基准),`--all` 才展开成 45 行。以后加第八种语言,审计自动跟上。 +2. **探针按 `` 换**。这才是关键的一步。 + +## 探针写死成拉丁字母时,审计是假绿的 + +脚本量行宽的办法是:造一个隐藏 ``,抄段落的 `font`,塞一串字符, +用总宽除以字符数得到平均字宽,再拿段落宽度去除。那串字符原本硬编码成 +`abcdefghijklmnopqrstuvwxyz ABC...`。 + +于是七种语言全部报 **72 字符**——看起来完美一致,其实那个数字只是 +"660px 容器换算成拉丁字母",跟页面里排的是什么字毫无关系。中日韩页面 +真实跑在 **41 个全角字**,而它从未出现在任何一份报告里。 + +改成按 `document.documentElement.lang` 选探针(`^(zh|ja|ko)` 用汉字串), +输出带 `cjk` 后缀。 + +## 量出来的结果 + +| 页型 | 拉丁(en/de/es/pt) | 中日韩(zh-CN/ja/ko) | +| --------------- | ------------------- | --------------------- | +| 首页(1152 框) | 71 | 41 全角 | +| 内容页(660) | 70–72 | 40–41 全角 | + +拉丁全部落在 45–75 区间内。中日韩的业界区间是 **30–40**(JLReq / CLReq), +实测 40–41,**高出 1,刻意不改**:660 ÷ 16px 字号 = 41.25,要压到 40 以内 +只能单独收窄段落,而整站的标题、发丝线、表格都对齐在 660 上,段落缩进去 +右边缘就参差了。数字记在 `docs/design-system.md` 里,下次动字号或列宽时 +知道它已经贴着上沿。 + +除此之外没有任何一种语言把版式撑出区间——德语的长复合词、日语的无空格 +换行都没有造成溢出(那部分本来就有 E2E 的溢出检查兜着)。 + +## 用例与反向验证 + +`test/unit/design-contract.test.ts` 新增两条,钉住审计脚本自己的覆盖面: +清单必须从 `LOCALES` 派生、必须有 CJK 探针分支。 + +反向验证:`git stash` 掉 `bin/design-audit.mjs` 的改动(退回英文清单 + 拉丁探针), +两条用例同时变红;`stash pop` 后恢复绿。 diff --git a/docs/explorations/2026-08-23-seven-languages-audit.md b/docs/explorations/2026-08-23-seven-languages-audit.md index d3ac5442..fd2b3b7a 100644 --- a/docs/explorations/2026-08-23-seven-languages-audit.md +++ b/docs/explorations/2026-08-23-seven-languages-audit.md @@ -111,7 +111,7 @@ E2E 的第 4/5 步立刻回落英文。 ## 还没做的 -- **`bin/design-audit.mjs` 只审英文页面**。七种语言的行宽/字号没有被审计脚本覆盖, - 目前靠 E2E 的溢出检查兜底。 +- ~~**`bin/design-audit.mjs` 只审英文页面**~~ 已补,见 + [2026-08-23-design-audit-all-locales.md](2026-08-23-design-audit-all-locales.md)。 - **`/history` 与 `/404` 仍是手写页**。它们的 chrome 与生成页重复了一份,这次的语言列表 漏更新就是这个重复的代价。要么把它们也纳入生成器,要么就靠刚加的那条单测钉着。 diff --git a/test/unit/design-contract.test.ts b/test/unit/design-contract.test.ts index 0ade9061..51acd921 100644 --- a/test/unit/design-contract.test.ts +++ b/test/unit/design-contract.test.ts @@ -163,3 +163,24 @@ describe('page chrome', () => { } }); }); + +/** + * The audit script is the only thing that measures the rendered pages, so a + * blind spot in it is a blind spot in the design system. It had one: the page + * list was six English routes plus `/zh-CN/`, which left five languages + * unmeasured, and the character probe was hard-coded Latin, which made the CJK + * pages report a Latin-equivalent number (72) instead of the 41 full-width + * characters they actually run at. + */ +describe('design audit coverage', () => { + const src = readFileSync(resolve(__dirname, '../../bin/design-audit.mjs'), 'utf8'); + + it('derives its page list from the locale table, so a new language is audited on arrival', () => { + expect(src).toMatch(/import \{ LOCALES \} from '\.\/build-pages\.mjs'/); + expect(src).toMatch(/Object\.keys\(LOCALES\)/); + }); + + it('measures CJK pages in full-width characters, not Latin ones', () => { + expect(src).toMatch(/\/\^\(zh\|ja\|ko\)\//); + }); +});