dsh-skin-keymark — 皮肤:动态壁纸 + 纯字体颜色标注
职责
为 DSH Desktop 提供四类皮肤能力,都不触碰边框 / 阴影:
壁纸层:静态图(本地压缩 data URL 或远程 URL)与动态视频(
mp4/m4v/webm/ogv/ogg/mov/mkv/avi/ts)自由切换;本地图片与视频都可从面板选文件导入,也可以直接把文件拖拽到面板(拖入时虚线框高亮;视频上传到宿主媒体库、同源回放,上限 512 MB);铺满 / 完整 / 居中 / 拉伸 / 平铺五种 fit(居中 = 原始像素尺寸 1:1 摆放,视频走object-fit:none、图片走background-size:auto);模糊、暗化、对比度、饱和度、面板不透明度、颗粒噪点六个滑条;每个滑条旁的数值框可直接键入精确值,双击标签恢复默认;主画布与侧栏按透明度半透明,气泡与输入卡保持不透明。- 播放控制(视频):播放倍速 0.25×–4×(原生
playbackRate,改档即时生效,不重载、不黑屏);水平翻转 镜像(纯合成器scaleX(-1),零主线程开销,图片同样生效)。 - 遮挡暂停三档:最小化 / 切到别的标签(自动,无需开关);窗口失焦时暂停;使用电池时暂停(后两档默认开,可各自关闭;电池档依赖 Chromium 的 Battery Status API,缺失时该档自动关闭)。
- 切换过场:换壁纸时可选六档过渡 —— 硬切 / 淡化 / 推移 / 擦除 / 光圈 / 缩放,另有过场速度(快 / 标准 / 慢)与过场方向(向左 / 向右,仅推移与擦除可选中,其余自动置灰)。实现方式是把旧画面冻成一张静态快照盖在新画面上再让它退场(视频抓帧、图片直接复用同一 URL),因此不必对正在解码的视频层做逐帧 transform。两个关键时序:只在来源真正变化时触发(否则拖动任意滑条都会重播),以及在新图可绘制之后才起播(否则会露出页面底色,比硬切更难看,故有界等待 + 超时兜底)。跨域视频抓帧被污染时自动退回硬切;系统开启「减少动态效果」时一律硬切。
- 自动轮播:文本域每行一个图片 / 视频地址,可设切换间隔(0.5–720 分钟)与播放顺序(顺序 / 随机,随机模式避免立刻重复);「把素材库全部加入」按钮会逐个预检、只收进真正能加载的源;切换前先把新源准备到可播放(视频等
canplaythrough/loadeddata,图片等onload,12 秒硬上限),因此换壁纸不会出现黑帧;坏源自动跳过(最多连跳 5 个)而不是卡住整轮,且无论成败都会重新排下一次,瞬时失败可自愈。本地素材库每行另有「轮播」按钮,可单项追加进列表。
- 播放控制(视频):播放倍速 0.25×–4×(原生
自动文字可读性:对壁纸取样 12×8 网格逐格评估,同时为「浅底深字」「深底浅字」两套方案打分(WCAG AA 正文 4.5:1),取最差区域仍最优的一套;必要时自动叠一层底板把正文对比度抬到 AA(上限 92%,不埋掉壁纸);面板显示建议方案与最差对比度读数。视频每 4 秒重采样,随画面变化自适应。
选壁纸时自动调优:在你选定壁纸的那一刻(URL 框「应用」、选择文件、拖拽、素材库「应用」、最近使用 chip)自动采样一次,把最适合这张壁纸的设置写进状态并落盘 ——
- 适配方式:按画面宽高比挑「铺满」或「完整」,尽量少裁切(比例接近屏幕 → 铺满;竖向手机照 / 超宽全景 → 完整);
- 表面配色(不对称规则):暗壁纸(平均亮度 < 0.5)→ 深底浅字,暗色画面配浅字正是它最易读的样子;亮壁纸 → 保持「跟随主题」不动。这个不对称是关键 ——
浅底深字会用#ffffff铺满整个应用,所以它只由你手动选择,永不自动推导(早期版本按采样结果自动套用它,一张白线稿壁纸就把整个界面刷成了白色); - 字色自动调优(
deriveMarkColours()):把 6 类标注色解在这张壁纸自己最差的那一格上,而不是按平均值 —— 平均值好看、在一处亮斑上照样消失。求解方式是先从最差格的表面亮度反推 WCAG 比值所需的文字亮度(tintSameLuminance()保证落在具体颜色上),因此这是「最清晰的字体颜色」的实现:在此之前标注色是一套静态预设,自动调优从不碰它。目标比值刻意定在 AA 之上(MARK_TARGET_RATIO = 6.5)—— 刚过 4.5:1 的颜色「技术上可读」但看上去发灰,用户看不出字色变清晰了。色相与彩度按固定偏移跟着壁纸主导色走(六类互相错开,不会撞成同一个色调);body也在内,彩度压得很低,读起来是近中性的正文而非彩色段落。亮色/深色两套一起推导,因为面板可随时切换,只推导当前那套的话,一换系统明暗就会变色到读不清。画面无主导色相(灰阶)时六类塌成中性灰,不凭空造壁纸里没有的颜色。调优写入即清除marks.preset标记(预设按钮不再假装描述当前画面)。
六类之间必须真的看得出差别,为此有一个「阶梯下限」(
MARK_LADDER_FLOOR = 0.92)。 目标是 6.5:1,但目标能不能达到由这张壁纸自己的表面亮度决定——在低不透明度下,正文区的表面亮度离纯白只有约 0.18,纯黑文字对它的比值上限只有约 4.5:1,6.5 在几何上不可达。旧实现的做法是把够不到的角色钳到纯黑/纯白,结果是六类完全同一个颜色:色相偏移、彩度差异、逐类目标比值(7.5 / 6.5 / 6.0 / 5.8 / 5.5 / 4.6)全部被吸收掉——「字色自动调优」看起来生效了,屏幕上却和没开一样。现在改为:先算出这张壁纸的可达上限(ceiling),当最高的角色目标超过上限时,把六个目标比值在可达区间内按序压缩(MARK_LADDER_FLOOR + u × (1 − MARK_LADDER_FLOOR)),压缩后仍保证六类严格有序、互不相同,且六类的目标比值两两不同(code因此从 5.5 调到 5.8,与link分开)。0.92不是拍的:实测扫描 18 个候选值,它是仍能产出 6 个互异色的最高值(0.95 就退化成 5 色,0.99 只剩 2 色),也就是在保住可辨识度的前提下保留最多对比度——body的对比度全程不受影响(实测恒定 4.54),阶梯只塑形装饰类角色,装饰类均值约 3.4:1,仍高于 WCAG 3:1 的界面底线。下游反解出的颜色因此不再撞在端点上。 4. 自动取色播种(并跨重启记住):调优这次解码本来就要读画面,于是顺手把主导色相也带走(内存里的tuneCache),直接喂给取色引擎。这修掉了取色长期静默失败的问题 —— 取色原本要等实时壁纸元素挂载、约 1.5 秒等不到就放弃(诊断里留下的tint-hue: none),而内联data:视频挂载更晚,几乎必然错过。现在换成取色面板会明确区分「分析中…」与「这张壁纸取不到主色(画面接近灰阶)」,不再永远停在「分析中…」。 - 测量结果与 URL 一起落盘(ui.tuneHue/ui.tuneSat/ui.tuneHueUrl),因为tuneCache是内存变量、重启即空 —— 重启后仍靠实时元素重推,就会又落回那条 1.5 秒必败的路径。落盘记录的是「这张已测量过」而不是「这张有颜色」:灰阶素材的null也是一个完成的答案,存成「未测量」会让每次启动都去重推同一个「无主色」结论。 - 因此取色引擎有两个来源:内存里的tuneCache(刚调优过)与状态里的持久记录,诊断后缀分别标(tuned)与(remembered),可以一眼看出色相是这一会话测的还是上一次会话记下来的。 - 调优还负责把字色推导接回启动路径(repaintMarks()):调优只在「选壁纸那一刻」跑,所以在此之前,重启一次、切一次主题、或宿主把状态恢复回来,都会把派生出来的六色丢掉,让静态预设重新压在壁纸上 —— 而它所依据的那张画面还在屏幕上。这个问题不能靠再采一次样解决:可读性采样对同一 URL 拒绝重采(否则每次开插件都要重新解码一遍素材),所以启动路径改为复用本次采样留下的原始像素,并由commitSample()在每次采样后触发重推导。整个求解链(暗化、底板 alpha、可读性加成)每次都按当前状态重算,因此推导晚于你拖动滑条时,解出的是「屏幕现在实际的样子」,而不是「采样那一刻的参数」。 - 重推导带许可标志(ui.marksAuto):它在你手改任意一类字色(setMark())或点选某个预设(applyPreset())时立即清除,此后开机不再覆盖你的选择;重新选一张壁纸会重新授权。这个标志必须持久化而非事后推断 —— 「插件自己派生的一套」与「你手选的预设」在状态文件里长得一模一样(都是六个十六进制色值),而且两者都会把marks.preset写空,唯一留着名字的反而正是你点选预设那次。所以名字在恰恰说明是你选的,不是插件的。老状态文件没有这个标志,因此无法判断:此时一律按false迁移,不做猜测。两种错法的代价不对等 —— 不重推导只是少一次开机刷新(下次选壁纸就会重新授权),猜错却会覆盖你手工调的那套颜色;而且颜色本身无论如何都不会动,迁移只决定「要不要在开机时重算」。全新安装不受影响(本来就没有老状态,走defaultState()的true)。调优写下的配色带一个
contrastFromTune标记:下次换成亮壁纸时,只有这个标记为真才会释放回「跟随主题」;你若手动点过配色按钮,标记即清除,你的选择不会被自动覆盖。调优不碰可读性守卫开关。早期版本每次选壁纸都会把它重新打开,于是你一旦关掉它以夺回背景层的控制权,下次换图就被静默收回 —— 即「背景层调不了」。现在它只在首次安装时默认开启,之后完全由「自动保证可读性」复选框说了算。
调优也不碰「壁纸自动取色」开关:那是整套界面的配色总闸,每次选图都替你打开,等于关不掉。色相照常带过来,但要不要用由你决定。
不写
面板不透明度。 这一项由你自己掌握。实现可读性所需的那层底板是运行时按需叠加的(lumState.boost),不会落盘 —— 早期版本把算出的「刚好达 AA」值直接写进了不透明度,而深色表面要压住明亮画面时这个值会高达约 0.9,等于把壁纸整张盖掉(表现为「背景都没了」)。可读性守卫只做「加法」,且有一个上限(
READABILITY_ALPHA_MAX = 0.45)。 上限与BOOST_CAP(0.92)是两个数,管的是两件事:BOOST_CAP只防止底板变成实心块;这个上限防止底板把壁纸抹掉 —— 深色表面配明亮画面要达 AA 需要约 0.78,那个透明度下画面已经不存在了。超过 0.45 之后宁可让正文对比度低于 AA,也不隐藏你选的壁纸。你自己的不透明度若设得更高,始终以你的为准。守卫对底板的作用方式是在你自己设的值上按比例补一点(
backingAlpha(),单一真源,渲染与面板读数共用),不是把它抬到某个下限。区别很实在:旧逻辑等价于max(你设的, 0.45),于是滑条在 0.45 以下整体失效 —— 手柄在动、数字在变、屏幕没反应,这就是「背景层调不了」的直接成因。现在渲染值对滑条严格单调(0→1 每一档都反映到屏幕上)、只加不减(你设得比建议值高时绝不回拉)、低位渐入(不会刚离开 0 就被一口气托高)。需要为零时一点不动;不透明度设为 0 时守卫完全退让(语义仍是「不要底板」)。面板读数显示的是实际生效的底板百分比,自动补的部分另标「自动 +N」。调优本身只在手动选壁纸时触发,刻意不挂在
commit()上:自动轮播换图与启动恢复也会改写wallpaper.url,若挂在那里,每次轮播切换和每次开插件都会覆盖你手调过的数值。但字色重推导不在此列 —— 它是纯函数式的重解(像素已缓存在内存里,不读盘、不联网、不覆盖任何用户设置),因此在每次采样后与宿主状态恢复后都会跑,这正是它区别于「调优」的地方:调优会改设置,重推导只是照着当前设置重新算一遍同一批像素。调优失败(URL 不合法、文件解不开、跨域采样)时静默保留当前设置。
标注字体颜色:对输入与输出中「有标注的」文字单独上色 —— 正文基色、加粗/强调、链接/文件引用、行内代码、标题、列表标记共 6 类。样式规则只写
color,绝不写background/border/box-shadow/outline,因此不会引入任何方框。这套颜色默认手调或用预设,也可以随壁纸自动调优(见上方第 2 项第 3 条,面板上有「字色随壁纸自动调优」开关,默认开);自动调优后预设按钮不再高亮,因为你手上这套已不是任何预设。「浅色 / 深色」两个编辑槽位跟随当前主题。 这两套颜色不是同一个东西的两份备份,而是两个不同的渲染层:浅色一套写在
:root上,深色一套写在[data-ds-dark-theme]之后。深色主题下后者会赢下它范围内每一个元素,所以在「浅色」槽位上的编辑会被整体遮住 —— 状态变了、样式表也变了,屏幕上却一个像素都不动。这正是「自己改就是没生效」的成因。现在面板打开与主题切换时都会把编辑槽位指向正在渲染的那一套;一旦你手动点过 tab,就以你的选择为准,此后后台的主题翻转不会再把你正在编辑的槽位换掉(这个闩锁只存在于运行时,刷新即恢复跟随,因此不涉及任何状态迁移)。预设:8 套,各占一个不同的色相家族 —— 原生 / 冷调 / 暖调 / 森野 / 樱霞 / 墨韵 / 霓虹 / 深海(其中墨韵刻意无色相,在杂乱或强染色的壁纸上,灰阶梯比任何彩色家族都更清晰)。原「高对比」已移除:它与其余几套的区别只是饱和度更高,占了位置却不构成一个真正的选择。所有预设的
body都是空串 —— 正文色要么由壁纸推导、要么由你手写,不属于预设的职责。此外还有主题包 JSON 导入导出、侧栏底部调色盘抽屉(实时预览)、三层持久化。界面主题:整套界面的配色调色板(画布 / 面板与浮层 / 边框 / 文字层级 / 代码块 / 滚动条 / 状态色)。当前三套:跟随原生(默认,什么都不写,DSH 主题照常说了算)、Codex(近黑的中性灰阶 + 一条克制的蓝)与 经典(暖米色纸感 —— 白天是米纸,夜里是夜纸)。每套都有浅色与深色两张表。
- 落在哪、为什么:调色板写成
<body>上的内联--dsw-alias-*变量。这是内置 ThemePresenter 自己用的那条通道(dsh-client-ui-layout也把 alias 变量写成 body 内联),因此确定性最高:没有服务时序、没有覆盖层校验,也不需要重启宿主(早期尝试走主题运行时的overrideTokens反而不可靠 —— 覆盖层只在theme服务发布后才存在,服务缺席时壁纸会永久隐藏)。 - 用到的 token 名不是编的:整套键名是从宿主 shell 自己的样式表里枚举出来的真实
--dsw-alias-*/--dsw-specific-*名字,不是照抄某个第三方皮肤的键表(两者并不重合)。 - 它是 BASE 层:
applyAll()里主题排在壁纸、透明层、文字配色、强调色之前,后面几层都复合在它画的画布上。因此applyTokens()不再用硬编码的#ffffff/#161616当底板,而是取当前主题自己的--dsw-alias-bg-base—— 否则在 15% 不透明度下用浅色底板去洗 Codex 的暗色画布,会把主题冲掉。 - 主题拥有自己的 token,透明层不许删它们:面板不透明度与文字配色在关闭时会「恢复」一批 token(label 三级、
bg-layer-1/2/3、浮层、代码块表面等),而这些正是主题自己的。所以恢复走restoreToken()—— 主题有值就写主题的值,主题没定义(跟随原生)才真正removeProperty()。旧写法无条件removeProperty(),会让壁纸一开着主题的文字层级和面板表面整片消失。 - 明暗跟随应用本身(读
data-ds-dark-theme),不跟随壁纸推导出的配色方案 —— 主题是界面的属性,不是素材的属性。切换主题只重画底板,不碰字色、不碰配色模式、不碰壁纸。清空主题时会把所有主题可能用过的键一起撤掉(而不只是当前主题的键),否则从 Codex 切回跟随原生会在 body 上留下约 57 条陈旧声明。 - 调色板把差异花在哪(实测得来,不是设计直觉):面板不透明度 0.15 时,表面 token 被写成
toRgba(主题画布, 0.176)再叠一层内容 wash,画布上 12 级的差距最终只剩 2.1/255 —— 换句话说,靠"改暗画布的颜色"永远做不出可见的主题。所以差异集中投在 wash 碰不到的 token 上:边框四级、文字五级、状态色、diff、滚动条、tooltip/toast、品牌色。这些 token 的 delta 100% 存活。当前可感知差异实测:深色 32 个 token、浅色 23 个(重设计前约 13)。 - 它刻意反着原生做:原生深色是偏蓝的中性灰阶(
bluish-950 #151517、bluish-875 #232324,文字带冷调)、边框极淡(#ffffff0f)、浅色主题的brand-primary是近黑而非彩色、state-idle是近乎不可见的#d4d4d4。Codex 就把这些反过来:纯中性灰阶(R=G=B,去掉冷调)、看得见的 hairline(浅色边框从 0.04 提到 0.12,3 倍)、更陡的文字阶梯、一条只在 brand/link/diff 出现的蓝,浅色brand-primary从近黑换成真蓝(chroma 差 +203)。 - 强调色与主题共用品牌 token,但顺序不能丢:
ACCENT_TOKENS与主题表重叠 3 个,而applyAll()里强调色排在主题之后。「跟随原生」时强调色那一趟同样走的restoreToken()而不是removeProperty()—— 否则用户只要没设强调色,主题的brand-primary/state-business-primary/button-primary-fill就会被静默抹掉,而那恰是可见差最大的一批(brand-primaryΔ-74/255)。这是与第 47 条同类的坑:任何"关闭时删除"的逻辑,都要先问一句「这个 token 是不是别人的」。 - 「经典」把「不要动字」做成结构保证:它的两张表里一个文字 token 都没有 —— 8 个
label-*、brand-primary、link、5 个state-*、button-primary-fill/hover、menu-icon、menu-group-header-fill、toast-label、switch-thumb、diff 两色,共 23 个键刻意不定义。于是applyUiTheme()根本不写它们,restoreToken()把它们原样交还主题层 —— 想改字色只能去「界面透明 / 文字配色」分区,主题不会偷偷覆盖。这不是靠自律,是数据形状:往色表里塞一个label-primary会同时触发结构审计(禁用键)与 T10/T11/T12(探针回读断言)。 - 暖纸的分层靠 layer,不靠边框:原生浅色把所有
bg-layer都写成#ffffff,分层全靠 0.04–0.16 的淡边框;在有色画布上这套行不通(边框会糊进纸里)。所以经典把层级差放进bg-layer-1..4(#faf6ec → #ffffff,越靠上越亮,读作"叠在桌上的纸"),同时把边框加深到 0.16–0.42。深色那张同理:#211d17 → #3b3428的暖褐阶梯,而不是原生的冷灰阶梯。
- 落在哪、为什么:调色板写成
亚克力(毛玻璃)—— 两套独立特效,互不联动
它们不是同一个开关的两半,存储与面板位置也分开,改一个绝不会牵动另一个:
| 特效 | 控在哪 | 存哪 | 作用对象 |
|---|---|---|---|
| 面板毛玻璃 0–40px | 「界面透明 / 文字配色」分区 | ui.glass.blur |
抽屉面板的 backdrop-filter,模糊它背后的应用内容 |
| 玻璃通透度 0–100% | 同上 | ui.glass.tint |
玻璃底色透明度;越低越透 |
| 模糊(画面模糊) 0–60px | 「壁纸」分区,与暗化/对比度/饱和度并列 | wallpaper.blur |
只模糊壁纸画面本身,不动应用任何表面 |
设计上刻意不做「一键亚克力预设」:一个按钮同时改两边,正是把两套特效绑在一起。每个滑条双击标签即可单独复位。
早期版本这里有两个模糊滑条(模糊 + 磨砂),但它们往同一条
filter链各推一个blur(),是完全重复的控件,已删除后者。壁纸模糊以「模糊」这一个滑条为准。
面板玻璃的底色跟随主题明暗(读 isDarkTheme() 取深/浅色基),因此浅色主题下不会出现一块深色玻璃。
关于「面板加 backdrop-filter 会破坏 position:fixed」这条旧结论:它针对的是 liteglass 把滤镜铺到整个应用外壳的做法(会让设置页的 fixed 定位改锚到该元素、跑进侧栏)。本插件的抽屉是自包含单节点、内部无 fixed 后代,而且它本来就带 transform + z-index、早已是 containing block 与 stacking context,再叠 backdrop-filter 不改变任何后代定位。提示条(toast)挂在 body 上,也不受影响。
壁纸模糊并入 applyWallpaper 已有的 filter 链而不是再写一条 filter 声明 —— filter 是单一属性,另起一条会静默覆盖掉暗化/对比度/饱和度三个滑条。切换过场的静态快照也会带上当前 filter,否则过场瞬间会出现「无滤镜 → 有滤镜」的跳变。
范围外:桌宠与表情包、任何方框式标注。
面板可用性
- 控件自动置灰:对当前媒体类型无作用的控件会自动变灰且不可点,避免「看着能用、实际无效」的死选项。视频专属项(播放倍速、无缝循环、指针环绕、失焦暂停、电池暂停)在图片壁纸下置灰;「切换过场」的「过场方向」只在推移/擦除下可用。判定复用
syncPanel(),与状态回填同一时机。 - 快捷操作:
Alt+W开关面板;Alt+[/Alt+]降低 / 提高播放倍速(0.25× 一档);Alt+0回到 1×。用 Alt 是因为 Ctrl+R / F5 已被宿主占用、裸字母归应用本身;光标在输入框内时不抢键。三者共用一条冒泡阶段的 keydown 监听。 - 不与宿主快捷键抢键(2026-10-12 修复):插件只处理 Alt 系键位,且 handler 首行即
if (event.defaultPrevented) return;。历史版本曾额外做「Ctrl+R / F5 刷新兜底」,在 capture 阶段preventDefault(),会把defaultPrevented置位,导致宿主自带的page.refresh(desktop 档Ctrl+R,注册于dsh-client-ui-sidebar-right)看到该标志后放弃处理 —— 表现为宿主 Ctrl+R 刷新被吞。该兜底(连同dshDesktop.keyboard订阅)已整体删除;那套「正式版没绑 Ctrl+R」的前提只对一半:菜单项的role:"reload"确实只在 development 分支,但框架自己注册了page.refresh快捷键,且主进程对主框架的非 priority 按键并不preventDefault,事件本来就能到达渲染层。 - 素材筛选与排序:本地素材库可按「全部 / 图片 / 视频」筛选,按「最新 / 最早 / 按大小」排序。两者是仅视图偏好,刻意不写进
state—— 省掉两条归一化分支,也避免每次切换都触发一次宿主写盘。 - 分区可折叠:点击任意分区标题(或右侧小三角)折叠 / 展开该分区;折叠状态存进
ui.collapsed并跨重启保留(换主题包导入的状态也生效)。右上角「全部展开」一键恢复。「壁纸」分区永不默认折叠 —— 残留的折叠状态不会让面板打开时看起来是空的。 - 最近使用:本地素材库顶部有一排 chips,按最近使用倒序、去重、上限 8 条;点一下直接切回该素材。视频源带蓝色圆点区分,当前壁纸对应的 chip 高亮。记录在
wallpaper.recent,进入时逐个过isSafeWallpaperUrl白名单。写入挂在commit()这个唯一收口上,因此六个改wallpaper.url的入口(URL 框、文件选择、拖拽、素材库「应用」、轮播切换、chips 自身)全部自动记录,不会漏。
入口
- 侧栏底部调色盘按钮 → 打开右侧抽屉面板。
- 面板分区:壁纸(含播放倍速 / 镜像 / 遮挡暂停 / 切换过场 / 自动轮播 / 拖拽区)/ 本地素材库(含最近使用 chips)/ 界面透明与文字配色(含「自动保证可读性」开关与检测读数、壁纸自动取色、「字色随壁纸自动调优」)/ 界面主题 / 强调色 / 标注颜色(编辑槽位跟随主题,手点过 tab 后以你的选择为准)/ 预设 / 导入导出。各分区标题可点击折叠。
持久化(三层)
| 层 | 位置 | 作用 |
|---|---|---|
| 内存 | 客户端闭包 | 当前渲染真源 |
| localStorage | 键 dsh-skin-keymark:state |
首屏即时恢复 |
| 宿主文件 | $DSH_HOME/dsh-skin-keymark.json |
跨 origin 的权威状态 |
DSH Desktop 每次启动把 web server 绑到 OS 随机端口(port: 0),origin 每次变化,localStorage 会「失忆」(数据仍在 leveldb,只是挂在旧端口 origin 下),因此必须有宿主文件通道。
本地视频走宿主的同源媒体库:浏览器直接回放 $DSH_HOME/dsh-skin-keymark-media/ 下的文件,不经过 data URL(1.8 MB 上限远小于任何视频),且同源使可读性采样器的 canvas 不会被跨域污染。
状态字段:ui
wallpaper.* 与 marks.* 是面板控件的直接镜像;ui.* 里有几个字段不是控件,而是推导与授权的记账,容易被误当成可随便重置的缓存:
| 字段 | 含义 |
|---|---|
theme |
界面主题 id(native 跟随原生 / codex / classic 经典)。取不到的 id 回落 native 而不是保留:新版导出的主题包会指名一个我们写不出 token 的调色板,保留它只会让面板显示「已选中却什么都没画」。 |
accent |
强调色(顶层字段,非 ui 下)。空串 = 跟随原生。与主题共用品牌 token 且排在主题之后,所以「跟随原生」时必须走 restoreToken():主题有值就还回主题的值,不能 removeProperty()。 |
tuneMarks |
「字色随壁纸自动调优」开关(默认开)。关掉后一切推导停止,不影响已写入的颜色。 |
marksAuto |
推导许可:本套六色是否由插件写下、因而可在开机时重算。手改任意一类色(setMark)或点选预设(applyPreset)即置 false。不可由 marks.preset 事后推断(见上文)。 |
tuneHue / tuneSat |
最近一次调优在 tuneHueUrl 这张素材上测到的主导色相与饱和度。null 是有效值,表示「已测量,无主色」,不是「未测量」。 |
tuneHueUrl |
上述测量对应的素材 URL。只有它等于当前壁纸时才认这份记录 —— 素材换了而记录还在,就是上一张的读数。为空时 tuneHue / tuneSat 一律作废(见下)。 |
contrastFromTune |
表面配色是由调优写下的标记(手动改配色即清除,见上文)。 |
readability |
「自动保证可读性」开关。调优不写它。 |
三个 tune* 字段与 marksAuto 都必须落盘:它们要么记录的是一次已完成的测量(重推一次的代价是重新解码整张素材,而灰阶素材重推的结果还是同一个「无主色」),要么记录的是一个用户意图(哪些颜色属于用户),这两类信息都不能靠内存变量跨重启存活。
所有字段都经 normalizeState() 逐项钳位与白名单校验;缺失字段取默认值,非法值静默回落,因此手写或旧版状态文件不会让插件进入异常态。特别注意三处:
tuneHue必须用typeof x === "number"判定,不能写Number(x):Number(null)与Number("")都是0,会把「无测量」变成真实的红色 0°。tuneHue/tuneSat的合法性取决于tuneHueUrl:三者只有成对才有意义,所以先判 URL 再判色相 —— URL 为空时两个数值一律归null。否则一份「色相 0、但没有对应素材」的记录会被读成「测出红色」,而 0° 恰好是个合法色相,没有任何数值特征能把它和无测量区分开。marks.preset为空串时必须原样保留(它表示「这不是命名预设」,回落成默认预设名会把用户手改的颜色重新贴上 stock 调色板标签,进而被开机重推导覆盖 —— 这就是「设置总被还原」的成因之一)。
宿主 API
状态:POST /dsh-skin-keymark/api
content-type: application/json。
| method | 请求体 | 响应 |
|---|---|---|
get |
{"method":"get"} |
{"ok":true,"value":{...}} |
set |
{"method":"set","patch":{"state":"<json>"}} |
{"ok":true} |
set 为按键合并:值为 string 则设、为 null 则删、未提及的键不动;仅接受 string / null 值。信任栅栏:Host 必须是 loopback 或受信 authority,sec-fetch-site: cross-site 拒绝,带 origin 时必须同 host(DNS-rebinding / 跨站防御,不是认证)。
媒体库:/dsh-skin-keymark/media
| 方法 | 请求 | 响应 |
|---|---|---|
POST |
?ext=<mp4|m4v|webm|ogv|ogg|mov|mkv|avi|ts|gif>,body 为原始字节 |
{"ok":true,"url":"/dsh-skin-keymark/media/<sha256>.<ext>","size":<n>} |
GET |
/<name> |
原始字节;支持 Range: bytes=(拖动进度返 206) |
文件按内容 sha256 命名写入 $DSH_HOME/dsh-skin-keymark-media/,重复上传幂等去重;启动时回收未被状态引用的文件与 .tmp-* 残片。同一信任栅栏。
错误码
| code | HTTP | 含义 |
|---|---|---|
method-error |
405 | 非 POST |
unsupported-media-type |
415 | content-type 不是 application/json,或媒体扩展名不在白名单 |
payload-too-large |
413 | 状态请求体超过 32 MiB,或媒体文件超过 512 MB |
bad-request |
400 | 请求体不是 JSON 对象,或 patch 不是普通对象 |
not-found |
404 | 未知 method,或媒体文件不存在 |
forbidden |
403 | 未通过信任栅栏 |
internal |
500 | 内部异常(不回显细节) |
工具清单
无 host tool。本插件只有浏览器半 UI 与两个回环 HTTP 端点(状态 + 媒体库)。
借鉴轮次(v0.1.8 · 2026-10-22)
对照社区高星皮肤/壁纸插件(RevolutionLA/dsh-dream-skin ★271、elysia395/dsh-wallpaper-engine ★1792、Tkingxiao/dsh-any-background、SLin-code/dsh-custom-skin、starsstreaming/beautiCode)后,本轮补三项本插件原本没有的能力,全部按纯 CSS / 纯合成器实现,不引入素材文件、不新增依赖:
| 能力 | 借鉴自 | 落点 | 关键约束 |
|---|---|---|---|
| 生成式渐变壁纸 8 套(极光 / 暮色 / 纸感 / 墨韵 / 弥散 / 余烬 / 深海 / 跟随主色) | dream-skin「壁纸 2.0 渐变预设」、any-background「动态生成背景」 | GRADIENT_PRESETS + gradientCss(),写进 #…-wallpaper 的 background-image |
与 wallpaper.url 互斥:选渐变即清空 url,所有素材入口反向清空 gradient。不建 canvas、不跑 shader |
| 渐变缓慢漂移(42s 一循环) | any-background「动态生成背景」 | ensureGradientRule() 生成 @keyframes,只动 background-position |
缺省关;prefers-reduced-motion 时整条动画被剥掉 |
| 构图编辑器(缩放 100–250% + 水平/垂直平移 ±50%) | any-background「位置编辑器」 | applyWallpaper() 的 transform 链,图片/视频/渐变共用 |
纯合成器 scale/translate;平移用视口百分比存,窗口缩放后构图不变 |
| 分部位透明度(气泡 / 输入卡 / 菜单与面板 / 代码块 / 侧栏行 / 全局悬停 / 提示气泡与 toast,七组各一根滑条,覆盖全部 26 个令牌) | any-background「分部位界面透明度」 | SURFACE_GROUPS + surfaceScaleFor(),在 applyTokens() 里按组系数乘在该令牌的不透明度上 |
存的是 0.2–1 的系数而非绝对值。刻意不再套第二层地板:系数 100% 时逐字等于旧行为,往下一律更透;再钳一次会让高地板组(菜单 0.92)的滑条在任何总强度下都拖不动 —— 又一根死控件。总强度=0 且系数=1 时直接归还宿主值,不写恒定声明 |
设计边界仍沿用既有两条铁律:自动适配只改与素材绑定的参数(渐变/构图属素材侧)、运行时安全余量不落盘(分部位系数只压缩地板以上的余量,不写可读性守卫算出的临时值)。
「跟随主色」渐变不凭空造色:色相取自 ui.tuneHue 的既有测量记录,彩度压在 0.06–0.16;无测量(灰阶素材)时塌成中性灰阶 —— 与 deriveMarkColours() 同一条规则。
验证方式(本轮新增)
tmp/skin-scan/harness2.mjs 用最小 DOM 桩(window.__ModuleLoader__ + document/getComputedStyle/Image 替身)在 Node 里装载真实的 lib/client.js,把内部符号经测试专用导出暴露后断言 27 项行为,全部通过:
- 8 套渐变 CSS 括号平衡、非空;
follow有主色时出 4 档互异色标、无测量时塌成灰阶 normalizeState对gradient/zoom/panX/panY/surfaces的钳位与未知键丢弃- 渐变壁纸落地:元素存在、
transform含scale(1.800) translate(10.00%, -6.00%) - 漂移开关:开时 style 节点含
@keyframes与animation:、background-size:220% 220%;关时动画消失、退回cover - 分部位系数纯乘法(
menu 0.2→ alpha0.95 → 0.19)、总强度 0 且系数 1 时归还宿主值 - 图片壁纸时渐变规则被清空、颗粒层在渐变下仍建出、
data-we-wallpaper握手标记照旧写入
跑法:node tmp/skin-scan/harness2.mjs(退出码 0 = 全绿)。
性能与无障碍(v0.1.10 · 2026-11-02)
这一轮全部是「不改外观、只减少无效工作」的改动。四条都有一条共同的判据:改了之后,用户看得见的像素必须一模一样。
| 机制 | 原来 | 现在 | 为什么这样定 |
|---|---|---|---|
| 滑条拖动 | range 的 input 每像素一次,一次拖动上百次 commit(),每次都跑满 10 个引擎 |
requestAnimationFrame 内只跑最后一次 |
一帧内画两次等于白画一次。数值框与双击复位刻意不合并:延迟一帧会让「点一下没反应」的错觉回来 |
| 引擎调度 | applyAll() 无条件跑 10 个引擎 |
8 个按输入签名门控(once()),applyWallpaper 与 applyTokens 仍无条件 |
前者是 data-we-wallpaper 握手与轮播计时的唯一入口,后者负责铺开层的挂/拆 —— 跳过它们的代价远大于收益 |
| 壁纸样式 | 每次 commit 重写整条 cssText(含 filter / transform) |
与上次完全一致时不写(paintWallpaperEl()) |
拿 css 字符串本身当签名:它由全部输入拼成,不需要再维护一份「哪些输入该进签名」的清单 —— 那种清单漏一项就是一个滑条失灵 |
| 渐变规则 | 每次 commit 重写 <style> 文本(含 @keyframes) |
文本没变就不写 | 重写会让浏览器把整段 @keyframes 重新解析 |
签名门控的两个坑,都已验证并修掉:
orbit的签名曾经是Boolean(getElementById(WALLPAPER_ID))。图片换视频时元素一直在,该项恒true,于是指针环绕再也不会重新挂上。改为wpEl.tagName(并纳入prefersReducedMotion())后,「换素材后环绕失灵」消失。- 内存里「上次画了什么」必须在元素被销毁时作废(
removeWallpaperEl()与ensureWallpaperEl()的换元素分支都会清),否则新元素拿到同样的 css 会被跳过,表现为换壁纸后白屏。
无障碍:prefers-reduced-transparency 现在会真正生效。插件在 body 上写内联变量,内联声明压过媒体的媒体查询规则,所以宿主自己的降级对插件无效。处置不是关壁纸(那是用户的美术选择),而是只停用「全窗铺开」这一层:面板、菜单、侧栏行回到不透明底板,画布仍按不透明度透出壁纸。系统设置变化时实时生效(matchMedia 的 change 监听),卸载时正确拆除。
验证(tmp/km-*.mjs,均从源码抽出真实函数后注入桩,不复刻逻辑):
| 脚本 | 断言 |
|---|---|
km-coverage.mjs |
SURFACE_GROUPS 7 组、CONTENT_TOKENS 26 项、无遗漏无多余 |
km-applyall.mjs |
40 次 commit 的引擎调用数:wallpaper/tokens 各 40,其余各 1,总计 88(未门控为 400);面板后建、图片转视频、卸载重挂三个场景均重新执行 |
km-paint.mjs |
同一元素同一 css 只写 1 次;换元素或 forget() 后必定重写;40 次相同绘制 → 1 次真实写入 |
km-sigs.mjs |
同签名跳过、变化重跑、resetApplySigs() 后重跑;全 false 的签名不会被当哨兵 |
构建与清理
客户端 bundle 为免构建形态(lib/client.js 使用 window.__ModuleLoader__.load),本插件没有 src/、没有构建步骤。
plugins/dsh-skin-keymark/
├── package.json
├── cordis.patch.yml
├── README.md
└── lib/
├── index.js # host 半:回环持久化 API + 本地媒体库
└── client.js # 浏览器半:壁纸引擎 + 可读性采样 + 标注引擎 + 面板 UI
- 注入:把
dsh-skin-keymark加进 profilepackage.json的dependencies(link:指向本目录)与dsh.profile.bundles,并在 profilenode_modules建同名 Junction。插件自带cordis.patch.yml会被 bundle 机制自动应用,无需改 profile 的 patch 文件。 - 校验:profile
node_modules/dsh-skin-keymark为 Junction;profile 重启或 patch 热重载后插件处于 active。 - 清理:本目录不产生
dist/、*.tgz、*.tsbuildinfo;不得在插件根执行npm install。媒体库文件位于$DSH_HOME/dsh-skin-keymark-media/,换壁纸后由宿主自动回收。
No comments yet. Be the first to write one.