---
version: alpha
name: 仪表舱 · 总览页设计契约
description: web/src 总览页（React 19 + antd 6 + cockpit.css）的设计系统正本。令牌取自 theme/tokens.ts 与 cockpit.css 的 :root，与代码一一对应；本文只登记已经存在或已裁定的规则，未决事项进 Open Decisions，不悄悄发明。

colors:
  bay: &bay "#0d1218"
  panel: &panel "#151c25"
  panel-raised: &panel-raised "#1c2530"
  edge: "#2a3542"
  edge-strong: "#374553"
  lit: &lit "#eaf1f7"
  dim: &dim "#a8b4c2"
  faint: &faint "#8494a5"
  data: &data "#3ccbbf"
  data-deep: "#1e6e68"
  caution: "#e8ac3a"
  lock: "#e86259"
  lock-deep: &lock-deep "#803631"

  # 下面八个不是新配色，是上面八个语义色在 antd `ThemeConfig` 里的角色名（见 tokens.ts
  # 的 colorBgBase / colorText / colorPrimary…）。值用 YAML 锚点引用，一处字面量。
  # 加 `antd-` 前缀是故意的：这些**不是 CSS 变量**，代码里只认 `--bay` 那一组。
  antd-colorBgBase: *bay
  antd-colorBgContainer: *panel
  antd-colorBgElevated: *panel-raised
  antd-colorPrimary: *data
  antd-onPrimary: *bay
  antd-colorText: *lit
  antd-colorTextSecondary: *dim
  antd-colorTextTertiary: *faint

typography:
  display-score:
    fontFamily: "var(--mono)"
    fontSize: 46px
    fontWeight: 700
    lineHeight: 0.90
    letterSpacing: 0px
  number-xl:
    fontFamily: "var(--mono)"
    fontSize: 34px
    fontWeight: 700
    lineHeight: 1
    letterSpacing: 0px
  number-lg:
    fontFamily: "var(--mono)"
    fontSize: 26px
    fontWeight: 700
    lineHeight: 1
    letterSpacing: 0px
  number-md:
    fontFamily: "var(--mono)"
    fontSize: 22px
    fontWeight: 700
    lineHeight: 1
    letterSpacing: 0px
  title-lg:
    fontFamily: "var(--mono)"
    fontSize: 17px
    fontWeight: 700
    lineHeight: 1.3
    letterSpacing: 0px
  body-md:
    fontFamily: sans
    fontSize: 15px
    fontWeight: 400
    lineHeight: 1.7
    letterSpacing: 0px
  label-md:
    fontFamily: sans
    fontSize: 13.5px
    fontWeight: 500
    lineHeight: 1.5
    letterSpacing: 0px
  body-sm:
    fontFamily: sans
    fontSize: 13px
    fontWeight: 400
    lineHeight: 1.5
    letterSpacing: 0px
  meta-md:
    fontFamily: sans
    fontSize: 12.5px
    fontWeight: 400
    lineHeight: 1.45
    letterSpacing: 0px
  meta-sm:
    fontFamily: sans
    fontSize: 12px
    fontWeight: 400
    lineHeight: 1.4
    letterSpacing: 0px
  number-sm:
    fontFamily: "var(--mono)"
    fontSize: 11px
    fontWeight: 500
    lineHeight: 1.35
    letterSpacing: 0px
  stamp-glyph:
    fontFamily: "var(--serif)"
    fontSize: 14px
    fontWeight: 700
    lineHeight: 1
    letterSpacing: 0px

rounded:
  none: 0px
  xs: 1px
  base: 2px

spacing:
  xxs: 2px
  xs: 4px
  sm: 6px
  md: 8px
  lg: 10px
  xl: 12px
  xxl: 14px
  section: 24px
  gutter: clamp(16px, 2.5vw, 34px)

