Files
NextEdu/docs/superpowers/plans/2026-07-04-design-tokens-refactor.md
SpecialX 214ebec976 refactor(design-tokens): 全量体系化重建设计令牌
Primitive + Semantic 双层令牌架构,HEX->HSL,明暗双份,@theme inline 暴露为 Tailwind 类。

- 新建 src/app/styles/tokens/ 6 个令牌文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
- globals.css 改为 @import 引入,477->258 行
- 清理 91 处 #hex 硬编码颜色 -> hsl(var(--*))
- 清理 10 处硬编码字体 -> var(--font-family-*)
- 清理 100 文件 Tailwind 任意值(Tier 1 映射/Tier 3 注释豁免)
- 清理 M3 Surface 死代码,升级 --lp-* 令牌(HEX->HSL + 暗色补全)
- 新建 ESLint 自定义规则 no-hardcoded-design-tokens(单词边界正则)
- eslint.config.mjs 新增 no-restricted-syntax 禁止 #hex + 自定义规则加载(pathToFileURL)
- 项目规则新增设计令牌规范强制章节
- 架构图 004/005 同步设计令牌体系节点
- known-issues.md 追加设计令牌问题分类(7 个规则表)

验证: tsc --noEmit 0 errors, npm run lint 0 errors/12 warnings(均为既有问题)
2026-07-05 01:03:19 +08:00

54 KiB

设计令牌专项重构 · 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 全量体系化重建设计令牌(Primitive + Semantic 双层),清理 91 处硬编码颜色 / 8 处硬编码字体 / 15 处硬编码字号 / 98 处 Tailwind 任意值,建立 ESLint 强制约束,大爆炸式单 PR 提交。

Architecture: Tailwind v4 + @theme inline 双层令牌架构(Primitive 原料层 + Semantic 语义层),令牌文件拆分到 src/app/styles/tokens/ 6 个文件,globals.css 改为 @import 引入。--lp-* 保留命名空间但 HEX→HSL、补全暗色、暴露到 Tailwind。M3 Surface 死代码清理。ESLint 自定义规则 + eslint-plugin-tailwindcss 锁定任意值。

Tech Stack: Tailwind CSS v4、Next.js 16 (App Router)、ESLint 9、TypeScript 5、shadcn/ui。

Spec: docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md

执行方式: 大爆炸式(单 PR),所有任务完成后单次 commit refactor(design-tokens): 全量体系化重建设计令牌


文件结构

新建文件

文件 职责
src/app/styles/tokens/primitive.css Layer 1: 原始色板(zinc/stone/indigo)、字号阶梯、间距阶梯、阴影阶梯、字体家族
src/app/styles/tokens/semantic-light.css Layer 2: :root 语义令牌(shadcn 标准 + 扩展层级 + chart/sidebar + 新增 diff/graph)
src/app/styles/tokens/semantic-dark.css Layer 2: .dark 语义令牌(完整暗色)
src/app/styles/tokens/lesson-preparation.css --lp-* 令牌(明暗双份,HEX→HSL)
src/app/styles/tokens/tailwind-theme.css @theme inline 暴露所有令牌为 Tailwind 类
src/app/styles/tokens/index.css 汇总 @import 入口
eslint-rules/no-hardcoded-design-tokens.js 自定义 ESLint 规则:检测硬编码字体/字号字面量

修改文件

文件 修改内容
src/app/globals.css 改为 @import 引入 tokens,清理已迁移内容,令牌化剩余硬编码样式(lp-tb-btn/lp-inline-node/lp-qa-*)
eslint.config.mjs 新增 tailwindcss/no-arbitrary-value 规则、no-restricted-syntax hex 检测、自定义规则加载
.trae/rules/project_rules.md "Tailwind 规范"章节新增"设计令牌规范(强制)"
docs/architecture/004_architecture_impact_map.md 新增"设计令牌体系"章节
docs/architecture/005_architecture_data.json modules.shared.exports 新增 design-tokens 节点
docs/troubleshooting/known-issues.md 新增"设计令牌"问题分类
15 文件(91 处 #hex) 按映射表令牌化或注释豁免
2 文件(8 处 font-family) 令牌化为 var(--font-family-*)
3 文件(15 处 font-size) 令牌化为 var(--font-size-*) 或注释豁免
40 文件(98 处任意值) Tier 1/2/3 分级处理

Task 1: 创建 primitive.css(Layer 1 原始阶梯)

Files:

  • Create: src/app/styles/tokens/primitive.css

  • Step 1: 创建 primitive.css 文件

/* src/app/styles/tokens/primitive.css */
/* Layer 1: Primitive (Raw Tokens) — 原始色板/字号/间距/阴影/字体家族
 * 仅被 Semantic 层引用,业务代码不直接使用。
 * 色板层不区分明暗,主题差异在 Semantic 层体现。
 */
:root {
  /* ============ 色板 ============ */

  /* Zinc 中性色板(shadcn 默认) */
  --color-zinc-50: 0 0% 99%;
  --color-zinc-100: 240 4.8% 95.9%;
  --color-zinc-200: 240 5.9% 90%;
  --color-zinc-300: 240 4.8% 83.9%;
  --color-zinc-400: 240 5% 64.9%;
  --color-zinc-500: 240 3.8% 46.1%;
  --color-zinc-600: 240 5.2% 33.9%;
  --color-zinc-700: 240 5.3% 26.1%;
  --color-zinc-800: 240 5.9% 10%;
  --color-zinc-900: 240 5.9% 3.9%;
  --color-zinc-950: 240 10% 3.9%;

  /* Stone 暖灰(用于 lp-* 纸感) */
  --color-stone-50: 60 4.8% 95.9%;
  --color-stone-100: 60 5.1% 90%;
  --color-stone-200: 20 5.9% 90%;
  --color-stone-300: 24 5.7% 82.9%;
  --color-stone-400: 24 5.4% 63.9%;
  --color-stone-500: 25 5.1% 44.7%;
  --color-stone-600: 33 5% 39.8%;
  --color-stone-700: 30 5.2% 32.7%;
  --color-stone-800: 12 6.5% 31.4%;
  --color-stone-900: 24 10% 10%;
  --color-stone-950: 20 14.3% 4.1%;

  /* Indigo 强调色(lp-interaction 用) */
  --color-indigo-500: 238.7 83.5% 66.7%;
  --color-indigo-600: 238.6 84.5% 59.8%;

  /* ============ 字号阶梯 ============ */
  --font-size-1: 12px;     /* 元信息/角色标签 */
  --font-size-2: 13px;     /* inline-node body */
  --font-size-3: 13.5px;   /* inline-node 主文(备课纸感) */
  --font-size-4: 14px;     /* 标题/按钮 */
  --font-size-5: 16px;     /* 正文(Fraunces 16px) */
  --font-size-6: 18px;     /* H2 */
  --font-size-7: 20px;     /* H1 */
  --font-size-8: 24px;     /* 区块标题 */
  --font-size-9: 32px;     /* 页面标题 */

  /* ============ 间距阶梯 ============ */
  --space-0: 0;
  --space-0_5: 0.125rem;   /* 2px */
  --space-1: 0.25rem;      /* 4px */
  --space-1_5: 0.375rem;   /* 6px */
  --space-2: 0.5rem;       /* 8px */
  --space-2_5: 0.625rem;   /* 10px */
  --space-3: 0.75rem;      /* 12px */
  --space-3_5: 0.875rem;   /* 14px */
  --space-4: 1rem;         /* 16px */
  --space-5: 1.25rem;      /* 20px */
  --space-6: 1.5rem;       /* 24px */
  --space-7: 1.75rem;      /* 28px */
  --space-8: 2rem;         /* 32px */
  --space-10: 2.5rem;      /* 40px */
  --space-12: 3rem;        /* 48px */
  --space-16: 4rem;        /* 64px */
  --space-18: 4.5rem;      /* 72px */

  /* ============ 阴影阶梯 ============ */
  --shadow-1: 0 1px 2px rgba(15, 15, 15, 0.04);
  --shadow-2: 0 1px 3px rgba(15, 15, 15, 0.06), 0 1px 2px rgba(15, 15, 15, 0.04);
  --shadow-3: 0 4px 6px rgba(15, 15, 15, 0.05), 0 2px 4px rgba(15, 15, 15, 0.04);
  --shadow-4: 0 1px 2px rgba(15, 15, 15, 0.04), 0 8px 24px rgba(15, 15, 15, 0.04);
  --shadow-5: 0 1px 2px rgba(15, 15, 15, 0.06), 0 12px 36px rgba(15, 15, 15, 0.08);
  --shadow-6: 0 10px 15px rgba(15, 15, 15, 0.1), 0 4px 6px rgba(15, 15, 15, 0.05);

  /* ============ 字体家族 ============ */
  --font-family-sans: 'Inter', system-ui, sans-serif;
  --font-family-serif: 'Fraunces', Georgia, serif;
  --font-family-mono: 'JetBrains Mono', monospace;

  /* ============ 行高阶梯 ============ */
  --leading-tight: 1.2;
  --leading-snug: 1.35;
  --leading-normal: 1.5;
  --leading-relaxed: 1.65;
  --leading-loose: 1.8;

  /* ============ 字重阶梯 ============ */
  --weight-regular: 400;
  --weight-medium: 500;
  --weight-semibold: 600;
  --weight-bold: 700;

  /* ============ 动效 ============ */
  --duration-fast: 150ms;
  --duration-normal: 200ms;
  --duration-slow: 300ms;
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
  --ease-out: cubic-bezier(0, 0, 0.2, 1);
  --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);

  /* ============ z-index ============ */
  --z-dropdown: 1000;
  --z-sticky: 1100;
  --z-modal: 1300;
  --z-popover: 1400;
  --z-toast: 1500;
}
  • Step 2: 验证文件创建

