# 设计推导 从 `nerv-hud` 那份 HUD 原型,到 DSH Web GUI 里的一套令牌——这篇文档记录每一步 「保留什么、放弃什么、为什么」。 ## 0.51.2:当前实现 - 官方 DeepSeek Harness 的 dsh-app 协议转发移除 Content-Length,原始 MP3 没有 Xing 总帧数头,duration 可能在前半段为 NaN/Infinity。构建时严格校验两段 MPEG-1 Layer III 启动语音并累计帧采样数,将文件准确时长同时写入 manifest 与客户端索引;运行时仅在原生总时长无效时回退,继续以 currentTime 驱动读数、进度条与同步环,并监听 durationchange 立即校准。不靠墙钟补进度,不增加运行时请求、完整解码或额外音源;只有 ended 推进舱门交接。回归覆盖前半段、迟到元数据和两段真实 MP3,且在官方 0.2.1-rc.2 原生界面复测。 - 全屏装饰与启动层通过共享挂载函数优先放在宿主 #root 内;无 root 的独立页面回退 body。macOS 宿主对 body 直属非 root 元素声明 no-drag,pointer-events:none 不能防止全屏矩形减去原生拖动区域。启动背景显式 drag,交互控件和整个头像至插入栓网格显式 no-drag。模态隔离只遍历祖先链的兄弟节点,不使启动层祖先 inert;退出恢复原有状态。提示音只监听 body 与当前 root 的直接子节点,root 替换时重新绑定,不为动画/文字更新增加整树监听。详见 [macOS 窗口拖动](macos-window-drag.md)。 - 选择前增加约三秒黑底引导:背景复用 GIF 的 #000101,标题按当前启动语言逐字写入,完整文本独立提供可访问名称;淡化的本地头像蒙版与角色主色编号作为点缀。结束后测量源标题和选择页标题一次,使用 Web Animations 的变换/颜色插值完成归位,背景柔和淡出;临时标题与定时器在完成、跳过、后台、减少动态效果、窗口变化和卸载时回收。选择页通过单次 Web Animations 淡入,移除原 CSS 两段阶段动画,避免清理 data-arriving 后重播淡入;结束或取消时取消该动画,底层 opacity 保持 1。交接中保留模态隔离,完成后再聚焦第一张角色卡。 - 插入栓的装配容器始终倾斜 14°;SVG 保留筒体、端盖和固定光照,前后表面以有界 CSS 变换模拟长轴旋转,避免整张平面翻转。确认装载时读取当前表面变换一次,在 400ms 内归正;无逐帧 JS 或额外图像。减少动态效果下静止,后台随启动层暂停。 - 标题首字触发随包 1.05 秒打字片段,独立音源不调用 BGM 降音;播放结束、跳过、后台与退出立即回收。 - 正常同步完成后保留 1.6 秒舱门交接,恢复宿主交互后播放所选驾驶员欢迎 MP3,BGM 此时已释放。欢迎音使用独立音源,不阻塞输入;播放受阻直接跳过,结束、异常、后台、十秒超时、重播和卸载均释放。提醒队列等待欢迎语音结束,避免与欢迎音重叠。取消和跳过不触发欢迎。烟雾试验代码已删除。 - 资源按类型集中到 assets/,manifest.json 记录名称、类型、摘要与大小。宿主注册有限静态路径,支持 GET/HEAD、ETag 与单段 Range;代码包不再携带大媒体 data URI。版本化资源可缓存一年,无版本地址重新验证,关闭插件时路由随作用域回收。 - 等高线高度场和曲线生成移至同源 Worker,每次最多一项计算在途,保持原 24 层路径、20fps 上限与初始静态图。不可用时使用相同几何函数回退;后台与减少动态效果停止调度,卸载终止线程。 - GIF/警报阶段预解码两套头像与半身 PNG;按下头像时准备启动音源,放手复用已加载音源。拖动缓存头像尺寸,位置更新不再反复读取布局;预览文本和进度时码只在内容改变时写入。性能记录见 [performance.md](performance.md)。 - 左侧角色改为参考图的侧身回望半身构图,截到腰部、露出侧脸:明日香长双马尾/绫波丽短发,使用透明 PNG 的 alpha 蒙版按角色主色绘制,右缘羽化。 - 与几何背景共用开关与强度;立绘以会话区域半宽为目标,保持 2:3 比例并受头部至输入框之间的可用高度限制,相对左侧锚点右移自身六分之一宽,底部紧贴输入框;CSS 补偿原图下方透明留白,使腰部轮廓实际贴边,窄屏减弱、右栏全屏收起,不更改正文排版或组件倍率。 - 两张线稿由内置 image_gen 生成并随包提供,保留最终透明 PNG 与完整提示词,无需外部图片请求。 - 插入栓按用户概念图重绘为白色长筒,橙金端盖与金色锁环保留材质色,角色色环/观察舱指示随预览切换。随包原生 SVG 与驾驶员蒙版在同一个倾斜 14° 的装配容器中,使用装载区容器宽高计算等比尺寸,宽度受限时仍保持 400∶800 坐标,避免头像脱离舱口;拖放继续由整个左侧装载区命中。标题显式使用完整中文字库,全局 h1 明朝字体和字距规则排除该标题,防止「驾驶」等字落入不同字库。 - 启动先在同色纯底上播放两倍尺寸原 GIF(512×512,原始 48 帧保留,帧延迟由 80ms 改为 40ms,1.92 秒循环两轮、开场仍为 3.84 秒),接约 3 秒黑底红色警报,经黑底打字与标题归位,再进入铺满应用视口的认证舱:左插入栓、右侧上下两张闭眼驾驶员头像。支持指针拖放、点击与键盘选择,通过官方 theme.setTheme 保存明日香暗色/绫波丽亮色,并覆盖全部 UI。用户提供的两段 MP3 保留原字节随包提供,进度条、同步环和数字只依据音频实际播放位置与时长。 - 警报用随包 SVG 重绘用户参考图的六边形 EMERGENCY、NERV 双行标语与时钟;总体明暗渐变和六边形错峰呼吸叠加颗粒、扫描纹理。横屏铺满裁切、竖屏完整保留中心海报;结束后衔接黑底打字,再由标题归位过渡至选择界面,音乐元素和播放位置保持连续。可跳过、Escape 退出,切后台或系统减少动态效果时取消警报并保留静态选择。警报计时与时钟间隔在衔接、退出、重播和卸载时取消。 - 认证舱背景使用橙色地形等高线:随时间移动高斯山峰与谷地并叠加低幅度波纹,Marching Squares 提取同一高度场的 24 层截线,再用二次曲线平滑;路径每秒最多更新 20 次,SVG 随视口等比裁切。后台和减少动态效果时取消动画帧并保留静态轮廓,返回后连续恢复;舱门交接与卸载取消监听和帧。机体编号、NERV 叶徽、刻度、互锁与 L.C.L. 铭文、警戒条纹和 MAGI 三机标识继续按角色换色;语音阶段的背景头像复用闭眼头像蒙版,无新增图片或网络请求。短窗口压缩信息层级,语音异常时继续/重试按钮保持可用。 - ended 后执行 1.6 秒双侧舱门开启,真实角色背景与聊天布局不移动,末段细边框引导至输入框;移除圆形黑幕和立绘缩放飞行。舱门期间保留模态隔离,结束后才恢复宿主交互和输入焦点;自己的启动弹层不隐藏其下方皮肤背景,其他宿主模态弹层仍暂时隐藏皮肤组件。 - 拖放由文档层接续 pointermove/pointerup,指针捕获中途丢失也可在插入栓内完成装载;放手按实际坐标命中,窗口失焦和取消则清理。宿主编辑器在 inert 解除后可能延迟一帧可聚焦,交接重试可由重播或卸载取消。 - 启动背景音乐使用网易云音乐客户端下载的《EM20#070720》原版 MP3,28% 循环铺底,角色语音期间降至 7.5%,舱门阶段淡出,聊天开始时 pause/移除 src/load 释放。背景与语音使用独立音频元素,背景结束不推进状态机,语音进度不受音乐时长影响;独立音量渐变在后台、跳过、重播和卸载时取消。自动播放受阻由首次主动操作重试,喇叭按钮可独立静音。 - 启动角色配色从 PALETTE 构建共用的 BOOT_THEMES,中文/英文名字、编号、头像与同步页读取对应角色主色,卡片和同步页使用对应深浅表面及文本阶梯;移除独立粉红/浅蓝与白色角色名字。GIF 运行时副本只调整控制块延迟与循环元数据,原文件、调色板、透明/处置标记与压缩帧逐字节保留;总时长继续使用原 GIF 一轮的 durationMs 驱动状态机。 - 选择应答使用用户的两段短 MP3,pointerdown 即播,移动不重复;拖放确认沿用该手势应答,点击和键盘则在确认时补齐。独立音频元素串行替换来源,并以代际标记忽略过期 play 拒绝。快速装载等待应答 ended 后再启动原长语音,期间进度保持零并显示本地化应答状态,BGM 降音;8 秒超时或错误降级至原启动。后台与取消回收短语音,后台期间等待显式继续长语音;重播/卸载回收事件、计时和音源。 - 驾驶员预览统一由 previewPilot 更新,底色、文字、SVG 填充/线条及边框使用 0.65 秒颜色过渡;独立的 0.7 秒接続扫光不拦截输入,切换时替换旧层。同步面板以 0.5 秒淡入,颜色过渡不影响真实语音时钟或拖放坐标;后台、退出、重播、减少动态效果时清理扫光,舱门阶段保持原有透明背景交接。 - 启动界面独立提供中文/日本語/English,通过节点文本和 ARIA 属性就地切换标题、角色名、状态、重试与音量提示,语言按钮暴露 aria-pressed;根节点 lang 跟随偏好。偏好单独持久化,默认中文;不重建头像/音频/进度,不修改宿主语言或替换日语语音。 - 角色提示音通过可选 sessions/uiSession 子作用域注入,不改变主依赖契约。人工审核订阅 sessionStatus 中 answerable 的 approval,使用会话+key 去重并在解除后取消;任务结束借用已载入主会话 binding.eventSource,只读取 append 中新增的 turn/end 且 reason.kind=completed,seq 水位去重。replace/prepend、失败、取消和子智能体不提示,不创建或保活会话,不触碰审核决策。 - 四份用户 MP3 原字节随包提供;单音源按当前主题在播放时选择角色,串行队列最多八项、有效期一分钟,等待中的审核优先于完成提醒。启动层或欢迎音存在时暂缓,重播暂停当前提醒;处理后或代际替换丢弃失效项。自动播放拒绝等待下一次手势重试,其他异常与十五秒超时跳过;静音与卸载取消队列、音源、事件、计时及服务订阅。面板提供持久化的统一开关和两种试听。 - 原始 SVG 作为 CSS mask 装饰时钟标题和播放器中心,不占用中央教条区的角色识别区域。 - GIF/PNG/MP3 与 NERV 标志通过包内同源静态路由提供;内容摘要决定缓存版本,音频支持 Range,紧凑警报和插入栓 SVG 保留内联以支持动态填充。GIF load 后计时,选择阶段用模态语义、焦点约束与可回收 inert 隔离宿主操作;播放异常可重试、可跳过,后台暂停语音,返回后显式继续。重播与卸载清理图片、音频、动画、拖动节点与监听器。减少动态效果时跳过 GIF、警报、打字与过渡,保留静态选择及语音。 - 暗色载入明日香:从用户角色图的战斗服红、黑色部位、领口绿与橙金发色构建深色 UI,主色提亮以保证可读;亮色载入绫波丽:瓷白、冰蓝和少量红色。 - 全部 107 个官方别名令牌同时覆盖两种模式。body 上定义装饰色桥,保证侧栏、输入栏、portal 菜单、设置、时钟、播放器和开机画面同步换色。 - 输入卡片两侧的外层铺底渐变与区域阴影已清除;会话列表底部淡出层透明,避免角色背景和插件市场上方出现矩形色块。 - 新会话保留按钮最外侧大框,作用域只匹配按钮,内部标签容器不描框;推理等级仅显示文字。 - 权限与模型选择栏共用常态及所有交互配色,保留宿主尺寸和选择交互;取消只在暗色生效的黑黄字面量规则。 - 中央教条区只显示当前角色的载入状态与名称,移除原时刻、视口、字号、色板读数和方块流。角色切换只更新文本,不重建组件或修改用户设置。 - 背景使用明日香的双斜切装甲 02 和绫波丽的同心环 00;增加分层轮廓、机体标注、分段刻度与角色色定位点,直接使用 SVG/CSS;中央留出会话阅读区域,移除高能预警与 MAGI 三贤人闪烁背景。 - 任务完成与用时栏使用稳定的 data-turn-process 属性绘制底部警戒带,不依赖 hr 或构建哈希;测绘网格与顶边压力线已移除。 - 计费标签和倒计时采用低谷绿色、高峰红色;周末与假日同属低谷。中性标签底色承托文字,两种主题均核对文字对比度。 - 主题令牌继续使用官方 overrideTokens,装饰与交互节点区分可访问性。 - HOST_HOOKS 集中记录语义属性和局部可读类名后缀,不再依赖构建哈希。 - 时钟新标尺的默认 1.5 对应旧 0.6 实际尺寸,旧设置保存单位后换算一次;最低可缩至默认尺寸的 40%。 - 组件保留用户设置倍率,随 DSH 原生页面缩放;允许覆盖输入卡片,不做反向缩小或抬升。 - 时钟采用与播放器一致的「時刻管制」面板:七段时分秒、独立的日期/时区/UTC 和真实秒刻度;解码灯排明确区分空闲与负载。 - 角色名统一优先使用中文 Songti SC/STSong/Noto Serif CJK SC,覆盖中央教条区、选择卡和启动标题,避免「绫」与日文可用汉字落入不同字库后产生基线错位。 - 深度求索、活动过程和活动轮次提示通过宿主稳定 data-* 属性使用角色主色,鲸尾继承正文颜色;全局与深度求索扫光令牌采用各自角色的高光色,保留宿主动画、完成提示与状态语义。 - 播放器 NERV SVG 以圆环中心加反向半尺寸平移定位,修复原固定 top=22px 比圆环中心高 4px 的问题,相同缩放下持续同心。 - 音乐采用当前角色配色的「音声管制」终端,机械双环与阶梯状态格只表达播放状态;暂停和减少动态效果时保持静态,进度来自真实系统数据。 - 音源首次出现/来源变化时显示 650ms 连接分段条,按角色主色绘制、保留控制区,点击与卸载均回收载入计时器;减少动态效果时跳过。控制按钮保留正常指针与连续操作,暂停即时预测、失败回滚,切歌用内部警戒条纹反馈;命令串行发送,结束后异步读取真实状态;若系统短暂返回旧播放状态,最多 1.5 秒绕过缓存校准,避免图标闪回。点击控制时取消旧查询,并按操作代次丢弃已完成的过期回复,宿主缓存同样隔离控制前的读取。系统控制只调用一次 JXA,在发送前核对当前 bundleId,不再先读完整元数据;不可用时保留 Music/Spotify 固定脚本回退。 - TPS 放在中央教条区下方,时钟移到右上;系统检测到播放后,播放器出现在时钟下方并提供暂停和切歌。 - 时钟、音乐、TPS、仪表和设置面板在一次布局中协调;右栏全屏时仅保留设置入口,不修改 DSH 正文布局。 - ResizeObserver、visualViewport 与宿主结构变化合并到一帧,后台页面停止采样与查询。 - 设置面板独立挂载于覆盖层:桌面停靠会话左侧,窄屏为底部抽屉,不受仪表或时钟倍率影响。常用尺寸与背景直接显示,装饰与排版默认折叠;支持清晰数值、加减、方向键、单项默认和显式关闭。 - 显示设置是非模态 region,不占用宿主 dialog 栈,避免远程控制的嵌套弹层保护阻止关闭。宿主 aria-modal 对话框打开时,皮肤覆盖层暂时隐藏,关闭后恢复。 - 两种尺寸共用 0.6–5 范围与 0.05 步长,默认均为 1.5。仪表基准宽度与时钟新标尺对齐,恢复默认后实际宽约 235px,同倍率下持续等宽;时钟原有大小与已保存倍率保持不变。 - 设置保留 11 个开关与 4 个滑杆;四角压暗和动态效果总开关已删除,动画尊重系统减少动态效果偏好。 - 几何回归见 tests/fixtures/layout.html 与 tools/run-layout-tests.mjs;模拟宿主页面与真实 DSH 验收分开标识。 以下章节保留早期版本的设计演进与取舍,旧配色、壁纸、变量作用域与哈希类名示例不代表当前实现;当前角色色桥定义在 body,因此覆盖层外控件也可继承。 ## 起点:原型的语言要素 原型里成立的要素,按可移植性排序: | 要素 | 能否移植到 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 令牌(只列有代表性的,全表见 [`theme-tokens.md`](theme-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 关闭。