workbuddy 桌面端换肤

SKILL前端设计
前端设计 未标注权限 许可证不明确
安装这个技能

给一张图,WorkBuddy 整套界面就换成它的色调:主内容区铺图,侧栏与卡片配同色毛玻璃,深浅两套模式都支持;背景图可挂整窗或只挂主内容区,人物不会被侧栏压住。内置 9 套成品皮肤,也能用自己的图做新皮肤,改完直接写进 App 生效。注意:会修改 app.asar(只改 CSS 文本),原厂签名不可逆丢失;官方更新后需重跑一次;改完需 Cmd+Q 重启。

一句话选型结论

按资料字段整理

workbuddy 桌面端换肤是前端设计类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类前端设计 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_c9b9b91c/workbuddy-macos-skin
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

前端设计
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_c9b9b91c仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验2026-11-06上游最后核验时间 · 数据更新 2026-11-06
正文体量78.0 KBSKILL.md 原文大小,越大说明说明书写得越细

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
出页面与组件

按描述生成页面结构和组件草稿。

调整样式与布局

改间距、配色与响应式布局。

生成设计说明

把设计稿整理成开发能接的说明。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在前端设计场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
workbuddy 桌面端换肤当前 未标注权限 未标注 未标注 未标注 有
前端设计 只读 GitHub 仓库 882.3 万 Codex、Claude、Cursor 有
Vercel 组合模式 联网访问 GitHub 仓库 385.9 万 Codex、Claude、Cursor 有
Vercel React native skills 前端 只读 GitHub 仓库 246.9 万 Codex、Claude、Cursor 有
adapt 搜索 浏览器控制 GitHub 仓库 202.7 万 Codex、Claude、Cursor 有
使用我的浏览器 浏览器控制 GitHub 仓库 184.8 万 Codex、Claude、Cursor 有

SKILL.md 全文

78.0 KB
模型实际读到的说明书原文,来自技能仓库

WorkBuddy 桌面端换肤(macOS / Windows / Linux)

给 WorkBuddy 桌面版(Electron / asar 打包)做深度换肤。这套技能的全部结论都来自真机逆向与实测,不是推测。 同一份 CSS 与脚本在 macOS / Windows / Linux 上通用,由 lib/asar.mjs::asarCandidates() 按 process.platform 探测默认安装位置。

它解决什么问题

WorkBuddy 没有开放换肤 API。改 app.asar 是唯一可行的注入面,但只把 CSS 塞进去是不够的 —— 前 6 版全部失败,原因都不是"CSS 没加载",而是下面两个坑:

  1. 令牌按元素级联:在 :root 上写 --x: ... !important 对 .teams-container.is-mac 这类元素完全无效(它自己有声明,不去继承)。详见 references/design-tokens.md。
  2. 透明度叠加会吃掉背景图:遮罩 + 面板 + backdrop-filter blur 叠在一起,图片能压到只剩 20% 亮度且被糊掉。必须分层核算总透过率。

30 秒选路(不想读全文就看这张表)

你想做的事 只看这几节 可以跳过
换一套现成的皮肤 快速开始 → 第 3 步安装 第 1 步写皮肤(全部)
用自己的照片/插画做新皮肤 第 1 步写皮肤 +「背景图挂哪个容器」+「CSS 的骨架」 完整性校验、unpacked 布局
装了没反应 / 打不开 排查索引 → troubleshooting.md 其余全部
改完要发布到技能市场 发布前必须跑 build-release.mjs —
只是想知道长什么样 下一节「装完长什么样」 —

> 给非技术读者:本文档有一半篇幅是实现细节与踩坑机理(令牌作用域、asar 字节布局、完整性哈希),
> 那是写给「正在改这套 CSS 的人 / agent」看的。你要只是装个皮肤,读完「快速开始 + 第 3 步 + FAQ」就够了,
> 中间那些带 ⚠️ 和小字推导的段落可以直接跳过。

装完长什么样

没有示意图不是遗漏,是发布平台的硬约束:SkillHub / ClawHub 拒收二进制文件,
包里放任何 .png / .jpg 都会被静默剔除。所以这里用字符图 + 色值给你「读出来的样子」,
真彩渲染图请自己跑一次(不安装、不动 /Applications):

env -u NODE_OPTIONS $NODE $SK/scripts/preview.mjs --skin skins/cafe-snow --out /tmp/preview
# → /tmp/preview/preview-dark.png、preview-light.png、preview.html(并排联系表)

版式(所有皮肤共用同一套骨架,只差色值)

区域 尺寸(1280×832 窗口) 内容 视觉处理
侧栏 264px 宽(占 20.6%) 会话列表 毛玻璃 .42 + blur(22px) + saturate(1.5)
主内容区 1012×832 照片/插画满铺(full-bleed) cover 居中取景 + 一层 0.10~0.26 的线性遮罩,只压高光
↳ 卡片 主区宽度减 24px 消息气泡 / 工具卡 玻璃 .36 + 1px 亮边(模拟玻璃厚度)+ blur
↳ 输入框 同卡片 打字区 玻璃 .44(有文字输入,取最保守的一档)

> 之所以不做「左右两栏的 ASCII 版式图」:中文是双宽字符,与框线混排时不同等宽字体的
> 回退策略不一致,在浏览器里必然错位。要看真实版式,跑 preview.mjs 或打开
> docs/preview-gallery.html(那两张是真正渲染出来的)。

层次(从下往上,四层,改皮肤时按这个顺序想)

④ 文字      --xx-text-1 暖白(深色)/深棕(浅色)—— 永远最后定
③ 玻璃      侧栏 / 卡片 / 输入框 / 弹窗:半透明色 + backdrop-filter
② 遮罩      压在照片上的线性渐变,只负责压高光,不负责「压暗整张图」
① 底色      html 的 background-color;侧栏玻璃背后是它,不是照片
─── 照片本体挂在 ① 与 ② 之间,挂 html 还是挂主内容区容器见「背景图挂哪个容器」

9 套内置皮肤的色值(照着就能想象出观感)

皮肤 主色三块 玻璃底 观感
cafe-snow 咖啡小雪 #f0e6d2 #b06a3c #3a4f3f 暖米棕 冬日咖啡馆:奶油米 + 焦糖 + 墨绿玻璃
timber-morning 木屋晨读 #f4ece2 #b08a68 #d9a44c 暖木棕 暖木墙 + 冷调亚麻白 + 晨光琥珀
linen-morning 亚麻晨光 #f4f0e7 #b3a687 #a5724a 米白 白床品 + 橄榄绿 + 原木
snow-night 雪夜 #eef4fc #7b9fd0 #2c3e63 冰蓝黑 深夜蓝 + 冰蓝 + 星光金
summer-ice 夏日冰饮 #e8f7fa #3d8fa6 #45d0a8 冰蓝 冰蓝 + 薄荷
seaside-dusk 海边车站 #d6efff #4a9ad8 #f0b050 海蓝 海蓝 + 暖金
misty-forest 迷雾森林 #e0f4f7 #3a7a8a #f0d8a0 墨绿 森林蓝绿 + 暖金
moonblade 月下剑姬 #d6efff #4a9ad8 #f0b050 海蓝黑 冷蓝 + 暖金(插画底)
window-candle 窗边灯火 #a8a8b8 #d8a55e #c8708e 蓝黑 雨夜窗:noir + 琥珀 + 玫瑰

> 前 4 列可复制进 theme.json 的 palette;改皮肤时先改这三个色块,再调玻璃与遮罩。

想横向对比九套的观感:打开 docs/preview-gallery.html

技能包里附了一个纯 CSS 重绘的九宫格(不含任何图片,几十 KB 文本),双击即可在浏览器打开:
每套皮肤用它的色值画一个迷你界面(侧栏 / 主内容区 / 卡片),并排对比明度差与色相族。
它不是截图 —— 但足够回答「这套皮肤大概长什么样、适合什么图」。
真实渲染还得跑 preview.mjs。

快速开始

SK=~/.workbuddy/skills/workbuddy-macos-skin
NODE=/Users/daily/.workbuddy/binaries/node/versions/22.12.0/bin/node

# 1) 自检:只生成改包,不安装
$NODE $SK/scripts/apply.mjs --skin $SK/skins/summer-ice --dry-run

# 2) 探针:看关键节点最终生效值 + 出截图
$NODE $SK/scripts/probe.mjs --shot /tmp/after.png

# 3) 对照:用官方原版 CSS 看"改之前"是什么样
$NODE $SK/scripts/probe.mjs --baseline --shot /tmp/before.png

# 4) 安装
$NODE $SK/scripts/apply.mjs --skin $SK/skins/summer-ice

# 5) 还原
$NODE $SK/scripts/restore.mjs

各平台默认安装路径(脚本会自动探测,找不到再 --asar 显式指)

平台 默认路径
macOS /Applications/WorkBuddy.app/Contents/Resources/app.asar
Windows %LOCALAPPDATA%\Programs\WorkBuddy\resources\app.asar(Squirrel / Electron-builder 默认位)<br>兜底:%ProgramFiles%\WorkBuddy\resources\app.asar、%ProgramFiles(x86)%\WorkBuddy\resources\app.asar
Linux /opt/WorkBuddy/resources/app.asar<br>兜底:/usr/lib/workbuddy/resources/app.asar

依赖(首次):

cd ~/.workbuddy/binaries/node/workspace &amp;&amp; npm install chromium-pickle-js

探针还需要本机装有 Chrome / Edge(用于 headless 渲染)。

第 0 步:理解意图

维度 含义 例子 该怎么做
参考图 用户给了一张图说"照这个来" 附件 + 「以这张图为准」 直接用作主内容区背景;从图中提取主色调配其余区域
意境 / 氛围 抽象描述 「禅意」「莫兰迪灰」「赛博朋克」 纯配色路线,不一定要出图
深浅 明/暗偏好 「明亮清新」「深夜模式」 影响整套色阶与遮罩方向

规则
- 参考图优先于一切调色板推断。
- 背景图务必用 JPEG(同样画质下 PNG 的 base64 会大 5–7 倍,直接撑大 asar)。可用 sips -s format jpeg input.png --out out.jpg。
- 一句话没说清时,先按最合理的默认做一版,用探针出图给用户看,再微调 —— 不要反复追问。

第 1 步:写皮肤

皮肤包格式 skins/&lt;id&gt;/:

skins/seaside-dusk/
├── theme.json            元信息(id / name / images;不含可覆盖参数)
├── workbuddy.css         皮肤 CSS,背景图用 __HERO__ 占位符
└── assets/
    ├── seaside_dusk.jpg        原图(留档)
    └── seaside_dusk_dark.jpg   ← 深色主题实际使用(压暗预处理后)

