# 设计语言

本文是这套主题的设计说明：**颜色是什么、为什么是这个值、落在哪个界面角色上**。想拿它当设计语言学习入口的，读这一篇就够。

工程实现细节在 [engineering-notes.md](engineering-notes.md)，测试在 [testing.md](testing.md)。

---

## 一、核心概念：工业编辑风

终末地官网的视觉不是「深色科技感」，而是**印刷编辑风**：一张有质感的纸、压在上面的墨黑字、以及一个用来做信号的高饱和色。三条规则贯穿全篇：

1. **纸与墨是主体，强调色是信号。** 强调色只用于「需要被看见的那一处」——按钮、悬停、选中行、焦点环——而不是大面积铺色。
2. **全直角。** 圆角会把界面推向「柔和产品」的观感；直角把它推向「技术文档 / 工程图纸」。只有形状本身是圆的（状态点、头像、加载圈）才保留圆形。
3. **数字等宽。** 开启 `tnum`，让表格、耗时、百分比在跳动时不产生横向抖动。

---

## 二、色板

### 中性色（纸 / 墨 / 线）

两套模式共用同一套语义，只换取值。

| 角色 | 令牌 | 亮色 | 暗色 |
| --- | --- | --- | --- |
| 纸底（应用背景、侧栏） | `--dsw-alias-bg-base` | `#e8e8e2` | `#101110` |
| 面板 / 气泡 | `--dsw-alias-bg-layer-1` | `#f2f2ec` | `#181a18` |
| 沉底块 | `--dsw-alias-bg-layer-2` | `#dcddd6` | `#1e201d` |
| 浮层（菜单、弹层） | `--dsw-alias-bg-overlay` | `#f2f2ec` | `#1c1e1c` |
| 细线 | `--dsw-alias-border-l1` | `#d8d9d5` | `#343633` |
| 重线 | `--dsw-alias-border-l2` | `#b6b8b3` | `#4a4d49` |
| 正文 | `--dsw-alias-label-primary` | `#101110` | `#f5f5f0` |
| 次要文字 | `--dsw-alias-label-secondary` | `#4a4c48` | `#898d89` |

亮色不用纯白 `#fff`、暗色不用纯黑 `#000`：`#e8e8e2` 带一点暖灰，是「纸」而不是「屏幕」；`#101110` 留一点绿倾向，避免深灰界面常见的死黑。

### 强调色：两套配色

设置页「主题配色」在两者间切换，**默认谷地黄**。

| 配色 | 强调色 | 相对亮度 | 说明 |
| --- | --- | --- | --- |
| **谷地黄**（默认） | `#fff500` | 86.6% | 终末地官网的信号黄 |
| **武陵青** | `#14d0d0` | 49.8% | 青碧色 |

存储字段 `palette`（UI/存储键 `dsh-theme-endfield-palette`，由 `PREFS_KEY_TO_FIELD` 映射），取值 `valley` / `wuling`。只有精确值 `wuling` 选中武陵青，未设置或异常值都回落到默认。

### 语义状态色

不参与配色切换——错误就该是红的。

| 角色 | 亮色 | 暗色 |
| --- | --- | --- |
| 错误 | `#ff3b30` | `#ff6b61` |
| 成功 | `#2f9e44` | `#4fbf5c` |
| 警告 | `#d9822b` | `#ffb700` |

---

## 三、强调色的派生角色