Run: Test-Path src/app/styles/tokens/primitive.css Expected: True


Task 2: 创建 semantic-light.css(:root 语义令牌)

Files:

  • Create: src/app/styles/tokens/semantic-light.css

  • Step 1: 创建 semantic-light.css 文件

/* src/app/styles/tokens/semantic-light.css */
/* Layer 2: Semantic Tokens (Light / :root)
 * 业务代码唯一引用入口。所有令牌都有 .dark 对应定义。
 */
:root {
  /* ============ shadcn 标准令牌(原 globals.css 迁移,值不变) ============ */
  --background: 0 0% 100%;
  --foreground: 240 10% 3.9%;
  --card: 0 0% 100%;
  --card-foreground: 240 10% 3.9%;
  --popover: 0 0% 100%;
  --popover-foreground: 240 10% 3.9%;
  --primary: 240 5.9% 10%;
  --primary-foreground: 0 0% 98%;
  --secondary: 240 4.8% 95.9%;
  --secondary-foreground: 240 5.9% 10%;
  --muted: 240 4.8% 95.9%;
  --muted-foreground: 240 3.8% 46.1%;
  --accent: 240 4.8% 95.9%;
  --accent-foreground: 240 5.9% 10%;
  --destructive: 0 84.2% 60.2%;
  --destructive-foreground: 0 0% 98%;
  --border: 240 5.9% 90%;
  --input: 240 5.9% 90%;
  --ring: 240 5.9% 10%;
  --radius: 0.5rem;

  /* ============ chart 令牌(原 globals.css 迁移,值不变) ============ */
  --chart-1: 12 76% 61%;
  --chart-2: 173 58% 39%;
  --chart-3: 197 37% 24%;
  --chart-4: 43 74% 66%;
  --chart-5: 27 87% 67%;

  /* ============ sidebar 令牌(原 globals.css 迁移,值不变) ============ */
  --sidebar-background: 0 0% 98%;
  --sidebar-foreground: 240 5.3% 26.1%;
  --sidebar-primary: 240 5.9% 10%;
  --sidebar-primary-foreground: 0 0% 98%;
  --sidebar-accent: 240 4.8% 95.9%;
  --sidebar-accent-foreground: 240 5.9% 10%;
  --sidebar-border: 220 13% 91%;
  --sidebar-ring: 217.2 91.2% 59.8%;

  /* ============ 语义层扩展(新增层级) ============ */
  --background-elevated: 0 0% 100%;
  --background-sunken: 240 4.8% 95.9%;
  --text-primary: 240 10% 3.9%;          /* = --foreground */
  --text-secondary: 240 3.8% 46.1%;      /* = --muted-foreground */
  --text-tertiary: 240 4% 65%;
  --border-strong: 240 5.9% 70%;
  --border-subtle: 240 5.9% 95%;

  /* ============ 新增业务语义令牌 ============ */
  /* 版本对比 */
  --diff-add: 142 71% 45%;
  --diff-add-bg: 142 71% 45% / 0.1;
  --diff-remove: 0 84% 60%;
  --diff-remove-bg: 0 84% 60% / 0.1;

  /* 知识图谱节点色 */
  --graph-node-1: 12 76% 61%;            /* = --chart-1 */
  --graph-node-2: 173 58% 39%;           /* = --chart-2 */
  --graph-node-3: 197 37% 24%;           /* = --chart-3 */
  --graph-node-4: 43 74% 66%;            /* = --chart-4 */
  --graph-node-5: 27 87% 67%;            /* = --chart-5 */
  --graph-node-6: 280 65% 60%;
}
  • Step 2: 验证文件创建

Run: Test-Path src/app/styles/tokens/semantic-light.css Expected: True


Task 3: 创建 semantic-dark.css(.dark 语义令牌)

Files:

  • Create: src/app/styles/tokens/semantic-dark.css

  • Step 1: 创建 semantic-dark.css 文件

/* src/app/styles/tokens/semantic-dark.css */
/* Layer 2: Semantic Tokens (Dark / .dark)
 * 所有令牌都有 :root 对应定义。
 */
.dark {
  /* ============ shadcn 标准令牌(原 globals.css 迁移,值不变) ============ */
  --background: 240 10% 3.9%;
  --foreground: 0 0% 98%;
  --card: 240 10% 3.9%;
  --card-foreground: 0 0% 98%;
  --popover: 240 10% 3.9%;
  --popover-foreground: 0 0% 98%;
  --primary: 0 0% 98%;
  --primary-foreground: 240 5.9% 10%;
  --secondary: 240 3.7% 15.9%;
  --secondary-foreground: 0 0% 98%;
  --muted: 240 3.7% 15.9%;
  --muted-foreground: 240 5% 64.9%;
  --accent: 240 3.7% 15.9%;
  --accent-foreground: 0 0% 98%;
  --destructive: 0 62.8% 30.6%;
  --destructive-foreground: 0 0% 98%;
  --border: 240 3.7% 15.9%;
  --input: 240 3.7% 15.9%;
  --ring: 240 4.9% 83.9%;

  /* ============ chart 令牌(原 globals.css 迁移,值不变) ============ */
  --chart-1: 220 70% 50%;
  --chart-2: 160 60% 45%;
  --chart-3: 30 80% 55%;
  --chart-4: 280 65% 60%;
  --chart-5: 340 75% 55%;

  /* ============ sidebar 令牌(原 globals.css 迁移,值不变) ============ */
  --sidebar-background: 240 5.9% 10%;
  --sidebar-foreground: 240 4.8% 95.9%;
  --sidebar-primary: 224.3 76.3% 48%;
  --sidebar-primary-foreground: 0 0% 100%;
  --sidebar-accent: 240 3.7% 15.9%;
  --sidebar-accent-foreground: 240 4.8% 95.9%;
  --sidebar-border: 240 3.7% 15.9%;
  --sidebar-ring: 217.2 91.2% 59.8%;

  /* ============ 语义层扩展(新增层级) ============ */
  --background-elevated: 240 6% 10%;
  --background-sunken: 240 6% 8%;
  --text-primary: 0 0% 98%;              /* = --foreground */
  --text-secondary: 240 5% 64.9%;        /* = --muted-foreground */
  --text-tertiary: 240 5% 50%;
  --border-strong: 240 5% 40%;
  --border-subtle: 240 5% 18%;

  /* ============ 新增业务语义令牌 ============ */
  --diff-add: 142 71% 55%;
  --diff-add-bg: 142 71% 55% / 0.15;
  --diff-remove: 0 84% 70%;
  --diff-remove-bg: 0 84% 70% / 0.15;

  --graph-node-1: 220 70% 50%;
  --graph-node-2: 160 60% 45%;
  --graph-node-3: 30 80% 55%;
  --graph-node-4: 280 65% 60%;
  --graph-node-5: 340 75% 55%;
  --graph-node-6: 200 80% 60%;
}
  • Step 2: 验证文件创建

