WorkBuddy 桌面端换肤(macOS / Windows / Linux)
给 WorkBuddy 桌面版(Electron / asar 打包)做深度换肤。这套技能的全部结论都来自真机逆向与实测,不是推测。 同一份 CSS 与脚本在 macOS / Windows / Linux 上通用,由 lib/asar.mjs::asarCandidates() 按 process.platform 探测默认安装位置。
它解决什么问题
WorkBuddy 没有开放换肤 API。改 app.asar 是唯一可行的注入面,但只把 CSS 塞进去是不够的 —— 前 6 版全部失败,原因都不是"CSS 没加载",而是下面两个坑:
- 令牌按元素级联:在
:root上写--x: ... !important对.teams-container.is-mac这类元素完全无效(它自己有声明,不去继承)。详见references/design-tokens.md。 - 透明度叠加会吃掉背景图:遮罩 + 面板 +
backdrop-filterblur 叠在一起,图片能压到只剩 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 && 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/<id>/:
skins/seaside-dusk/
├── theme.json 元信息(id / name / images;不含可覆盖参数)
├── workbuddy.css 皮肤 CSS,背景图用 __HERO__ 占位符
└── assets/
├── seaside_dusk.jpg 原图(留档)
└── seaside_dusk_dark.jpg ← 深色主题实际使用(压暗预处理后)
theme.json 关键字段:
{
"id": "seaside-dusk",
"name": "海边车站 Seaside Dusk",
"schemaVersion": 2,
"mode": "dark",
"images": { "hero": "assets/seaside_dusk_dark.jpg" }
}
theme.json 只承载元信息与图片声明 —— apply.mjs / preview.mjs 只读 id / name / images,
不会从 theme.json 注入任何 CSS 变量。所有可调数值(遮罩、玻璃、主色、模糊)都写在
workbuddy.css 里,调参直接改 CSS 即可。
workbuddy.css 里写 url("__HERO__"),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="sidebar"]) |
主内容区 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 < 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="sidebar"]) {
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 试过
「单张亮图 + 深遮罩压暗」,结果遮罩把细节整个糊死,毛玻璃背后是一片均匀暗色。
正确做法是在图上就把亮度调对,遮罩只留很薄一层:
"images": {
"hero": "assets/cafe_snow_dark_v6.jpg", // 兜底(未声明 heroDark 时使用)
"heroDark": "assets/cafe_snow_dark_v6.jpg", // → __HERO__
"heroLight": "assets/cafe_snow_light_v5.jpg" // → __HERO_LIGHT__(用户原图,未改动)
}
| theme.json 键 | 注入到 | 缺省行为 |
|---|---|---|
heroDark |
__HERO__ |
回退到 hero |
hero |
__HERO__(兜底) |
无图则警告,纯配色皮肤可忽略 |
heroLight |
__HERO_LIGHT__ |
不声明就不替换,CSS 里的兜底值生效 |
发布包里 mime 由 <key>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(> body:is(.light, .cb-light, .vscode-light, [data-vscode-theme-name="IDE Light"]))。
顺带用 :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*="popover"]」兜底 —— 类名对不上就漏,
于是在浅色模式下这些悬浮层直接画成近黑块,压上浅色模式的深色文字 → 完全不可读
(实测那块是 #2c241d,肉眼只看到"深色块",查遍 :hover 规则一无所获,因为根因是令牌不是规则)。
正确做法:按令牌覆盖,不要依赖类名。
排查利器(比逐条猜规则快得多)——扫出「主块深色 & 浅色块未覆盖」的令牌:
# 主块里平均值 <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 |
交市场时实际用这个 |
它会依次完成五步,任一步失败即非零退出,不会带着缺口继续:
- 递归复制,把每个二进制图片转成 base64 明文存成
assets/<名字>.txt(每 76 字符换行便于 diff); - 改写
theme.json里所有图片键(hero/heroDark/heroLight/ 以后新增的都算)
指向.txt,并写入对应的<key>Mime; - 引用可达自检(v1.4 新增):逐个打开
theme.json声明的图片路径确认存在; - 黑名单段扫描 + 零遗漏自检:文件名不得含任何二进制扩展名段;
再按同一套排除规则算出期望文件集与产物实际文件集逐一对账,有缺失或多余即报错; - 直接调用系统
zip产出dist/workbuddy-macos-skin.zip。
> 第 2、3 步是 v1.4 补的。旧版只改写 images.hero,导致用 heroDark / heroLight
> 的皮肤(cafe-snow)在发布包里 theme.json 仍指向已被改名的 .jpg,一 apply 就报
> 「主题声明的背景图不存在」。这个缺陷在本地开发态永远不会出现(本地是二进制图,
> 路径不变),只有发布态会炸,所以:
>
> zsh
> # 打完包必须用「发布包形态」再验一次,别只测本地
> $NODE $SK/scripts/apply.mjs --skin $SK/../../build/release/workbuddy-macos-skin/skins/cafe-snow --dry-run
>
> 两条底图都应打印出 背景图 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
> node scripts/apply.mjs --skin ~/.workbuddy/skins-local/<id>
> node scripts/preview.mjs --skin ~/.workbuddy/skins-local/<id> --out /tmp/preview
>
>
> 打包侧还有一层兜底: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: <兜底底色> !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 > *, 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="IDE Night"],
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 与全部样式表(按真实 <link> 顺序),用真实 DOM 结构渲染,headless Chrome 计算最终值。
> 样式表来源随版本分叉(别写死数量):
> 老版本(≤5.7.x)里 index.html 有 53 个 <link rel=stylesheet>,直接按链接顺序抽即可;
> 5.8.3+ 的 index.html 里 href="./assets/…" 全是 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("data:image/jpeg;...")
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):确认"改之前"确实是旧值,否则你不知道自己改了什么。
透明度预算(这是背景图能不能看见的关键):
下面用 <px> 代指各皮肤自己的令牌前缀(如 --cs- / --mf- / --sd- / --si- /
--tm- / --rc- / --sh-)——各套前缀不同,跨皮肤复制时必须整段替换,
只改色值不改前缀会导致令牌全部解析不到。
| 层 | 建议值 | 说明 |
|---|---|---|
--<px>veil-top/bottom |
0.22 / 0.14 | 配已压暗的壁纸;原图很亮时往上加,但别超过 0.35 |
--<px>glass-content-area |
0.10 | 主内容区只留薄着色 |
主内容区 backdrop-filter |
none | 见下方硬性规则,不要给主内容区加任何 blur |
--<px>glass-sidebar |
0.62 | 侧栏需要可读性,可以重一些 |
--<px>blur |
20px | 侧栏/卡片/弹窗的模糊 |
注意同色层叠乘算:两层 0.74 叠出的是 1−0.26² ≈ 0.93,几乎实心。侧边栏内部的 .conversation-list 要显式设为 transparent。
毛玻璃的两条硬性规则(违反会崩)
backdrop-filter 的成本 ≈ 面积 × 模糊半径 × 嵌套层数。
- 全屏容器一律不加
backdrop-filter。类名带workspace/pane/main-area/content的几乎都是全屏,
一律backdrop-filter: none+background-color: transparent;需要压暗就用linear-gradient
当background-image(纯 paint,零合成成本)。 - 只在小面积、非嵌套的元素上用:侧边栏最外层一处、输入框、弹窗/菜单/卡片。
内部子容器改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] <= i <= 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('RGB')
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, 'JPEG', 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' = k·max、min' = 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 > 1e-6, v2 / np.where(v > 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 之上)。
配套三处(只压图不改这三处,玻璃会把辛苦调暗的底图又提亮回去):
- 冷白蒙版减薄(0.32→0.42 改 0.26→0.36)
- 主区着色减薄(0.35 → 0.24)
- 近白玻璃整体下移一档(
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, 'JPEG', 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 想"保住明暗分布",结果左侧浮出头发、
# 右侧浮出小腿,整张图像三重曝光,只能推倒重做。
# 若边缘本身就贴着人物(常见于满幅构图),SW 再缩到 24~32px。
def stretch(side): # 取边缘色柱横向拉伸铺满一侧
strip = src.crop((0,0,SW,H)) if side == "left" 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("RGB", (W, H))
canvas.paste(prog_blur(stretch("left")), (0, 0))
canvas.paste(src, (FW, 0))
canvas.paste(prog_blur(stretch("right").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("RGB", (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("L", (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("RGB", (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("L", (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 < 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/ 内,用户重装或覆盖安装就会消失。做三处冗余:
~/.workbuddy/backups/app.asar.pristine-<时间戳>← 主备份,重装不丢/Applications/.../app.asar.bak← 脚本自动建/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], "hero_light.jpg")
「界面变浅」靠三样,全在 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 实机验证过的浅色层;色相取各皮肤背景/表面令牌的环境色调圆均值。
注意不能用调色板强调色算均值(蓝+金+珊瑚一混就跑到绿区)。
写这类工具时踩过的三个坑(血泪,别再踩)
- 切主题作用域必须按 CSS 块的选择器判定,不能
css.indexOf('[data-theme="light"]')
—— 那个字符串会先在注释或组合选择器里出现,把浅色声明错划进主块,报出几百个假阳性。 - 求值必须在「主块 ∪ 浅色覆盖」合并表上做:
--a: var(--b)在主块解析出深色,
但浅色块可能把--b改浅了,真实渲染是浅色。 - @media/@supports 条件块只能用来填空:
@supports not (backdrop-filter…)里藏着
!important降级值,Electron 支持毛玻璃、那块根本不生效;被无条件收编后它那句
!important会假性压过浅色值。 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/<id>/,不要放进 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/<id>/ → 重启一次。
内置皮肤随技能目录卸载。
深度排查索引(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)延展后两侧浮出人形残影 | 本文档「满幅图做不了延展:画框式」 |
已知限制
- 版本敏感:CSS 文件名带 Vite 内容哈希,随版本变化。
apply.mjs用正则匹配(index-*.css等),大版本变动时若匹配不到会明确报错。 - 依赖
chromium-pickle-js;探针依赖本机 Chrome/Edge。 - 调试端口是封死的:直接给 Electron 加
--remote-debugging-port会报bad option,不要浪费时间(详见references/troubleshooting.md)。 - 5.8.3+ 的 unpacked 布局下,皮肤必须经
apply.mjs(自动转交apply-unpacked.mjs)
完成「磁盘写入 + asar 头部同步 + plist + 重签名」四步;手工把 CSS 贴进磁盘文件不会被正确加载
(size/integrity 会对不上)。 - 在本机 shell 里跑
grep查大文件可能静默返回空(PATH 上的 broker shim 会接管grep),
而内置的 Grep 工具是正常的。排查时优先用 Grep 工具 /Read/python -c做文本提取,
别因为一次grep没输出就以为内容不存在。