一个强调色不够用。同一个色在不同表面上需要不同的取值，这些派生量在样式表里都是 CSS 变量，**声明在 `body` 上**（原因见[工程笔记](engineering-notes.md#变量必须声明在-body-而不是-root)）。

| 变量 | 谷地黄 | 武陵青 | 用途 |
| --- | --- | --- | --- |
| `--edge-accent` | `#fff500` | `#14d0d0` | 实心底、图标、焦点环、光标 |
| `--edge-accent-rgb` | `255, 245, 0` | `20, 208, 208` | 约 30 处 `rgba(var(--edge-accent-rgb), α)` 半透明色块 |
| `--edge-accent-deep` | `#e8e000` | `#10b8b8` | 悬停 / 按下加深 |
| `--edge-accent-onpaper` | `#d9c700` | `#14d0d0` | 亮色模式下唯一需要「作填充压在纸上」的槽位 |
| `--edge-status-light` | `#6b5d00` | `#006a6a` | 亮色模式回合状态文字（0.2 经 `--dsw-alias-label-deep-diving` 生效） |
| `--edge-status-light-mid` | `#3f3600` | `#003f3f` | 亮色模式流光亮带（0.2 经 `--dsw-alias-label-deep-diving-shimmer` 生效） |
| `--edge-status-dark` | `#fff500` | `#14d0d0` | 暗色模式回合状态文字（同上） |
| `--edge-status-dark-mid` | `#a08a00` | `#7ee7e7` | 暗色模式流光亮带（同上） |
| `--edge-glow-light` | `0.08` | `0.08` | hero 背景光晕透明度 |
| `--edge-glow-dark` | `0.05` | `0.04` | 同上，暗色 |

### 为什么亮色模式的强调色要「下沉」

这是最容易踩的一条。**在奶油纸底上，`#fff500` 不是一种配色选择，而是一次抹除**——黄色文字压在纸底上只有 1.07:1（面板 `#f2f2ec` 上更低，1.02:1），完全不可见。同理青色也过不了：`#14d0d0` 在纸底上仅 1.56:1。

所以亮色模式里凡是「强调色作为文字」的地方，都换成同一色相的**深色档**：

- 谷地黄 → `#6b5d00`（5.35:1，亮色下第一个达标的金）
- 武陵青 → `#006a6a`（5.22:1）

在纸上，`#007070` 以上的青都过不了双底 AA——这是**纸的属性**，不是强调色亮度的问题，所以武陵青提亮后这两个值完全没变。

而暗色模式方向相反：底是近黑，强调色本身就够亮，直接用原色。

### 为什么暗色的「亮带」方向不一致

回合状态标签（0.1.x 是渐变流光文字，0.2 是遮罩扫光文字，见[工程笔记](engineering-notes.md#四类回合状态标签)）中间有一条亮带扫过字形。

- 谷地黄的亮带是**压暗**的 `#a08a00`——黄色向上已经到顶，再亮就成白色。
- 武陵青的亮带是**提亮**的 `#7ee7e7`——青色在这个亮度向下的余量比黄色小，更深的青（`#0a7d7d`）只有 3.54:1 不达标。

同一个规则（AA 4.5:1），两个相反的方向。这类「不能照抄」的地方是本主题最主要的复杂度来源。

### 武陵青为什么是 `#14d0d0`

初版用了 `#0daaaa`，相对亮度只有 31.7%——不到谷地黄（86.6%）的四成，在近黑页面上存在感明显弱一截。`#14d0d0` 保持同一条色轴（R 低、G = B，仍是青碧而非灰青），亮度提高到 49.8%。

上界同样有约束：到 `#16dcdc` 时，亮色模式下色块与纸底只剩 1.39:1（现行 1.56:1），开始「融进纸里」，而且那么浅的青读起来偏白。**`#14d0d0` 是两种模式下都还站得住的最亮一档。**

这两条边界写成了断言（亮度下限 45%、上限 56%、必须留在青碧色轴上）。

---

## 四、对比度规则

**每个角色都要对它实际落在的表面验算，不达标就构建失败。** `test/palette-contrast.test.js` 覆盖全部 27 项。

| 角色 | 谷地黄 | 武陵青 | 门槛 |
| --- | --- | --- | --- |
| 实心强调底 + 墨色字 | 16.50:1 | 9.88:1 | AA 4.5 |
| 悬停加深底 + 墨色字 | 13.60:1 | 7.72:1 | AA 4.5 |
| 暗色图标 / 链接强调色 | 15.26:1 | 9.14:1 | ≥3 |
| 回合状态·亮色主色 | 5.35:1 | 5.22:1 | AA 4.5 |
| 回合状态·亮色亮带 | 9.82:1 | 9.58:1 | AA 4.5 |
| 回合状态·暗色主色 | 15.26:1 | 9.14:1 | AA 4.5 |
| 回合状态·暗色亮带 | 5.11:1 | 12.06:1 | AA 4.5 |

三条附加规则：

- **回合状态要对两种底色都达标**（`bg-base` 与 `bg-layer-1`），因为亮带会扫过字形；0.1.x 的渐变文字在 `prefers-reduced-motion` 下被上游把 `background-size` 钉成 100%，那条亮带会**永久留在**字里，0.2 的遮罩扫光同样要把两个令牌都按「会压在字形上」来取值。
- **两配色的等高线合成对比度相差 ≤20%**：相同 alpha ≠ 相同存在感，所以 alpha 按「合成后对比度对齐」分别调参，而不是照抄。
- **hero 光晕只许更轻，不许更响**：判据是单侧的——比它替换的 `#6187D8` @8% 更安静永远可接受。

> **装饰性表面的对比度描述的是「有多抢眼」，不是可读性。** 水印永远在正文之后，等高线在所有内容之下，它们的对比度数值用来控制干扰强度。经验下限约 `1.06:1`，低于此形同不存在。

---

## 五、令牌映射：中和残留品牌蓝

DSH 自带 DeepSeek 品牌蓝。主题把这些令牌整组重映射，否则会在纸墨界面里留下零散的蓝点。

| 令牌组 | 处理 |
| --- | --- |
| `--dsw-static-deepseek-50…900` | 整组换成中性灰阶（亮色为纸阶，暗色为墨阶） |
| `--dsw-static-deepseek-450` | 亮色 → `--edge-accent-onpaper`，暗色 → `--edge-accent` |
| `--dsw-static-deepseek-500/600` | → 墨 / 纸色（**不可动**，见下） |
| `--dsw-alias-button-info-fill` | 亮色 → 墨黑，暗色 → 强调色 |
| `--dsw-alias-state-business-primary` | 同上 |
| `--dsw-specific-sidebar-nav-item-active-accent` | 同上 |
| `--dsw-alias-label-primary-bluish` | → 正文色 |
| `--dsw-specific-bubble` / `-highlight` | → 面板 / 沉底块 |

`--dsw-static-deepseek-500/200` 是**共享令牌**：同时支撑 `button-info-fill`、`state-business-primary` 与 `bubble-highlight`。要改回合状态标签的颜色时不能动它们，只能覆盖那条规则自己的 `background-image`。

### 强调色交互一览

| 表面 | 声明 |
| --- | --- |
| 文本选中 | `::selection { color:#000; background: var(--edge-accent) }` |
| 输入光标 | `caret-color: var(--edge-accent)` |
| 焦点环 | `outline: 2px solid var(--edge-accent)`，`offset: 1px` |
| 滚动条 | 轨道透明，滑块 `--edge-line`，悬停 `--edge-accent` |
| 链接悬停 | 下划线着色；暗色模式下文字也变强调色 |
| 表格行悬停 | 实心强调底 + 墨色字 |
| 半透明色块 | `rgba(var(--edge-accent-rgb), α)`，亮色 α 略低于暗色 |

亮色与暗色的 α 不同：**在近黑底上叠加亮度，比在纸底上减去亮度显得响得多**。同一个 α 在暗色下更吵，所以暗色的半透明块普遍取更高的 α（因为强调色本身在暗色下要更突出），而装饰性图层（水印）在暗色下反而要**降低** α。

---

## 六、直角化怎么实现

直角不是逐个组件改，而是**先全部清零，再恢复该圆的**：

```css
body:not(.theme-endfield-round) [class] { border-radius: 0 !important; }
body:not(.theme-endfield-round) [class*='avatar'],
body:not(.theme-endfield-round) [class*='spinner'],
body:not(.theme-endfield-round) [class*='dot'],
body:not(.theme-endfield-round) [class$='_iconButton'] { border-radius: 50% !important; }
```

用 `[class]` 而非 `*`，避免命中无类名的结构性节点；用 `body:not(.theme-endfield-round)` 作前缀，让「主题圆角」开关只是 `<body>` 上加减一个 class，不需要重新注册样式表。

---

## 七、配色切换是一个 class

整份样式表里所有强调色都读**调色板变量**，所以切换配色只是 `<body>` 上加减一个 class：不重新注册令牌层、不重算样式、无 JS 重绘。

这一点能成立依赖一个事实：**应用是把主题令牌写成 `<body>` 的行内样式的**（`dsh-client-ui-layout` 对每个令牌调 `body.style.setProperty`）。因此主题把 `--dsw-alias-brand-primary` 的暗色值声明为 `var(--edge-accent)`，该令牌就随 class 自动重解析。

唯一需要 JS 介入的表面是**等高线画布**——canvas 描边不能是 CSS 变量，所以它由一个监听 `<body>` class 的 `MutationObserver` 触发重绘（顺带让「别的标签页改了配色」也能跟着变）。

配色切换后的实测像素占比（`test/verify-shots.js` 解码真实截图统计）：

| 截图 | 黄色像素 | 青色像素 |
| --- | --- | --- |
| 谷地黄 · 亮色 | **5.47%** | 0.44% |
| 武陵青 · 亮色 | 0.39% | **6.51%** |
| 谷地黄 · 暗色 | **2.51%** | 0.42% |
| 武陵青 · 暗色 | 0.34% | **5.40%** |

约 93% 的中性像素（纸底、墨字、边框）在两版之间基本不变——切换只动强调色。

---

## 八、可复用的几条经验

如果只带走几句话：

1. **颜色是量出来的，不是挑出来的。** 每个色值都对它实际落在的表面算过对比度；不达标的写法（哪怕看起来「就该是这个颜色」）一律改掉。
2. **同一个色在亮/暗模式下往往需要相反的处理。** 亮色下沉、暗色提亮，或者反过来，取决于该色相在那个亮度还剩多少余量。
3. **「装饰」和「前景」用两套标准。** 前景守 AA 4.5:1；装饰守「不抢眼但可见」的区间（约 1.06 ～ 1.6:1）。
4. **主题令牌自身可以是 `var()` 引用。** 这是把「换配色」从一次重绘降级成一次 class 切换的关键。
5. **底色归主题、文字归应用 = 裂缝。** 只接管背景令牌而不接管前景，会造出「白字压在黄底上」这类 1.05:1 的不可见组合。改一个背景令牌时，要一起检查谁在上面写字。