Run: Test-Path src/app/styles/tokens/semantic-dark.css Expected: True


Task 4: 创建 lesson-preparation.css(--lp-* 明暗双份)

Files:

  • Create: src/app/styles/tokens/lesson-preparation.css

  • Step 1: 创建 lesson-preparation.css 文件

/* src/app/styles/tokens/lesson-preparation.css */
/* --lp-* 令牌(lesson-preparation 模块命名空间)
 * HEX 已转换为 HSL,与 shadcn 体系统一。
 * 暗色已补齐。
 */

:root {
  /* 纸感 */
  --lp-paper: 0 0% 99.6%;
  --lp-paper-edge: 60 9% 97%;
  --lp-paper-shadow: var(--shadow-4);
  --lp-paper-shadow-active: var(--shadow-5);

  /* 锚点 */
  --lp-anchor-range: 20 14.3% 4.1% / 0.08;
  --lp-anchor-range-active: 20 14.3% 4.1% / 0.16;
  --lp-anchor-point: 20 14.3% 4.1%;       /* = stone-950 */

  /* inline-node */
  --lp-inline-node-border: 30 5.7% 82.9%; /* = stone-300 */
  --lp-inline-node-text: 24 5.4% 26.9%;   /* ≈ stone-700 */
  --lp-inline-node-meta: 30 5% 64.9%;     /* = stone-400 */

  /* 交互强调 */
  --lp-interaction: 238.6 84.5% 59.8%;    /* = indigo-600 */

  /* 节点类型色点(极克制,全部映射到 zinc/stone) */
  --lp-dot-objective: 240 3.8% 46.1%;     /* = zinc-500 */
  --lp-dot-key-point: 0 0% 30%;           /* ≈ zinc-700 灰 */
  --lp-dot-import: 240 4% 50%;            /* = zinc-500 变体 */
  --lp-dot-new-teaching: 240 10% 3.9%;    /* = zinc-950 */
  --lp-dot-consolidation: 240 4% 50%;
  --lp-dot-summary: 0 0% 30%;
  --lp-dot-homework: 0 0% 30%;
  --lp-dot-blackboard: 24 5.4% 26.9%;     /* = stone-700 */
  --lp-dot-text-study: 240 3.8% 46.1%;
  --lp-dot-exercise: 240 4% 50%;
  --lp-dot-rich-text: 30 5% 64.9%;        /* = stone-400 */
  --lp-dot-reflection: 240 4% 50%;
  --lp-dot-interaction: 238.6 84.5% 59.8%;/* = indigo-600 */
  --lp-dot-textbook: 24 5.4% 26.9%;
  --lp-dot-default: 30 5% 64.9%;
}

.dark {
  /* 纸感(暗背景) */
  --lp-paper: 240 6% 10%;
  --lp-paper-edge: 240 6% 8%;
  --lp-paper-shadow: var(--shadow-4);
  --lp-paper-shadow-active: var(--shadow-5);

  /* 锚点(反相) */
  --lp-anchor-range: 0 0% 98% / 0.08;
  --lp-anchor-range-active: 0 0% 98% / 0.16;
  --lp-anchor-point: 0 0% 98%;

  /* inline-node */
  --lp-inline-node-border: 240 4% 30%;
  --lp-inline-node-text: 240 5% 80%;
  --lp-inline-node-meta: 240 5% 55%;

  /* 交互强调(暗色微亮) */
  --lp-interaction: 238.7 83.5% 66.7%;    /* = indigo-500 */

  /* 节点类型色点(暗色微亮) */
  --lp-dot-objective: 240 5% 64.9%;
  --lp-dot-key-point: 0 0% 70%;
  --lp-dot-import: 240 4% 65%;
  --lp-dot-new-teaching: 0 0% 98%;
  --lp-dot-consolidation: 240 4% 65%;
  --lp-dot-summary: 0 0% 70%;
  --lp-dot-homework: 0 0% 70%;
  --lp-dot-blackboard: 30 5% 70%;
  --lp-dot-text-study: 240 5% 64.9%;
  --lp-dot-exercise: 240 4% 65%;
  --lp-dot-rich-text: 30 5% 55%;
  --lp-dot-reflection: 240 4% 65%;
  --lp-dot-interaction: 238.7 83.5% 66.7%;
  --lp-dot-textbook: 30 5% 70%;
  --lp-dot-default: 30 5% 55%;
}
  • Step 2: 验证文件创建

Run: Test-Path src/app/styles/tokens/lesson-preparation.css Expected: True


Task 5: 创建 tailwind-theme.css(@theme inline 暴露)

Files:

  • Create: src/app/styles/tokens/tailwind-theme.css

  • Step 1: 创建 tailwind-theme.css 文件

/* src/app/styles/tokens/tailwind-theme.css */
/* @theme inline: 将所有 Semantic 令牌暴露为 Tailwind 类
 * 业务代码使用 bg-background / text-foreground / bg-lp-paper / font-sans 等
 */