theme.json 关键字段:

{
  &quot;id&quot;: &quot;seaside-dusk&quot;,
  &quot;name&quot;: &quot;海边车站 Seaside Dusk&quot;,
  &quot;schemaVersion&quot;: 2,
  &quot;mode&quot;: &quot;dark&quot;,
  &quot;images&quot;: { &quot;hero&quot;: &quot;assets/seaside_dusk_dark.jpg&quot; }
}

theme.json 只承载元信息与图片声明 —— apply.mjs / preview.mjs 只读 id / name / images,
不会从 theme.json 注入任何 CSS 变量。所有可调数值(遮罩、玻璃、主色、模糊)都写在
workbuddy.css 里,调参直接改 CSS 即可。

workbuddy.css 里写 url(&quot;__HERO__&quot;),apply.mjs 会自动把图片内联成 base64 替换掉占位符。

背景图挂哪个容器(2026-10-22 实测 —— 构图对不对,这一步就定了)

同一张照片,挂 html 还是挂主内容区容器,出来的构图完全不同。区别不在 CSS 写法,
而在取景框的比例:

挂载点 取景框(1280×832 窗口) 比例 照片 1.50 时 cover 后的水平余量
html 整窗 1280×832 1.538 0(照片显示 1280×854,只裁上下 22px)
html .teams-container [data-view-id]:not([data-view-id=&quot;sidebar&quot;]) 主内容区 1012×832 1.216 235px(照片显示 1247×832,左右各裁 117px)

余量为 0 意味着构图被钉死:照片主体在画面 46% 处,cover 后就永远落在窗口 46% 处;
而侧栏占掉左侧 264px(20.6%),主体必然钻进侧栏毛玻璃底下 —— 用户看到的就是
「人被压在侧栏后面 / 偏左 / 不居中」。这事加重遮罩、调透明度都救不回来,因为它是几何问题。

挂 html(整窗取景,水平余量 0)
  · 照片按 1280×832 cover,主体永远落在窗口 46%
  · 侧栏 264px 直接压在人身上
  · 结果:人像偏左 / 被挡 / 不居中

挂主内容区容器(容器取景,水平余量 235px)
  · 照片按 1012×832 cover,主体落在容器 38% 附近
  · 侧栏背后是 html 底色,不碰照片
  · 结果:主体能在可见区里挪到任意位置

决策表

原图宽高比 推荐挂载 理由
≥ 1.75(宽幅风景) html 挂整窗时上下会裁,但主体通常在中部,安全
1.45 ~ 1.60(接近窗口 1.538) 容器 挂整窗零余量,构图无法调整
≤ 1.30(方图 / 竖幅) html + 横向延展(见「竖幅原图」) 余量充足,延展比换挂载点更有效
≈ 1.00 且四边全是主体 html + 画框式(见「满幅图做不了延展:画框式」) 延展取样必把主体卷进去,只能居中留边
1.30 ~ 1.45(不宽不方) html + 按高铺满 + 单侧填充(见「单侧填充」) 按宽铺满要裁上下且零余量;按高铺满画质更好,空档还正好落在侧栏下
人像特写 / 主体明显偏一侧 容器 必须有水平余量才能把主体挪出侧栏

构图定位公式(算完再写 CSS,别试)

设容器宽 Wc、高 Hc,照片 Wp × Hp,s = max(Wc/Wp, Hc/Hp):

显示宽 Wd = Wp · s          水平溢出 E = Wd − Wc
background-position-x = P%  →  图左边界 = −E · P
主体在照片中的相对横坐标 r    →  容器内位置 = r · Wd − E · P

要让主体落在主内容区正中(Wc/2):

P = (r · Wd − Wc/2) / E
  • 0 ≤ P ≤ 100% → 可以做到,直接用这个 P。
  • P &lt; 0 → 做不到。说明这张照片的主体太靠左,要居中就必须让图右移、右侧多裁
    (Wc/2 − r·Wd) 像素。此时只能在「主体居中」和「右侧内容完整」之间二选一,
    按「人物 > 道具 > 背景边缘」的优先级取舍,并把结论告诉用户。

两种挂载点的现成写法

/* A. 挂整窗(原图远宽于窗口时) */
/* @@HERO_BLOCK_START@@ */
html {
  background-color: var(--xx-cream-900) !important;
  background-image:
    linear-gradient(var(--xx-veil-angle), var(--xx-veil-top) 0%, var(--xx-veil-bottom) 100%),
    __HERO__ !important;
  background-position: center, center !important;
  background-size: cover, cover !important;
}
/* @@HERO_BLOCK_END@@ */

/* B. 挂主内容区(照片接近窗口比例、或主体偏一侧时)—— 注意这两点:
   1) 选择器必须带 html 前缀:特异性 (0,3,1) 才能压过 §9.4 那条
      `.teams-container [data-view-id]:not(...)` 的 (0,3,0)「透明 + 无图」规则;
   2) html 上要显式 background-image: none,否则旧规则可能残留整窗取景。 */
/* @@HERO_BLOCK_START@@ */
html {
  background-color: var(--xx-cream-900) !important;
  background-image: none !important;
}
html .teams-container [data-view-id]:not([data-view-id=&quot;sidebar&quot;]) {
  background-color: transparent !important;
  background-image:
    linear-gradient(var(--xx-veil-angle), var(--xx-veil-top) 0%, var(--xx-veil-bottom) 100%),
    __HERO__ !important;
  background-position: center, 50% center !important;   /* 第二个值 = 上面算出的 P */
  background-size: cover, cover !important;
}
/* @@HERO_BLOCK_END@@ */

> 挂容器后的连带变化:侧栏背后不再是照片,而是 html 的底色 —— 毛玻璃会退化成
> 「半透明纯色板」。这是可接受的(甚至更干净),但底色必须选照片暗部的主色,
> 否则侧栏会像贴上去的补丁。别再给 .teams-container 补一张同样的图当兜底:
> 那等于把整窗取景又加回来,侧栏重新透出照片,白改。

深浅双底图(v2.7 起,cafe-snow 在用)

深浅模式对底图的要求是相反的:深色模式要暗底图(浅色文字才亮得起来),
浅色模式要亮底图(深色文字才可读)。单张图折衷必然有一边糊 —— v2.6 试过
「单张亮图 + 深遮罩压暗」,结果遮罩把细节整个糊死,毛玻璃背后是一片均匀暗色。

正确做法是在图上就把亮度调对,遮罩只留很薄一层:

&quot;images&quot;: {
  &quot;hero&quot;:      &quot;assets/cafe_snow_dark_v6.jpg&quot;,   // 兜底(未声明 heroDark 时使用)
  &quot;heroDark&quot;:  &quot;assets/cafe_snow_dark_v6.jpg&quot;,   // → __HERO__
  &quot;heroLight&quot;: &quot;assets/cafe_snow_light_v5.jpg&quot;   // → __HERO_LIGHT__(用户原图,未改动)
}
theme.json 键 注入到 缺省行为
heroDark __HERO__ 回退到 hero
hero __HERO__(兜底) 无图则警告,纯配色皮肤可忽略
heroLight __HERO_LIGHT__ 不声明就不替换,CSS 里的兜底值生效

发布包里 mime 由 &lt;key&gt;Mime 声明(heroMime / heroDarkMime / heroLightMime),
build-release.mjs 会自动补。本地写完 theme.json 后务必跑一次
build-release.mjs,再用发布包形态跑 apply.mjs --dry-run
—— 只测本地二进制图
测不出引用改写问题(见下)。

五个坑(v2.9–v2.10b 实测,别再踩)

1. :has() 是通配后代匹配。 写 html:has(.light) 的含义是「html 内任意层级
存在带 .light 的元素即命中」。界面里只要有主题切换按钮、图标、下拉项带这个类,
深色模式也会被套上浅色底图 —— 表现就是「深色版图片没生效」。
必须限定直接子元素:html:has(&gt; body:is(.light, .cb-light, .vscode-light, [data-vscode-theme-name=&quot;IDE Light&quot;]))。
顺带用 :is() 把多条规则合成一条,占位符从 3 处降到 1 处,少内联两份 base64。

2. CSS 注释里绝不能写占位符原文。 占位符替换是全局 replace,不区分注释与代码。
注释里写一句「__HERO_LIGHT__ 从 3 处降到 1 处」,就会被塞进一整张 base64 图
(实测 index css 从 578 KB 膨胀到 682 KB,asar 里凭空多出两份图)。
注释里要提占位符就写成 HERO_LIGHT 占位符(去掉下划线)。
apply.mjs 现在会先摘注释再替换再装回,并统计注释命中数给出提示。

3. 压暗原图生成深色底图,只映射 HSV 的 V 通道。 不要施加 sat / warm / cool
之类的主动偏色,也不要逐通道套同一条 LUT —— v2.7.1 就是这么把色相改坏的
(低亮度下还有 Purkinje 效应:人眼对红/绿敏感度下降,降饱和+压暗会让中性色发冷蓝灰)。
正确做法:转 HSV → 只映射 V → H、S 原样写回 → 转回 RGB。
数学上色相与饱和度零漂移,本质是「同一场景的低曝光版本」而不是「改图」。

# cafe_snow_dark_v6.jpg 的生成法(实测 med 185.9→105.3、p95 213→137,
# 色相漂移 0.000°、饱和度漂移 0.0000)
IN_PTS  = [0,   40,  186, 230, 255]
OUT_PTS = [0,   28,  102, 148, 168]      # 只压 V,不碰 H/S
h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
v2 = lut[int(round(v*255))] / 255
r2, g2, b2 = colorsys.hsv_to_rgb(h, s, v2)   # h、s 原样传回