components:
  stamp-pass:
    backgroundColor: "{colors.data}"
    textColor: "{colors.bay}"
    typography: "{typography.stamp-glyph}"
    rounded: "{rounded.base}"
    width: 30px
    height: 30px
  stamp-fail:
    backgroundColor: "{colors.lock-deep}"
    textColor: "{colors.lit}"
    typography: "{typography.stamp-glyph}"
    rounded: "{rounded.base}"
    width: 30px
    height: 30px
  stamp-unknown:
    backgroundColor: "color-mix(in srgb, {colors.caution} 9%, transparent)"
    textColor: "{colors.caution}"
    typography: "{typography.stamp-glyph}"
    rounded: "{rounded.base}"
    width: 30px
    height: 30px
  stamp-na:
    backgroundColor: transparent
    textColor: "{colors.faint}"
    typography: "{typography.stamp-glyph}"
    rounded: "{rounded.base}"
    width: 30px
    height: 30px
  panel-card:
    backgroundColor: "{colors.panel}"
    textColor: "{colors.lit}"
    typography: "{typography.body-md}"
    rounded: "{rounded.none}"
    padding: "{spacing.xxl}"
  rail-cell:
    backgroundColor: "{colors.panel-raised}"
    textColor: "{colors.data}"
    typography: "{typography.number-xl}"
    rounded: "{rounded.none}"
    states:
      - default
      - hover
      - focus-visible
      - active
  shortlist-row:
    backgroundColor: "{colors.panel}"
    textColor: "{colors.lit}"
    typography: "{typography.body-md}"
    rounded: "{rounded.none}"
    height: 34px
    states:
      - default
      - hover
      - selected
      - focus-visible
  chip-meta:
    backgroundColor: "{colors.panel-raised}"
    textColor: "{colors.dim}"
    typography: "{typography.meta-md}"
    rounded: "{rounded.base}"
    padding: "1px 6px"
    states:
      - default
      - hover
      - disabled
  button-default:
    backgroundColor: transparent
    textColor: "{colors.dim}"
    typography: "{typography.body-sm}"
    rounded: "{rounded.base}"
    height: 34px
    states:
      - default
      - hover
      - focus-visible
      - disabled
  input-search:
    backgroundColor: "{colors.panel}"
    textColor: "{colors.lit}"
    typography: "{typography.body-md}"
    rounded: "{rounded.base}"
    height: 34px
    states:
      - default
      - hover
      - focus-visible
      - error

states:
  hover:
    components:
      - rail-cell
      - shortlist-row
      - chip-meta
      - button-default
    requirement: cockpit.css 有 19 条 hover 规则；antd 表格行由 tokens.ts 的 rowHoverBg 接管。
  focus-visible:
    components:
      - rail-cell
      - shortlist-row
      - button-default
      - input-search
    requirement: 一律 2px solid {colors.data} + outline-offset 2px；非文字对比 8.56:1，满足 3:1。
  active:
    components:
      - rail-cell
      - chip-meta
      - button-default
    requirement: 一档静态底色（沉到 bay、字提到 lit）。这一页 motion:false，按下不能靠动画。表行与 .have-jump 故意不进这一套。
  disabled:
    components:
      - chip-meta
      - button-default
    requirement: 12 条规则；文案侧由 test_web_copy 的 ADisabledControlPromisesNothing 盯住「禁用态不许承诺还没发生的事」。
  loading:
    components:
      - button-default
      - rail-cell
    requirement: 有意不用。写操作一律乐观更新 + 可回滚，spinner / 变灰恰恰是用户报过四次的那个「点了没反应」。
  empty:
    components:
      - panel-card
    requirement: EmptySectionsSayTheyAreEmpty 断言空区块必须自陈是空的。
  error:
    components:
      - input-search
    requirement: 用 {colors.lock} 表述越界与不满足；ReassuranceIsNotStyledAsWarning 禁止把安慰渲染成警告。

rules:
  - 零圆角。borderRadius 一路压到 2（tokens.ts），判定类界面不许出现药丸形；3px 属于漂移。
  - 等宽字体只给纯数字与拉丁文，且不加字距——0.17em 摊在数字上会把一个数拆成几个。中文进 mono 类由 test_css_and_cjk_text 拦。
  - 汉字不许小于 12px（FLOOR=12.0），由 test_cjk_never_goes_below_the_floor 拦静态字面量；数据驱动的文案它测不到。
  - 颜色只用 :root 的 12 个语义变量，不在应用代码里写 hex 或 rgba。语义名而非色名。
  - 要半透明底纹就从语义色派生（color-mix(in srgb, var(--data) 8%, transparent)），不另写字面量。
  - 压暗用的黑色 alpha（方章斜纹、浮层投影）不算配色，不受上一条约束；除此之外不许出现 rgba。
  - 覆盖 antd 组件与本层控件的定点样式，作用域必须从 body 起，不能挂 .cockpit：面板（Modal）与 Tooltip 是 portal 到 body 的，挂在 .cockpit 底下的一条都进不去。钉在 test_css_and_cjk_text 的作用域判据上。
  - 分层不靠投影。boxShadow 系列令牌全为 none，靠 {colors.edge} 线、间距和底色层级分开区块；「抬起」用 inset 描边画，不用外投影。
  - 浮层是唯一的例外：盖在页面之上的那层允许一条真实外投影，用来压住背景，不用于卡片。
  - inline style 只许用于**由数据算出来的值**（进度条宽度、判词颜色）；静态样式一律进 class。
  - 写盘按钮点下去，屏幕上必须立刻有东西变（乐观更新 + 失败回滚）；不许用 spinner 或变灰冒充反馈。
  - 关掉 antd 进场动画（motion: false），因为 prefers-reduced-motion 下 antd 会把元素停在 opacity:0 那一帧。
  - 关掉 antd 的中文按钮插空格（button.autoInsertSpace: false），否则「挂了」上屏变成「挂 了」。
  - 给用户看的措辞换成内地求职者自己的话；内部框架词与判定码不上屏，由 test_display_wording 扫。
  - 从文件正文流向界面的字段要先剥掉 markdown 标记（** 与反引号），显示层负责，不让星号上屏。
  - 每一处「下一步」都要写出可以照敲的命令，且只写一条。