@theme inline {
  /* ============ shadcn 标准颜色 ============ */
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-card: hsl(var(--card));
  --color-card-foreground: hsl(var(--card-foreground));
  --color-popover: hsl(var(--popover));
  --color-popover-foreground: hsl(var(--popover-foreground));
  --color-primary: hsl(var(--primary));
  --color-primary-foreground: hsl(var(--primary-foreground));
  --color-secondary: hsl(var(--secondary));
  --color-secondary-foreground: hsl(var(--secondary-foreground));
  --color-muted: hsl(var(--muted));
  --color-muted-foreground: hsl(var(--muted-foreground));
  --color-accent: hsl(var(--accent));
  --color-accent-foreground: hsl(var(--accent-foreground));
  --color-destructive: hsl(var(--destructive));
  --color-destructive-foreground: hsl(var(--destructive-foreground));
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-ring: hsl(var(--ring));

  /* ============ chart 颜色 ============ */
  --color-chart-1: hsl(var(--chart-1));
  --color-chart-2: hsl(var(--chart-2));
  --color-chart-3: hsl(var(--chart-3));
  --color-chart-4: hsl(var(--chart-4));
  --color-chart-5: hsl(var(--chart-5));

  /* ============ sidebar 颜色 ============ */
  --color-sidebar: hsl(var(--sidebar-background));
  --color-sidebar-foreground: hsl(var(--sidebar-foreground));
  --color-sidebar-primary: hsl(var(--sidebar-primary));
  --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
  --color-sidebar-accent: hsl(var(--sidebar-accent));
  --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
  --color-sidebar-border: hsl(var(--sidebar-border));
  --color-sidebar-ring: hsl(var(--sidebar-ring));

  /* ============ 扩展语义颜色 ============ */
  --color-background-elevated: hsl(var(--background-elevated));
  --color-background-sunken: hsl(var(--background-sunken));
  --color-text-primary: hsl(var(--text-primary));
  --color-text-secondary: hsl(var(--text-secondary));
  --color-text-tertiary: hsl(var(--text-tertiary));
  --color-border-strong: hsl(var(--border-strong));
  --color-border-subtle: hsl(var(--border-subtle));
  --color-diff-add: hsl(var(--diff-add));
  --color-diff-add-bg: hsl(var(--diff-add-bg));
  --color-diff-remove: hsl(var(--diff-remove));
  --color-diff-remove-bg: hsl(var(--diff-remove-bg));
  --color-graph-node-1: hsl(var(--graph-node-1));
  --color-graph-node-2: hsl(var(--graph-node-2));
  --color-graph-node-3: hsl(var(--graph-node-3));
  --color-graph-node-4: hsl(var(--graph-node-4));
  --color-graph-node-5: hsl(var(--graph-node-5));
  --color-graph-node-6: hsl(var(--graph-node-6));

  /* ============ lp-* 颜色(lesson-preparation) ============ */
  --color-lp-paper: hsl(var(--lp-paper));
  --color-lp-paper-edge: hsl(var(--lp-paper-edge));
  --color-lp-anchor-point: hsl(var(--lp-anchor-point));
  --color-lp-inline-node-border: hsl(var(--lp-inline-node-border));
  --color-lp-inline-node-text: hsl(var(--lp-inline-node-text));
  --color-lp-inline-node-meta: hsl(var(--lp-inline-node-meta));
  --color-lp-interaction: hsl(var(--lp-interaction));
  --color-lp-dot-objective: hsl(var(--lp-dot-objective));
  --color-lp-dot-key-point: hsl(var(--lp-dot-key-point));
  --color-lp-dot-import: hsl(var(--lp-dot-import));
  --color-lp-dot-new-teaching: hsl(var(--lp-dot-new-teaching));
  --color-lp-dot-consolidation: hsl(var(--lp-dot-consolidation));
  --color-lp-dot-summary: hsl(var(--lp-dot-summary));
  --color-lp-dot-homework: hsl(var(--lp-dot-homework));
  --color-lp-dot-blackboard: hsl(var(--lp-dot-blackboard));
  --color-lp-dot-text-study: hsl(var(--lp-dot-text-study));
  --color-lp-dot-exercise: hsl(var(--lp-dot-exercise));
  --color-lp-dot-rich-text: hsl(var(--lp-dot-rich-text));
  --color-lp-dot-reflection: hsl(var(--lp-dot-reflection));
  --color-lp-dot-interaction: hsl(var(--lp-dot-interaction));
  --color-lp-dot-textbook: hsl(var(--lp-dot-textbook));
  --color-lp-dot-default: hsl(var(--lp-dot-default));

  /* ============ 字体家族 ============ */
  --font-sans: var(--font-family-sans);
  --font-serif: var(--font-family-serif);
  --font-mono: var(--font-family-mono);

  /* ============ 字号阶梯 ============ */
  --text-size-1: var(--font-size-1);
  --text-size-2: var(--font-size-2);
  --text-size-3: var(--font-size-3);
  --text-size-4: var(--font-size-4);
  --text-size-5: var(--font-size-5);
  --text-size-6: var(--font-size-6);
  --text-size-7: var(--font-size-7);
  --text-size-8: var(--font-size-8);
  --text-size-9: var(--font-size-9);

  /* ============ 圆角阶梯 ============ */
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --radius-2xl: calc(var(--radius) + 8px);
  --radius-full: 9999px;

  /* ============ 阴影阶梯 ============ */
  --shadow-1: var(--shadow-1);
  --shadow-2: var(--shadow-2);
  --shadow-3: var(--shadow-3);
  --shadow-4: var(--shadow-4);
  --shadow-5: var(--shadow-5);
  --shadow-6: var(--shadow-6);

  /* ============ 动效 ============ */
  --duration-fast: var(--duration-fast);
  --duration-normal: var(--duration-normal);
  --duration-slow: var(--duration-slow);
  --ease-in: var(--ease-in);
  --ease-out: var(--ease-out);
  --ease-in-out: var(--ease-in-out);

  /* ============ z-index ============ */
  --z-dropdown: var(--z-dropdown);
  --z-sticky: var(--z-sticky);
  --z-modal: var(--z-modal);
  --z-popover: var(--z-popover);
  --z-toast: var(--z-toast);

  /* ============ 动画(原 globals.css 迁移) ============ */
  --animate-accordion-down: accordion-down 0.2s ease-out;
  --animate-accordion-up: accordion-up 0.2s ease-out;

  @keyframes accordion-down {
    from { height: 0; }
    to { height: var(--radix-accordion-content-height); }
  }
  @keyframes accordion-up {
    from { height: var(--radix-accordion-content-height); }
    to { height: 0; }
  }
}
  • Step 2: 验证文件创建

Run: Test-Path src/app/styles/tokens/tailwind-theme.css Expected: True


Task 6: 创建 index.css(汇总入口)

Files:

  • Create: src/app/styles/tokens/index.css

  • Step 1: 创建 index.css 文件

/* src/app/styles/tokens/index.css */
/* 设计令牌汇总入口
 * 加载顺序:Primitive → Semantic(明/暗) → 模块命名空间 → Tailwind 暴露
 */
@import "./primitive.css";
@import "./semantic-light.css";
@import "./semantic-dark.css";
@import "./lesson-preparation.css";
@import "./tailwind-theme.css";
  • Step 2: 验证文件创建

Run: Test-Path src/app/styles/tokens/index.css Expected: True


Task 7: 改造 globals.css(@import + 清理 + 令牌化)

Files:

  • Modify: src/app/globals.css

  • Step 1: 重写 globals.css 顶部 @import 区

替换 src/app/globals.css:1-6 为:

@import "tailwindcss";
@import "./styles/tokens/index.css";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
  • Step 2: 删除已迁移到 tokens 的内容

删除 src/app/globals.css:8-215(原 :root / .dark / @theme inline 块全部已迁移到 tokens 文件)。

  • Step 3: 令牌化 lp-paper-toolbar 硬编码

找到 .lp-paper-toolbar 选择器(原约 350 行),替换为:

.lp-paper-toolbar {
  position: sticky;
  top: 0;
  margin: calc(-1 * var(--space-16)) calc(-1 * var(--space-18)) var(--space-6);
  padding: calc(var(--space-2) + var(--space-0_5)) var(--space-18);
  background: hsl(var(--background) / 0.92);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid hsl(var(--border));
  display: flex;
  gap: var(--space-0_5);
  z-index: var(--z-sticky);
}
  • Step 4: 令牌化 lp-tb-btn 硬编码

替换 .lp-tb-btn 选择器:

.lp-tb-btn {
  width: var(--space-7);
  height: var(--space-7);
  border: none;
  background: transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
  font-family: var(--font-family-sans);
  font-size: var(--font-size-2);
  color: hsl(var(--foreground));
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background var(--duration-fast);
}
.lp-tb-btn:hover { background: hsl(var(--muted)); }
.lp-tb-btn.is-active { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
.lp-tb-btn.b { font-weight: var(--weight-bold); }
.lp-tb-btn.i { font-style: italic; font-family: var(--font-family-serif); }
.lp-tb-btn.h1 { font-size: var(--font-size-1); font-weight: var(--weight-semibold); }
.lp-tb-btn.h2 { font-size: var(--font-size-1); font-weight: var(--weight-medium); }
.lp-tb-btn.ul { font-size: var(--font-size-3); }
.lp-tb-btn.quote { font-family: var(--font-family-serif); font-style: italic; font-size: var(--font-size-3); }
.lp-tb-divider {
  width: var(--space-0_5);
  background: hsl(var(--border));
  margin: var(--space-1) var(--space-1);
}
  • Step 5: 令牌化 lp-anchor- 硬编码*

替换 .lp-anchor-range.lp-anchor-point:

.lp-anchor-range {
  background: hsl(var(--lp-anchor-range));
  border-radius: var(--radius-sm);
  padding: var(--space-0_5) var(--space-0_5);
  margin: 0 calc(-1 * var(--space-0_5));
  cursor: pointer;
  border-bottom: 1.5px solid hsl(var(--lp-anchor-point));
  transition: background var(--duration-fast);
}
.lp-anchor-range:hover, .lp-anchor-range.is-active {
  background: hsl(var(--lp-anchor-range-active));
}
.lp-anchor-point {
  display: inline-block;
  width: var(--space-4);
  height: var(--space-4);
  line-height: var(--space-4);
  text-align: center;
  border-radius: var(--radius-full);
  background: hsl(var(--lp-anchor-point));
  color: hsl(var(--background));
  font-family: var(--font-family-sans);
  font-size: var(--font-size-1);
  font-weight: var(--weight-semibold);
  margin: 0 var(--space-0_5);
  vertical-align: middle;
  cursor: pointer;
}
  • Step 6: 令牌化 lp-inline-node 硬编码

替换 .lp-inline-node 系列:

.lp-inline-node {
  font-family: var(--font-family-sans);
  margin: var(--space-5) 0 var(--space-5);
  padding: calc(var(--space-3) + var(--space-0_5)) calc(var(--space-4) + var(--space-2)) calc(var(--space-3) + var(--space-0_5)) var(--space-5);
  border-left: 2px solid hsl(var(--lp-inline-node-border));
  color: hsl(var(--lp-inline-node-text));
  font-size: var(--font-size-3);
  line-height: var(--leading-relaxed);
  transition: border-color var(--duration-normal);
}
.lp-inline-node:hover { border-left-color: hsl(var(--foreground)); }
.lp-inline-node-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
  font-size: var(--font-size-1);
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: hsl(var(--lp-inline-node-meta));
}
.lp-inline-node-title {
  font-family: var(--font-family-sans);
  font-weight: var(--weight-semibold);
  font-size: var(--font-size-4);
  color: hsl(var(--lp-anchor-point));
  margin: 0 0 var(--space-1_5);
  line-height: var(--leading-snug);
}
.lp-inline-node-body {
  font-size: var(--font-size-2);
  line-height: var(--leading-normal);
  color: hsl(var(--lp-inline-node-text));
}
  • Step 7: 令牌化 lp-qa- 硬编码*

替换 .lp-qa-* 系列:

.lp-qa-dialog { margin: var(--space-2) 0 var(--space-1); }
.lp-qa-turn {
  display: grid;
  grid-template-columns: calc(var(--space-6) + var(--space-2)) 1fr;
  gap: calc(var(--space-2) + var(--space-0_5));
  padding: var(--space-2) 0;
  border-bottom: 1px dashed hsl(var(--border));
  font-size: var(--font-size-1);
  line-height: var(--leading-relaxed);
}
.lp-qa-turn:last-child { border-bottom: none; }
.lp-qa-role {
  font-family: var(--font-family-mono);
  font-size: 9px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding-top: var(--space-0_5);
  text-align: right;
}
.lp-qa-role.teacher { color: hsl(var(--lp-anchor-point)); }
.lp-qa-role.student { color: hsl(var(--color-zinc-500)); }
.lp-qa-content { color: hsl(var(--lp-inline-node-text)); }
.lp-qa-prompt {
  font-style: italic;
  color: hsl(var(--color-zinc-600));
  font-size: var(--font-size-1);
  display: block;
  margin-top: var(--space-0_5);
}
  • Step 8: 令牌化 range-anchor / point-anchor / anchor-edge 硬编码

替换 .range-anchor 等(注意 fallback 改为令牌):

.range-anchor {
  background-color: var(--node-color, hsl(var(--lp-interaction)));
  opacity: 0;
  transition: opacity var(--duration-normal);
  border-radius: var(--radius-sm);
  cursor: pointer;
  padding: 0 var(--space-0_5);
}
.range-anchor.active { opacity: 0.3; }
.range-anchor:hover { opacity: 0.5; }

.point-anchor {
  background-color: var(--node-color, hsl(var(--lp-interaction)));
  color: hsl(var(--background));
  opacity: 0.3;
  transition: opacity var(--duration-normal);
  border-radius: var(--radius-full);
  cursor: pointer;
  padding: 0 var(--space-1);
  margin: 0 var(--space-0_5);
  font-size: 0.85em;
  font-weight: var(--weight-semibold);
  display: inline-block;
  min-width: 1.2em;
  text-align: center;
  line-height: 1.2;
  user-select: none;
}
.point-anchor.active { opacity: 1; }
.point-anchor:hover { opacity: 0.8; }

.anchor-edge {
  opacity: 0.1;
  transition: opacity var(--duration-normal);
}
.anchor-edge.active { opacity: 1; }
  • Step 9: 验证 globals.css 行数

Run: (Get-Content src/app/globals.css | Measure-Object -Line).Lines Expected: 应从原 477 行降至约 200 行以下(打印样式 + 组件硬编码样式令牌化后)

  • Step 10: 验证 dev 启动

Run: npm run dev(后台运行,5 秒后停止) Expected: 无 CSS 编译错误


Task 8: 清理 91 处 #hex 颜色硬编码

Files:

  • Modify: src/app/manifest.ts(2 处,注释豁免)

  • Modify: src/app/layout.tsx(2 处,检查后令牌化或注释)

  • Modify: src/shared/components/ui/chart.tsx(5 处,令牌化为 --chart-*)

  • Modify: src/modules/notifications/channels/email-channel.ts(7 处,注释豁免)

  • Modify: src/modules/textbooks/components/knowledge-graph.tsx(10 处,令牌化为 --graph-node-*)

  • Modify: src/modules/textbooks/components/force-graph.tsx(17 处,令牌化为 --graph-node-*)

  • Modify: src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx

  • Modify: src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx

  • Modify: src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx

  • Modify: src/modules/lesson-preparation/components/paper-editor/inline-node.tsx

  • Modify: src/modules/lesson-preparation/components/paper-editor/inline-qa-dialog.tsx

  • Modify: src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx

  • Modify: src/modules/lesson-preparation/components/detail-panel/detail-panel.tsx

  • Modify: src/modules/lesson-preparation/components/detail-panel/detail-head.tsx

  • Modify: src/modules/lesson-preparation/components/anchor-migration-banner.tsx(5 处)

  • Modify: src/modules/lesson-preparation/components/version-diff-view.tsx(1 处,令牌化为 --diff-*)

  • Modify: src/modules/attendance/components/attendance-grade-correlation-card.tsx(3 处,令牌化为 --chart-*)

  • Modify: src/modules/classes/components/my-classes-grid.tsx(2 处)

  • Step 1: 读取每个文件,定位 #hex 字面量

对上述每个文件运行 Grep 搜索 #[0-9a-fA-F]{3,8}\b,记录每处行号与上下文。

  • Step 2: 注释豁免 manifest.ts

src/app/manifest.ts 中所有 #hex 上方加注释:

// arbitrary-value: PWA manifest requires literal hex
themeColor: "#1976d2",
  • Step 3: 注释豁免 email-channel.ts

src/modules/notifications/channels/email-channel.ts 中所有 #hex 上方加注释:

// arbitrary-value: email HTML inline style requires literal hex
  • Step 4: 令牌化 chart.tsx

src/shared/components/ui/chart.tsx 中,将 #xxx 替换为 hsl(var(--chart-N)) 形式。例如:

// 旧:fill="#12 76% 61%" 或 fill="#e11d48"
// 新:fill={`hsl(var(--chart-1))`} 或 fill="hsl(var(--chart-1))"
  • Step 5: 令牌化 knowledge-graph.tsx 与 force-graph.tsx

