From fd6551b0badf57277c2672699b53cd06805ea697 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 11 Jul 2026 07:18:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E7=A8=B3=E5=81=A5=E8=B7=A8=20OS=20?= =?UTF-8?q?=E8=A1=AC=E7=BA=BF=E5=85=9C=E5=BA=95=E6=A0=88=20+=20=E5=8F=8C?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=20token=20=E5=A5=87=E5=81=B6=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 决策:不自托管 CJK webfont(数 MB + 构建期外拉,违反 CSP 友好/首屏) 改用跨 OS 系统衬线栈,保证中文标题可靠命中衬线,零 webfont 字节 - 新增 themeParity 测试:paper/night 两块 --color-* 必须成对,防漏配破面 --- apps/web/lib/ui/themeParity.test.ts | 42 +++++++++++++++++++++++++++++ apps/web/tailwind.config.ts | 26 +++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 apps/web/lib/ui/themeParity.test.ts diff --git a/apps/web/lib/ui/themeParity.test.ts b/apps/web/lib/ui/themeParity.test.ts new file mode 100644 index 0000000..90d33c0 --- /dev/null +++ b/apps/web/lib/ui/themeParity.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +// 双主题 token 奇偶校验(P1-8):paper 与 night 两块的 --color-* 变量必须成对, +// 任一模式漏配某个颜色 token 都会让该主题回退为无效变量而破面——此测试提前拦截。 + +const cssPath = fileURLToPath( + new URL("../../app/globals.css", import.meta.url), +); +const css = readFileSync(cssPath, "utf8"); + +function colorVarsInBlock(selector: string): Set { + const start = css.indexOf(selector); + if (start === -1) { + throw new Error(`未找到主题块选择器:${selector}`); + } + const open = css.indexOf("{", start); + const close = css.indexOf("}", open); + const body = css.slice(open + 1, close); + const names = body.match(/--color-[\w-]+/g) ?? []; + return new Set(names); +} + +describe("theme token parity", () => { + const paper = colorVarsInBlock('[data-theme="paper"]'); + const night = colorVarsInBlock('[data-theme="night"]'); + + it("defines at least the core palette in each theme", () => { + expect(paper.size).toBeGreaterThan(10); + expect(night.size).toBeGreaterThan(10); + }); + + it("keeps paper and night color tokens paired (no theme drops a --color-* var)", () => { + const missingInNight = [...paper].filter((name) => !night.has(name)); + const missingInPaper = [...night].filter((name) => !paper.has(name)); + + expect(missingInNight, "night 缺失(paper 有)").toEqual([]); + expect(missingInPaper, "paper 缺失(night 有)").toEqual([]); + }); +}); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 271d647..e2b0744 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -31,9 +31,29 @@ const config: Config = { info: "var(--color-info)", }, fontFamily: { - serif: ['"Noto Serif SC"', '"Songti SC"', "serif"], - sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"], - mono: ['"JetBrains Mono"', "ui-monospace"], + // 衬线 display 与 sans 分工是品牌声音的一部分(对标 DESIGN.md)。 + // 决策:不自托管 CJK webfont —— Noto Serif SC 全量子集达数 MB,会拖慢 + // 首屏且需构建期外部拉取(违反 CSP 友好)。改用跨 OS 系统衬线兜底栈, + // 保证中文标题在任意设备可靠命中高质量衬线,零 webfont 字节。 + // macOS/iOS → Songti/STSong;Windows → SimSun/YaHei;Linux/Android → Noto CJK。 + serif: [ + '"Noto Serif SC"', + '"Source Han Serif SC"', + '"Songti SC"', + "STSong", + "SimSun", + '"Noto Serif CJK SC"', + "serif", + ], + sans: [ + '"Noto Sans SC"', + '"PingFang SC"', + '"Source Han Sans SC"', + '"Microsoft YaHei"', + "system-ui", + "sans-serif", + ], + mono: ['"JetBrains Mono"', "ui-monospace", "monospace"], }, fontSize: { // 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow)