dsh-theme-windowsxp
DeepSeek Harness Web GUI 的 Windows XP 体验包,一个插件两半:
- Luna Blue 皮肤 —— 米色面板、渐变按钮、
#7F9DB9输入框、圆角蓝色滚动条、Tahoma 界面字体; - 完成提醒 —— 一轮跑完时有提示音和弹窗,智能体停下来等你批准时换一种完全不同的声音。
配色完全通过 --dsw-* 设计 token 注入,没有任何一行依赖 DSH 内部的哈希类名——所以 DSH 升级改版式时,这个主题基本不会碎。
dsh-theme-windowsxp/
├─ package.json # dsh.bundle / dsh.client / manifestVersion 声明
├─ cordis.patch.yml # 把自己插进浏览器插件名册(id = 插件页上的开关行)
├─ lib/
│ ├─ index.js # 宿主半侧:把 assets/ 里的素材经由同源路由发出去
│ └─ client.js # 浏览器半侧(由 scripts/build.mjs 生成,已提交)
├─ src/
│ ├─ windowsxp.css # ★ 皮肤真正的定义
│ ├─ notify.css # 弹窗卡片(Shadow DOM 内)
│ └─ client.js # 浏览器半侧的源码模板(皮肤 + 提醒)
├─ assets/
│ ├─ bliss.jpg # Bliss 桌面壁纸(微软版权,不入 files)
│ └─ chime.wav # 跑完提示音(微软版权,不入 files)
├─ scripts/
│ ├─ build.mjs # 把两个 CSS 内联进 lib/client.js
│ ├─ check-tokens.mjs # 拿真实 token 清单校验,抓拼错的变量名
│ ├─ check-cascade.mjs # 校验规则优先级顺序
│ ├─ smoke.mjs # 无浏览器验证协议 / 皮肤 / 两种提醒
│ └─ smoke-host.mjs # 验证素材路由(含穿越与条件请求)
├─ locale/ # 插件页显示的中英文标题与介绍
├─ token-inventory.json # DSH 全部 403 个 --dsw-* token 及其明暗默认值
└─ preview/
└─ windowsxp-preview.html # 静态预览页,改 CSS 后刷这个页面看效果
一、Luna 调色板
| 用途 | 色值 |
|---|---|
| 面板 / 对话框底色(3D Objects) | #ECE9D8 |
| Luna 框架蓝 | #0054E3 |
| 标题栏渐变亮端 | #3A93FF |
| 控件深蓝描边 | #003C74 |
| 选中高亮 | #316AC5 |
| 热跟踪浅蓝 | #E4EDFA |
| 输入框边框 | #7F9DB9 |
| 禁用文字 / 柔和分隔线 | #ACA899 |
标题栏那条经典渐变(预览页用的就是它):
linear-gradient(180deg, #0058ee 0%, #3593ff 4%, #288eff 6%, #127dff 8%, #036ffc 10%,
#0262ee 14%, #0057e5 20%, #0054e3 24%, #0055eb 56%, #005bf5 68%,
#0055eb 78%, #0054e3 86%, #0054e3 92%, #0054e3 94%, #0055e9 96%, #0057f0 100%)
二、DSH 主题的三条关键事实
1. 颜色住在 --dsw-* token 里
权威定义在 @deepseek-ai/dsh-client-ui-theme 的 design-platform.css,结构就两句:
body { --dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00); } /* 浅色 */
body[data-ds-dark-theme] { --dsw-alias-bg-base: var(--dsw-static-neutral-bluish-950); } /* 深色 */
全库共 403 个 --dsw-* token(token-inventory.json 里有逐个的明暗默认值),其中 119 个是语义 token(--dsw-alias-* / --dsw-specific-* / --dsw-menu-surface-fill),本皮肤覆盖了 113 个——剩下 6 个是 onboarding 与 settings-card,它们从别的 token 派生,会自动跟随。
两个关键发现:
ui-layout的.frame和ui-conversation的.root都读--dsw-alias-bg-base。所以这一个值是「文件区」的底色,本皮肤设成白色。--dsw-specific-sidebar-fill在ui-sidebar里是用background:(不是background-color:)消费的:._2H3hWW_root{ … background:var(--dsw-specific-sidebar-fill); … }这意味着可以直接塞 CSS 渐变——XP 那条蓝色任务窗格渐变因此是纯 token 覆盖,不需要任何 DOM 钩子。
XP Explorer 的三条色带
截图里那个窗口本质是三种材质并排,DSH 能一一对上:
| Explorer | 材质 | DSH 对应 | 实现方式 |
|---|---|---|---|
| 菜单 / 工具栏带 | 米色 #ECE9D8 |
会话区顶部的 <header> |
chrome 规则:body[data-dsh-windowsxp] header |
| 任务窗格 | 长春花蓝渐变 #7BA2E7 → #5F78D6 |
侧栏列 [class*='sidebarCol'] |
token:--dsw-specific-sidebar-fill 塞渐变 |
| 文件列表区 | 纯白 | 会话根 .root |
token:--dsw-alias-bg-base: #FFFFFF |
侧栏里的白色卡片(File and Folder Tasks 那种)对应的是侧栏里的 <button>:
body[data-dsh-windowsxp] [class*='sidebarCol'] button:not(:disabled) {
background: #ffffff; /* 白卡片浮在蓝色渐变上 */
border: 1px solid #b8c8ea;
border-radius: 4px;
color: #00309c; /* XP 任务窗格的深蓝字 */
}
为什么用 [class*='sidebarCol'] 而不是完整类名:DSH 的 CSS Module 类名长这样 ._2H3hWW_root,_2H3hWW 这个哈希每次重建都会变,但语义后缀不会。sidebarCol 来自 ui-layout,是官方侧栏列的稳定钩子(另一个成熟皮肤 maid-atelier 也用它)。
侧栏里踩过的三个坑(都是实测出来的,写下来省得再踩):
hover 和 selected 是同一个 token。
ui-workspace里写的是:.projectRow:hover, .sessionRow:hover, .sessionRow.selected{ background:var(--dsw-alias-interactive-bg-hover)}所以「选中」没有任何独立 token——你为 hover 选的颜色,就是选中的颜色。第一版皮肤把侧栏文字翻成白色,又让这个 token 保持浅蓝
#E4EDFA,结果选中行白字白底直接隐身(「选中了之后看不清了」就是这么来的)。修法:在侧栏作用域里把这个 token 重新绑成浅蓝#DCE9F9系,配合深色 ink。不要把
--dsw-alias-label-*在侧栏翻成白色。那是让文字在蓝底上好看的本能做法,但一旦列表行改成白色卡片,白字立刻又看不见了。正确做法是让卡片承载文字(XP 任务窗格本来就是这么做的):ink 保持深色,行/按钮各自是白卡片。给行加卡片会把「当前会话」压平。
ui-workspace用.sessionRow.selected标记当前会话,但那条规则只有(0,2,0);上面那条行卡片规则是(0,3,1),直接盖掉它——于是每一行长得一模一样,看不出自己在哪个会话(第二轮踩的)。修法:补一条更高特异性的选中规则,并且放在行卡片规则之后:body[data-dsh-windowsxp] [class*='sidebarCol'] :is([class*='projectRow'],[class*='sessionRow'],[class*='searchResultRow']):is([class*='selected'],[class*='folderActive']) { background: var(--xp-highlight); /* #316AC5 实心选中,一眼就能看见 */ color: #fff; /* 子元素是直接读 token 的(.time 用 --dsw-alias-label-tertiary), 所以要在「行本身」重绑 token,而不是只设 color */ --dsw-alias-label-primary: #ffffff; --dsw-alias-label-tertiary: #eaf0ff; }顺带一条选择器经验:白卡片规则必须同时列出
button:is([class*='...Row'])这个变体,因为搜索结果行是<button>,否则它会和通用按钮规则打成平手然后被 Luna 渐变盖掉。
行与行的区隔:ui-workspace 的行本身 background 是透明的、也没有边框,所以一列会话会糊成一整块字。给每行一张白卡片即可:
body[data-dsh-windowsxp] [class*='sidebarCol'] :is([class*='projectRow'], [class*='sessionRow'], [class*='searchResultRow']) {
background: #ffffff;
box-shadow: inset 0 0 0 1px #b8c8ea; /* 用 box-shadow 而不是 border */
color: #00309c;
margin-bottom: 3px;
}
用 box-shadow 而不是 border:行自带 height 和 padding,加边框会把布局顶歪,内阴影不会。
弹层都 portal 到 <body>,所以侧栏作用域扫不到它们。
通用按钮规则的边界(标签页 / 菜单项 / 列表项)
那条 button:not(:disabled) 的 Luna 渐变规则是把「大扫把」。它扫到 role="tab" 上时,把 ui-conversation 的无边框下划线标签页(.tab{background:0 0;border:none;padding:0 0 9px} 配 2px :after 下划线)涂成了一个个厚盒子,还多顶出 2px 高度 —— 就是「对话 / 轨迹 不协调」那一版。
role="menuitem" 和 role="option" 是同一类东西(8 个包里都有),所以一起排除。关键是排除,不是覆盖:
body[data-dsh-windowsxp]
button:not(:disabled):where(:not([role='tab']):not([role='menuitem']):not([role='option'])) { … }
给这些角色强写 background: transparent 看着更直接,但会抹掉全 App 菜单与列表框的选中高亮——组件自己的 .item[aria-selected] 规则比这条弱得多,压不住。
那个 :where() 是承重的::not([attr]) 每用一次就加一个 class 级特异性,直接连写会把这条从 (0,2,2) 抬到 (0,5,2),反超侧栏白卡片的 (0,3,2),把上一轮的修复又干掉。:where() 记 0 分,所以它留在 (0,2,2)。
因为「新规则悄悄压过老规则」已经栽了三次,所以加了
scripts/check-cascade.mjs:它自带一个特异性计算器(:is/:not取最具体参数、:where记 0、#id算数、注释里的{}先剥掉),并断言下面这几条关键排序,平手时按文档顺序判——侧栏白卡片 (0,3,2) > 通用按钮 (0,2,2) 侧栏行卡片 (0,3,2) > 通用按钮 (0,2,2) > ui-workspace 自己的行 (0,1,0) 当前会话 (0,4,2) > 侧栏行卡片 (0,3,2)
Windows 标题栏那条横条(caption strip)
截图里「应用 / 编辑」所在的那条横条,一开始我以为是网页画的,扒完才发现它是两半拼起来的,而且一半能改、一半完全改不了:
| 部分 | 谁画的 | 能不能改 |
|---|---|---|
| 40px 横条底色 | Electron titleBarOverlay.color |
✅ 能,而且就是通过 token |
| 「应用 / 编辑」文字与悬停态 | preload 用 Shadow DOM 渲的 | ⚠️ 只有 token 能表达的部分 |
| 点开后的下拉菜单 | 原生 OS 菜单(主进程 Menu.popup) |
❌ 完全不行 |
关键链路在 /lib/preload-app.cjs:
const probe = document.createElement("span");
probe.style.cssText = "position:fixed;visibility:hidden;"
+ "background-color:var(--dsw-specific-sidebar-fill);"
+ "color:var(--dsw-alias-label-primary)";
document.body.append(probe);
…
const color = nativeColor(getComputedStyle(probe).backgroundColor); // → 横条底色
const symbolColor = nativeColor(getComputedStyle(probe).color); // → 标题栏符号色
ipcRenderer.send(DESKTOP_IPC.windowsAppearance, lang, color, symbolColor);
主进程收到后就 mainWindow.setTitleBarOverlay({ color, symbolColor })。
也就是说:横条的颜色 = --dsw-specific-sidebar-fill 的 background-color。 早期版本我把这个 token 设成了渐变,而 background-color: linear-gradient(…) 是非法值 → 探针读出来是透明 → 横条退回系统默认的 #f9fafb 浅灰,跟整个主题格格不入(就是「这里和风格不符」)。
修法:token 必须是纯色,渐变改由 chrome 层重新画回侧栏:
--dsw-specific-sidebar-fill: #6c86dc; /* 纯色 → 喂给标题栏 */
body[data-dsh-windowsxp] [class*='sidebarCol'] > [class*='_root'] {
background-image: var(--xp-pane); /* 渐变只留给侧栏 */
}
> 是承重的:只有侧栏自己的根节点直接挂在布局列下,侧栏内部托管的各个面板要保留自己的表面。
剩下三样改不了,说清楚免得白折腾:
- 下拉菜单是原生的。主进程里
Menu.buildFromTemplate(items).popup({ window, x, y })弹出,坐标由网页提供。CSS 碰不到它,任何主题插件都碰不到 —— 想让它是 Luna 风格,只能改 DSH 自己。 - 横条的 40px 高度来自 Electron 的
titleBarOverlay.height。 - 「应用 / 编辑」按钮的 6px 圆角写死在 preload 的 shadow 样式表里。能通过 token 影响的只有字体、文字色、悬停底色 —— 而这三样本主题本来就在管(
--dsw-font-family、--dsw-alias-label-secondary、--dsw-alias-interactive-bg-hover)。
顺带一个挺巧的发现:
chromeFallbackFill()返回#f9fafb(浅)/#1b1b1c(深),而#f9fafb正好就是--dsw-specific-sidebar-fill的默认值neutral-bluish-50—— 所以出厂状态下「回退色」和「默认色」是一致的,只有主题动了这个 token 才会分叉。
发送 / 停止按钮:白图标压在米色按钮上
ui-conversation 的 .primary —— 发送、排队、插话、停止是同一个按钮 —— 自带一套主操作样式:
.primary { background: var(--dsw-alias-button-info-fill); color: #fff;
border-radius: 999px; width: 34px; height: 34px }
.primary:hover { background: var(--dsw-alias-button-info-hover) }
.primary:disabled { opacity: .4 }
我那条通用按钮规则改了底色,却动不了写死的 color: #fff —— 于是运行时这个按钮变成「停止」(启用),白图标压在米色按钮上,直接看不见。这就是「在运行的时候分不清」。
修法是把它从通用规则里抠回来,让组件自己那套生效:
body[data-dsh-windowsxp] [data-composer-seat] button[class*='_primary'] {
background: var(--dsw-alias-button-info-fill); /* 本主题里已经是 Luna 蓝 */
border: 0;
border-radius: 999px;
box-shadow: none;
}
视觉上不但没有损失,反而更像 XP:一个实心蓝色圆按钮配白色图标,正是 XP 主操作按钮的样子。
一个诚实的边界:发送和停止共用同一个元素、同一个类名,状态只体现在
aria-label(多语言)和图标(<rect>对<path>)上,所以 CSS 没法单独给「停止」换个颜色。能做的、这次做的,是保证两种状态下图标都清晰可辨。
桌面壁纸(Bliss)
内容区那张「蓝天 + 白云 + 绿色山丘」现在是真照片:assets/bliss.jpg,由宿主半侧的同源路由发出来(/skin-assets/windowsxp/bliss.jpg,见 lib/index.js)——和 maid 皮肤发它那几十张立绘是同一套机制。这样图片不必 base64 塞进 lib/client.js。
版权:Bliss 是微软 / Charles O'Rear 的版权照片,不在本包的 MIT 授权范围内。它在这儿只因为主人自己给的原图、自己机器上用;
package.json的files里故意没有assets,所以npm publish出来的包不含这张图,会自动退回下面的 CSS 复刻版。详见NOTICE。
壁纸因此是三层兜底:
--xp-wallpaper-image: url("skin-assets/windowsxp/bliss.jpg") center / cover no-repeat;
--xp-wallpaper-fallback: /* 纯 CSS 复刻:蓝天 + 四个云团 + 绿色山丘弧顶 */;
--xp-wallpaper:
linear-gradient(白纱, 白纱), /* 压字用 */
var(--xp-wallpaper-image), /* 照片 */
var(--xp-wallpaper-fallback); /* 图不在了就用它 */
图片 URL 用相对路径,相对 document.baseURI 解析——反向代理挂到子路径下也正确。拆成三个属性是为了让预览页能只换 --xp-wallpaper-image 一个值(预览是从文件系统打开的,没有那条路由),其余规则一律不动。
关键决定:没有挂在 --dsw-alias-bg-base 上。 那个 token 被十几个组件当颜色消费(附件小卡片、缩略图、行内编辑器、吸顶行头、浏览器 iframe、日程面板……),塞进图片会让每个小控件里都长出一座迷你山丘;而且 ui-theme 自己是用 background-color: var(--dsw-alias-bg-base) 读它的,图片在那里直接非法。
改成挂在 [data-conversation-scroll] 上——它是会话的滚动体,包住消息区和输入区,正好是主人圈的那块。滚动容器只画一次自己的背景、不随内容滚动,所以消息是在静止的壁纸上滑过,就是「桌面在窗口后面」那个感觉。
唯一值得调的旋钮是白纱浓度(把文字压在壁纸上的那层白):
/* src/windowsxp.css 第 1 节 */
--xp-wallpaper-veil: 0.32; /* 0 = 裸照片,0.8 = 只剩一点淡淡色调 */
默认 0.32:山丘依然鲜艳,正文在最深的草地阴影上还有约 5.8:1 对比度(设成 0 会掉到约 2.5:1,基本没法读)。
如果内容区四周出现白色缝隙,把
body[data-dsh-windowsxp] [class*='centerCol']加进那条选择器就能铺满整列。故意没加:那一列还挂着终端 / 浏览器 / 日程面板,它们该保留自己的表面。
2. 手写客户端插件完全合法,不需要打包器
DSH 的浏览器插件是惰性 CJS 工厂协议:
window.__ModuleLoader__.load({
id: "dsh-theme-windowsxp", // 必须等于包名
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
/* 副作用全放这里面,包括插样式表 */
exports.apply = apply;
return module.exports;
},
});
执行 bundle 只注册工厂,副作用要等物化才跑。宿主侧则靠 package.json 的 dsh.bundle.patch + dsh.client 和一份 cordis.patch.yml:
- insert:
- id: ui-skin-windowsxp # 这行 id 就是插件页上的开关行
name: 'dsh-theme-windowsxp'
3. 官方 ctx.theme 是另一条路,有两个坑
ctx.theme.register({ id, colorScheme, tokens }) // 注册第三方主题
ctx.theme.overrideTokens(source, { token: { light, dark } }) // 叠加覆盖层
- 内置「设置 → 通用 → 外观」只有写死的三个方块(light/dark/system),第三方主题 id 不会出现,得自己注册设置行或用插件启停当开关;
overrideTokens的值会变成<body>上的内联样式(ui-layout对snapshot.active.tokens调body.style.setProperty),优先级压过任何样式表。想让本主题在装了别的皮肤时仍然必胜,在apply()里加:
ctx.inject(["theme"], (themeCtx) => { // 用 inject 而非 exports.inject:
themeCtx.theme.overrideTokens(PACKAGE_NAME, { // theme 缺失时皮肤照样能用
"--dsw-alias-bg-base": { light: "var(--xp-face)", dark: "var(--xp-face)" },
// …把第 2 节的 token 逐个搬过来
});
});
validateOverrides 对裸字符串会抛错,必须 { light, dark } 成对。
三、完成提醒(融合部分)
这个包里还有一套提醒,因为它和 XP 皮肤在体验上是一件事:XP 的样子 + XP 的提示音,一个包、一个开关。
两种事件、两种声音
| 事件 | 声音 | 弹窗 |
|---|---|---|
| 一轮跑完 | 上行的 A 大调琶音(A5→C#6→E6),钟一样的金属泛音,长尾 ~1.34 秒 | 绿点,标题是会话名,本轮已完成 · 可以回来看看结果。 |
| 智能体停下来等你(批准操作 / 回答问题) | E6-E6-B5:同一个高音敲两下再落下来,三个泛音、尾巴只有 0.45 秒 | 琥珀点,标题是会话名,等待你的确认 · 智能体停在这里,等你处理。 |
两种声音在每一个维度上都不同 —— 走向(上行 vs 先平后落)、节奏(三个不同音 vs 同音重复)、泛音数(2 vs 3)、长度(1.34s vs 0.81s)。闭着眼睛也分得出来。
跑完的音效优先用打包的 WAV(assets/chime.wav),没有就退回合成音;批准音永远是合成的 —— 因为「和跑完的声音不同」这件事,换个音型比换个文件更可靠。
检测:两个会话级状态位,都不看当前视图
sessions.list.getSnapshot().byId[id].running // 跑完
uiSession.sessionStatus.getSnapshot().get(id).pendingInteraction // 等你
ui-session 每个会话维护一条状态记录 { running, pendingInteraction, completionUnread },由宿主的 api-session/status 流喂。
为什么不用 DOM:
| 想用的钩子 | 为什么不行 |
|---|---|
[data-chat-running] |
只反映当前显示的会话。切走 → 误报「完成了」;后台跑完 → 完全不响 |
[data-approval-key] |
批准面板只在你看那个会话时才在 DOM 里,后台卡住的会话会静默 |
| 「记住某个转录节点,稍后看它还在不在」 | 转录区用 @tanstack/virtual-core 虚拟滚动,节点随滚动窗口挂载/卸载 |
会话级状态位三个问题一起解决:换视图不伪造、后台事件不漏、还能带上是哪个会话。
三个必须处理的细节:子会话要排除(subagent 有自己的运行位/待交互位)、首帧只建基线、从列表消失 ≠ 完成(只删记录不提醒)。批准音按上升沿触发,所以同一次请求重渲染不会重复响。
四、安装与一键开关
安装(profile 名按需替换,本机是 desktop):
dsh plugin --profile desktop add "C:\Users\76777\Documents\deepseek-harness\default-workspace\dsh-theme-windowsxp"
开关位置:左侧边栏 → 「插件」 页 → 「已安装」 分组 → 展开 dsh-theme-windowsxp → 点它组件行上的开关(tooltip 写作「启用组件 …」)。
页面顶部会提示 「更改将在下次启动生效」——所以开关后要重启一次 DSH。
注意:独立插件
dsh-notify-complete里是同一套提醒逻辑。两者不能同时开,否则每次都会响两遍、弹两个窗。本机的ui-turn-notify行已经关掉了。哪天你换掉 XP 主题还想留着提醒,就把那一行打开。
卸载:
dsh plugin --profile desktop remove dsh-theme-windowsxp
五、调试与迭代
cd dsh-theme-windowsxp
node scripts/build.mjs # windowsxp.css + notify.css → lib/client.js
node scripts/check-tokens.mjs # 校验每个 --dsw-* 名字在真实清单里存在
node scripts/check-cascade.mjs# 校验规则优先级顺序
node scripts/check-contrast.mjs # 校验每个文字 token 都过 4.5:1
node scripts/smoke.mjs # 47 项:协议 / 皮肤 / 两种提醒 / 两个方向的误判 / 卸载
node scripts/smoke-host.mjs # 38 项:素材路由(含路径穿越与条件请求)
三个检查器各管一类已经栽过的坑:
| 检查器 | 它防的事 |
|---|---|
check-tokens |
token 名拼错,静默失效 |
check-cascade |
新规则悄悄压过老规则(栽过三次) |
check-contrast |
把装饰灰当文字灰用 —— #ACA899 压在米色上只有 1.6:1,主人报「很多字我都看不清」就是这个 |
smoke.mjs 里的DOM stub 是故意不完整的:没有 MutationObserver、没有 requestAnimationFrame、没有 setInterval。万一将来又退回「监听 DOM 判断状态」的写法,测试会直接报错而不是悄悄通过。
先看预览页:直接双击 preview/windowsxp-preview.html。它 <link> 的是 src/windowsxp.css(源文件,不是构建产物),改配色 → 刷新页面就能看到,不用碰 DSH。鼠标放到「发送」上能看到 Luna 的金色高光。
再看真界面:DevTools 里 <body data-dsh-windowsxp>,Elements 面板选 body,Computed 里筛 --dsw-alias-,能直接看到哪个 token 被谁覆盖。
改配色只动 src/windowsxp.css 第 1 节(--xp-* 调色板),第 2 节的 token 映射会自动跟着变。改提醒文案/音型动 src/client.js 顶部的 MESSAGES 和 CHIME/APPROVAL。
六、已知限制(实话实说)
- 只吃语义层。113 个语义 token 全覆盖了,但组件内部用哈希类名(
.BynINW_frame这种)写死的几何、间距、动画碰不到——那种改法只能靠 DSH 暴露的data-*钩子([data-composer-seat]、[data-conversation-scroll]、[data-chat-flow]、[data-phase]、[data-menu-material]、[data-slot='…']等)去挂,属于第二阶段的活。 - 和其它皮肤互斥。多个皮肤同时启用时靠文档顺序决定胜负。请只启用一个。
- 提醒逻辑在包里有两份。
dsh-notify-complete是独立版(给非 XP 主题用),本包这份是融合版。改一边不会同步另一边。 - 首帧可能闪一下米色。客户端插件是惰性的,
data-dsh-windowsxp要等插件物化才挂上。想彻底消除,得在宿主半侧ctx.on('webserver/index-inject', …)里注入 head CSS + body 脚本(ui-theme的bootThemeInjections就是干这个的,lib/index.js注释里留了写法)。 - 真机验证只覆盖了一部分。token 映射、优先级、模块协议都按源码核对过,
check-tokens.mjs证明 134 个变量名全部真实存在,两个 smoke 共 85 项覆盖了生命周期与两条提醒路径。已真机确认:问题对话框里选中项的蓝色描边 —— 这条曾经因为通用按钮规则漏排除role='radio'而完全不可见(选中的和悬停的长得一模一样),是主人实测发现并确认修好的。还没确认:其它组件的观感、XP 界面配 Bliss 壁纸的整体效果、两种提示音的实际听感。 - 通用按钮规则需要不断补排除项。它为了做出 Luna 外观必须拥有 (0,2,2),而组件自己的状态规则只有 (0,1,0),所以每遇到一个新的「长得像按钮、但外观属于它所在的列表/选择器」的元素,都得往排除表里加一条。排查方法:把客户端包 grep 一遍
role: "…"看哪些 role 落在可交互元素上。check-cascade.mjs里那张表就是护栏,漏一个会直接报错。 - 按钮的
border/background被 Luna 样式替换,原本的焦点环会丢,所以本皮肤额外补了 XP 风格的虚线焦点框(只加在被本皮肤改过的控件上,不动全局:focus-visible规则)。
许可
MIT。
No comments yet. Be the first to write one.