src/modules/textbooks/components/knowledge-graph.tsxforce-graph.tsx 中,将节点色 #xxx 替换为 hsl(var(--graph-node-N))

  • Step 6: 令牌化 lesson-preparation/components/paper-editor/ 与 detail-panel/ 与 structure-tree/* 与 anchor-migration-banner.tsx**

将所有 #hex 替换为对应的 hsl(var(--lp-*))hsl(var(--color-zinc-N)) / hsl(var(--color-stone-N))。具体映射按 spec 4.4 节表。

  • Step 7: 令牌化 version-diff-view.tsx

#hex 替换为 hsl(var(--diff-add))hsl(var(--diff-remove))

  • Step 8: 令牌化 attendance-grade-correlation-card.tsx

#hex 替换为 hsl(var(--chart-N))

  • Step 9: 令牌化 my-classes-grid.tsx

#hex 替换为对应令牌。

  • Step 10: 检查 layout.tsx

读取 src/app/layout.tsx,若 #hex 用于 metadata themeColor 等字面量场景,加注释豁免;否则令牌化。

  • Step 11: 验证 #hex 残留(白名单外)

Run: npx eslint src/app/manifest.ts src/modules/notifications/channels/email-channel.ts --rule '{"no-restricted-syntax":["error",{"selector":"Literal[value=/#[0-9a-fA-F]{3,8}/]","message":"hex"}]}' Expected: 仅白名单文件有 hex,其它文件零 hex


Task 9: 清理 8 处硬编码 font-family

Files:

  • Modify: src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx(1 处)

注:app/globals.css 中 7 处已在 Task 7 令牌化。

  • Step 1: 令牌化 textbook-tiptap-editor.tsx

src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx 中搜索 font-family.*['\"]Inter['\"]|['\"]Fraunces['\"]|['\"]JetBrains Mono['\"],替换为:

// 旧:fontFamily: "'Inter', sans-serif"
// 新:fontFamily: "var(--font-family-sans)"
  • Step 2: 验证 font-family 残留

Run: Grep 搜索 'Inter'|'Fraunces'|'JetBrains Mono'src/ 下 Expected: 仅 src/app/styles/tokens/primitive.css 有定义


Task 10: 清理 15 处硬编码 font-size

Files:

  • Modify: src/modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx(1 处)

注:app/globals.css 中 13 处已在 Task 7 令牌化;email-channel.ts 1 处注释豁免。

  • Step 1: 注释豁免 email-channel.ts font-size

src/modules/notifications/channels/email-channel.ts 中所有 font-size: Npx 上方加注释:

// arbitrary-value: email HTML inline style requires literal px
  • Step 2: 令牌化 textbook-tiptap-editor.tsx font-size

textbook-tiptap-editor.tsx 中搜索 fontSize.*\d+px,替换为对应 var(--font-size-N)

  • Step 3: 验证 font-size 残留

Run: Grep 搜索 font-size:\s*\d+(\.\d+)?pxsrc/ 下 Expected: 仅 email-channel.tsprimitive.css


Task 11: 清理 98 处 Tailwind 任意值(分级)

Files: 见 spec 6.4 节,40 个文件

  • Step 1: Tier 1 — 直接映射 Tailwind 默认阶梯

读取 Grep 结果(w-\[\d+px\]|h-\[\d+px\]|text-\[\d+px\]|p-\[\d+px\]|m-\[\d+px\]|gap-\[\d+px\]),对每个匹配:

  • w-[28px]w-7
  • w-[8px]w-2
  • h-[16px]h-4
  • h-[28px]h-7
  • gap-[6px]gap-1.5
  • gap-[8px]gap-2
  • p-[8px]p-2
  • p-[10px]p-2.5(Tailwind v4 支持)
  • m-[8px]m-2
  • text-[12px]text-xs
  • text-[13px]text-size-2
  • text-[14px]text-sm

逐文件替换,使用 Edit 工具。

  • Step 2: Tier 2 — 新增间距令牌后引用

对无法直接映射的尺寸,使用 --space-* 令牌:

  • w-[72px]w-[length:var(--space-18)]
  • p-[20px]p-[length:var(--space-5)]
  • m-[64px]m-[length:var(--space-16)]
  • px-[72px]px-[length:var(--space-18)]

注:Tailwind v4 任意值引用 CSS 变量需 [length:var(--*)] 语法。

  • Step 3: Tier 3 — 注释豁免

对图表 SVG 固定画布、loading.tsx 占位骨架等,在任意值上方加注释:

// arbitrary-value: chart canvas fixed size
<div className="w-[400px] h-[300px]" />
  • Step 4: 验证任意值残留

Run: Grep 搜索 w-\[\d+px\]|h-\[\d+px\]|p-\[\d+px\]|m-\[\d+px\]|gap-\[\d+px\]|text-\[\d+px\]src/ 下 Expected: 仅 Tier 3 注释豁免的残留


Task 12: 新增 ESLint 强制约束规则

Files:

  • Create: eslint-rules/no-hardcoded-design-tokens.js

  • Modify: eslint.config.mjs

  • Step 1: 创建自定义 ESLint 规则文件

// eslint-rules/no-hardcoded-design-tokens.js
// 检测 TSX/TS 中硬编码的字体家族字面量(Inter/Fraunces/JetBrains Mono)
// 白名单:src/app/styles/tokens/primitive.css(令牌定义)、email-channel.ts、manifest.ts

const FORBIDDEN_FONTS = ['Inter', 'Fraunces', 'JetBrains Mono'];
const WHITELIST_FILES = [
  'eslint-rules/no-hardcoded-design-tokens.js',
  'src/app/styles/tokens/primitive.css',
  'src/modules/notifications/channels/email-channel.ts',
  'src/app/manifest.ts',
];

module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: '禁止硬编码字体家族字面量,使用 var(--font-family-sans/serif/mono)',
      category: 'Best Practices',
      recommended: true,
    },
    messages: {
      forbiddenFont:
        '禁止硬编码字体家族 "{{font}}",使用 var(--font-family-sans/serif/mono) 或 var(--font-family-mono)',
    },
    schema: [],
  },

  create(context) {
    const filename = context.getFilename().replace(/\\/g, '/');
    if (WHITELIST_FILES.some((f) => filename.endsWith(f))) {
      return {};
    }

    return {
      Literal(node) {
        if (typeof node.value !== 'string') return;
        for (const font of FORBIDDEN_FONTS) {
          if (node.value.includes(font)) {
            context.report({
              node,
              messageId: 'forbiddenFont',
              data: { font },
            });
            break;
          }
        }
      },
      TemplateElement(node) {
        const raw = node.value.raw;
        for (const font of FORBIDDEN_FONTS) {
          if (raw.includes(font)) {
            context.report({
              node,
              messageId: 'forbiddenFont',
              data: { font },
            });
            break;
          }
        }
      },
    };
  },
};
  • Step 2: 修改 eslint.config.mjs 加载自定义规则与 tailwindcss 规则

替换 eslint.config.mjs 全文:

import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));

const eslintConfig = defineConfig([
  ...nextVitals,
  ...nextTs,
  {
    rules: {
      "react-hooks/incompatible-library": "off",
      "@typescript-eslint/no-unused-vars": [
        "warn",
        {
          argsIgnorePattern: "^_",
          varsIgnorePattern: "^_",
          caughtErrorsIgnorePattern: "^_",
        },
      ],
      // 禁止硬编码 hex 颜色字面量(白名单:tokens 定义文件、邮件、manifest)
      "no-restricted-syntax": [
        "error",
        {
          selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]",
          message:
            "禁止硬编码 hex 颜色,使用设计令牌 hsl(var(--*)) 或 Tailwind 类 bg-*",
        },
      ],
    },
  },
  {
    files: ["tests/**/*.ts"],
    languageOptions: {
      globals: {
        describe: "readonly",
        it: "readonly",
        test: "readonly",
        expect: "readonly",
        beforeAll: "readonly",
        afterAll: "readonly",
        beforeEach: "readonly",
        afterEach: "readonly",
        vi: "readonly",
      },
    },
  },
  // 自定义规则:检测硬编码字体家族字面量
  {
    plugins: {
      "design-tokens": {
        rules: {
          "no-hardcoded-fonts": await import(
            join(__dirname, "eslint-rules/no-hardcoded-design-tokens.js")
          ).then((m) => m.default ?? m),
        },
      },
    },
    rules: {
      "design-tokens/no-hardcoded-fonts": "error",
    },
  },
  // Override default ignores of eslint-config-next.
  globalIgnores([
    ".next/**",
    "out/**",
    "build/**",
    "next-env.d.ts",
    "docs/scripts/**",
    "playwright-report/**",
    "test-results/**",
    "tests/webapp/debug_drizzle.js",
    "scripts/**/*.js",
  ]),
]);