---

# 仪表舱 · 总览页设计契约

## Overview

这一页是**本机跑的求职流水线总览**（`tools/serve.py` 端上 `web/dist`，端口 29029），
不是公开站点。它的气质是「舱内仪表盘」：深色底、硬边、方章、数字对齐可比。
使用者是一个人独用、盯着自己的数据做决定，所以密度优先于留白，
判定结果优先于装饰。核心动作是「盖章 / 判定」，四条取向（零圆角、等宽数字、
中文优先的字号下限、靠线不靠影）都从这里派生。

## Colors

`{colors.bay}` 是舱内底，`{colors.panel}` 是面板，`{colors.panel-raised}` 是抬起的面板
（hover / 选中）。`{colors.edge}` 只作分隔线，`{colors.edge-strong}` 标可交互边界。
文字三档 `{colors.lit}` / `{colors.dim}` / `{colors.faint}`，对最亮那层底
`{colors.panel-raised}` 实测 13.58 / 7.35 / 4.98:1。
语义色四个：`{colors.data}` 青＝读数与「满足」，`{colors.caution}` 琥珀＝待确认，
`{colors.lock}` 红＝不满足与越界表述，`{colors.faint}` 兼作「跳过」。
`{colors.data-deep}` 只用作青色边框的暗调；`{colors.lock-deep}` 是红色侧的对称位——
斜纹方章的印面底色，掺黑比例照 `data-deep` 那一档，不是新色相。

对比度一律按**最亮的那层底**算——在最不利的一层成立，在 bay / panel 上自然更高。
`tests/test_contrast.py` 把这些数当断言验，注释和色值对不上就红。

frontmatter 里另有一组 `antd-color*` 键，值全是上面语义色的锚点，**不是第十三种颜色**：
它们记的是 `tokens.ts` 把哪个语义色交给了 antd 的哪个角色，顺带让预览画布的对比度配对
有名字可匹配。写代码时只用 `var(--bay)` 那一组；`antd-` 前缀就是为了让它一眼不像 CSS 变量。

## Typography

正文走 `body-md`（15px / 1.7），次要说明走 `label-md`、`body-sm`、`meta-md`、`meta-sm`。

frontmatter 里 `fontFamily` 只写三个短名，不写字面量——它们是**指向正本的引用**，
正本各只有一份，抄进本文就会和代码飘：

```
sans   antd fontFamily 令牌 = tokens.ts 的 FONT_SANS
       "Noto Sans SC","PingFang SC","Microsoft YaHei",system-ui,sans-serif
mono   cockpit.css:22 --mono（tokens.ts 的 fontFamilyCode 是同族另一份字面量）
serif  cockpit.css:23 --serif，只给方章的印文用
```

`mono` / `serif` 有 CSS 变量可直接 `var(--mono)`；`sans` 没有（页面从 `body` 继承），
所以本文用 `sans` 这个名字指代它，不复制那串。

其中 `{typography.meta-md}` 12.5px 是**全页用量最大的档**（cockpit.css 出现 75 次），
`{typography.meta-sm}` 12px 是中文的地板。
`{typography.number-sm}` 11px 只许进纯数字与拉丁文（`.mono-label`、`.rail-step`）。
数字展示档 `number-md` … `display-score` 是给分数和流水线读数用的等宽大字，
不构成文字层级。