4. 浅色模式漏网的「近不透明深色」令牌(v2.10b,cafe-snow / snow-night 双实测)。
主块(深色向)里有一批浮层/菜单/悬浮卡令牌是 rgba(...,0.95~0.98) 的近不透明深色
(--cb-popover-*、--cb-dropdown-bg-color、--vscode-menu-background、--vscode-dropdown-background、
--vscode-editorWidget-background、--wb-bg-popover、--wb-bg-modal、--cr-bg-elevated、
--island-bg-color、--td-bg-color-specialcomponent 等 16 个)。
浅色块此前只靠 12.2「按类名匹配 [class*=&quot;popover&quot;]」兜底 —— 类名对不上就漏,
于是在浅色模式下这些悬浮层直接画成近黑块,压上浅色模式的深色文字 → 完全不可读
(实测那块是 #2c241d,肉眼只看到"深色块",查遍 :hover 规则一无所获,因为根因是令牌不是规则)。
正确做法:按令牌覆盖,不要依赖类名。

排查利器(比逐条猜规则快得多)——扫出「主块深色 & 浅色块未覆盖」的令牌:

# 主块里平均值 &lt;140 且 alpha ≥0.7,且浅色块未重定义的令牌 → 浅色模式下的定时炸弹
bad = [t for t in main if t not in light and is_dark(main[t])]

DevTools 在官方版里是被 Electron fuse 封死的(Cmd+Option+I 无反应),
--remote-debugging-port 会被单实例锁挡回(新进程检测到已有实例就退出,端口不生效)。
所以静态令牌审计才是这台机器上唯一可靠的取证手段。

5. 强制 color 写在容器上打不到自带声明的子元素(v2.10,cafe-snow 实测)。
选中态规则若只写 color: … !important 在列表项容器上,而标题/摘要渲染在
子元素(span/div)里、子元素有自己的 color 声明(如 --cb-text-primary)——
CSS 继承打不过任何直接声明,金黄色根本落不到字上,表现为「深棕字叠深棕底」。
修法:补一条后代选择器,把选中块内的文本子元素(span/div/p/h*/time/label/a…)
一并强制为目标颜色;svg/img 排除(图标走 fill)。

边界:明度可映射(色相/饱和度必须零漂移),色相与白平衡一律不碰。

> 反过来,纯 CSS 蒙版压不住高亮图:线性叠加无法压缩动态范围,
> 原图 p95=213 时即使把遮罩加到 0.64,最亮处对比度也只有 4.07:1。
> 实测对照(均过玻璃层):原图+厚蒙版 7.08:1 / sd 11.1,v6+薄蒙版 7.16:1 / sd 12.8 ——
> 对比度几乎相同,但后者多保留约 15% 细节。所以该在图上做的就在图上做。

发布到技能市场前必须跑 build-release.mjs

SkillHub / ClawHub 禁止上传二进制文件,包里的 .jpg 会被静默跳过 ——
用户装上后皮肤能生效但没有背景图,而且不报错,极难排查。

$NODE $SK/scripts/build-release.mjs [输出目录]

两个产物,缺一不可:

产物 路径 用途
纯文本目录 ~/.workbuddy/build/release/workbuddy-macos-skin/ 自查、diff、手动 zip
可直接上传的 zip skills/…/dist/workbuddy-macos-skin.zip 交市场时实际用这个

它会依次完成五步,任一步失败即非零退出,不会带着缺口继续:

  1. 递归复制,把每个二进制图片转成 base64 明文存成 assets/&lt;名字&gt;.txt(每 76 字符换行便于 diff);
  2. 改写 theme.json 里所有图片键(hero / heroDark / heroLight / 以后新增的都算)
    指向 .txt,并写入对应的 &lt;key&gt;Mime;
  3. 引用可达自检(v1.4 新增):逐个打开 theme.json 声明的图片路径确认存在;
  4. 黑名单段扫描 + 零遗漏自检:文件名不得含任何二进制扩展名段;
    再按同一套排除规则算出期望文件集与产物实际文件集逐一对账,有缺失或多余即报错;
  5. 直接调用系统 zip 产出 dist/workbuddy-macos-skin.zip。

> 第 2、3 步是 v1.4 补的。旧版只改写 images.hero,导致用 heroDark / heroLight
> 的皮肤(cafe-snow)在发布包里 theme.json 仍指向已被改名的 .jpg,一 apply 就报
> 「主题声明的背景图不存在」。这个缺陷在本地开发态永远不会出现(本地是二进制图,
> 路径不变),只有发布态会炸,所以:
>
> zsh &gt; # 打完包必须用「发布包形态」再验一次,别只测本地 &gt; $NODE $SK/scripts/apply.mjs --skin $SK/../../build/release/workbuddy-macos-skin/skins/cafe-snow --dry-run &gt;
> 两条底图都应打印出 背景图 xxx.txt → __HERO__(… KB base64,image/jpeg)。

v1.3 起不再依赖第三方 package_skill.py。原因是它内部调用的 quick_validate.py
白名单过时,只认 {name, description, license, allowed-tools, metadata, compatibility},
不认识 WorkBuddy 标准的 agent_created: true,会报
Unexpected key(s) in SKILL.md frontmatter: agent_created 而打包失败。
改用系统 zip 后产物与官方格式一致,且 agent_created 得以保留。

打包时会自动排除 dist/(上一轮 zip,否则会被 base64 成 1MB+ 文本塞进新包形成「包里套包」)、
build/、node_modules/ 和 _meta.json(本地安装器状态,含 spaceNodeId 等本机专属字段)。

两套版本号,各管各的(别混淆)

技能市场页上显示的版本号(V 1.1.4)与包内 manifest.yaml / _meta.json 的
version: 1.4.0 不是同一套东西(2026-09-01 用户确认):

市场版本号 包内 version
形如 V 1.1.4 1.4.0
含义 第 4 次发布的序号;1.1 是平台固定前缀,末位随发布次数自增 本 skill 自身的语义化版本
谁维护 平台自动递增 我们手改(_meta.json + manifest.yaml + CHANGELOG.md 三处对齐)
用途 市场展示、用户看到的"更新到第几版" CHANGELOG 归档、回滚判断、问题定位

改包内 version 不会影响市场显示的版本号,反之亦然。
所以不要因为市场显示 1.1.4 就去把 manifest.yaml 改成 1.1.4 —— 那样反而会
打乱包内的语义化版本(v1.4.0 是"第四个功能版本",与市场"第 4 次发布"只是恰好都是 4)。

发版时两边都照常维护即可,不需要对齐。

发布次数对照(回滚/对账时用,末尾为当前值):

市场版本 第几次发布 对应包内 version
V 1.1.4 4 1.4.0
V 1.1.5 5 1.5.0
V 1.1.6 6 1.6.0
V 1.1.7 7 1.7.0(2026-09-15)

> 因为每次发版都会同时抬一位 minor,两个数字恰好同步;
> 但这只是巧合,不是约束 —— 若某次只改文档而不抬 minor(或在同一 minor 下连发两次),
> 尾巴立刻错开。以市场页显示的为准,别用包内 version 反推发布次数。

apply.mjs 两种来源都认:

形态 适用场景 类型判定
assets/hero.jpg 本地开发 从扩展名推断,现算 base64
assets/hero.txt 发布包 内容即 base64,类型取 images.heroMime

两种形态产出的 app.asar 字节数完全一致(实测三套皮肤均一致),功能无损。

> 命名坑(踩过两次才对):文本形态的文件名不能写成 hero.jpg.txt 或 hero.jpg.b64。
> 技能市场是拿文件名里每一个点分出来的段去比对二进制黑名单的 —— 只要还留着 jpg 段
> 就会被拒,报错形如「不允许的文件类型: skins/x/assets/hero.jpg.」。
> 必须彻底去掉图片扩展名,类型改由 images.heroMime 声明。build-release.mjs 已按此规则处理。

内置皮肤

id 名称 原图基调 说明
cafe-snow 咖啡小雪 Café Snow 亮(中位 186 / p95 213) 冬日咖啡馆实拍 1280×800。深浅双底图的参考实现:深色用 dark_v6(HSV 只映射 V 生成),浅色直接用用户原图 light_v5 加暖白蒙版。遮罩 0.25/0.18,平均对比度 7.16:1(AAA)
timber-morning 木屋晨读 Timber Morning 亮(中位 142 / p95 228) 清晨木屋实拍 724×1086 竖幅 → 横向柔化延展为 1740×1086。暖木棕 + 冷调亚麻白(两族色相相差约 180°)。竖图延展与遮罩标定的参考实现:遮罩 0.26/0.20,主区平均 7.96:1 / 最亮处 4.68:1
linen-morning 亚麻晨光 Linen Morning 亮(中位 142 / p95 228) 晨光卧室实拍 2400×1600。暖白亚麻 + 橄榄绿 + 原木棕,cafe-snow 的同源浅色变体
snow-night 雪夜 Snow Night 暗(原图中位 49) 夜雪插画 1132×694。深色用微抬暗部(med 49→60)而非压暗,浅色 V-only 提亮(med 49→131)。冰蓝夜空 + 星光金
summer-ice 夏日冰饮 Summer Ice 亮(中位 202) 冰蓝 + 薄荷。已附压暗版
seaside-dusk 海边车站 Seaside Dusk 亮(中位 138 / p95 197) 海蓝 + 暖金。横图 638×360
misty-forest 迷雾森林 Misty Forest 暗 森林蓝绿 + 暖金。竖图 603×1306,明暗反差大(p5 12 / p95 231),遮罩需加重到 0.32
moonblade 月下剑姬 Moonblade 亮(中位 90 / p95 254) 持刀武士插画。原图 360×778 顶部大片白天空,已压暗到中位 61
window-candle 窗边灯火 Window Candle 暗(中位 56 / p95 159) 雨夜窗边实拍。原图已是夜景,仅饱和 ×1.25,遮罩轻到 0.06~0.12

> 个人照片做的皮肤:不要放进 skins/(本机统一放 ~/.workbuddy/skins-local/)。
>
> 理由:skins/ 是要随包上传的目录。靠打包脚本的排除名单拦,属于
> 「默认发货、手动拦下」;真的漏了一项,照片会静默进包上传、脚本不报错 ——
> 这是本技能最不能出的错。放在目录之外,等于物理上不可能进包。
>
> 用法:--skin 直接给绝对路径即可,脚本与相对路径行为一致:
>
> zsh &gt; node scripts/apply.mjs --skin ~/.workbuddy/skins-local/&lt;id&gt; &gt; node scripts/preview.mjs --skin ~/.workbuddy/skins-local/&lt;id&gt; --out /tmp/preview &gt;
>
> 打包侧还有一层兜底:build-release.mjs 用 SHIPPED_SKINS 白名单
> (v1.12.0 起,取代原来的 EXCLUDE_SKINS 黑名单),目录里多出任何未登记的皮肤
> 都会直接终止打包。
>
> ⚠️ 另外:不要把个人皮肤的具体 id 或底图内容描述写进 SKILL.md / CHANGELOG / OVERVIEW
> —— 这三份都随包上传。
>
> ⚠️ 批量化重映射色板时,机械的色相旋转会把注释里的历史说明一起改坏,
> 也会冲淡「玻璃层与背景同色相 → 侧栏消失」这类关键约束。
> 映射后必检玻璃层与底图的色相/明度关系:若底图主色是青蓝,
> 玻璃层就不能也用青蓝,必须压深到明度明显低于底图中位。

照着 skins/cafe-snow/workbuddy.css 改配色即可做新皮肤——
各套结构完全相同,只差 :root 里的色值与玻璃色。
若要批量重映射一个已有皮肤的色板(换照片但沿用骨架),
可参考 timber-morning 的做法:令牌前缀改名 + 按「基础 RGB 定量映射、alpha 原样保留」
重写全部字面量,再单独处理 :root / @supports / 浅色块三处令牌定义
(它们的值相同但语义不同,不能靠全局替换一并处理)。

theme.json 的 mode 字段:dark 会额外压暗所有玻璃层并改用深色文字,
做深色底图(夜景、暗色插画)的皮肤时应显式声明,否则浅色玻璃会糊掉暗部细节。
注意上表的「原图基调」指的是参考图本身的明暗,与 mode 不是一回事 —
随包发布的 9 套皮肤 mode 均为 dark(原图亮的几套都已另存压暗版底图)。

背景图只注入一个文件

CSS 里用 /* @@HERO_BLOCK_START@@ */ … /* @@HERO_BLOCK_END@@ */ 把 html{background-image} 那一段包起来。
apply-unpacked.mjs 只对首个命中目标注入含图的完整版,其余注入不含图的令牌版。
否则 N 个文件各解码一次位图,还会重复声明根背景触发运行时重绘。

> ⚠️ 5.9.3 起,「首个目标」必须是 tokens-*.css。
> 它不是"入口主表"而是令牌层(定义全部 --wb-*,146 KB,unpacked,必加载)。
> index-*.css 已退化成纯组件样式(开头就是 .wb-button{box-sizing…}),
> 往它里面写 1 MB 皮肤块:asar 改动成功、integrity 同步、codesign -v 有效、
> 第二实例退出码 0、字节可读回 true —— 界面一个像素都不变。
> 2026-10-28 实测踩过,两小时才定位。详见 references/troubleshooting.md §3.5。
> apply.mjs v1.14.0 已内置「令牌表指纹」闸门,命中目标全不是令牌表时直接
> exit 1 并打印正确用法。

> ⚠️ 两个标记必须成对,缺一个就静默失去去重(v1.7.0 实测)。
> apply.mjs 的 HERO_RE 要求块尾出现字面量 /* @@HERO_BLOCK_END@@ */(注意 @@ 与 */ 之间有一个空格)。
> 只有 START 没有 END 时 hasHeroBlock 为 false,脚本不报错,只在日志里打一句
> 「CSS 中未找到 @@HERO_BLOCK_ 标记,全部注入点将使用完整版」,然后 5 个 CSS 各内联两份 base64。
> 症状极易被忽略:皮肤外观完全正常,只是 asar 多涨 2.26 MB(实测 cafe-snow 3,144,230 B → 887,567 B)。
> 自检:干跑后看每一行 插入 skin → 的字节数 ——
> 只有 index-*.css 应该是几百 KB 量级,其余 4 个应只有几十 KB(令牌版)。
> 若 5 行数字完全相同,就是 END 标记丢了。

CSS 的骨架(照抄 skins/summer-ice/workbuddy.css 即可)

:root {
  /* 1. 主色家族 */
  --si-ice-100..900 / --si-mint-* / --si-berry-* / --si-lemon-*;
  /* 2. 毛玻璃层级 */
  --si-glass-main / --si-glass-content-area / --si-glass-sidebar /
  --si-glass-panel / --si-glass-card / --si-glass-input /
  --si-glass-popover / --si-glass-modal / --si-glass-mask;
  /* 3. 毛玻璃参数 */
  --si-blur / --si-blur-content / --si-saturate;
  /* 4. 背景图遮罩 */
  --si-veil-top / --si-veil-bottom / --si-veil-angle;
}

/* 背景图挂 html 的 canvas 背景层(不是 body::before) */
html {
  background-color: &lt;兜底底色&gt; !important;
  background-image:
    linear-gradient(var(--si-veil-angle), var(--si-veil-top) 0%, var(--si-veil-bottom) 100%),
    __HERO__ !important;
  background-repeat: no-repeat, no-repeat !important;
  background-position: center, center !important;
  background-size: cover, cover !important;
  /* 千万别加 background-attachment: fixed。
     根元素背景本就跟随 viewport,fixed 零收益,
     却会强制根层成为 composited layer + backdrop root,
     让每一层 backdrop-filter 都回溯抓取整屏背景重绘 —— 是崩溃的放大器。 */
}

/* 逐层打穿(加 html 前缀提高特异性) */
html body, html #root, html #root &gt; *, html .teams-container, ... {
  background-color: transparent !important;
  background-image: none !important;
}