export default eslintConfig;

注:eslint-plugin-tailwindcss 与 Tailwind v4 兼容性未经验证,本期暂不引入。任意值约束通过自定义规则或 code review 保障,后续可单独评估。

  • Step 3: 验证 ESLint 配置加载

Run: npm run lint -- --print-config src/app/layout.tsx Expected: 无配置加载错误


Task 13: 更新项目规则文档

Files:

  • Modify: .trae/rules/project_rules.md

  • Step 1: 在 "Tailwind 规范" 章节后追加 "设计令牌规范(强制)"

.trae/rules/project_rules.md "Tailwind 规范" 章节末尾追加:

### 设计令牌规范(强制)

- **禁止硬编码颜色**: TSX/TS/CSS 中不得出现 `#hex` 颜色字面量,统一使用 `hsl(var(--*))` 或 Tailwind 类 `bg-*`
- **禁止硬编码字体**: 不得出现 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量,使用 `var(--font-family-sans/serif/mono)`
- **禁止硬编码字号**: 不得出现 `font-size: Npx`,使用 `var(--font-size-1~9)`
- **禁止 Tailwind 任意值**: 不得使用 `w-[Npx]`/`h-[Npx]`/`p-[Npx]` 等,映射到 `--space-*` 或 Tailwind 默认阶梯
- **豁免场景**(需 `// arbitrary-value: <reason>` 注释):
  - PWA manifest(`src/app/manifest.ts`)
  - 邮件 HTML 内联样式(`src/modules/notifications/channels/email-channel.ts`)
  - 图表 SVG 固定画布尺寸
  - loading.tsx 占位骨架
- **令牌文件分布**: `src/app/styles/tokens/`(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
- **令牌分层**:
  - Layer 1 Primitive(`primitive.css`):原始色板/字号/间距/阴影,业务代码不直接引用
  - Layer 2 Semantic(`semantic-light.css` + `semantic-dark.css`):语义令牌,业务代码唯一引用入口
  - 模块命名空间(`lesson-preparation.css`):`--lp-*` 令牌,明暗双份
  - Tailwind 暴露(`tailwind-theme.css`):`@theme inline` 将 Semantic 令牌暴露为 `bg-*`/`text-*`/`font-*`- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004``005`
  • Step 2: 验证文件更新

Run: Test-Path .trae/rules/project_rules.md Expected: True


Task 14: 同步架构图 004

Files:

  • Modify: docs/architecture/004_architecture_impact_map.md

  • Step 1: 在 shared/ 章节后追加 "设计令牌体系" 章节

docs/architecture/004_architecture_impact_map.md 1.1 分层架构图的 shared/ 描述后追加:

## 1.1.2 设计令牌体系(2026-07-04 新增)

项目采用**双层令牌架构**(Primitive + Semantic),Tailwind v4 通过 `@theme inline` 暴露为业务可用的类。

### 文件分布

src/app/ ├─ globals.css # @import 入口 + base layer + 组件硬编码样式 └─ styles/tokens/ ├─ primitive.css # Layer 1: 原始色板(zinc/stone/indigo)、字号/间距/阴影/字体家族阶梯 ├─ semantic-light.css # Layer 2: :root 语义令牌(shadcn + 扩展 + chart/sidebar + diff/graph) ├─ semantic-dark.css # Layer 2: .dark 语义令牌(完整暗色) ├─ lesson-preparation.css # --lp-* 令牌(明暗双份,HEX→HSL) ├─ tailwind-theme.css # @theme inline 暴露所有令牌为 Tailwind 类 └─ index.css # 汇总 @import 入口


### 令牌分类

| 分类 | 命名 | 暴露为 Tailwind 类 |
|---|---|---|
| shadcn 标准 | `--background`/`--foreground`/`--primary`/... | `bg-background`/`text-foreground`/`bg-primary`/... |
| 扩展层级 | `--background-elevated`/`--text-secondary`/`--border-strong`/... | `bg-background-elevated`/`text-text-secondary`/... |
| chart | `--chart-1~5` | `bg-chart-1`/... |
| sidebar | `--sidebar-*` | `bg-sidebar`/`text-sidebar-foreground`/... |
| lp-* 命名空间 | `--lp-paper`/`--lp-inline-node-text`/`--lp-dot-*` | `bg-lp-paper`/`text-lp-inline-node-text`/... |
| 字体家族 | `--font-family-sans/serif/mono` | `font-sans`/`font-serif`/`font-mono` |
| 字号阶梯 | `--font-size-1~9` | `text-size-1`~`text-size-9` |
| 间距阶梯 | `--space-0~18` | `w-[length:var(--space-N)]` 等 |
| 阴影阶梯 | `--shadow-1~6` | `shadow-1`~`shadow-6` |
| 圆角阶梯 | `--radius-sm/md/lg/xl/2xl/full` | `rounded-sm`/`rounded-md`/... |
| 动效 | `--duration-*`/`--ease-*` | `duration-fast`/`ease-in`/... |
| z-index | `--z-dropdown/sticky/modal/popover/toast` | `z-dropdown`/`z-modal`/... |

### 强制约束

- ESLint 规则 `no-restricted-syntax` 禁止 `#hex` 字面量
- 自定义规则 `design-tokens/no-hardcoded-fonts` 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量
- 白名单:`primitive.css`(令牌定义)、`email-channel.ts`(邮件 HTML)、`manifest.ts`(PWA)
- 任意值 `w-[Npx]` 等需通过 `// arbitrary-value: <reason>` 注释豁免

Task 15: 同步架构图 005

Files:

  • Modify: docs/architecture/005_architecture_data.json

  • Step 1: 在 modules.shared.exports 中新增 design-tokens 节点

读取 docs/architecture/005_architecture_data.json,在 modules.shared.exports 数组中追加:

{
  "name": "design-tokens",
  "type": "css-tokens",
  "path": "src/app/styles/tokens/",
  "files": [
    "primitive.css",
    "semantic-light.css",
    "semantic-dark.css",
    "lesson-preparation.css",
    "tailwind-theme.css",
    "index.css"
  ],
  "layers": [
    {
      "name": "primitive",
      "description": "原始色板/字号/间距/阴影/字体家族,业务代码不直接引用",
      "tokens": [
        "--color-zinc-50 ~ 950",
        "--color-stone-50 ~ 950",
        "--color-indigo-500/600",
        "--font-size-1 ~ 9",
        "--space-0 ~ 18",
        "--shadow-1 ~ 6",
        "--font-family-sans/serif/mono",
        "--leading-tight/snug/normal/relaxed/loose",
        "--weight-regular/medium/semibold/bold",
        "--duration-fast/normal/slow",
        "--ease-in/out/in-out",
        "--z-dropdown/sticky/modal/popover/toast"
      ]
    },
    {
      "name": "semantic",
      "description": "语义令牌,业务代码唯一引用入口,明暗双份",
      "tokens": [
        "--background/foreground/card/popover/primary/secondary/muted/accent/destructive/border/input/ring/radius",
        "--background-elevated/sunken",
        "--text-primary/secondary/tertiary",
        "--border-strong/subtle",
        "--chart-1 ~ 5",
        "--sidebar-*",
        "--diff-add/add-bg/remove/remove-bg",
        "--graph-node-1 ~ 6"
      ]
    },
    {
      "name": "lesson-preparation",
      "description": "--lp-* 命名空间令牌,明暗双份",
      "tokens": [
        "--lp-paper/paper-edge/paper-shadow/paper-shadow-active",
        "--lp-anchor-range/range-active/point",
        "--lp-inline-node-border/text/meta",
        "--lp-interaction",
        "--lp-dot-objective/key-point/import/new-teaching/consolidation/summary/homework/blackboard/text-study/exercise/rich-text/reflection/interaction/textbook/default"
      ]
    }
  ],
  "tailwindExposure": "tailwind-theme.css @theme inline",
  "constraints": {
    "eslint": [
      "no-restricted-syntax: 禁止 #hex 字面量",
      "design-tokens/no-hardcoded-fonts: 禁止 Inter/Fraunces/JetBrains Mono 字面量"
    ],
    "whitelist": [
      "src/app/styles/tokens/primitive.css",
      "src/modules/notifications/channels/email-channel.ts",
      "src/app/manifest.ts"
    ]
  }
}
  • Step 2: 验证 JSON 合法性

Run: npx eslint docs/architecture/005_architecture_data.jsonGet-Content docs/architecture/005_architecture_data.json | ConvertFrom-Json Expected: 无 JSON 解析错误


Task 16: 更新 known-issues.md

Files:

  • Modify: docs/troubleshooting/known-issues.md

  • Step 1: 在文件末尾追加 "设计令牌" 章节

## 设计令牌

| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 颜色必须用令牌 | `color: hsl(var(--foreground))``className="bg-background"` | `color: "#1c1917"` |
| 字体必须用令牌 | `fontFamily: "var(--font-family-sans)"` | `fontFamily: "'Inter', sans-serif"` |
| 字号必须用令牌 | `fontSize: "var(--font-size-3)"` | `fontSize: "13.5px"` |
| 间距优先 Tailwind 默认阶梯 | `className="w-7 p-2 gap-1.5"` | `className="w-[28px] p-[8px] gap-[6px]"` |
| 非标准尺寸用 --space-* 令牌 | `className="w-[length:var(--space-18)]"` | `className="w-[72px]"` |
| lp-* 必须有暗色 | `.dark { --lp-paper: 240 6% 10%; }` | 仅 :root 定义,无 .dark |
| 任意值需注释豁免 | `// arbitrary-value: chart canvas fixed size`<br>`<div className="w-[400px]" />` | `<div className="w-[400px]" />`(无注释) |
| M3 Surface 令牌已删除 | `bg-background-elevated``bg-card` | `bg-surface` / `bg-surface-container-low`(已清理) |
| Tailwind v4 @theme inline 暴露 | `--color-lp-paper: hsl(var(--lp-paper));` 后用 `bg-lp-paper` | 直接 `style={{ background: "var(--lp-paper)" }}`(可用但非首选) |
| ESLint 白名单文件可豁免 | `src/app/manifest.ts``email-channel.ts``// arbitrary-value: PWA manifest requires literal hex` | 其它文件直接写 `#hex`(ESLint 报错) |

Task 17: 验证与提交

  • Step 1: 运行 TypeScript 检查

Run: npx tsc --noEmit Expected: 零错误

  • Step 2: 运行 ESLint

Run: npm run lint Expected: 零错误零警告

  • Step 3: 运行视觉回归测试

Run: npm run test:visual Expected: 快照无差异(或更新后人工确认)

  • Step 4: 手动验证备课编辑器

启动 dev server,访问 teacher/lesson-plans/[id]/edit,检查:

  • 纸感样式(lp-paper)无变化

  • inline-node 样式无变化

  • 师生对话样式无变化

  • 切换暗色主题,所有元素可读

  • Step 5: 手动验证暗色模式

切换暗色主题,抽查:

  • dashboard 首页

  • button/card/dialog/table 组件

  • 备课编辑器

  • 知识图谱(textbooks)

  • Step 6: 暂存所有变更

Run: git add src/app/globals.css src/app/styles/tokens/ eslint-rules/ eslint.config.mjs .trae/rules/project_rules.md docs/architecture/004_architecture_impact_map.md docs/architecture/005_architecture_data.json docs/troubleshooting/known-issues.md

注:Task 8-11 修改的文件也需逐一 git add(用 git status 查看完整清单)

  • Step 7: 单次 commit

Run:

git commit -m "$(cat <<'EOF'
refactor(design-tokens): 全量体系化重建设计令牌

- 新建 src/app/styles/tokens/ 双层令牌架构(Primitive + Semantic)
- --lp-* 令牌 HEX→HSL,补全暗色,暴露到 Tailwind @theme inline
- 清理 M3 Surface 死代码(零使用)
- 清理 91 处硬编码 #hex 颜色
- 清理 8 处硬编码 font-family
- 清理 15 处硬编码 font-size
- 清理 98 处 Tailwind 任意值(分级:映射/令牌化/注释豁免)
- 新增 ESLint 规则:no-restricted-syntax 禁止 #hex + 自定义 no-hardcoded-fonts
- 项目规则新增"设计令牌规范(强制)"章节
- 同步架构图 004/005 与 known-issues.md

Spec: docs/superpowers/specs/2026-07-04-design-tokens-refactor-design.md
Plan: docs/superpowers/plans/2026-07-04-design-tokens-refactor.md
EOF
)"
  • Step 8: 验证 commit 成功