文字档现在落在 `cockpit.css` 的 `:root` 上：`--fs-micro` 12 / `--fs-meta` 12.5 /
`--fs-small` 13 / `--fs-label` 13.5 / `--fs-content` 14 / `--fs-body` 15，另有一枚
`--fs-stamp` 14 给方章印文（那一格见方里的单字不是文字层级，别混进标度）。
原来 20 个散取值里的 14.5 与 15.5 是纯漂移，各收进相邻一档（差 0.5px，无视觉台阶）。
**数字与拉丁的小字号 11 / 11.5px 与展示用大字（17 / 19 / 20 / 22 / 25 / 26 / 27 / 28 / 34 / 46）
仍写字面量**：前者是层级不是文字档，后者每个数都绑在它那块盒子里，
硬套一套「大字标度」是给不存在的需求让路。

## Layout

`.cockpit` 居中，`max-width: 1180px`，外边距 `{spacing.gutter}`（`clamp(16px, 2.5vw, 34px)`）。
## Spacing

组件内距从 `{spacing.xxs}` 到 `{spacing.xxl}` 取；`{spacing.section}` 用于区块之间。
**观察到的实际用量比这套标度细**：gap 出现过 1/3/5/7/9/11/13 这些奇数档。
本契约不主张把这些一次性往上取整——密集仪表盘里 1px 的差别是视觉事实，
重新对齐一遍的收益抵不上抖动。规则只立一条：新写的间距优先用上面已命名的档。

## Shapes

只有三档：`{rounded.none}`、`{rounded.xs}`、`{rounded.base}`。
出现第四个值就是漂移，不是「还有一档」——2026-09-30 那一轮 3px 就是照着这条清的。
方章的 `::before` 用 `{rounded.base}`，与 antd 侧的 `borderRadius: 2` 同一个数。无 pill。

## Elevation & Depth

没有外投影。`boxShadow` / `boxShadowSecondary` / `boxShadowTertiary` 全部 `none`，
`primaryShadow` / `defaultShadow` 也全部 `none`。层次靠三件事：底色层级
（bay → panel → panel-raised）、`{colors.edge}` 线、以及间距。
「抬起」用 `inset` box-shadow 描边画（见方章与选中行），不是投影。

**一处例外，也是有意的**：盖在整页之上的浮层留一条真实外投影——那层需要压住背景，
靠边线和间距分不出「浮在上面」。除浮层之外任何区块都不许加外投影。

## Components

实现层与本文的对应关系（详见 `web/src/components/`）：

- `GateStamp.tsx` → 四态方章 `stamp-pass` / `stamp-fail` / `stamp-unknown` / `stamp-na`，
  30px 见方、宋体单字。`data-size="sm"` 是 17px 无字版。
- `Shortlist.tsx` → `shortlist-row`（antd Table，行 `role="button"` + Enter/Space）。
- `JobReadout.tsx` → 行内详情，复用方章与 `InterviewLog`。
- `Cmd.tsx` → `chip-meta` 形态的命令复制片。
- `Portals.tsx` / `HidePrefs.tsx` / `HrAnswers.tsx` → 渠道行与全原生 button 的偏好面板。
- `App.tsx` 的 `nav.rail` → `rail-cell`（5 格流水线）；`nav.deskbar` + `Modal` → 7 个 desk。

每个族自带哪些状态，frontmatter 的 `components.*.states` 逐项列了，这里是判读口径：
可点的族（`rail-cell` / `shortlist-row` / `chip-meta` / `button-default` / `input-search`）
必须有 `default` / `hover` / `focus-visible` / `disabled` 四件，`input-search` 另加 `error`；
`:active` 是一套统一的静态底色（见 States），新控件要一起进那条选择器列表；
`loading` 这一族**不适用**——写操作走乐观更新，理由见下面 States。
方章 `stamp-*` 四态是**判定结果**不是交互态，所以它不吃 `hover` / `focus`，只吃 Tooltip 的
aria 关联。

## States

`hover`、`focus-visible`、`disabled` 三件成体系，`empty` 与 `error` 有专门的文案断言在盯。
**`:active` 是一档静态底色**：底色沉到 `{colors.bay}`、字提到 `{colors.lit}`，
统一挂在 `body` 上（面板里的按钮与主页面同源）。表行走整行展开、`.have-jump` 的底色是
筛选开/关的**状态色**，两者都不叠这一下。
`loading` **不算缺口，是不该有**——`serve.py` 写完盘要在响应前同步跑导出子进程
（实测 3.4-4.2 秒），那几秒用乐观更新吸收；`tests/test_every_write_button_answers_immediately.py`
明写「把控件转起来或变灰」不算反馈，那正是用户报过四次「点了没反应」的东西。
所以这一页不引 spinner，写操作一律**点下去立刻变 + 失败回滚**。
键盘可达性上 `tabIndex` 全仓只有 1 处（表格行），其余交互件都是原生 `<button>`，
浮层有 Esc 与外点出口。

