# 设计推导 从 `nerv-hud` 那份 HUD 原型,到 DSH Web GUI 里的一套令牌——这篇文档记录每一步 「保留什么、放弃什么、为什么」。 ## 起点:原型的语言要素 原型里成立的要素,按可移植性排序: | 要素 | 能否移植到 GUI | 处理 | | --- | --- | --- | | 配色(黑底 + 橙 + 荧光绿 + 警戒红 + 暖白) | ✅ 完整 | 107 个令牌全覆盖 | | 发丝线边框(1px 橙) | ✅ 完整 | `--dsw-alias-border-l1..l4` | | 硬边(无圆角) | ✅ 完整 | `--dsw-radius-*` 归零 | | CRT 质感(扫描线 / 暗角 / 噪点) | ⚠️ 需谨慎 | 保留扫描线与暗角,**放弃噪点**(在全屏 UI 上等于持续闪烁) | | 等宽数据 + 字号层级断层 | ✅ 大部分 | 全局 `tabular-nums`;字号层级不能动(那是组件的排版) | | 明朝体汉字标题 | ⚠️ 仅大字 | 只作用于 `h1~h3` | | 大写字距加宽的小标签 | ❌ 放弃 | GUI 的标签类名是 CSS Modules 哈希(`Re_GOa_cubeRow`),按类名选中等于绑死在构建产物上 | | 斜切角按钮 / 四角 L 标记 / 危险条纹 | ❌ 放弃 | 同上,且属于组件内部结构,不属于令牌层 | | 打字机 / 闪烁 / 撕裂动画 | ❌ 放弃 | 在一个日常工具里会变成噪音;原型的这类动效是「演示品」不是「工作界面」 | **结论**:能在 GUI 里长期成立的是**令牌层 + 通用元素层 + 覆盖层**。凡是需要按类名 命中具体组件的东西都会随 DSH 每次构建失效,所以整体放弃——这不是妥协,是让皮肤能 活过大版本升级的唯一办法。 ## 三条原则 ### 1. 令牌优先于选择器 `ctx.theme` 组合出的快照由 ui-layout 逐条写成 `` 的 inline style: ```js body.style.setProperty(name, value) ``` inline style 压过样式表,所以覆盖 `--dsw-alias-*` 必须走 `ctx.theme.overrideTokens(source, tokens)`,让覆盖进入快照本身。 **这是本插件唯一正确的配色通道**,也是 README 里反复强调的一点的由来。 ### 2. 只用语义别名,不碰静态调色板 DSH 的令牌分两层:`--dsw-static-*`(原始色阶,如 `neutral-bluish-950`)与 `--dsw-alias-*`(语义别名,如 `bg-base`)。组件只消费别名层。改静态层会把语义无关的 地方一起改掉(同一个色阶可能同时喂给成功态和某个装饰),属于过度侵入。 ### 3. 装饰层与令牌层分离 令牌表达不了的东西(扫描线、网格、暗角、硬边、明朝体)走插件自有样式表,全部挂在 `html[data-eva-*]` 属性上:选项关掉即失效,插件卸载即整表移除。 两层的生命周期都挂在 `ctx.effect` 上,所以 HMR 重载不会留下半套皮肤。 ## 配色映射 原型变量 → GUI 令牌(只列有代表性的,全表见 [`TOKENS.md`](TOKENS.md)): | 原型 | GUI 令牌(深色值) | 说明 | | --- | --- | --- | | `--bg #08090a` | `bg-base` | 不用纯黑,留 CRT 余晖感 | | `--bg-2 #0d0f11` | `bg-layer-1` | 表面靠发丝线分层,不靠阴影 | | `--line rgba(255,122,26,.34)` | `border-l3` | 发丝线的中间档 | | `--accent #ff6a00` | `brand-primary` / `state-business-primary` / `button-primary-fill` | 主色同时是「业务色」,因此焦点环也自动变橙 | | `--data #7cff4f` | `state-success-primary` | 数据绿 | | `--alarm #ff1e1e` | `state-error-primary` | 警戒红 | | `--warn #ffcc00` | `state-warn-primary` | 注意黄 | | `--ink #e8e4dc` | `label-primary` | 暖白,刻意不用纯白 | | `--ink-dim` | `label-secondary` / `label-tertiary` | 四级文本阶梯 | 一处刻意的偏离:`label-primary-bluish` 官方是 blue-900 / neutral-bluish-50, 这里收成暖中性。它的语义是「和 `label-primary` 有细微差别的那个 primary」, 在暖色调皮肤里保留蓝味会显脏。 ## 配色体系:四个角色(v0.13 起) 上面的「配色映射」讲的是**令牌层**(原型色 → GUI 令牌)。从 v0.13 起,输入框 / 侧边栏 / 弹出菜单按 EVA 参考图改成了**黑底贴片**,用的是**字面量**而不是令牌, 于是另有一层规矩:**颜色按角色分配,不按位置随手取**。 | 角色 | 颜色 | 值 | 用在哪 | | --- | --- | --- | --- | | **面板 / 结构** | 亮黄 | `#ffe600` | 输入框硬边、权限块、侧栏分组标题与品牌名、新会话按钮 | | **数据** | 主题橙 | `var(--dsw-alias-brand-primary)` | 仪表读数、TPS 波形、余额 | | **正常 / 盈余** | 绿 | `var(--dsw-alias-state-success-primary)` | 峰谷标签栏、模型名(暗色下即 `#7cff4f`) | | **危险 / 否定** | 红 | `#ff2d2d`(贴片内)/ `state-error-primary`(印章) | 危险条纹、推理等级、否定印章 | | **警示胶带** | 琥珀 | `var(--dsw-alias-state-warn-primary)` | 屏幕底缘与侧栏右缘那条 5px 斜纹带 | 三条配套规矩: 1. **结构与数据必须分色**。参考图(MAGI 系统界面 / 内部·外部电源面板)本身就是这个分法: 青绿走结构、橙色走数据、红色走否定、红色斜纹走危险。我们用**黄替青绿** (黑底上黄更亮、更贴「内部/外部」那张图),其余照搬。 2. **黑底贴片用字面量,不用令牌**。`#ffe600` / `#ff2d2d` 在深浅两套主题下都可读; 而浅色主题的 `state-warn` 是**暗琥珀**,压在黑底上根本读不清。 代价是不跟随主题 —— 这些贴片本来就当「仪表」而不是「主题表面」。 3. **这些贴片必须限定深色主题**(见「字面量黑底面板必须限定在深色主题」一节)。 浅色主题下 app 的文字是近黑(`label-primary` 浅色 `#16181c`), 压在近黑面板上对比度约 1.05:1,等于看不见。 ### 壁纸用「设定色」,不走界面配色 壁纸不是界面配色,而是**设定素材**,所以各用它在作品里的代表色 —— 固定字面量、不跟随主题(设定色本来与明暗无关): | 壁纸 | 设定出处 | 颜色 | | --- | --- | --- | | **AT 力场** | 使徒展开的屏障:琥珀橙的六角光壁 | `#ff9500` | | **生命之树** | 中央教条区地面刻印 / OP 里那棵树 | `#ffc400` | | **太阳系** | **非设定素材**(自己加的),没有「设定色」可取 | 回落主题橙 | 三个壁纸都不透明度 8% —— 只做极淡的底纹,不参与正文可读性。 ## 形状:为什么要做硬边 `nerv-hud` 里没有一个圆角。GUI 的默认圆角尺度是 `xs 4 / sm 8 / md 12 / lg 16 / xl 20 / panel 28`,全压到 0 之后: - 好处:界面立刻从「现代消费级软件」变成「仪表盘」,这是整套语言里最省力的一刀; - 代价:个别组件(胶囊形输入框、圆形头像的容器)会显得生硬。 因此它被做成默认开启、可以一键关掉的开关,而不是写死。 顺带一个实现事实:**圆角令牌不在主题快照里**(快照只含 107 个 `--dsw-alias-*`), 所以它可以用普通 CSS 覆盖;而颜色令牌不行。这条差异决定了两个层的分工。 ## 排版:明朝体只上大字号 原型里把整页汉字都改成了明朝体,结果是小字号(9~11px)在暗背景上几乎看不见—— 明朝体的横画太细,暗底会把它吃掉。当时靠「字号抬 1~2px + W6 字重 + 0.3px 描边」 补救,但那是给全屏仪表盘用的手段,在正文里会把文字糊成一团。 GUI 里的处理: - 只在 `h1~h3` 上用明朝体(标题本来就是大字); - 靠 `font-family` 的**逐字符回退**把拉丁与汉字分开:列表前几项是系统无衬线, 汉字不在其中,于是继续往下落到 `Hiragino Mincho ProN`,而拉丁在 `-apple-system` 就被接住了; - 补 0.2px 描边(`-webkit-text-stroke`)让细横画在暗底不至于消失。 ## 浅色主题:为什么必须做 令牌的值是 `{ light, dark }` 成对的——ui-layout 按当前色板取值。如果只给深色一套, 用户切到浅色主题就会得到「黑底 + 深色文字」或者「白底 + 荧光绿」这种混合体。 所以浅色不是可选项,而是**同一套语言的另一份材质**:EVA 的技术资料纸质版。 暖白图纸 `#f2efe8`、同一个橙但压深到 `#c94f08`(浅底上要够对比度)、 边框改成低透明度的深橙、状态色换成各自的暗档。 扫描线与暗角在浅色下会自动减弱(扫描线不透明度乘 0.45,暗角 0.7 → 0.22): 这两样在纸面上没有物理依据,留着只是为了材质统一。 ## 覆盖层:为什么盖在最上面 扫描线层的 `z-index` 取最大值,连弹窗和提示一起盖住。理由是:CRT 的扫描线是**显示设备** 的属性,不是页面里某个面板的属性——如果弹窗浮在扫描线之上,观感立刻穿帮。 代价是它必须 `pointer-events:none`,且浓度要做得很低(默认 0.5,实际 3px 周期里 只有 1px 是 30% 的黑)。任何更高的浓度都会开始影响长文阅读——这也是它可关的原因。 ## 0.2.0:为什么把装饰全塞进右下角 0.1.0 只有令牌与质感,被评价为「EVA 元素太少」。补装饰时踩到的第一个坑不是样式, 而是**位置**。 第一版把一个橙底反白标签放在右上角、一个幽灵标签放在左下角。装进真实界面截图一看 ——右上那个正好压在应用的**侧栏折叠按钮**上,左下那个正好压在**「设置」**上。 两个控件都被挡住(`pointer-events:none` 让点击仍然有效,但用户看不见它)。 教训是:**在一个不是自己设计的界面里加装饰,第一约束不是「好不好看」,而是 「有没有盖住别人的控件」。** 所以现在的规则是: 1. **所有装饰集中到一个角**(右下),纵向用 flex 排列,互不重叠,不靠手算高度; 2. 选这个角是因为它是本界面里唯一稳定空着的区域:正文列居中且有最大宽度、 左栏固定、右栏可选; 3. 四角规格编号也刻意从顶角下移到 `top:52px`——顶栏两端分别是应用自己的徽标与折叠按钮。 ## 0.2.0:元素装饰怎么挂钩子 应用没有为第三方皮肤预留类名,而 CSS Modules 的类名(`WYye1W_newSession`)每次构建 都会变,拿它当选择器等于把皮肤绑死在某个构建产物上。 真正的钩子是应用自己的 `data-*` 语义属性——它显然也拿这些当样式锚点: ``` data-conversation-header / -title-row / -region / -content / -scroll data-composer-card / -input / -placeholder data-sidebar-right-panel / data-rightbar-col / data-dockkit-surface / data-slot ``` 这些名字描述的是**结构角色**而不是外观,所以比类名稳定得多。 第二个约束是**只碰不会引起副作用的属性**: | 用 | 不用 | 原因 | | --- | --- | --- | | `box-shadow` | `border-width` | 改边框宽度会挤动布局;inset 阴影画分隔线不占位 | | `outline` / `border-color` | `background-image` | 应用的底纹可能是语义的一部分,覆盖会丢信息 | | `letter-spacing` | `::before` / `::after` | 应用自己的伪元素可能正承载角标或图标 | | 走查确认过的 `position:relative` | `clip-path` | 斜切角很好看,但会裁掉应用自己的溢出内容 | 代价是放弃了一批最「EVA」的形状语言(斜切角按钮、切角面板、L 型角标贴在面板上)。 补偿办法是把它们放进**完全由自己控制的覆盖层**:框架、角标、刻度尺、危险条纹、 标签块全是插件自建的 DOM,想怎么斜切都行,而且永远不会碰到应用的结构。 ## 教训:覆盖层里的 CSS 变量,层外元素取不到 本项目的颜色变量(`--eva-accent` / `--eva-hair` / `--eva-solar` …)都定义在 `.eva-magi-layer` 上,**只对层内的装饰生效**。 一旦要装饰**应用自己的元素**(它们不在这层里),`var(--eva-accent)` 会解析失败, 而且失败方式很隐蔽: - `color: var(--eva-accent)` → 整条声明被丢弃,颜色静默不变; - `box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--eva-accent) 55%, transparent)` → `color-mix()` 拿到非法颜色,**整条 box-shadow 被丢弃**(实测读到 `"none"`)。 **规则:装饰层外元素时,一律用主题令牌 `--dsw-alias-*`**(主题服务把它们内联在 `` 上,层内层外都能用)。 这条已经踩过两次:① 开机序列的 NERV 标志(挂在 `body` 上);② 模型/推理等级 选择器(在输入框里)。测试台里加了两条守卫断言:选择器样式块**不得出现 `var(--eva-*)`**,并且**不得触碰 width/height/padding/font-size/display**。 ## 教训:字面量黑底面板必须限定在深色主题 插件原有皮肤用**主题令牌**(`--dsw-alias-*`)上色,浅/深两套自动跟随,没问题。 但从 0.13.0 起,输入框 / 侧边栏 / 弹出菜单改用了**字面量**黑底 + 亮黄(复刻 EVA「内部/外部」面板,黑底贴片不跟随主题 —— 这是有意的)。 代价在浅色主题下暴露: ``` label-primary 浅色 #16181c(近黑) 面板底色 color-mix(#000 88%) ≈ 近黑 → 对比度 ≈ 1.05:1,文字等于看不见 ``` **修法**:把这些规则全部限定在 `body[data-ds-dark-theme]` 下 —— 这**正是 app 自己切换明暗用的属性**(app 的令牌块与菜单样式都挂在该属性下), 所以作用域与官方机制完全一致。深色标记不在时,这些样式整体退场, 回落到 app 原生外观(浅色下是近白底 + 近黑字,可读)。 实测:移除标记后输入框底色 `rgb(255,255,255)`、侧栏 `rgb(249,250,251)`、 文字回落为 app 原生命中色。 **两条检查**已进测试台:① 所有「黑底贴片」规则都必须带该作用域(缺一即失败); ② 测试台自身也要给 `body` 加上这个属性 —— 否则深色作用域的规则在测试台里永不生效, 查计算样式的断言会**失真地通过**。 ### 顺带一条改 CSS 的教训 批量给选择器加前缀时,我连续踩了两个坑,都是把**注释当成选择器**: ① 前缀插到了注释前面(漏掉真选择器、还生成非法 CSS); ② 按逗号切分选择器列表时把注释里的逗号也切了(注释里有 `:is([role=dialog],[role=menu],…)` 和 `0,2,1`),注释被切碎、每片都被加了前缀。 正确做法:**切分时跟踪注释状态**,并断言「注释与改动前逐字一致」「改动只发生在选择器行」 「无遗漏」「前缀不落在注释前」四项校验全部通过才算改完。 ## 禁忌(踩过两次):注释里不能出现反引号 `lib/client.js` 的整段 CSS 是 **JS 模板字符串**(`const SKIN_CSS = \`...\``)。 在 CSS 注释里写一个反引号,就会**提前结束这个字符串**,整份客户端脚本语法错误 —— 而且是静默的:实机表现是插件完全不加载(探测输出全是 `undefined`)。 `node --check lib/client.js` 必定能抓到(每次改完都必须跑)。 顺带一条同类禁忌:CSS 是模板字符串,所以注释里也不要写 `${`(会被当插值解析)。 ## 技法:用「类名可读后缀」匹配没有 data-slot 的元素 DSH 的类名是 CSS-module 的 `Hash_name` 形式(`tPVXea_sectionLabel`、`WYye1W_panelTitle`…)。 对**没有 data-slot 可用**的元素,写死哈希很脆(换个版本就失效),但可以匹配可读后缀: ```css [data-slot="sidebar"] [class*="_sectionLabel"]{color:#ffe600} ``` 哈希前缀变了照旧生效,改动只在**组件改名**时才失效。作用域锚在最近的有钩子的祖先上, 把误伤范围压到最小。测试台有一条断言禁止在类选择器里写死哈希前缀(`.xxxxxx_name` 形态)。 **另一个必须记住的事实**:`data-slot` 元素大多带 `display:contents`,而且**只是 portal 锚点、 不是容器** —— 例如插件标题的真实祖先链是 `nav.WYye1W_panelList → div.WYye1W_root`, **不在 `data-slot="sidebar.panellist"` 底下**。所以「从 slot 往下选」只在部分情况下成立, 写之前必须实测链路。 ## 验证技法:像素级取证(把截图塞回浏览器) 判断「屏幕上到底有没有某个颜色的东西」时,**不要靠肉眼看缩放后的截图** —— 缩略图要经过缩放与 JPEG 重编码,高对比边缘会产生彩色压缩伪影, 我据此误报过一次不存在的缺陷(见下)。 可靠做法:把截图**塞回浏览器用 canvas 解码**,逐像素判定。这条链路不需要任何 图像库,而且拿到坐标后还能用 `elementFromPoint` 把像素映射回 DOM: ```js const shot = await send("Page.captureScreenshot", { format: "png" }); // 在页面里: const img = new Image(); img.src = "data:image/png;base64," + shot.data; await new Promise((res) => (img.onload = res)); const cv = document.createElement("canvas"); cv.width = img.width; cv.height = img.height; cv.getContext("2d").drawImage(img, 0, 0); const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data; // 逐像素判定,例如「蓝色系」:b > 60 && b > r + 25 && b > g + 20 ``` 配合 `Page.captureScreenshot` 的 `clip` 参数,或直接把 canvas 裁切后 `toDataURL()` 写回磁盘,就能得到**未经二次压缩**的局部原图 —— 放大看也不失真。 ### 教训:一条不存在的「蓝线」 我曾报告「输入框左下有一条细蓝线」(列入 P8),并在坐标范围内扫描元素 —— 结果**一个蓝色元素都没有**。最后用上面的方法逐像素判定: - 全屏 1600×913:蓝色像素 **0**; - 四种交互状态(初始 / 聚焦 / 输入后 / 全选):**全部 0**; - 回头扫那张旧截图:蓝色像素**只有 81 个**,且全部集中在 **右上角 x 1480–1490 / y 20–30**(`rgb(10,115,145)`,是 app 工具栏里的图标), 与输入框无关; - 把旧截图的输入框区域裁出来(未经二次压缩):蓝色像素 **0**, 那个位置其实是**红橙色的斜向警戒装饰**。 结论:**这条蓝线是我自己看缩放 JPEG 时的误判**,不是缺陷。P8 关闭。