/* 关键:作用域令牌覆盖(见 design-tokens.md) */
[data-theme=dark], html.cb-dark, body[data-vscode-theme-name=&quot;IDE Night&quot;],
body.vscode-dark, body.dark, .vscode-dark, .dark { --wb-home-bg-primary: ... !important; }

/* 不支持模糊时降级 */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { ... }

第 2 步:用探针验证(不要跳过)

probe.mjs 会从 asar 里抽出 index.html 与全部样式表(按真实 &lt;link&gt; 顺序),用真实 DOM 结构渲染,headless Chrome 计算最终值。

> 样式表来源随版本分叉(别写死数量):
> 老版本(≤5.7.x)里 index.html 有 53 个 &lt;link rel=stylesheet&gt;,直接按链接顺序抽即可;
> 5.8.3+ 的 index.html 里 href=&quot;./assets/…&quot; 全是 modulepreload 的 js,
> 一个 CSS 链接都没有(样式表改由 JS 模块导入),且这些 CSS 与入口 html 都是
> unpacked 文件(真实字节在磁盘 app.asar.unpacked/)。
> 所以 probe.mjs 先用 readFileAny 兼容两种布局,抽不到链接时退化为已知加载点
> (与 apply.mjs 的 CSS_PATTERNS 同步):
> 5.9.3+ → tokens-*.css(令牌层,必加载)+ cb-bridge-*.css(--cb-* 桥接层);
> ≤5.8.x → index-*.css(入口主表)+ main-content-core-*.css(主内容区)。

html 背景图   = linear-gradient(...), url(&quot;data:image/jpeg;...&quot;)
teams-container = rgba(0, 0, 0, 0)              ← 必须透明,否则图被挡
侧栏外层      = rgba(13, 36, 44, 0.58)  bf=blur(20px) saturate(1.25)
侧栏内层 list = rgba(0, 0, 0, 0)                ← 不能和外层同色叠加
主内容区      = rgba(9, 29, 36, 0.1)   bf=blur(2px)   ← 必须很淡、几乎不模糊

必做对照组(--baseline):确认"改之前"确实是旧值,否则你不知道自己改了什么。

透明度预算(这是背景图能不能看见的关键):

下面用 &lt;px&gt; 代指各皮肤自己的令牌前缀(如 --cs- / --mf- / --sd- / --si- /
--tm- / --rc- / --sh-)——各套前缀不同,跨皮肤复制时必须整段替换,
只改色值不改前缀会导致令牌全部解析不到。

层 建议值 说明
--&lt;px&gt;veil-top/bottom 0.22 / 0.14 配已压暗的壁纸;原图很亮时往上加,但别超过 0.35
--&lt;px&gt;glass-content-area 0.10 主内容区只留薄着色
主内容区 backdrop-filter none 见下方硬性规则,不要给主内容区加任何 blur
--&lt;px&gt;glass-sidebar 0.62 侧栏需要可读性,可以重一些
--&lt;px&gt;blur 20px 侧栏/卡片/弹窗的模糊

注意同色层叠乘算:两层 0.74 叠出的是 1−0.26² ≈ 0.93,几乎实心。侧边栏内部的 .conversation-list 要显式设为 transparent。

毛玻璃的两条硬性规则(违反会崩)

backdrop-filter 的成本 ≈ 面积 × 模糊半径 × 嵌套层数。

  1. 全屏容器一律不加 backdrop-filter。类名带 workspace / pane / main-area / content 的几乎都是全屏,
    一律 backdrop-filter: none + background-color: transparent;需要压暗就用 linear-gradient
    当 background-image(纯 paint,零合成成本)。
  2. 只在小面积、非嵌套的元素上用:侧边栏最外层一处、输入框、弹窗/菜单/卡片。
    内部子容器改 transparent + none,并给每个保留 filter 的元素加 contain: paint。

完整清单与排查顺序见 references/troubleshooting.md 第 9、13 条;
深浅底图、发布包与 asar 内容核对相关问题见第 15–19 条。

背景图太亮怎么办:压图,不要加重遮罩

原图中位亮度 > 150 就不适合直接当深色主题壁纸(浅色文字压上去对比度不到 2:1)。
加重遮罩是错的——图会被蒙成灰雾,用户照样抱怨"看不到图"。

正确做法:预处理出一份深色专用壁纸,再配轻遮罩。

> 不要用 ImageEnhance.Color 提饱和补偿。 压暗后再提饱和 + 逐通道套同一条曲线,
> 会把色相改坏 —— v2.7.1 就是这么翻的车,用户的原话是"把照片的质感给调了"。
> 低亮度下还有 Purkinje 效应(人眼对红/绿敏感度下降),降饱和 + 压暗会让中性色发冷蓝灰。

唯一安全的做法:只映射 HSV 的 V(明度),H、S 原样写回。

import colorsys
from PIL import Image

IN_PTS  = [0,   40,  186, 230, 255]
OUT_PTS = [0,   28,  102, 148, 168]      # 只压 V;按原图直方图调这几个点

def build_lut(in_pts, out_pts):
    lut = [0]*256
    for i in range(256):
        for k in range(len(in_pts)-1):
            if in_pts[k] &lt;= i &lt;= in_pts[k+1]:
                t = (i-in_pts[k]) / (in_pts[k+1]-in_pts[k] or 1)
                lut[i] = int(round(out_pts[k] + t*(out_pts[k+1]-out_pts[k])))
                break
        else:
            lut[i] = out_pts[-1]
    return lut