## Accessibility

文字：三档灰阶在最亮底上 ≥4.98:1，全部达 AA。
非文字：焦点环取 `{colors.data}`，对 `panel` 8.56:1，满足 1.4.11 的 3:1；
antd 侧由 `colorPrimaryBorder` 显式钉成同一个色。
**已知不达标一处**：`{colors.edge-strong}` 对 `panel` 1.75:1，当它是「这里可以点」的
唯一线索时不够（WCAG 1.4.11 要 3:1）；靠它划**分隔**的地方不受这条约束，那是装饰。
方章那一处已经修掉了：`stamp-fail` 原来是白字压 `{colors.lock}`，纯色底只有 3.32:1，
而 14px 不算大号字、AA 要 4.5；现在印面走 `{colors.lock-deep}`、字走 `{colors.lit}`，
实测 7.40:1。斜纹（26% 黑）只会让暗底更暗，所以对亮字是**只帮不害**——
这一族的配色前提是「暗底亮字」，反过来用就把斜纹的余量吃干了。
触屏目标按**渲染后的盒子**量，不按 CSS 里写的数猜：2026-09-30 首屏加七个面板全量扫完，
低于 24px 的只剩两类——antd 的复制图标（13×15，全页 141 颗）和四个文字型小控件
（`.nextstep-more` 21、`.have-jump` 23、`.portal-refresh` 21、`.portal-sw` 22）。
复制图标那批不是尺寸不够，是**作用域进不了面板**（见 Rules）；两者都按同一个办法修：
内边距撑开命中区、等量负边距抵掉，版面一个像素不动。现在复测：图标 27×29，四颗全 ≥24。
`controlHeight: 34` 本身已过 2.5.8，不为 AAA 的 44px 整体拉高。
中文地板同理——静态扫不到「从数据来的文案」，实测只有漏斗图例一处 11px，已抬到 12.5。

## Motion

`motion: false`。原因写在 `tokens.ts`：antd 的 `ant-zoom-appear` / `ant-fade-appear`
把 `opacity: 0` 放在动画起始帧，而本页在 `prefers-reduced-motion` 下关掉动画，
元素会永远透明——所以从源头关，而不是缩短时长。这一页本来没有需要动画传达的信息。

## Do's and Don'ts

Do：只用 12 个语义色变量；新间距优先取已命名档；焦点环统一 `{colors.data}`；
中文标签走 `.kicker`，等宽只给数字；给用户看的下一步必须是一条可照敲的命令。

Don't：不要在应用代码里写 hex 或 `rgba()`；不要写 `border-radius: 3px`；
不要用 `var(--不存在的变量)`（不会报错，只会让整条声明静默失效，
`CommandBook.tsx` 的 `--text-secondary` / `--text-tertiary` 就是这么活下来的）；
不要把 markdown 的 `**` 和反引号送上屏；不要用红色表述「安慰」类文案。

## Responsive

四个宽度断点：900 / 720 / 620 / 560（`max-width`），集中在 cockpit.css 的一节里，
由 `BreakpointsStayTogether` 盯住不许散落。媒体查询选择器必须比被覆盖规则多一层特异性
（`test_media_queries_actually_win`）。
**已知不足**：没有 ≥1180 的宽屏档，`.cockpit` 在宽屏上只是两侧留白变宽。
窄屏的塌缩规则原来有 9 条挂在 `.cockpit` 下、进不了面板（含 `.portal-row`、`.cmdbook-row`
那批），2026-09-30 改到 `body` 作用域后 390px 实测：`.portal-row` 两列、`.cmdbook-row` 一列。
同一次实测还量到面板内容 2px 的假溢出（313>311）——与 cockpit.css 里那条
「0.01px 溢出长出两条滚动条」同根源，缩放小数取整造成，没有长出滚动条。

## Known Gaps

以下按 `needs-design-decision` 登记，不在文档里悄悄定策：

- needs-design-decision: `loading` 反馈用 spinner、骨架，还是沿用「只改按钮文案」这一家做法。
- needs-design-decision: `edge-strong` 作为可控件边界不达标时，是加边框宽度还是抬亮。
- needs-design-decision: 宽屏（≥1180）要不要用多列而非留白。
