feat(ui): 稳健跨 OS 衬线兜底栈 + 双主题 token 奇偶校验
- 决策:不自托管 CJK webfont(数 MB + 构建期外拉,违反 CSP 友好/首屏) 改用跨 OS 系统衬线栈,保证中文标题可靠命中衬线,零 webfont 字节 - 新增 themeParity 测试:paper/night 两块 --color-* 必须成对,防漏配破面
This commit is contained in:
42
apps/web/lib/ui/themeParity.test.ts
Normal file
42
apps/web/lib/ui/themeParity.test.ts
Normal file
@@ -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<string> {
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,9 +31,29 @@ const config: Config = {
|
|||||||
info: "var(--color-info)",
|
info: "var(--color-info)",
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
serif: ['"Noto Serif SC"', '"Songti SC"', "serif"],
|
// 衬线 display 与 sans 分工是品牌声音的一部分(对标 DESIGN.md)。
|
||||||
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
|
// 决策:不自托管 CJK webfont —— Noto Serif SC 全量子集达数 MB,会拖慢
|
||||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
// 首屏且需构建期外部拉取(违反 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: {
|
fontSize: {
|
||||||
// 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow)
|
// 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow)
|
||||||
|
|||||||
Reference in New Issue
Block a user