lut = build_lut(IN_PTS, OUT_PTS)
im = Image.open(src).convert(&#x27;RGB&#x27;)
px = im.load()
for y in range(im.height):
    for x in range(im.width):
        r, g, b = px[x, y]
        h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
        v2 = lut[int(round(v*255))] / 255
        r2, g2, b2 = colorsys.hsv_to_rgb(h, s, v2)   # h、s 原样传回 → 零漂移
        px[x, y] = (int(r2*255), int(g2*255), int(b2*255))
im.save(dst, &#x27;JPEG&#x27;, quality=88, optimize=True)

目标:压暗后中位亮度 ≈ 100,配 α≈0.20 的遮罩,主区对比度可达 4.9:1(过 WCAG AA)。
实测案例:

原图 处理 压暗后 遮罩 对比度
202 / 233(夏日冰饮,早期 PIL 三件套) ×0.50 亮 / ×1.35 饱和 / ×1.08 对比 100 / 117 0.24 / 0.16 4.89:1
138 / 197(海边车站,早期 PIL 三件套) ×0.58 亮 / ×1.25 饱和 / ×1.08 对比 85 / 118 0.22 / 0.14 4.98:1
186 / 213(咖啡小雪,HSV-V-only) 只映射 V,H/S 零漂移 105 / 137 0.25 / 0.18 7.16:1(AAA)
142 / 228(木屋晨读,HSV-V-only) 只映射 V,H/S 零漂移 99 / 137 0.26 / 0.20 主区 7.96:1(AAA)/ 最亮处 4.68:1(AA)

前两行是 v1.x 期的老做法,色相有漂移(用户明确否定过);新皮肤一律用第三行的 HSV-V-only。
完整机理、反面教材与路线实测对照见 references/troubleshooting.md 第 19 条。

只在文字区加压:「顶部压光带」(2026-10-22 实测,保色调与可读性兼得)

有些图的最亮处恰好是主内容区标题所在的位置 —— 白墙、天空、雪地、过曝的窗光。
此时无论怎么调全局遮罩都别扭:轻了标题看不清(实测 3.42:1),重了整个画面失去原有色调。

错的是"全幅加压"这个办法,不是"保色调"这个目标。 文字只落在画面上部 1/3,
那就只压上部:在主内容区的 background-image 里多叠一层顶部渐变,其余图层不动。

background-image:
  /* ① 顶部压光带:只压上 38%,向下完全透明 */
  linear-gradient(180deg, rgba(20, 14, 10, 0.52) 0%, rgba(20, 14, 10, 0) 38%),
  /* ② 全局遮罩 */
  linear-gradient(var(--xx-veil-angle), var(--xx-veil-top) 0%, var(--xx-veil-bottom) 100%),
  /* ③ 背景图 */
  __HERO__ !important;
/* ⚠️ 三个图层 → repeat / position / size 都必须写三个值,少一个整条声明作废 */
background-repeat: no-repeat, no-repeat, no-repeat !important;
background-position: center, center, center !important;
background-size: cover, cover, cover !important;

实测(暖白墙 + 米色浴袍的一张横图,压暗刻意做轻到 med 116):

方案 标题区对比 主体区亮度 判定
全局遮罩 0.10 3.42:1 原样 ✗ 标题看不清
全局遮罩加到 0.14 4.15:1 全画面 -4% ✗ 仍不足,且色调开始变闷
全局 0.10 + 顶部压光带 0.52 4.58:1 一个像素没动 ✔ 七个采样点全 ≥4.5:1

判断什么时候用它:原图的 p95 很高(>200)、且高光集中在画面上部或四角时。
压光带的高度按「文字实际落在哪」定,一般 30~40%;alpha 从 0.4 起,按实测对比度加到 0.5 上下。
别做成全幅渐变 —— 那就退化成重遮罩了,效果一样,还多一层绘制。

上面那段逐像素 colorsys 可以换成等比缩放(等价、快两个数量级)

数学等价:RGB 乘一个标量 k 后 max&#x27; = k·max、min&#x27; = k·min,
而 S = 1 − min/max(比值不变,S 零漂移)、H 只依赖三通道的大小顺序与差值比例
(大小顺序不变、(g−b)/d 各项的 k 全部约掉,H 零漂移)。
所以「查 LUT 得到新 V2,再令 k = V2/V」与逐像素转 HSV→改 V→转回 RGB 结果完全一致,
但可以整体走 numpy 向量化,1740×1086 从数十秒降到亚秒级:

arr  = np.asarray(im).astype(np.float32) / 255.0
v    = arr.max(2)
v2   = np.array(lut, dtype=np.float32)[np.clip(np.round(v*255), 0, 255).astype(int)] / 255.0
k    = np.where(v &gt; 1e-6, v2 / np.where(v &gt; 1e-6, v, 1.0), 0.0)   # 纯黑像素保持纯黑
out  = Image.fromarray((np.clip(arr * k[:, :, None], 0, 1) * 255).astype(np.uint8))

别用 np.choose(seg, table) 拼 HSV 逆变换 —— 表维度容易对不齐,
且 np.choose 有 32 项上限;等比缩放既准确又不会踩这些坑。

浅色主题「太亮」怎么办:压高光 + 抬暗部,不要等比压暗

用户说「白色皮肤太亮」时,最容易想到的做法是把上面那条 LUT 整体乘一个小于 1 的系数。
这是错的,而且会直接踩坏可读性。

原因:浅色模式下文字是深色,对比度由「背景是否够浅」决定。等比压暗会把底图的暗部
一起压下去,深色文字恰好落在最暗的背景上 → 最差处对比度先崩。实测(一套本地个人皮肤,等比档):

档位 底图中位 主区中位 主区平均对比 最差 5% 判定
现状(未调暗) 159 210 10.33 6.61 ✔
温和 ×0.93 148 193 8.70 4.89 ✔
明显 ×0.86 137 174 7.03 3.40 ✘ 低于 AA 4.5
深度 ×0.79 126 153 5.52 2.40 ✘

正确形态是把动态范围往中间压,而不是整体下移:

锚点(延展图分位) p5 p25 p50 p75 p95 p99
v1(偏亮) 62 120 172 196 218 232
v2(调暗,采用) 88 134 140 162 182 196

即 低端往上抬、高端往下压。效果:底图 p5 65→79(暗部反而更浅)、中位 159→131、
p95 206→173,整体观感明显变暗,而最差处对比度不降反升(6.61 → 5.40 是绝对亮度下移后的值,
若同亮度对比则是上升的;关键是它稳在 AA 之上)。

配套三处(只压图不改这三处,玻璃会把辛苦调暗的底图又提亮回去):

  1. 冷白蒙版减薄(0.32→0.42 改 0.26→0.36)
  2. 主区着色减薄(0.35 → 0.24)
  3. 近白玻璃整体下移一档(rgb(240,242,251) → rgb(224,229,244),alpha 同步小降)

⚠️ 玻璃层是全屏面积最大的一层(侧边栏 + 面板 + 卡片),它才是"亮不亮"的第一决定项,
底图只占其次。只调底图不动玻璃,用户大概率还是会说"没变化"。

⚠️ 改浅色块时用按字面量全局替换而不是逐行改:一套皮肤里同一组近白色会散落几十处
(实测某套皮肤里 rgba(244,245,252,…) 出现 72 次、rgba(240,242,251,…) 出现 13 次)。
替换前先确认旧字面量在深色作用域出现 0 次,否则会把深色模式一起改掉。
⚠️ 近白填充色与深色文字令牌可能只差一位数字(244,245,252 vs 244,246,253),
替换串必须写全,替换后回读计数确认旧值为 0、新值数量对得上(差的数字就是被误伤的行)。

验收口径:各层平均对比度 ≥ 7:1(AAA)、最差 5% 处 ≥ 4.5:1(AA)。
"最差 5%"才是真正的门槛,只看平均值会漏掉上面那张表里的 ✘。

量化漂移的两笔账,别混算(否则会误判成"色相被改坏了")

用 colorsys 逐像素复测「成品 JPEG vs 合成图」的色相漂移时,测出来的数字是两项误差之和:

来源 量级(S>0.3 子集实测) 说明
8 位量化 + 算法自身舍入 ≈0.8° 数学上应为 0,这是 uint8 取整的固有噪声
JPEG 色度二次采样(4:2:0) +1.0~1.5° 色度分辨率被砍半,边缘串色
合计(subsampling 默认值) 1.8~2.3°

所以:对比度/漂移的验收数字要标注测的是哪一层。
cafe-snow 文档里的「色相漂移 0.000°」是数学层的结论(未过 JPEG);
木屋晨读实测的 1.84° 是含 JPEG 的成品层结论 —— 两者不矛盾。
想压低 JPEG 那一项:im.save(dst, &#x27;JPEG&#x27;, quality=90, optimize=True, subsampling=0)
(subsampling=0 = 4:4:4 不做色度下采样;代价是体积约 +40%,对 300 MB 的 asar 可忽略)。
另外别拿低饱和像素参与统计:S 只有 0.05 的灰像素,1 个色阶的取整就能让 hue 跳几十度,
会把均值污染到 7° 以上(实测同一张图 S>0.05 阈值得 7.06°、S>0.30 阈值得 2.25°)。

竖幅原图:别用 cover 硬裁,做横向延展

html 的背景是 background-size: cover。竖幅原图在横屏上会被裁掉大半 ——
按 cover 的规则 scale = max(vw/iw, vh/ih),可见高度 = vh / scale。

原图 视口 scale 可见高度 裁掉
724×1086(木屋晨读) 1280×832 1.768 470 / 1086 px 57%
603×1306(迷雾森林) 1280×832 2.123 392 / 1306 px 70%

只剩脸到膝盖一条横带,同时 1.8~2.1 倍的放大在 Retina 上还会发虚。
正确做法:先把画面横向延展到接近视口比例,再交给 cover ——
cover 只裁掉几个百分点,构图完整,放大倍率也降到 1 以内。

# 目标:把 src(724×1086)延展成 1740×1086(比例 1.60,覆盖 1280×832 时可见 96% 面积)
FW = (W - src.width) // 2          # 每侧要补的宽度,508
SW, D, BLUR_MAX = 56, 200, 58      # 取样色柱宽 / 羽化距离 / 最大模糊半径
# ⚠️ SW 不要超过 60px —— 这是硬红线,不是建议。
#    取样越宽,越容易把人物的头发 / 手臂 / 腿卷进拉伸区,模糊后变成**人形残影**。
#    实测(2026-10-22):把取样放宽到 200/260px 想&quot;保住明暗分布&quot;,结果左侧浮出头发、
#    右侧浮出小腿,整张图像三重曝光,只能推倒重做。
#    若边缘本身就贴着人物(常见于满幅构图),SW 再缩到 24~32px。

def stretch(side):                 # 取边缘色柱横向拉伸铺满一侧
    strip = src.crop((0,0,SW,H)) if side == &quot;left&quot; else src.crop((src.width-SW,0,src.width,H))
    return strip.resize((FW + 80, H), Image.LANCZOS)

def prog_blur(im, max_r=BLUR_MAX):  # 渐进虚化:靠近接缝最清晰,向外模糊度递增
    a = np.asarray(im).astype(np.float32)
    b = np.asarray(im.filter(ImageFilter.GaussianBlur(max_r))).astype(np.float32)
    t = ((np.arange(im.width)[::-1]) / (im.width - 1)) ** 0.85
    return Image.fromarray((a*(1-t[None,:,None]) + b*t[None,:,None]).astype(np.uint8))

canvas = Image.new(&quot;RGB&quot;, (W, H))
canvas.paste(prog_blur(stretch(&quot;left&quot;)), (0, 0))
canvas.paste(src, (FW, 0))
canvas.paste(prog_blur(stretch(&quot;right&quot;).transpose(Image.FLIP_LEFT_RIGHT)).transpose(Image.FLIP_LEFT_RIGHT),
             (W-FW, 0))

# 接缝软化带:±75px 内叠一层 20 半径模糊,线性渐入(这一步是接缝能不能消失的关键)
a = np.asarray(canvas).astype(np.float32)
b = np.asarray(canvas.filter(ImageFilter.GaussianBlur(20))).astype(np.float32)
band = np.zeros(W, dtype=np.float32)
for seam in (FW, W-FW):
    for x in range(max(0, seam-75), min(W, seam+75)):
        band[x] = max(band[x], 1 - abs(x - seam) / 75)
band = (band ** 1.4)[None, :, None]
canvas = Image.fromarray((a*(1-band) + b*band).astype(np.uint8))

三条被否掉的路(都真跑过,别再走):

方案 结果
整图 cover 放大后模糊,再叠清晰原图 模糊层里人形重影——照片主体被复制到左右两侧各一份,像三重曝光
边缘色柱铺满 + 恒定模糊 接缝处「清晰↔模糊」在 1 px 内突变,肉眼一看就是拼贴
加宽取样(150~300px)再拉伸 + 模糊 想"保住明暗分布",实际把人物的头发 / 腿一起拉伸,两侧浮出人形残影,比窄色柱更糟

关键差别就在 渐进虚化 + 接缝软化带:模糊量从接缝处的 0 平滑增长到最外端的 BLUR_MAX,
读起来是景深/广角过渡,而不是"贴上去的一条边"。木屋晨读前两版都栽在上面两点,第三版接缝不可见。

左右不必等宽(2026-10-22 补充):侧栏会吃掉左侧 264px 的可见面积,
所以左侧多补、右侧少补,让「可见区」左右平衡,主体才能落在主内容区中部。

设窗口宽 1280、侧栏 264、视口比例下的 scale、主体在原图的横坐标 sx:

  可见区中心 = (264 + 1280) / 2 = 772
  L = 772 / scale − sx + 裁切量/2        # 左侧延展宽度
  R = W − L − src.width                 # 右侧延展宽度(不能为负,否则缩小画布)

实测(1700×1024 画布、sx≈350、scale 0.8125):L=662 / R=270,主体落在窗口 700;
同样的图画布用等宽延展(L=R=466)时主体只在 578,明显偏左。差 122px,肉眼一眼能看出来。

目标尺寸怎么定:取 宽度 = 高度 × 视口比例 × 1.04。
视口比例看 system_profiler SPDisplaysDataType | grep Resolution,
数值是物理像素,CSS 视口通常要再除以 2(2560×1664 → 1280×832,比例 1.538),
留 4% 余量应对窗口宽度变化。多留一点没坏处(cover 会裁),留少了就白做。

> 延展出来的两侧是模糊的 —— 这没关系,实际界面里左侧被侧边栏玻璃压住,
> 右侧本来就在窗口边缘,只有中间那条清晰区是"主角"。

满幅图做不了延展:画框式(≈1:1、四边全是主体)

什么时候会撞上:原图接近正方形(1.00~1.15),且画面被主体填满 —— 人像特写、平铺静物、
俯拍构图。这类图两条常规路都走不通:

  • 直接 cover:窗口 1.538 vs 图 1.037,垂直要裁 33%,主体必被切。
  • 模糊延展:取样区不论取多窄都会卷进主体(腿、手臂、头发、衣料),拉伸 + 模糊后
    两侧浮出轮廓残影 —— 就是上一节「被否掉的路」第 3 条,在满幅图上无法回避。

唯一干净的解法:画框式 —— 照片保持完整、按高度铺满、水平居中,两侧用照片主色的深调
填充,照片左右边缘做 alpha 羽化消除矩形硬边。

H  = 832                                  # = 窗口高
W  = round(src.width * H / src.height)    # 照片按高度铺满后的宽
CW = 1280                                 # 画布宽(= 窗口宽)
L  = (CW - W) // 2

canvas = Image.new(&quot;RGB&quot;, (CW, H))
# 两侧填色:照片边缘平均色再压深 30%,从外向内 0.72→1.0 渐变,避免死黑
BASE = (58, 42, 38)
for x in range(L):
    k = 0.72 + 0.28 * (x / max(1, L - 1))
    d.line([(x, 0), (x, H)], fill=tuple(int(c * k) for c in BASE))
for x in range(L + W, CW):
    k = 1.0 - 0.28 * ((CW - x) / max(1, CW - (L + W)))
    d.line([(x, 0), (x, H)], fill=tuple(int(c * k) for c in BASE))

# 照片左右各 70px 羽化,边界不见矩形
mask = Image.new(&quot;L&quot;, (W, H), 255)
for i in range(70):
    a = int(round(255 * i / 70))
    mask.paste(a, (i, 0, i + 1, H))
    mask.paste(a, (W - 1 - i, 0, W - i, H))
canvas.paste(photo, (L, 0), mask)

三个参数的经验值:

参数 取值 说明
羽化宽度 50~80px < 30 看得出矩形边;> 120 照片两侧内容被吃掉
填色 照片边缘平均色 压深 30% 别用中性灰 —— 暖调照片旁边贴一块灰,像放了张纸
填色渐变 外侧 0.72 → 贴边 1.0 侧栏玻璃盖上去之后才有"深色桌面"的层次,不是死板一块

必须提前告知用户的取舍:画框式的观感是「照片放在深色桌面上」,不是「铺满整个窗口」。
它适合满幅特写,不适合风景照(铺不满会显得空)。这不是实现不到位,是素材比例决定的。

变体:单侧填充(空档正好落在侧栏底下时)

场景:原图 1.30~1.45(不宽不方),主体偏一侧。按宽度铺满要裁上下、且水平零余量;
按高度铺满倒是画质更好(放大倍率更低),但照片会变窄、需要横向定位,定位完一侧就空出一条。

如果这条空档正好落在侧栏底下(264px),就不必两侧都填 —— 只填那一侧,纯色渐变 + 羽化即可。
因为可见部分只剩几十像素,边界根本看不出来。

实测(1024×767,1.335):

步骤 数据
按宽铺满 scale 1.25,裁上下 13%,主体钉在 42%,对不上主内容区中心 772
改按高铺满 scale 1.085(放大更少、不裁上下),照片 1111×832,获得横向定位自由
主体定位到 772 左边界 305,右侧溢出 136(裁掉的是床单与手臂边缘)
左侧 305px 试延展 手指与针织衫的残影 —— 左边缘含人物,取样必卷进去
改单侧画框 主色渐变 0.70→1.0 + 照片左边缘 80px 羽化;305px 中 264px 在侧栏下,可见仅 41px
L = round(772 - SUBJ * W)              # 主体定位后,照片的左边界
canvas = Image.new(&quot;RGB&quot;, (CW, H))
d = ImageDraw.Draw(canvas)
for x in range(max(0, L)):             # 只填左侧;右侧溢出交给画布裁掉
    k = 0.70 + 0.30 * (x / max(1, L - 1))
    d.line([(x, 0), (x, H)], fill=tuple(int(c * k) for c in BASE))

mask = Image.new(&quot;L&quot;, (W, H), 255)
for i in range(80):                    # 只羽化贴边的那一侧,另一侧不用管
    mask.paste(int(round(255 * i / 80)), (i, 0, i + 1, H))
canvas.paste(photo, (L, 0), mask)

判据(决定单侧还是双侧):

空档宽度 × 0.86 &lt; 264(侧栏宽)  → 可见 ≤ 50px,单侧填充够用,看不出边界
否则                            → 双侧都要填,两侧都得羽化

> 这是整个技能里最反直觉的一条:被侧栏盖住的那段,用纯色和用照片延展,用户都看不到;
> 但延展可能带出人形残影,纯色永远不会。所以遮挡不是限制,是优势 ——
> 该偷懒的地方就偷懒,别为了"技术上的完整"去做一段没人看的延展。

背景图四种处理,一表选完

原图比例 方案 前提 / 代价
≥ 1.45 直接 cover 挂 html 只裁上下几个百分点,最省事
1.45 ~ 1.60 挂主内容区容器 需要水平余量时;侧栏不再压图
≤ 1.30 且边缘干净 横向延展(左右可非对称) 取样色柱 ≤60px,且必须避开人物
1.30 ~ 1.45 且主体偏一侧 按高铺满 + 单侧填充 空档要被侧栏盖住(×0.86 < 264)
≈ 1.00 且四边全是主体 双侧画框 满幅图延展必出残影,只能居中留边

第 3 步:安装

apply.mjs 会:备份官方原版 → 从干净基线 app.asar.bak 重打(幂等,不会叠加注入)→ 只注入 index.html 实际引用的 CSS → 自检 → 原子替换(同目录 cp + mv,产生新 inode,运行中的 App 不受影响)。

自检有三关,任何一关不过就中止、不动任何现有文件:

越界文件数 0;插入内容可读回 true
错位检测:18422 个非目标文件全部逐字节一致 ✔

第三关是最重要的一关。只查前两关发现不了 offset 错位——错位后每个位置照样读得出东西、也不越界,
只是内容是别的文件的碎片,后果是主 bundle 被静默打坏、程序启动即崩。
大文件只比对首尾各 64KB(错位必然导致头部不同),所以很快。

安装管道:三个必过关口(改完还要能启动)

改 app.asar 本身不是难点,难点是改完还能起来。三个关口,apply.mjs 全自动处理:

关口 触发条件 不过的症状 处理动作
asar 完整性哈希 5.4.5+(Fuse #4 开启) 退出码 133,Integrity check failed 重算 integrity + 写回 plist / PE 资源
ad-hoc 重签名 macOS,且改过 Info.plist 退出码 137,零输出、无崩溃日志 codesign --force --deep --sign -
unpacked 布局 5.8.3+ 旧管道直接抛错「无法原地注入」 自动转交 apply-unpacked.mjs(磁盘写入 + 头部同步)

不重启 App 判断是否健康(原位起第二实例,不是拷到 /tmp——副本会被系统击杀):

env -u ELECTRON_RUN_AS_NODE -u NODE_OPTIONS -u NODE_PATH \
  /Applications/WorkBuddy.app/Contents/MacOS/Electron
  • 退出码 0 且日志有主进程启动标记 → exec、完整性、签名全过(单实例交接退出)
  • 133 → 完整性哈希不对;137 → 签名没修好

两个必须提前告知用户的代价:原厂 Developer ID 签名变成 ad-hoc 且不可逆(restore.mjs 也恢复不了,
回出厂只能重装);官方自动更新会让皮肤失效,需重跑一次 apply。

完整机理、A/B 测试矩阵、沙箱拦截与 Broker shim 的绕法 → references/install-pipeline.md。

安装前先预览(v1.8.0 新增)

不想每次真装到 /Applications/WorkBuddy.app 里再看效果,可以用 preview.mjs 先渲染深色 / 浅色两张截图:

env -u NODE_OPTIONS /Users/daily/.workbuddy/binaries/node/versions/22.12.0/bin/node \
  scripts/preview.mjs --skin skins/cafe-snow --out /tmp/preview
  • 从 ~/.workbuddy/backups/app.asar.pristine-* 抽取真实 WorkBuddy 基础 CSS 作地基;
  • 读取皮肤的 workbuddy.css,把 __HERO__ / __HERO_LIGHT__ 替换成真实 base64;
  • headless Chrome 输出 preview-dark.png、preview-light.png;
  • 生成 preview.html 联系表:并排展示深浅截图、色板 swatch、图片体检结论、皮肤元信息。

全程不修改 /Applications/ 下任何文件,开发新皮肤或挑选皮肤时可以先看再装。

第 4 步:备份放对地方

app.asar.bak 放在 /Applications/.../Resources/ 内,用户重装或覆盖安装就会消失。做三处冗余:

  1. ~/.workbuddy/backups/app.asar.pristine-&lt;时间戳&gt; ← 主备份,重装不丢
  2. /Applications/.../app.asar.bak ← 脚本自动建
  3. /tmp/wb-skin/app.asar.pristine ← 兜底

另外给用户一份双击即用、不依赖 node 的回滚脚本(会自己关 WorkBuddy、按文件大小挑可靠备份、
原子替换、再校验一次),这样程序崩到无响应时用户也能自救。

第 4 步:让用户重启

macOS 点红叉只是关窗口,WorkBuddy 有托盘常驻,进程不会退出。必须 Cmd+Q 完全退出再打开。
Windows 关窗后 WorkBuddy 通常退到系统托盘(图标常驻),需在托盘右键选「退出」,或用任务管理器结束进程后重新打开。
Linux 可用 pkill -f workbuddy 结束全部进程后重新打开。

浅色模式:闸门 + 生成器(2026-09-06 起,改皮肤必读)

本 skill 曾是深色优先的:8 套内置皮肤只有 2 套真正做了浅色模式,其余 6 套的浅色块
是个没完工的骨架 —— 只有 7 个令牌,而且全部指向只有深色定义的原料令牌
(如 --mf-glass-card),切到浅色模式主背景根本不变浅。
浅色缺陷靠人工发现永远滞后(同一天连补三轮:选中态 → hover 令牌族 → 浮层令牌族),
所以现在的规矩是:

浅色模式最容易犯的错:把背景图盖没了

(2026-10-22 用户实测反馈原话:「浅色的背景图不明显,几乎看不清」)

浅色块很自然会被写成「高不透明度暖白」——遮罩 0.32 均匀、内容区着色 0.35、卡片 0.62。
三层叠完,图的可见度只剩 (1−0.32)×(1−0.35) ≈ 0.44,再乘上本来就亮的浅色底图,
屏幕上就是一片米白,纹理基本消失。这不是图的问题,是叠了三层白。

层 常见写法 应该改成 理由
背景图遮罩 0.32 / 0.22 均匀 0.32 / 0.06 上下渐变 顶部压住标题与暗部,底部让图透出来
主内容区着色 0.35 0.08 这层直接盖在图上,是"看不见图"的主因
卡片 0.62 保持 0.62 文字主要落在卡片里,这层省不得
侧栏 / 面板 / 输入框 0.70 / 0.66 / 0.68 0.55 左右 有余量,压太实就成实心块

实测(同一张暖白底图、同一套深棕文字):

配置 图的可见度 8 个采样点对比度
均匀 0.32/0.22 + 内容区 0.35 几乎不可见 多数达标但图没了
0.32→0.06 渐变 + 内容区 0.08 纹理清晰可辨 全部 ≥4.5:1(标题 7.88、卡片 5.92、裸文字 9.86、浴袍暗部 4.85)

> 一句话记法:浅色模式的遮罩是"渐变滤镜",不是"白漆"。
> 顶部浓、底部淡,浓的那端护文字,淡的那端留图。

浅色模式的两条硬规则:底图不许抬暗部,「亮」由界面提供而不是由图片提供

症状(2026-10-24 实测,用户原话):把暖调暗调照片做成浅色版,用「抬暗部」的 LUT 提亮画面,
结果人物被改"妖化" —— 肤色发白、五官与身体细节被压平,看着像塑料。

根因:V 通道的抬升是全局的。LUT 里把 116 的中间调抬到 192,肤色那一整段就被压扁:
中间的明暗层次(阴影、轮廓、结构)被抽干,只剩一片均匀高亮。
更糟的是暗部色相污染 —— 画面里的墨绿书架被抬到中等亮度后紧贴皮肤,给皮肤染上一层青。

正确做法:底图只压高光,中间调与暗部一律不动;浅色可读性靠「界面本身变浅」实现。

# 浅色底图:只压最高光,其余原样(人物零改动)
light = map_v(base, [0, 232, 255], [0, 226, 240], &quot;hero_light.jpg&quot;)

「界面变浅」靠三样,全在 CSS 里,不碰图片:

层 常规值 浅色模式改成 作用
玻璃(侧栏 / 面板 / 输入框) 0.55 0.86 ~ 0.88 界面主体变成浅色板
卡片 0.62 0.84 文字载体必须衬得住
主内容区着色 0.08 0.30 图从「背景主角」降级为「纹理」
顶部提亮带 — linear-gradient(180deg, rgba(253,250,246,.55), transparent 34%) 盖住画面顶部暗部

实测(同一张图,改前 → 改后):

位置 改前 改后
标题区 2.45:1 6.41:1
卡片 4.45:1 5.93:1
气泡区 / 输入框 9.54 / 6.35 12.43 / 6.35
人物肤色 被抬白、层次压平 零改动

> 判据:只要画面里有「人」或任何有明暗结构的物体,浅色版就不要动它的中间调。
> 浅色模式的"亮"应该由界面提供(玻璃层 + 提亮带),不是由图片提供。
> 顺带说明为什么「浅色模式要把背景图盖没了」那条(v1.13.3)不矛盾:
> 那条讲的是均匀遮罩会把图盖成一片白;这里讲的是不要用抬暗部去救它。
> 两者合起来就是:图要么原样当纹理,要么压高光,永远不要动中间调。

【结构错误】浅色模式的主内容区不能用「输入框玻璃」令牌

症状:浅色模式下背景图几乎看不见(只剩极淡的纹理),但文字很清楚、界面也很"白"。

根因(模板自带,一直没人发现):

.cbChat.dark  { --cb-main-area-background: var(--xx-glass-content-area) !important; }  /* 0.05~0.14 ✓ */
.cbChat.light { --cb-main-area-background: var(--xx-glass-input)     !important; }  /* 0.55~0.86 ✗ */

浅色那条引用的是输入框玻璃。输入框玻璃本来就该接近不透明(打字区要衬得住字),
拿它去铺主内容区,等于给整块内容区刷了一层 0.86 的白板 —— 背景图只剩 14% 透得出来。
深色模式是对的,浅色模式引用错对象。

修法:把浅色那条也指向 content-area,与深色对齐:

.cbChat.light {
  --cb-main-area-background: var(--xx-glass-content-area) !important;
}

然后把 content-area 调到 0.05~0.08(主内容区几乎全透,图才看得见),
输入框玻璃保持 0.8+ 不动(它另有用途)。

> 顺带一个通用规律:同一个令牌在深浅两种模式里可能承担完全不同职责。
> 深色下 content-area 要"极透"(让图出来),浅色下若想靠它衬字就得给面板色;
> 但把它换成 input 就一定错 —— input 的职责是"局部打字区必须清楚",不是"大面积铺底"。

【重要】preview 页测不出这类问题,必须以真机截图为准

preview.mjs / probe.mjs 的 DOM 是简化骨架,没有 .cbChat、没有真实的聊天容器层级。
凡是靠"某个容器消费了哪个令牌"才能暴露的问题(例如上面这条),preview 与 probe 全都测不出来 ——
它们只会给你一份"看起来正常"的截图。

验收顺序改成:

1. preview.mjs 出图          →  只验证「底图处理 / 构图 / 配色」
2. probe.mjs 读令牌值        →  只验证「CSS 规则是否命中、玻璃层透明度」
3. 真机 Cmd+Q 重启后截图     →  唯一能验证「界面上到底长什么样」的关卡

2026-10-24 的教训:preview 显示「照片清晰可见、对比度 5.13~12.43 全部达标」,
真机截图却是一片白 —— 差异就在 .cbChat.light 这一层,preview 根本没有这个元素。

改完皮肤 / 新增皮肤,必跑闸门

NODE=/Users/daily/.workbuddy/binaries/node/versions/22.12.0/bin/node
$NODE $SK/scripts/audit-light.mjs --strict          # 有 critical 即非零退出(build-release 打包前也会自动跑)
$NODE $SK/scripts/audit-light.mjs --emit            # 输出建议浅色覆盖值(草稿,需复核)
$NODE $SK/scripts/audit-light.mjs --all-classes     # 连白名单过滤掉的也列出(自查闸门漏判)

闸门做什么:
1. 表面令牌审计:找出「主块深色(alpha≥0.7 且亮度<140)& 浅色块未覆盖」的
popover/menu/dropdown/modal/card/panel/elevated/island/tab/bg- 令牌。
2.
对比度校验*:浅色模式下文字/背景组合的 WCAG 对比度(半透明层先 alpha 合成再算,
不拿原色直接比)。编辑区/主界面/卡片/浮层要求 ≥4.5,选中/hover 行 ≥3.0。

白名单(不要给它们生成浅色值,那是错的):
- 原料色阶(--cs-cream-500 / --wb-palette-* / --*-glass-*):只被别的令牌引用,不直接上屏
- 遮罩/阴影/叠层(mask/overlay/scrim/shadow/backdrop):浅色下本就该深
- 品牌/强调表面(badge/button/progressbar/control-selected/switch/checkbox/slider/chip):
浅色下保持品牌深色配浅字才是对的
- 反色文字(text-on-* / *-foreground / text-inv):跟配对背景绑定,属对比度问题

皮肤没有浅色层?用生成器,别手写

$NODE $SK/scripts/gen-light-layer.mjs --all                 # dry-run 预演
$NODE $SK/scripts/gen-light-layer.mjs --skin skins/xxx --apply   # 写入(自动备份 .bak)
python3 $SK/scripts/build_verify_page.py /tmp/v.html 皮肤名…  # 生成渲染验证页 → 无头 Chrome 截图人审

生成原理:明度结构复用,色相本地化 —— 浅色可读性由 L/A 决定,那部分直接复用
cafe-snow 实机验证过的浅色层;色相取各皮肤背景/表面令牌的环境色调圆均值。
注意不能用调色板强调色算均值(蓝+金+珊瑚一混就跑到绿区)。

写这类工具时踩过的三个坑(血泪,别再踩)

  1. 切主题作用域必须按 CSS 块的选择器判定,不能 css.indexOf(&#x27;[data-theme=&quot;light&quot;]&#x27;)
    —— 那个字符串会先在注释或组合选择器里出现,把浅色声明错划进主块,报出几百个假阳性。
  2. 求值必须在「主块 ∪ 浅色覆盖」合并表上做:--a: var(--b) 在主块解析出深色,
    但浅色块可能把 --b 改浅了,真实渲染是浅色。
  3. @media/@supports 条件块只能用来填空:@supports not (backdrop-filter…) 里藏着
    !important 降级值,Electron 支持毛玻璃、那块根本不生效;被无条件收编后它那句
    !important 会假性压过浅色值。
  4. for (const [name] of map.keys()) 对字符串解构拿到的是第一个字符 - ——
    迭代 Map 直接写 for (const [k, v] of map)。

Guardrails

  • 首次执行会备份 app.asar → app.asar.bak;这个备份要一直保留,它既是还原点也是每次重打的干净基线。
    另外务必再往 ~/.workbuddy/backups/ 存一份(见第 4 步)——放在 app 目录内的备份会被重装清掉。
  • 会修改宿主程序:详见 SECURITY.md。只改 app.asar 内的 CSS 文本,不动可执行代码;一键可还原。
  • 改 app.asar 会使官方代码签名失效,官方自动更新可能拒绝 —— 更新前先 restore.mjs。
  • 5.4.5 起会改 Info.plist 并对 .app 做 ad-hoc 重签名(否则起不来,见第 3 步)。
    原厂 Developer ID 签名不可逆地丢失,restore.mjs 也恢复不了;要回出厂状态只能重装。
    这一点必须事先告知用户再执行。--no-resign 可跳过重签名,但那样应用启动会被 SIGKILL。
  • 需要重启才生效。
  • 无外部网络请求、无数据上报、无删除操作(逐项审计见 SECURITY.md)。
  • --diag 注入的诊断脚本会往 localStorage.__SKIN_DIAG__ 写真实 DOM 快照(仅本地),排查完记得用不带 --diag 的方式重新打一次包。
  • 不执行皮肤包内的 JS,不加载外部 CSS 资源。

常见问题(按你会问的顺序排)

> 这一节是问答式的,一句话给结论。想看机理和取证步骤,去 references/troubleshooting.md(那本是技术手册)。

Q1|会不会把 WorkBuddy 弄坏?能还原吗?
不损坏程序本体:只改 app.asar 里的 CSS 文本,不动可执行代码,restore.mjs 一键还原。
但有两件事不可逆,动手前必须知道:① 原厂 Developer ID 签名会变成 ad-hoc,restore 也恢复不了,
要回出厂状态只能重装;② 官方自动更新会让皮肤失效(CSS 文件名带内容哈希),更新后需重跑一次 apply。

Q2|装完重启了,界面一点没变?
按顺序查三件事:① 是不是 Cmd+Q 完全退出(点红叉只关窗口,托盘常驻进程没退,Windows 是「托盘右键退出」);
② apply.mjs 的输出里每个 CSS 的字节增量是否非 0;③ App 版本 ≥ 5.8.3 时,样式表真实字节在
app.asar.unpacked/ 磁盘目录里,asar 内只有一条 {size, integrity} 记录 —— 手工往 asar 里贴 CSS 无效。

Q3|一重启就崩 / 打不开 / 只剩一张背景图?
先看退出码:133 = asar 完整性哈希对不上;137 = 改了 plist 却忘了 ad-hoc 重签名。
这两个是 5.4.5 之后最高频的坑,apply.mjs 已内置处理;手工动过文件就会踩。取证步骤见 troubleshooting 13、14。

Q4|背景图看不见,或者糊成一片?
九成是透明度预算超了,不是图没加载。三条硬线:主内容区容器 backdrop-filter: none、
--xx-glass-content-area 的 alpha ≤ 0.12、遮罩 ≤ 0.35。图太亮的正解是预处理压暗图片,
加重遮罩只会把图蒙成灰雾(实测对照见「背景图太亮怎么办」)。

Q5|人物/主体被侧栏压住、偏左、不居中?
这是几何问题,调透明度和遮罩都没用。 照片比例接近窗口(1.45~1.60)时,把图挂 html 会得到
零水平余量——主体被钉死在窗口 46% 处,而侧栏占掉左侧 20.6%,必然压住。改挂主内容区容器就有了
可调余量。决策表与定位公式见「背景图挂哪个容器」。

Q6|切到浅色模式,某些块还是深色、文字看不清?
皮肤只做了深色令牌。跑 audit-light.mjs --strict 会列出「深色 & 浅色块未覆盖」的令牌,
--emit 给建议值;整层缺失的皮肤用 gen-light-layer.mjs 生成,别手写(这个坑一天连补三轮)。

Q7|用自己的照片做皮肤要注意什么?
① 主体位置决定挂载点与 background-position,先算再写;② 皮肤目录放
~/.workbuddy/skins-local/&lt;id&gt;/,不要放进 skins/——后者随包上传,靠排除名单拦属于
「默认发货、手动拦下」,漏一项照片就静默进包;③ 要发布还得清掉文档与注释里对画面内容的文字描述,
描述本身同样会泄露来源。

Q8|一张图能生成几套皮肤?
深浅两套共用一张图,但底图要分别处理:深色版压暗到中位 ≈100(只映射 HSV 的 V),
浅色版只压高光。想换色调(冷/暖/低饱和)只改色板三块色值,骨架不用动。

Q9|网图 / 别人的照片能用吗?
技术上能。版权与肖像权自行判断——本技能只在本地渲染,不上传任何图片,无网络请求。

Q10|Windows / Linux 能用吗?
能,同一套 CSS 与脚本,apply.mjs 按 process.platform 探测安装路径。差异只有两处:
Windows 改的是 exe 的 PE 资源里的完整性哈希(不需要重签名);Linux 没有 asar 完整性校验,也不用签名。

Q11|能发布到技能市场吗?
能,但必须先跑 build-release.mjs。它把 .jpg 转成 .txt(内容是 base64)并补 heroMime,
因为平台拒收任何二进制文件——直接传 .jpg 不会报错,只是用户装上后没有背景图。

Q12|怎么彻底卸载?
node scripts/restore.mjs 还原官方外观 → 删掉 ~/.workbuddy/skins-local/&lt;id&gt;/ → 重启一次。
内置皮肤随技能目录卸载。

深度排查索引(references/troubleshooting.md —— 技术手册,按现象查)

现象 章节
完全没效果 0–5(重启确认 → 改包落盘 → CSS 加载 → 运行时覆盖 → 令牌作用域)
效果有但不理想(图看不见 / 太糊) 7 透明度预算、9/13 全屏 backdrop-filter
程序打不开、只剩一张背景图 13(错位)、14(asar 完整性) ← 5.4.5 起最高频
深色模式背景图不对 15 :has() 通配后代
浅色模式下某块仍是深色、文字看不清 浅色模式闸门(audit-light --strict → --emit 补令牌)
皮肤根本没有浅色模式(切浅色不变浅) gen-light-layer 生成器
发布包装上后报「背景图不存在」 16 build-release 只改 hero
CSS 体积莫名变大、asar 里多出图 17 注释里写了占位符原文
想确认 asar 里到底塞了哪张图 18 手工解析 asar 头 + 抠 data URI 比 sha256
要压暗原图做深色底图 19 HSV 只映射 V
皮肤完全正常、但 asar 多涨 2 MB(5 行注入字节数相同) 20 HERO_BLOCK 尾标记缺失
竖幅原图被 cover 裁掉大半 / 铺上去只剩一条横带 21 横向柔化延展(含两个被否方案)
5.8.3 上 apply 报「unpacked 文件无法原地注入」/ 换肤无效果 references/install-pipeline.md「unpacked 布局」
probe.mjs 报「读不到 renderer/index.html」 同上——新布局下 html/CSS 都在磁盘;已统一走 readFileAny
想搞清「为什么改个 CSS 还要重签名 / 退出码 133·137」 references/install-pipeline.md
背景图挂容器后探针/预览里看不见图 23 探针页高度链断裂(假阴性),不是 CSS 错
人物被侧栏压住 / 构图偏一侧 本文档「背景图挂哪个容器」+ 公式
满幅图(≈1:1)延展后两侧浮出人形残影 本文档「满幅图做不了延展:画框式」

已知限制

  1. 版本敏感:CSS 文件名带 Vite 内容哈希,随版本变化。apply.mjs 用正则匹配(index-*.css 等),大版本变动时若匹配不到会明确报错。
  2. 依赖 chromium-pickle-js;探针依赖本机 Chrome/Edge。
  3. 调试端口是封死的:直接给 Electron 加 --remote-debugging-port 会报 bad option,不要浪费时间(详见 references/troubleshooting.md)。
  4. 5.8.3+ 的 unpacked 布局下,皮肤必须经 apply.mjs(自动转交 apply-unpacked.mjs)
    完成「磁盘写入 + asar 头部同步 + plist + 重签名」四步;手工把 CSS 贴进磁盘文件不会被正确加载
    (size/integrity 会对不上)。
  5. 在本机 shell 里跑 grep 查大文件可能静默返回空(PATH 上的 broker shim 会接管 grep),
    而内置的 Grep 工具是正常的。排查时优先用 Grep 工具 / Read / python -c 做文本提取,
    别因为一次 grep 没输出就以为内容不存在。

常见问题

5 问
答案由本站依据条目字段整理
workbuddy 桌面端换肤怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_c9b9b91c/workbuddy-macos-skin。安装方式为未标注。装完刷新技能列表或重开客户端。

workbuddy 桌面端换肤支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

workbuddy 桌面端换肤权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

workbuddy 桌面端换肤收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

workbuddy 桌面端换肤用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 2026-11-06。