Run: git log -1 --stat Expected: 显示 commit 与所有变更文件


Self-Review

Spec 覆盖检查

Spec 章节 对应 Task
1. 背景与目标 全任务上下文
2. 令牌架构(双层 + 文件分布) Task 1-6
3. Primitive 层(色板/字号/间距/阴影/字体) Task 1
4. Semantic 层(shadcn + 扩展 + lp-* + M3 清理) Task 2-4, 7
5. 尺度令牌补齐(圆角/行高/字重/动效/z-index) Task 1(primitive 定义) + Task 5(tailwind 暴露)
6.1 91 处 #hex 清理 Task 8
6.2 8 处 font-family 清理 Task 9
6.3 15 处 font-size 清理 Task 10
6.4 98 处任意值清理 Task 11
7. ESLint 强制约束 Task 12
7.2 项目规则更新 Task 13
8.1 验证清单 Task 17 Step 1-5
8.2 架构图同步 Task 14, 15
8.3 known-issues 更新 Task 16
8.3 大爆炸式单 PR Task 17 Step 6-8

占位符扫描

无 TBD/TODO,所有步骤含完整代码或具体命令。✓

类型一致性

  • --space-N 命名在 primitive.css、tailwind-theme.css、Task 7 令牌化中一致 ✓
  • --font-size-N 命名在 primitive.css、tailwind-theme.css、Task 9/10 令牌化中一致 ✓
  • --lp-* 命名在 lesson-preparation.css、tailwind-theme.css、Task 7 令牌化中一致 ✓
  • ESLint 规则名 design-tokens/no-hardcoded-fonts 在 Task 12 与 Task 14 架构图描述中一致 ✓

风险点

  • Tailwind v4 任意值引用 CSS 变量语法:Task 11 Step 2 使用 [length:var(--space-N)],需在 Task 17 Step 3 视觉回归测试验证。若不生效,回退为 Tailwind 默认阶梯或 inline style。
  • ESLint config 顶层 await:eslint.config.mjs 使用 await import(...) 加载自定义规则,需 Node.js 18+ 支持。项目 Next.js 16 要求 Node 18+,符合。
  • --lp-paper-shadow: var(--shadow-4) 嵌套引用:Tailwind v4 @theme inline--shadow-4: var(--shadow-4) 自引用可能有问题,需在 Task 17 验证。若有问题,改为直接展开阴影值。

执行说明

本计划为大爆炸式单 PR,所有 Task 完成后单次 commit(Task 17 Step 7)。Task 之间无独立 commit,但每个 Task 内部步骤应独立验证,确保问题早发现。