dsh-background-by-model 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
- > The full release history lives here; the two most recent releases (v0.7.4 / v0.7.3) are also listed in [README.md](./README.md#recent-optimizations).
3
+ > The full release history lives here; the two most recent releases (v0.7.6 / v0.7.5) are also listed in [README.md](./README.md#recent-optimizations).
4
+
5
+ ## v0.7.6
6
+
7
+ - **The rules page turns from a stack of self-growing cards into one tab strip and one editor** — the report was "如果规则多且不收起的话会拉的很长". Every rule used to be a card that expanded itself, so the page was as tall as the sum of the cards and only got shorter once the user collapsed them by hand. Now **every rule is a tab and exactly one editor is mounted**: `ModelBgPage` holds the selection (`selId`), `RuleCard` no longer owns an open/collapsed state and is always rendered — it is the tab panel. The page height is therefore a function of one card plus the strip, whatever the rule count. The tabs carry what only the card head used to show: the number, a thumbnail of the rule's first image, the match string, and the two states worth knowing without opening the rule — whether it is the one in use, and whether it rotates or is switched off. The strip scrolls sideways rather than wrapping, for the same reason as above (a wrapped strip hands the page height straight back to the rule count), and the tabs are a fixed 34px with an elided match label, so switching rules cannot reflow the row. **The `+` and the "which rule" menu sit OUTSIDE the scroller**, as icon tiles at its right edge: inside, the only control that can ADD a rule scrolled away from the end of a long list. The strip's scrollbar now declares the host's own `--dsw-scrollbar-*` block (5px, rounded, layer-2 tint, no stepper arrows) instead of the platform's thick arrowed default sitting next to the panel's slim vertical one; every selector is scoped to a `dab-` class because this stylesheet is injected into the host page and a bare webkit-scrollbar selector would restyle the whole harness. **The match tester moved to the profile page** — it answers a question about the configuration as a whole ("which rule does this model land on?"), is read once rather than consulted while editing a rule, and on the rules page it pushed the strip a whole card down; the host-stale notice went with it, since that is also about config writes as a whole. The two decisions behind the strip are pure and now live in `src/client/rules-view.ts` (which rule the editor shows, and where a deletion lands — its neighbour, never rule 1), pinned by the new `check:rules`, which also pins the page structure the layout rests on: one editor drawn, no old stacked container, no per-card open state, a strip that can neither wrap nor grow, and its scrollbar block staying scoped. Also, the page description loses its `max-width:56ch`, which left it wrapping early with dead space beside it while the cards below ran the full width.
8
+
9
+ - **The rotation card's controls were reshuffled, at your request** — four changes, all explicit asks ("'一小时'删掉,'切换模型时也换一张'放到'随机'旁边,'下一张、正在显示...'放到外面'轮换这组图'旁边,常驻" and "把多图轮换板块放到主题色板块之下,把图二元素删掉"). **The manual step is always there**: the Next button and the "On screen 2 / 3" readout beside it used to be gated behind `rotatable && enabled`, so they vanished together with the rotation switch — and that readout is the only place on the card that says which picture is showing. The button now sits in the switch row, outside both the rotation panel and that test, so it is present for every rule state, enabled whenever the ACTIVE rule has two or more pictures (which is exactly `rotateNow`'s own test — it gates on the active rule and on `nextIndex`, never on `rotate.enabled`); below two pictures it is disabled and the readout is omitted rather than printing "0 / 0". **"Also step on a model switch" joined the order row**, beside In order / Shuffle: all three answer "when does a picture change", and the row without it read as the whole story of when the rotation steps. **"1 hour" is gone** from the dwell-time presets — five chips now (10 s / 30 s / 1 min / 5 min / 30 min) plus the custom field, which still takes 3600 s like any other value in the 5 s – 24 h clamp. **"图二元素" — the row of eight fixed preset swatches — is deleted**: the wheel, the numeric inputs, Extract from this image, the eyedropper and Follow system theme all stay; the row was a shortcut into the same setter the wheel drives. It was the only reader of three things, which went with it: the `PALETTE` table in `state.ts`, the `.dab-swatches` / `.dab-swatch` rules in `ui.css.ts`, and the `.dab-swatch:focus-visible` entry in the shared outline group; `.dab-swatch-title` and `.dab-swatch-lg` merely share the prefix and are untouched. **The whole rotation block moved into the second column**, below the theme colour and the wallpaper sliders: it used to sit between the image strip and the layout mode, which is the same place in the wide two-column card but the worst place in the collapsed one (below ~760px, `.dab-rule-cols`), where the tallest block of the card — a titled switch, a bordered panel with three rows inside it — landed between the pictures and the colour controls that edit them. Nothing inside the block changed; it is the same JSX, moved. Both READMEs lose the swatch row ("an inspiration palette" / 「灵感色板」), "1 h" from the dwell-time list, and now say where the manual step lives.
10
+
11
+ - **The festival art stops shipping in the package: it is fetched on the day, and cached by asset** — the two holiday wallpapers (228 KB + 500 KB) were two thirds of the entire tarball, for a picture that is looked at one day a year, and they were the one thing in the package that is a picture rather than code — with their binary history kept in `.git` forever besides. They now live in the project's own [`HarmlessFunny/assets`](https://github.com/HarmlessFunny/assets) repository (beside the README screenshots, in a directory that is marked separately), and the node half downloads, validates and caches one on the first read of the day, under `~/.dsh/.dsh-background-by-model-data/holiday-cache/`. **The reference is pinned to a commit, never a branch**: runtime art has to be immutable — a branch reference would let a later reshuffle of the documentation screenshots silently kill a shipped feature on a day nobody is watching, with nothing in the log — and jsDelivr caches a commit reference forever, which is exactly what a file that never changes wants. **Two mirrors**: `cdn.jsdelivr.net` first (reachable from networks where `raw.githubusercontent.com` is not), raw as the fallback, both pointing at the same revision so a fallback cannot mix versions. **A failed download is a normal state, not an error path**: with both mirrors down the holiday simply does not take over and the model rules keep painting — the "does this slot actually have bytes" gate in `pickHoliday` was written for exactly this. **A cache entry is validated, not trusted**: a cached file that is not an image is treated as a miss and overwritten by the next download, because a process killed mid-write would otherwise break that holiday permanently with no way back short of deleting a file the user does not know exists; the write goes through a temp name and a rename, so a half-written file can never be read. **Neither invariant moved an inch**: the cache is keyed by ASSET name (`mid-autumn.webp`), deliberately outside the slot namespace, so `writeImage` still refuses a holiday slot and the store still never holds a `modelbg-h-*` file. `globalThis.fetch` is replaced by a stub throughout `check:node`, so the suite neither needs a CDN to be up nor asserts today's byte count of a file it does not own; the new assertions cover: loading the plugin and reading the config fetch nothing, one cold read is enough, the second read comes off disk with no transfer, two simultaneous cold reads share one transfer, a dead first mirror falls through, a failed fetch caches nothing, a mirror answering 200 with HTML is rejected, a corrupt cache entry is re-downloaded, and both mirrors serve the same commit.
12
+
13
+ - **A recommended profile, installed in one click** — the Config page gained a third card that downloads the author's own configuration from the same [`HarmlessFunny/assets`](https://github.com/HarmlessFunny/assets) repository and writes it in place of yours. **It is hosted in the store's own shape**: `preset/preset.json` (the shape gate), `preset/theme-config.json` (6.5 KB of readable JSON) and `preset/modelbg-<slot>` (one raw file per slot). It was first written as a single theme file with every wallpaper inlined as base64 — 2.9 MB where 99.8% of the bytes were a picture, which is not a document anyone can hand-edit, since changing one rule's `match` string means finding it inside one line of several hundred thousand characters. That is why the format was changed: editing the config is now editing 6.5 KB of JSON on GitHub, replacing a wallpaper is replacing a file, and publishing the whole thing is a `cp` out of `~/.dsh/.dsh-background-by-model-data/`. On the node side a new `fetchPresetConfig` (fetch the manifest and the config, run the SHARED sanitizer on this side, then derive which files to ask for) and `fetchPresetImage` (one file by slot, magic-byte checked, returned as a data URL) replace the old single-document `fetchPreset`, both reusing the general `fetchAsset` the previous release split out of the festival-art downloader. **The request set is derived from the config rather than from a second manifest**, so adding a wallpaper is adding a rule entry and a file with nothing to keep in sync — which also means an untrusted config decides how many times this process goes to the network, so every slot name is checked against `SLOT_RE` (in `normalizeImage`) and the count is capped besides. **The two holiday slots a config legally names are dropped rather than requested**: `holidays.items` always carries `h-midautumn` / `h-nationalday`, no `modelbg-h-*` file can ever exist (a hard assertion added by the previous release), and asking for one would fail the whole feature on a file the plugin itself guarantees is not there. The client fetches **one file per request**, four of them in flight at a time — measured at 8.3 s sequential against 4.8 s concurrent for the real profile, since what is being paid is per-request latency over HTTP/2 — showing `3/7` on the button; each file therefore gets its own mirror fallback and its own validation. **One missing wallpaper fails the whole attempt and names the slot** — a profile that quietly arrived short a picture is precisely the silent failure that pinning the festival art to a commit exists to prevent, and naming it is what keeps a branch reference acceptable here. It still lands through the same `applyThemeFile` a hand-picked file goes through (assembled on the client into the shape `exportTheme` writes), so the version gate, the sanitizer and the wipe are one path; `preset.json` is what lets the version gate fire before a single wallpaper is requested. It remains the only destructive action in the panel: the download happens before the question (so the confirmation can state what is actually in hand), the wipe happens only after the download succeeds (an unreachable CDN must not cost the user their configuration on the way to a profile that never arrived), a host too old to write this shape is refused before anything is deleted, `holiday-cache/` is untouched, and nothing is cached to disk. `check:node`'s stub grew a "serve a whole directory by file name, a name it does not hold is a 404" mode, covering: the manifest and config fetched in order and sanitized on arrival, the request set derived from the rules with the holiday slot dropped, one request per wallpaper with its type sniffed rather than trusted from a header, a fallback to raw for a single file, a named wallpaper the host does not have being an error rather than a short profile, the two refusals that happen before any request at all (a holiday slot, a malformed slot name), a manifest version that is not a number, a profile written for a newer shape refused before anything is fetched, a config that is not JSON, one that is a JSON array, one naming more images than the cap, and a mirror announcing an oversized body.
14
+
15
+ - **The published package drops from 1.1 MB to about 220 kB** — besides the two festival pictures above, two other things were taking up room that the people downloading this plugin should not be paying for. **The documentation screenshots moved out**: `example_img/` (three webp files, 220 KB) now lives in the same assets repository under `dsh-background-by-model/`, and both READMEs reference it by absolute jsDelivr URL — the screenshots were put into `files` in the first place so the images on the npm page would not break, and an absolute URL does that too without putting binaries in the tarball or their history in `.git`. `screenshots.json` is deleted: nothing in the whole DSH installation or `~/.dsh` consumes it. **`lib/client.js.map` is no longer published**: it is a source map for the browser bundle, 624 KB, useless to anyone installing the package, and it stays in the repository (anyone debugging should be debugging a local build anyway). Ten files, about 220 kB packed / 690 kB unpacked, against thirteen files and 1.1 MB / 2.0 MB before. (Both figures are deliberately approximate — this entry and the two READMEs are themselves part of the number, so they nudge it every time they are edited. What the number says is the point: after the pictures left, the tarball is code and documentation and very little else.)
16
+
17
+ ## v0.7.5
18
+
19
+ - **The layout mode belongs to the picture, not the rule** — 适应 / 填充 / 拉伸 / 平铺 / 居中 was one value per rule, covering every picture of a rotation, which is the argument that moved the framing down in 0.7.1: a rotation through a landscape photo and a tall phone screenshot could not letterbox one and fill the other, so at least one of them always accepted the other's compromise. The mode now lives on `images[].bgMode`, read back from the image that is being painted (`rBgMode`), and the chip row under the filmstrip edits the image selected there through the same slot-addressed, sanitized, `repaintIfMoved`-closed write path the framing editor uses. The row is **disabled** rather than hidden on a rule with no picture: a rule starts empty, and a row that vanished there would leave the card's own "add an image" hint to explain a control the user has never seen. README and README.zh were updated everywhere they claimed the mode was the rule's.
20
+ - **`SCHEMA_VERSION` moves to 6, because both directions of this move are silent (a fix)** — a schema-5 host rebuilds every image entry from the keys it knows, so a per-image mode written to it would vanish on the first write (the symptom being a picture set to 拉伸 coming back 适应); and a schema-6 read of a schema-5 config has to **lift** the rule-level value onto each of its images, or every existing wallpaper would land on the default. `normalizeImage` grew the third parameter that carries it, resolved once in `normalizeRule`, and the pre-0.7 single `slot`/`bgState` pair goes through the same lift while `migrateLegacy` writes the new shape directly. `bgMode` joined `LEGACY_RULE_KEYS`, so the host log does not report the field it is deliberately consuming as drift. Verified against a copy of a real config before anything was touched: 填充 on the rule became 填充 on both of its images, on the way in and on disk, with no drift warning — and a hand-written pair of different modes round-trips unchanged.
21
+ - **A holiday's image is 填充 by definition** — full-bleed festival art must not letterbox. The mode is taken from the definition like the slot and the colours beside it, so a hand-edited value cannot repaint a festival; the framing next to it stays yours. `check:node` asserts that the definition wins.
22
+ - **The fade only animates what the palette in force writes (a fix)** — reported as "有时候切换背景时(图上为浅色切换为浅色),白色色块会渐变为黑色色块…白色渐变完黑色后立马切到白色", with the white value measured as `237, 243, 254`. That number is the answer: `#edf3fe` is `--dsw-static-deepseek-50`, which is what the host's **light** scheme paints `--dsw-specific-bubble` with — the user message bubble — and its dark-scheme value is `#2c2c2e`. The fade sheet had registered that name because `fadeTokenNames()` asks for the **dark** branch's token list (the superset) and handed the whole thing to the `:root` and `body` scopes: the light branch writes **35** of those **80** names, so the other 45 — the bubble, `--dsw-specific-selector` (the settings dropdown fills), the tip/tooltip/toast surfaces, the code-block header segments, the status colours, the toolbar and ghost button fills — still belong to the **host**. The host changes those on a schedule of its own, and this plugin is what provoked it: it disposes and re-registers its theme on every colour change, and the host's disposer resets the preference to the default — `system` — when the theme backing it goes away ("resets the preference to the default so the UI never keeps tokens of an unregistered theme", ui-theme's own `register()`), which on a dark desktop hands the whole interface to the host's dark palette for the length of that call, with the presenter's own forced style flush (`getComputedStyle(body).backgroundColor`) as the point where an armed transition picks the dark values up. The reported session runs its switch at `durationMs: 3000`, which is what turned a frame into a three-second ramp; the value then came back to the light one without an animation of its own, which is the "snaps back to white" half. Two changes, one per half of the mechanism: a scope's token is emitted only when the palette **in force** writes it (the new `written` input, passed as `Object.keys(tokens)` of the block being written — the registrations still cover all 80 names, and the dark palette, which writes every one of them, is unchanged), and a skin whose `colorScheme` already holds is not rebuilt at all (what the skin contributes is its scheme; its tokens are re-emitted by the plugin's own `!important` rule, and a same-scheme skin carries the same names either way). Measured on the reported palette (hue 192): `body`'s transition list goes from 80 names to 35, `--dsw-specific-bubble` and `--dsw-specific-selector` are out of it, and `--dsw-alias-bg-base`, `--dsw-alias-bg-layer-2` and the code plate stay in — nothing the plugin actually paints lost its fade.
23
+ - **The code block follows the palette, and its plate has an opacity of its own (a fix)** — reported as "代码块背景有点问题": dark green ink on a near-black plate inside a **light** interface. The cause is a `var()` substituted where it is DECLARED: the host's shiki sheet declares `--shiki-background: var(--dsw-alias-markdown-code-block)` (and `--shiki-foreground`) on `:root`, while the host itself — and therefore this plugin — declared those tokens on `body` only, so on the root element there was nothing to read but the fade registration's `initial-value`: the host's own colour, sampled while the plugin's sheet was muted. The plate stayed on the host's dark value (`#1b1b1c`) through every light palette while the ink, which does flip with the scheme (`body[data-ds-dark-theme]{--shiki-token-*}`), turned dark — `#2f9e44` green on `#1b1b1c`, about 2:1. The palette rule is now emitted for `:root` as well as `body`, and `:root` joins the fade scopes (only the element that DECLARES a token animates it, so without that scope the block jumped to the new tint while every surface around it faded); the plate and its banner are palette tints again — a constant can only ever match one of the two ink sets — with the light branch emitting the banner instead of silently borrowing the host's near-white. A new **`code`** opacity part drives both surfaces, each through its own plugin-owned variable (the writer sets one variable per token **name**, so a shared one would let the banner, written last, paint the plate as well); it defaults to opaque, which is what a code block is, and it is opacity-only, because frosting the wallpaper under the syntax only softens the plate.
24
+ - **Four small ones (a fix)** — the rotation switch no longer survives its own pictures: with rotation on, deleting images until one was left kept the switch reading "on", and the panel disables that switch below two images, so it could not be turned off either — the setting said one thing and the picture count said another, with no way to reconcile them. Removing an image now clears `enabled` when fewer than two are left, and `normalizeRule` enforces the same invariant for everything that does not pass through the panel (an imported profile, a config written back after a picture went away elsewhere); re-adding a picture finds the switch off, because rotation is a decision, not a consequence of a count. "切换时长" was printed twice, as a section heading and again as the slider's own label one line below it. The profile footer's package name and the brand tile are links to the project's repository — the two places a reader looks for "where does this come from" — both reading the one address in `repo.ts`, whose value `check:ui` compares against `package.json`'s `repository` so a renamed or moved project fails the gates instead of shipping a link that looks exactly like a working one.
25
+ - **Gates** — `pnpm test` is now **eight** checks. `check:code-plate` (the plate token is declared on `:root` as well as `body`, both branches emit the banner, and each surface reads its own variable) and `check:image-mode` (the lift, plus the wiring — wiring it wrongly fails silently, with every image painting the same way) join the suite; `check:rotation` gained the switch-and-count invariant on both sides, `check:ui` the repository-link agreement, `check:transition` the ownership rule (a registered name the palette does not write keeps its registration and gets no transition, and the written set is intersected with the registrations rather than unioned into them) and `check:repaint` the two lines that arm the fade.
4
26
 
5
27
  ## v0.7.4
6
28
 
package/CHANGELOG.zh.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # 更新日志
2
2
 
3
- > 这里保留完整的历史记录;最近两个版本(v0.7.4 / v0.7.3)同时列在 [README.zh.md](./README.zh.md#近期优化) 里。
3
+ > 这里保留完整的历史记录;最近两个版本(v0.7.6 / v0.7.5)同时列在 [README.zh.md](./README.zh.md#近期优化) 里。
4
+
5
+ ## v0.7.6
6
+
7
+ - **规则页从一摞会自己长大的卡片变成一条选项卡 + 一个编辑器** —— 报告是「这个界面如果规则多且不收起的话会拉的很长」。此前每条规则是一张自展开的卡片,页面的高度等于所有卡片之和,只有用户手动一张张收起才会缩短。现在**每条规则是一个选项卡,页面上只挂载一个编辑器**:`ModelBgPage` 持有选中项(`selId`),`RuleCard` 不再拥有开合状态、永远渲染,它就是那个面板。于是页面高度只跟「一张卡片 + 一条选项卡」有关,与规则条数无关。选项卡把原本只在卡头上才看得到的东西搬了上来 —— 序号、首图缩略图、匹配串,以及两条不用展开就该知道的状态(它是不是当前生效的那条、它在轮换还是被关掉了);条子横向滚动而不是换行,理由和上面一样:一换行就把高度还给了规则条数,而且选项卡固定 34px、匹配串省略号截断,所以切换规则不会让整行抖动。**「+」和「切换哪条规则」这两个动作挪到了滚动区之外**,作为图标贴在条子右端:放在里面时,一个长列表里**唯一能新增规则**的控件会被滚走。条子的滚动条现在也用宿主自己那套 `--dsw-scrollbar-*`(5px、圆角、layer-2 染色、无步进箭头),此前它在面板那条细竖向滚动条旁边显示的是平台默认的粗浅色带箭头版本;选择器全部限定在 `dab-` 类上,因为这个样式表是注入宿主页面的,一个裸的 webkit-scrollbar 选择器会把整个 harness 一起改掉。**匹配测试器搬到了「资料」页** —— 它回答的是「这个模型落在哪条规则上」这个关于整份配置的问题,读一次就够,不是编辑某条规则时要反复看的东西,而在规则页上它把选项卡整整推下去一张卡片的高度;宿主过期的提示一起走了,因为那同样关乎整份配置的写入。选项卡背后那两个判定是纯的,现在住在 `src/client/rules-view.ts`(编辑器显示哪条规则、删除之后落在哪条 —— 落在邻居上,永远不是规则 1),由新的 `check:rules` 钉住,它同时还钉住布局所依赖的页面结构:只画一个编辑器、没有旧的堆叠容器、没有逐卡片的开合状态、条子既不换行也不长高、滚动条样式块仍然被限定住。另外页面描述去掉了 `max-width:56ch` —— 它让那段话提前换行、在旁边留出一块死空间,而下面的卡片是跑满宽度的。
8
+
9
+ - **轮换卡片的控件按你的要求重新排了一遍** —— 四处改动,都是明确的请求(「'一小时'删掉,'切换模型时也换一张'放到'随机'旁边,'下一张、正在显示...'放到外面'轮换这组图'旁边,常驻」「把多图轮换板块放到主题色板块之下,把图二元素删掉」)。**常驻的手动换图**:此前「下一张」按钮和它旁边那句「正在显示第 2 / 3 张」被关在 `rotatable && enabled` 的判定里,于是它们会跟着轮换开关一起消失 —— 而那句读数正是这张卡片上**唯一**说明当前是哪张图的地方。现在按钮住在「轮换这组图」那一行的外侧,对所有规则状态都在,判据只有一条:当前生效的规则有两张以上图就可用(这正是 `rotateNow` 自己的判据,它只问生效规则和 `nextIndex`,从来不问 `rotate.enabled`);不足两张时按钮禁用、读数整句省略而不是印一个「0 / 0」。**「切换模型时也换一张」挪到顺序那一行**,和「顺序」「随机」并排:三者都在回答「什么时候换」,缺了它的那一行读起来像是把话说完了。**「1 小时」从停留时长预设里删掉**,剩五个(10 秒 / 30 秒 / 1 分 / 5 分 / 30 分)加自定义输入 —— 自定义输入照旧接受 3600 秒,5 秒到 24 小时的夹紧范围没变。**「图二元素」—— 那排八个固定预设色板 —— 删掉了**:滚轮、数值输入、从本图提取、吸管、跟随系统主题都还在,那一排只是通往同一个 setter 的快捷方式。它是 `state.ts` 里的 `PALETTE` 表、`ui.css.ts` 里的 `.dab-swatches` / `.dab-swatch` 规则(以及共享描边分组里的 `.dab-swatch:focus-visible`)唯一的读者,这些跟着它一起走了;`.dab-swatch-title` 和 `.dab-swatch-lg` 只是名字前缀相同,未受影响。**整个轮换块移到了第二列**,在主题色和壁纸滑块之下:它此前夹在胶片条和布局模式之间,在宽版两列卡片里是同一个位置,但在折叠成单列的窄版(`~760px` 以下,`.dab-rule-cols`)里,它是卡片里最高的一块 —— 一个带标题的开关、一个内部三行的带框面板 —— 正好落在图片和编辑图片的颜色控件中间。块内部一个字段都没动,就是同一段 JSX 挪了位置。两份 README 里描述那排色板的地方(「灵感色板」/ "an inspiration palette")、停留时长列表里的「1 小时」、以及手动换图的位置都相应改了。
10
+
11
+ - **节日画不再随包发布:当天按需下载、按素材名缓存** —— 两张节日壁纸(228 KB + 500 KB)此前打进包里,占了整个 tarball 的三分之二 —— 为了一张一年只被看一天的画,而且是包里唯一属于「图片」而不是「代码」的东西,还要把它的二进制历史永久留在 `.git` 里。现在它们托管在项目自己的 [`HarmlessFunny/assets`](https://github.com/HarmlessFunny/assets) 仓库(和 README 截图放在一起,但目录分开标注),节点半边在节日当天第一次读取时下载、校验、写进 `~/.dsh/.dsh-background-by-model-data/holiday-cache/`。**引用钉死在 commit 上,不是分支**:运行时素材必须是不可变的 —— 分支引用意味着以后整理一次文档截图,就能让一个已经发布的功能在某个没人盯着的那天静默失效、日志里什么都不说;而 jsDelivr 对 commit 引用永久缓存,正是一个永不改变的文件想要的。**两个镜像**:`cdn.jsdelivr.net` 主用(在 `raw.githubusercontent.com` 不通的网络里也能到),raw 兜底,两个都指向同一个 revision,所以回退不可能混版本。**下载失败不是一个异常路径,而是一个正常状态**:两个镜像都不通,这个节日今天就不接管,模型规则继续画 —— `pickHoliday` 那道「槽位里到底有没有字节」的闸门本来就是为这个写的。**缓存条目会被校验而不是被信任**:不是图片的缓存文件按未命中处理并在下次下载时覆盖,否则一个写到一半被杀掉的进程会让这个节日永久报废,而用户根本不知道有那个文件存在;写入走临时名 + rename,读到一半的文件不可能被看见。**两条不变量一个字都没改**:缓存按**素材名**(`mid-autumn.webp`)命名,刻意放在槽位命名空间之外,所以 `writeImage` 照旧拒绝节日槽位、数据目录里照旧永远不会出现 `modelbg-h-*`。`globalThis.fetch` 在 `check:node` 里被整体替换成桩,测试因此不依赖任何 CDN 是活的,也不去断言一个不属于自己的文件今天有多少字节;新增的断言覆盖:加载插件和读配置不发起任何下载、冷读一次就够、第二次读盘不再传输、两次并发冷读共用一次传输、第一个镜像死了会回退、失败的抓取什么都不缓存、镜像返回 200 但内容是 HTML 会被拒绝、损坏的缓存会被重新下载、两个镜像指向同一个 commit。
12
+
13
+ - **一键装上「推荐配置」** —— 「配置」页多了第三张卡,从同一个 [`HarmlessFunny/assets`](https://github.com/HarmlessFunny/assets) 仓库下载作者本人正在用的那套配置并就地覆盖你的。**它托管在数据目录自己的形状里**:`preset/preset.json`(形状闸门)、`preset/theme-config.json`(6.5 KB 可读 JSON)和 `preset/modelbg-<slot>`(每个槽位一个原始文件)。第一版是单个主题文件、所有壁纸内联成 base64,2.9 MB 里 99.8% 是图片 —— 在几十万字符的一行里找一个 `match` 字符串不是任何人能做的手改,格式就是为这个改的;换过来之后,改配置是在 GitHub 上编辑 6.5 KB 的 JSON,换壁纸是替换一个文件,发布是从 `~/.dsh/.dsh-background-by-model-data/` 里 `cp` 一下。node 半边新增 `fetchPresetConfig`(取 manifest 与 config、在**这一侧**跑共享 sanitizer、再从这个配置推导出要请求哪些文件)与 `fetchPresetImage`(按槽位取一个文件,魔数校验后返回 data URL),旧的单文档 `fetchPreset` 删掉;`fetchHolidayAsset` 上一版拆出的通用 `fetchAsset` 被两者复用。**请求清单从配置推导,而不是来自第二份清单**:加一张壁纸就是在 config 里加一条并放进同名文件;反过来说,一份不受信任的配置决定了这个进程会去网络几次,所以每个槽位名都要过 `SLOT_RE`(在 `normalizeImage` 里),数量另有上限。**config 里合法存在的两个节日槽位会被剔掉而不是去请求** —— `holidays.items` 里必然有 `h-midautumn` / `h-nationalday`,而数据目录里永远不可能有 `modelbg-h-*` 文件(上一版刚加的硬断言),照单去请求会让整个功能在一份插件自己保证不存在的文件上失败。客户端**一个文件一个请求**地取、同时最多四路 —— 实测同一份配置顺序取 8.3 秒、并发取 4.8 秒,因为付的是 HTTP/2 上的每请求延迟 —— 按钮上显示 `3/7`;每个文件因此有自己的镜像回退与自己的校验。**少一张就整体失败并报出是哪个槽位**:一份悄悄少了图的配置恰恰是「节日素材钉死 commit」那条契约要防的静默失效,把它报出来才让分支引用在这里站得住。落地仍然走本地文件那条 `applyThemeFile`(在客户端组装成 `exportTheme` 写出的形状),所以版本闸门、sanitizer、清空都还是同一条路;`preset.json` 让版本闸门在请求第一张壁纸之前就能生效。它仍然是这个面板里唯一会销毁东西的操作:下载在**提问之前**(确认框因此能如实说出手里有什么),清空在**下载成功之后**(CDN 不通不该让你在等一份永远没来的配置的路上先弄丢自己的配置),一个旧到写不了这套形状的宿主在删除任何东西之前就被拒绝,`holiday-cache/` 不碰,配置也从不落盘缓存。`check:node` 的桩新增了「按文件名供应一整个目录、缺名就是 404」这一档,覆盖:manifest 与 config 按序取且当场 sanitize、请求清单由规则推导且节日槽位被剔掉、一张壁纸一个请求且类型是嗅出来的、对单个文件回落到 raw、config 点名而托管处没有的文件是一个错误、节日槽位与畸形槽位名在任何请求之前就被拒绝、manifest 版本不是数字、写给更新形状的配置在请求第一张壁纸之前被拒绝、config 不是 JSON / 是数组 / 图片数超限、以及镜像谎报超长。
14
+
15
+ - **发布包从 1.1 MB 瘦到约 220 kB** —— 除了上面那两张节日画,另外两样东西也在占地方,而且都不该由下载这个插件的人来付。**文档截图搬走了**:`example_img/`(三个 webp,220 KB)移到同一个 assets 仓库的 `dsh-background-by-model/` 目录下,两份 README 改用 jsDelivr 绝对地址引用 —— 这个插件此前专门把截图放进 `files` 是为了让 npm 页面上的图不裂,而绝对地址同样做得到,还不用把二进制塞进包里、更不用写进 `.git` 的历史。`screenshots.json` 删掉了:翻遍整个 DSH 安装目录和 `~/.dsh` 都没有任何消费方。**`lib/client.js.map` 不再发布**:它是给浏览器 bundle 用的源码映射,624 KB,对装包的人没有用处,仓库里仍然保留(要调试本地构建的人本来就该对着本地构建调)。10 个文件、约 220 kB packed / 690 kB unpacked,此前是 13 个文件、1.1 MB / 2.0 MB。(这两组数字刻意写成约数 —— 这条记录和两份 README 本身就是这个数字的一部分,每改一次就把它推高一点。这个数字想说的是:图片搬走之后,包里剩下的确实只有代码和文档。)
16
+
17
+ ## v0.7.5
18
+
19
+ - **布局模式属于图片,不属于规则** —— 适应 / 填充 / 拉伸 / 平铺 / 居中 此前是每条规则一个值、覆盖一轮轮换里的每一张图;这跟 0.7.1 把取景下放是同一条理由:一轮里既有横构图照片又有竖屏截图时,没法让一张留黑边、另一张铺满,总得有一张接受另一张的妥协。现在模式存在 `images[].bgMode` 上,从**正在画的那张图**读回(`rBgMode`),胶片条下面那排胶囊编辑的也是那里选中的那张图 —— 走的是取景编辑器同一条按槽位寻址、过 sanitizer、以 `repaintIfMoved` 收尾的写入路径。规则还没有图片时这排胶囊是**禁用**而不是隐藏:新规则天生是空的,把它抽掉就等于让卡片自己那句「点这个上传」去解释一个用户从没见过的控件。README 与 README.zh 里凡是说「模式属于规则」的地方都改了。
20
+ - **配置格式升到 6,因为这次迁移两个方向都是无声的(修复)** —— schema 5 的宿主会按自己认识的键重建每一个图片条目,写进去的逐图模式会在第一次写入时消失(症状就是设成 拉伸 的图变回 适应);而 schema 6 读一份 schema 5 的配置时**必须**把规则级的值抬到它的每一张图上,否则所有现成壁纸都会掉到默认值。`normalizeImage` 多了一个承载它的参数,在 `normalizeRule` 里只解析一次;0.7 之前那个单 `slot`/`bgState` 老形态走同一条抬升路径,`migrateLegacy` 则直接写成新形状。`bgMode` 进了 `LEGACY_RULE_KEYS` —— 它是被**刻意消费**掉的迁移字段,不该在宿主日志里报成漂移。动手前先拿一份真实配置的副本验过:规则上的 填充 变成了它两张图各自的 填充,读入时与落盘后都是,drift 警告 0 条;手写的一对**不同**模式能原样读回。
21
+ - **节日的图按定义就是 填充** —— 满幅的节日画不该有黑边。模式和它旁边的槽位、颜色一样取**定义**、不取磁盘,所以手改过的值换不掉节日的模式;旁边的**取景**仍然是你自己的。`check:node` 钉住了「定义胜出」这条。
22
+ - **渐变只作用于当前调色板真正写入的东西(修复)** —— 报告是「有时候切换背景时(图上为浅色切换为浅色),白色色块会渐变为黑色色块…白色渐变完黑色后立马切到白色」,白色值量到的是 `237, 243, 254`。这个数字就是答案:`#edf3fe` 是 `--dsw-static-deepseek-50`,也就是宿主**浅色**方案画 `--dsw-specific-bubble` 用的值 —— 你自己那条消息的气泡 —— 它的深色方案值是 `#2c2c2e`。渐变样式表之所以注册了这个名字,是因为 `fadeTokenNames()` 要的是**深色**分支的 token 表(那份是超集),而 `fadeScopes()` 把整张表都交给了 `:root` 与 `body` 两个作用域:浅色分支只写其中 **35** 个(共 **80** 个),另外 45 个 —— 气泡、`--dsw-specific-selector`(设置里的下拉框底色)、提示与气泡框、代码块右上角那排小按钮、状态色、工具条与幽灵按钮的填充 —— 仍然归**宿主**。而这些名字宿主是按自己的时间表改的,触发者还是插件自己:它每次换配色都销毁并重新注册主题,而宿主的销毁器会把偏好重置为默认值 `system`(ui-theme 自己的 `register()`:「重置为默认偏好,这样界面不会留着已注销主题的 token」),在深色桌面上这就等于把整个界面交给宿主的深色调色板、直到那次调用结束;而 presenter 自己那句强制的样式刷新(`getComputedStyle(body).backgroundColor`)正是已经武装好的过渡抓住深色值的那一刻。报告里那次会话的切换时长是 `durationMs: 3000`,一帧的错色因此被拉成了三秒的渐变;值随后变回浅色且自身没有动画,这就是「立马切到白色」那一半。两处修改各治一半:作用域的 token 只在**当前调色板**确实写它时才发出(新增的 `written` 入参,传的就是本次要写的那一块的 `Object.keys(tokens)` —— 注册表仍然覆盖全部 80 个名字,而全部写入这 80 个的深色调色板完全没变),以及配色方案没变就**不再重建**皮肤(皮肤真正贡献的只是它的 `colorScheme`,token 本来就是我们自己用 `!important` 重发的,同一方案下两边名字集合完全一样)。在报告那套调色板上实测(色相 192):`body` 的过渡列表从 80 个名字降到 35 个,气泡与下拉框底色都不在里面了,而 `--dsw-alias-bg-base`、`--dsw-alias-bg-layer-2` 和代码块底板仍在 —— 插件真正绘制的表面一个都没丢掉渐变。
23
+ - **代码块跟着调色板走,而且它自己的底板有了独立透明度(修复)** —— 报告是「代码块背景有点问题」:**浅色**界面里,深绿色文字压在近黑色的底板上。成因是 `var()` 在**声明它的地方**就被替换了:宿主的 shiki 表在 `:root` 上声明 `--shiki-background: var(--dsw-alias-markdown-code-block)`(还有 `--shiki-foreground`),而宿主自己 —— 也因此包括本插件 —— 只在 `body` 上声明这些 token,于是根元素上除了过渡注册的 `initial-value` 什么也读不到:那是宿主自己的颜色,且是在插件样式表被静音时采样的。结果底板在每一套浅色调色板下都停在宿主的深色值(`#1b1b1c`),而会随方案翻转的墨色(`body[data-ds-dark-theme]{--shiki-token-*}`)变成了深色 —— `#2f9e44` 的绿压在 `#1b1b1c` 上,对比度约 2:1。现在调色板规则对 `:root` 与 `body` 一并发出,`:root` 也加入渐变作用域(只有**声明**该 token 的元素才会做过渡,少了这个作用域,代码块会在周围每块表面淡入时自己硬跳到新色);底板与它的 banner 重新是调色板的染色 —— 写死一个常量只可能匹配两套墨色中的一套 —— 且浅色分支也发出 banner,不再偷偷借宿主的近白色。新增 **`code`** 透明度档位驱动这两块表面,各自读自己的插件自有变量(写入者是按 token **名字**设变量的,共用一个变量会让后写的 banner 把底板也一起画了);它默认不透明 —— 代码块是拿来读的,不是拿来看穿的 —— 而且只做透明度:把语法底下的壁纸冻上一层霜,只会让底板更糊。
24
+ - **四个小修(修复)** —— 轮换开关不再「活过」它自己的图片:开着轮换把图片删到只剩一张时,开关仍显示「开」,而面板在少于两张时把这个开关禁用了,于是也关不掉 —— 设置说一套、图片数量说另一套,在下一张图到来之前无从调和。现在删图后不足两张就清掉 `enabled`,`normalizeRule` 对不经过面板的路径(导入的配置、别处删图后写回的配置)强制同一条不变量;重新加图时开关是关的,因为轮换是一个决定,不是数量的后果。「切换时长」被印了两遍(一个小节标题,隔一行又是滑块的标签)。资料页页脚的包名与品牌磁贴现在是指向项目仓库的链接 —— 这两处正是读者会去找「这东西哪来的」的地方 —— 两者都读 `repo.ts` 里那一个地址,`check:ui` 拿它和 `package.json` 的 `repository` 对比,于是改过名或搬过家的项目会让门禁失败,而不是发出去一个看起来完全正常的坏链接。
25
+ - **门禁** —— `pnpm test` 现在是**八道**。`check:code-plate`(底板 token 在 `:root` 与 `body` 上都有声明、两个分支都发 banner、每块表面读自己的变量)与 `check:image-mode`(抬升本身,外加接线 —— 接错了不会报错,只会每张图长得一样)加入套件;`check:rotation` 增加了开关与数量的不变量(两侧都验),`check:ui` 增加仓库链接一致性,`check:transition` 增加归属规则(注册了但当前调色板不写的名字保留注册、但不给它过渡;写入集合与注册集合是**求交**、绝不并集),`check:repaint` 钉住武装渐变的那两行。
4
26
 
5
27
  ## v0.7.4
6
28
 
package/README.md CHANGED
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  > Forked from [`Tkingxiao/dsh-any-background`](https://github.com/Tkingxiao/dsh-any-background) and renamed to `dsh-background-by-model`.
11
11
 
12
- A **DeepSeek Harness** appearance plugin built around an **ordered list of model rules**. Each rule carries its own wallpaper, theme color, layout mode, framing, opacity and blur — switch the model, and the background switches with it, through the global [switch effect](#switch-effect-global) you choose on the Config page.
12
+ A **DeepSeek Harness** appearance plugin built around an **ordered list of model rules**. Each rule carries its own wallpapers and opacity and blur, and **each image carries its own layout mode, framing and theme color** — switch the model, and the background switches with it, through the global [switch effect](#switch-effect-global) you choose on the Config page.
13
13
 
14
14
  > **v0.3.0 is a breaking release.** Video wallpapers, generated dynamic backgrounds and the single global theme color were removed. See the [v0.3.0 entry in CHANGELOG.md](./CHANGELOG.md#v030).
15
15
 
@@ -49,21 +49,21 @@ Notes:
49
49
  One interface, one config — only the current model differs:
50
50
 
51
51
  <p align="center">
52
- <img src="example_img/wallpaper-deepseek.webp" alt="Appearance under DeepSeek" width="880">
52
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/wallpaper-deepseek.webp" alt="Appearance under DeepSeek" width="880">
53
53
  <br/>
54
54
  <em>DeepSeek V4.1 Flash · matched the rule whose match string contains <code>deepseek</code>: a light wallpaper with a matching theme color</em>
55
55
  </p>
56
56
 
57
57
  <p align="center">
58
- <img src="example_img/wallpaper-kimi.webp" alt="Appearance under Kimi" width="880">
58
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/wallpaper-kimi.webp" alt="Appearance under Kimi" width="880">
59
59
  <br/>
60
60
  <em>Kimi K2.7 Code · matched the rule containing <code>kimi</code>: a dark wallpaper, a dark theme, and the whole interface reskinned with it</em>
61
61
  </p>
62
62
 
63
63
  <p align="center">
64
- <img src="example_img/rule-editor.webp" alt="One rule's editor" width="660">
64
+ <img src="https://cdn.jsdelivr.net/gh/HarmlessFunny/assets@main/dsh-background-by-model/rule-editor.webp" alt="One rule's editor" width="660">
65
65
  <br/>
66
- <em>One rule on the Model Background tab · image, layout mode, theme color (wheel / HSL / RGB / extract from this image / pick from image), background opacity and background blur are all properties of that rule alone</em>
66
+ <em>One rule on the Model Background tab · the wallpaper, its opacity and its blur belong to the rule; the layout mode, the framing and the theme color (wheel / HSL / RGB / extract from this image / pick from image) belong to the image it is editing</em>
67
67
  </p>
68
68
 
69
69
  <p align="center">
@@ -76,11 +76,11 @@ One interface, one config — only the current model differs:
76
76
 
77
77
  - **Ordered Rule List** — Add, remove, reorder and name rules. Each rule is matched by its own match string; the first hit wins and rule 1 is the fallback.
78
78
  - **Live Match Readout** — The top of the Model Background tab shows the current state, e.g. `Current model deepseek-flash → matched · Rule 1`, so you can verify a rule on the spot. If the current model can't be detected, a hint is shown instead.
79
- - **Per-rule Appearance** — Every rule owns its wallpapers, layout mode, opacity and blur, and **every image owns its framing and its theme color**. Nothing is shared globally except the Interface tab.
79
+ - **Per-rule Appearance** — Every rule owns its wallpapers, opacity and blur, and **every image owns its layout mode, its framing and its theme color**. Nothing is shared globally except the Interface tab.
80
80
  - **Several Images per Rule** — A rule holds an ordered set of images, shown as a filmstrip: add several at once (file picker, drag & drop, or a list of URLs, one per line), reorder them, promote one to first, **replace one in place** (which keeps its position in the rotation), or remove them one by one. The image a rule paints when nothing rotates is its **first**. Removing the last image simply leaves the rule empty — and empty is not the same as off: while the rule keeps a theme color of its own it still matches, still serves as the fallback, and paints the interface instead of a wallpaper; only a rule with neither (a freshly added one) is skipped by matching. So an emptied rule is a visible, reversible state rather than one you get stuck in. While it holds no picture the filmstrip is hidden and the big preview tile **is** the upload button, so there is exactly one obvious place to add one.
81
- - **Import / Export** — Export every rule **including all of its images** to a `dsh-background-by-model-theme.json` (format version 6, images inlined as base64) and restore it anywhere. Files written by earlier releases still import: each rule's single image is lifted into its image list, a color written before per-image colors is lifted onto every image that has none, and a file written before the [switch effect](#switch-effect-global) gets that setting's shipped default — so an older theme file restores its look unchanged.
81
+ - **Import / Export** — Export every rule **including all of its images** to a `dsh-background-by-model-theme.json` (format version 6, images inlined as base64) and restore it anywhere. Files written by earlier releases still import: each rule's single image is lifted into its image list, a color written before per-image colors is lifted onto every image that has none, a layout mode written when it was the rule's is lifted onto each of its images, and a file written before the [switch effect](#switch-effect-global) gets that setting's shipped default — so an older theme file restores its look unchanged. The same document can also be **downloaded from the project's asset host** instead of picked from disk — see [Recommended profile](#recommended-profile) — with one difference, which is that it empties the store before it writes.
82
82
  - **File-based Persistence** — All settings and images are stored on the filesystem under `~/.dsh/.dsh-background-by-model-data/`, not `localStorage`.
83
- - **Automatic Migration** — Old single-wallpaper configs are upgraded in place on first read. See the [v0.3.0 entry in CHANGELOG.md](./CHANGELOG.md#v030).
83
+ - **Automatic Migration** — Old single-wallpaper configs are upgraded in place on first read, and so is a layout mode that used to live on the rule. See the [v0.3.0 entry in CHANGELOG.md](./CHANGELOG.md#v030).
84
84
  - **Bilingual** — Full Chinese / English UI with automatic locale detection.
85
85
  - **Theme Watchdog** — Re-asserts the custom theme if the host resets it.
86
86
 
@@ -89,13 +89,13 @@ One interface, one config — only the current model differs:
89
89
  Per rule, and **off by default** — cycling spends real bandwidth, memory and battery, so nothing turns it on for you.
90
90
 
91
91
  - **Enable it per rule** — the switch appears in the rule card and only becomes usable once the rule holds two or more images.
92
- - **Dwell time** — 10 s / 30 s / 1 min / 5 min / 30 min / 1 h, or any custom value in seconds (clamped to 5 s – 24 h).
92
+ - **Dwell time** — 10 s / 30 s / 1 min / 5 min / 30 min, or any custom value in seconds (clamped to 5 s – 24 h).
93
93
  - **Order** — **In order** walks the list top-down and wraps around; **Shuffle** picks uniformly among every image *except* the one on screen, so a tick never looks like it was missed.
94
94
  - **Also step on a model switch** — an optional second trigger: every time the model changes and lands on that rule, it steps once. That is the whole feature for a rule meant to show "a different picture every time" without any timer running.
95
95
  - **Only the active rule rotates** — one timer for the whole plugin, aimed at whichever rule the current model resolved to. It stops while the tab is hidden and resumes on a fresh interval; a single-image rule (and every [holiday](#holiday-backgrounds)) never schedules anything.
96
- - **Next image** — steps the wallpaper immediately, for when you want to check the set.
97
- - **What follows a switch is the rule — except the color, which travels with the picture** — layout mode, opacity and blur belong to the rule and stay put. The theme color belongs to the **image**, so a rotation can move through a green picture, a red one and a system-themed one, and the interface palette changes with the wallpaper. The cross-fade between two images is the same one a model switch already used.
98
- - **Framing is per image, and so is the color** — a crop and an accent both belong to a picture, so each image keeps its own while sharing the rule's layout, opacity and blur. The filmstrip marks the images that carry a color of their own, and the color controls always say which image they are editing.
96
+ - **Next image** — steps the wallpaper immediately, for when you want to check the set. It sits with the rotation switch (beside *which image is on screen*) and works whether or not the timer is running.
97
+ - **What follows a switch is the rule — except everything that belongs to the picture** — opacity and blur belong to the rule and stay put. The **layout mode**, the **framing** and the **theme color** belong to the image, so a rotation can letterbox a tall screenshot and fill the landscape photo next to it, and can move through a green picture, a red one and a system-themed one with the interface palette following the wallpaper. The cross-fade between two images is the same one a model switch already used.
98
+ - **Layout mode, framing and color are per image** — 适应/填充 decides how *this* picture meets the viewport, a crop and an accent belong to it too, so each image keeps its own while sharing the rule's opacity and blur. The filmstrip marks the images that carry a color of their own, and the color controls always say which image they are editing.
99
99
  - **Only the images it needs are loaded** — boot reads each rule's first image; the rest stream in when a card is expanded or when the rotation is about to need one. Ten images per rule therefore do not mean ten wallpapers transferred before the first frame.
100
100
 
101
101
  ### Switch effect (global)
@@ -120,15 +120,15 @@ A small one, not a settings block: **one switch on the Config page and nothing e
120
120
  - **National Day** — October 1–7.
121
121
  - **Mid-Autumn wins the overlap** — the two do collide (2025-10-06 was both); the single precise day is the better answer.
122
122
  - **A holiday with no image falls through** to the model rules rather than blanking the wallpaper.
123
- - **Built-in wallpapers** — Both holidays ship with a compressed wallpaper (228 KB and 500 KB at native resolution, against 8.37 MB of source art). Only the holiday that can paint *today* is ever fetched, so a profile that is not on a holiday transfers none of it.
124
- - **Neither the art nor the palette is swappable, by design** — an easter egg that asks to be configured is not an easter egg. A holiday slot is read-only: a file dropped into `modelbg-h-midautumn` / `modelbg-h-nationalday` is ignored by every read, and the plugin refuses to write, fetch into or delete those slots. The theme colors are constants in the holiday definition and are forced on every read — on the holiday **and on its image**, since a theme color lives on the image now — so a hand-edited `color` in `theme-config.json` cannot repaint a festival either. Theme exports therefore carry your rules only.
123
+ - **The art is downloaded, not shipped** — a compressed wallpaper per holiday (228 KB and 500 KB at native resolution, against 8.37 MB of source art), hosted in the project's [assets repository](https://github.com/HarmlessFunny/assets) rather than in the package: they were two thirds of the tarball for a picture that is looked at one day a year. The download is gated on the **calendar**, so a profile that is not on a holiday fetches nothing at all, and the first read on the day caches the file under `holiday-cache/` — every boot after that, and every year after that, is answered from disk and needs no network. The reference is pinned to a **commit**, never a branch, so reshuffling the repository's screenshots cannot break a shipped festival; a `jsDelivr` mirror is tried first and `raw.githubusercontent.com` second, and if both are unreachable the holiday simply does not take over — the model rules keep painting.
124
+ - **Neither the art nor the palette is swappable, by design** — an easter egg that asks to be configured is not an easter egg. A holiday slot is read-only: a file dropped into `modelbg-h-midautumn` / `modelbg-h-nationalday` is ignored by every read, and the plugin refuses to write, fetch into or delete those slots. The download lands in `holiday-cache/`, keyed by asset name and never by slot, so the store still never holds a `modelbg-h-*` file. The theme colors are constants in the holiday definition and are forced on every read — on the holiday **and on its image**, since a theme color lives on the image now — so a hand-edited `color` in `theme-config.json` cannot repaint a festival either. Its layout mode is the definition's **Fill** for the same reason (the art is full-bleed, and a stale `fit` would letterbox it), while the **framing** next to it stays yours. Theme exports therefore carry your rules only.
125
125
  - **Turning it off** — the switch on the Config page, or `"holidays": { "enabled": false }` in the same file.
126
126
 
127
- ### Per-rule settings
127
+ ### Per-rule and per-image settings
128
128
 
129
129
  - **Wallpaper** — Give a rule one or more images; each image is its own file. The filmstrip picks which one you are editing, and the "first" one is what the rule paints when no rotation is running. A rule may also hold **no** image: it then paints no wallpaper, but as long as it has a theme color of its own it still matches and still serves as the fallback (painting the interface alone), and only with neither is it skipped by matching until you give it something.
130
- - **Theme Color** — HSL wheel plus numeric input and an inspiration palette, with **Extract from this image** and an **eyedropper**. **The color belongs to the image**: those controls edit the one selected in the filmstrip, the strip shows which images already carry a color of their own, and an image that has none follows the system theme rather than inheriting its rule's. A rule with **no image at all** has only its own color left to edit — and that is exactly what such a rule paints. Generates the full CSS design-token set in real time. Following the system theme hands the palette back to the host while the wallpaper and the Interface-tab opacity/blur sliders keep working — the surface colors are read back from the host's own tokens and re-emitted with your alpha, so the wallpaper is never buried under an opaque plate.
131
- - **Layout Mode** — Fit / Fill / Stretch / Tile / Center.
130
+ - **Theme Color** — HSL wheel plus numeric input, with **Extract from this image** and an **eyedropper**. **The color belongs to the image**: those controls edit the one selected in the filmstrip, the strip shows which images already carry a color of their own, and an image that has none follows the system theme rather than inheriting its rule's. A rule with **no image at all** has only its own color left to edit — and that is exactly what such a rule paints. Generates the full CSS design-token set in real time. Following the system theme hands the palette back to the host while the wallpaper and the Interface-tab opacity/blur sliders keep working — the surface colors are read back from the host's own tokens and re-emitted with your alpha, so the wallpaper is never buried under an opaque plate.
131
+ - **Layout Mode** — Fit / Fill / Stretch / Tile / Center, and it is stored **per image**: a rotation can letterbox one picture and fill the next. On a rule that holds no picture the row is disabled (there is nothing to lay out) rather than hidden, so the card shows what becomes editable once you add one.
132
132
  - **Framing** — Drag to pan and scroll to zoom inside a viewport-proportional editor; **only editable in Fit mode**, and the committed framing stays consistent across window resizes and cross-monitor moves. Stored per image.
133
133
  - **Background Opacity** — `0–100%` for the rule's wallpaper layer.
134
134
  - **Background Blur** — `0–60 px`, applied to the wallpaper layer.
@@ -140,6 +140,30 @@ A small one, not a settings block: **one switch on the Config page and nothing e
140
140
  - **Conversation & Trajectory** — The message list is wrapped in a translucent card automatically, and the trajectory page gets whole-page opacity & blur controls, letting the wallpaper shine through the content.
141
141
  - **Right Panel** — The column that slides in from the right when you open a file now has a card of its own: its opacity follows **Main background** until you drag it (then this card owns it), and its blur stacks on top of the main-background blur. While closed it takes no space and costs nothing.
142
142
 
143
+ ### Recommended profile
144
+
145
+ The Config tab's first card downloads the author's own configuration — rules, wallpapers and interface settings — from the project's [assets repository](https://github.com/HarmlessFunny/assets) and installs it in one click.
146
+
147
+ **It is hosted in the store's own shape**, not as one bundled document:
148
+
149
+ ```
150
+ preset/
151
+ ├── preset.json the shape gate — {"version": 6}
152
+ ├── theme-config.json the rules and the interface settings (6.5 KB)
153
+ └── modelbg-m1 … modelbg-m7 each wallpaper as its raw bytes
154
+ ```
155
+
156
+ So it is editable where it lives: the config is a few kilobytes of readable JSON that can be edited on GitHub directly, and replacing one wallpaper is replacing one file. It also makes publishing it a `cp` out of `~/.dsh/.dsh-background-by-model-data/`. The plugin fetches the manifest and the config, derives the file list from `rules[].images[].slot` — there is no manifest of images to keep in sync — and then fetches the wallpapers **one request each, up to four in flight**, showing the count on the button while it runs.
157
+
158
+ It is the only action in the panel that **destroys** something, and it is built around that:
159
+
160
+ - **You are asked second, not first.** The download happens before the confirmation, so the question can say what is actually in hand — *7 rules · 7 wallpapers · 2.9 MB* — instead of asking about a file that may never arrive. If the download fails, nothing has happened yet.
161
+ - **One missing wallpaper fails the whole thing**, and says which. A profile that quietly arrived without a picture — because a file was renamed where it is hosted — would be exactly the silent failure that pinning the festival art to a commit exists to prevent; naming the file keeps that failure loud, which is what makes a branch reference acceptable here.
162
+ - **The store is emptied before the profile is written.** `nextSlot` allocates slots as `m1`, `m2`, … — a counter, not a random token — so a downloaded profile and the store it lands in collide on those names as a matter of course. "Replace everything with this" is only true if the everything is genuinely gone, and this is also why there is no undo.
163
+ - **The wipe runs after the download, never before it.** An unreachable CDN must not cost you your configuration on the way to a profile that never arrived — which matters more now that the download is several transfers rather than one.
164
+ - **The festival art is not touched.** `holiday-cache/` is downloaded art that belongs to no slot; it is not part of anybody's configuration, and re-fetching it would be a network round trip for nothing. The config *does* name the two holiday slots (they are part of any config), and they are dropped from the request set rather than asked for: no `modelbg-h-*` file can exist.
165
+ - **It is referenced by branch, unlike the festival art's commit** — and for the opposite reason. A profile fails *loudly* (you are looking at the button when it happens) where a holiday fails silently, so pinning would buy nothing and would cost a plugin release for every correction to one JSON file. The risk that is real — a profile written for a config shape this build cannot read — is what `preset.json` is for: it is refused before a single wallpaper is requested, and long before anything is deleted.
166
+
143
167
  ## Settings
144
168
 
145
169
  Settings → **Theme** now has three tabs:
@@ -147,8 +171,8 @@ Settings → **Theme** now has three tabs:
147
171
  | Tab | Scope | Contents |
148
172
  | --- | --- | --- |
149
173
  | **Interface** | Global, shared by every model | Opacity & blur for the main background, left panel, right panel, cards & panels, input & controls, settings panel, conversation text box and trajectory page |
150
- | **Model Background** | Per rule | The ordered rule list, the live match readout, and each rule's images (filmstrip), theme color, layout mode, per-image framing, opacity, blur and [rotation](#image-rotation) |
151
- | **Config** | — | The global [switch effect](#switch-effect-global), import / export of the whole rule set (`dsh-background-by-model-theme.json`), plus the single [holiday background](#holiday-backgrounds) switch |
174
+ | **Model Background** | Per rule, per image | The ordered rule list, the live match readout, and each rule's images (filmstrip), **per-image layout mode, framing and theme color**, plus the rule's opacity, blur and [rotation](#image-rotation) |
175
+ | **Config** | — | The global [switch effect](#switch-effect-global), the whole rule set as a file — [recommended profile](#recommended-profile), import and export (`dsh-background-by-model-theme.json`) — plus the single [holiday background](#holiday-backgrounds) switch |
152
176
 
153
177
  The old **Color** tab is gone — the theme color is now a property of each rule. The old **Background** tab became **Model Background**.
154
178
 
@@ -160,7 +184,7 @@ Data directory: `~/.dsh/.dsh-background-by-model-data/` (Windows: `C:\Users\<you
160
184
  | --- | --- |
161
185
  | `theme-config.json` | The rule list (each rule with its image list and rotation) plus the global Interface settings, the global switch effect and the holiday overrides |
162
186
  | `modelbg-<slot>` | One image, stored as raw bytes without a file extension — one file per image, so a rule with three images owns three slots |
163
- | `modelbg-h-midautumn` / `modelbg-h-nationalday` | Not used: both holiday wallpapers ship inside the package and are served straight out of it |
187
+ | `holiday-cache/<asset>` | Festival art downloaded from the CDN, one file per holiday (`mid-autumn.webp`, `national-day.webp`). Keyed by **asset name**, deliberately outside the slot namespace — a holiday slot still never holds a file, which is what keeps it read-only. Delete a file here and it is simply re-downloaded the next time that holiday is in force |
164
188
 
165
189
  ## Installation
166
190
 
@@ -212,14 +236,17 @@ To mount a working copy into a profile instead, add it to the profile's `package
212
236
 
213
237
  After changing anything under `src/`, run `pnpm run bundle` again — the mounted profile loads `lib/`, so edits do not take effect until the bundle is rebuilt.
214
238
 
215
- `pnpm test` builds, typechecks and runs six checks (`tsdown && tsc -p tsconfig.json && node scripts/holiday-check.ts && node scripts/rotation-check.ts && node scripts/transition-check.ts && node scripts/repaint-check.ts && node scripts/ui-strings-check.ts && node scripts/node-half-check.mjs`). None of them needs a dependency, a transformer or a browser, and each can be run alone with `pnpm check:holiday` / `pnpm check:rotation` / `pnpm check:transition` / `pnpm check:repaint` / `pnpm check:ui` / `pnpm check:node`:
239
+ `pnpm test` builds, typechecks and runs nine checks (`tsdown && tsc -p tsconfig.json && node scripts/holiday-check.ts && node scripts/rotation-check.ts && node scripts/transition-check.ts && node scripts/repaint-check.ts && node scripts/code-plate-check.ts && node scripts/image-mode-check.ts && node scripts/rules-view-check.ts && node scripts/ui-strings-check.ts && node scripts/node-half-check.mjs`). None of them needs a dependency, a transformer or a browser, and each can be run alone with `pnpm check:holiday` / `pnpm check:rotation` / `pnpm check:transition` / `pnpm check:repaint` / `pnpm check:code-plate` / `pnpm check:image-mode` / `pnpm check:rules` / `pnpm check:ui` / `pnpm check:node`:
216
240
 
217
241
  - **`check:holiday`** — the two holiday windows against known dates, the Beijing day boundary (23:59 vs 00:01), leap eighth months across 1900–2100, and the four gates `pickHoliday` applies before a holiday is allowed to take over the background.
218
242
  - **`check:rotation`** — the rotation's decisions (`nextIndex` / `isRotating`): no walking off the end of a list, shuffle never repeating the image already on screen (across the whole support of the roll), and "rotating" never claimed for a rule with nothing to rotate.
219
243
  - **`check:transition`** — the switch effect's decisions (`resolveFadeMs` / `transitionPlan`), which live in `src/client/transition.ts` and are the ones that fail without a sound: that the shipped default still produces **exactly** the transition string earlier releases wrote (`opacity 320ms ease`, opacity only — an unchanged property must not be put on the transition list), that `Instant`, a `0` duration, a `0` unified duration and a non-numeric duration each mean a hard cut, that reduced motion vetoes rather than shortens, that every effect starts somewhere other than where it ends (the "I picked the new effect and nothing happened" failure), and that every effect lands fully visible and **unscaled** — a zoom that never resets would crop every later wallpaper.
220
244
  - **`check:repaint`** — the decision that says whether an edit has to repaint the interface (`shouldRepaint`), including the case that kept failing in the field: an edit that **creates** the winner (a rule's first picture, its first or auto-extracted color) must repaint even though the edited rule was not the active one yet. It also fails if any of the write paths in `src/client/index.tsx` stops asking — so re-introducing the old `id === activeRuleId` test breaks the build's checks instead of the user's next upload — and if a rotation step stops re-emitting the interface palette (a theme color belongs to the image, so a step changes it).
245
+ - **`check:code-plate`** — the code block's plate: that the surface tokens it reads are declared on **both** `:root` and `body` (a `var()` is substituted where it is *declared*, so a root-level consumer otherwise reads the host's colour and not the palette's), that the light branch emits the banner instead of borrowing the host's near-white, and that each of the two surfaces reads its **own** plugin variable — one shared variable would let the banner's write paint the plate too.
246
+ - **`check:image-mode`** — the 0.7.5 lift of the layout mode onto the image and, more to the point, the **wiring**: which image a control edits and which image the render layer reads. A mis-wired read does not throw, it just makes every picture in a rotation look the same.
247
+ - **`check:rules`** — the two decisions behind the tab strip, which are pure and live in `src/client/rules-view.ts` (which rule the editor shows, and where a deletion lands — its neighbour, never rule 1), plus the page structure the layout rests on: one editor drawn, no stacked per-card container, no per-card open state, a strip that can neither wrap nor grow, and its scrollbar block staying scoped to the plugin's own classes.
221
248
  - **`check:ui`** — every `t('…')` key exists in both dictionaries, both dictionaries carry the same key set, every `dab-…` class has a rule in the stylesheet, and no dictionary entry has gone unreferenced.
222
- - **`check:node`** — the built node half driven through its real RPC handler with `DSH_HOME` redirected to a throwaway directory: the holiday block through the shared sanitizer, the packaged wallpapers served from an empty slot (without being copied into the data directory), a holiday slot refusing writes, deletes and URL fetches while a file dropped into it is ignored, an ordinary rule slot still writing, listing and deleting, a hand-edited config unable to redirect a holiday at another rule's image, the per-image color shape: the read-time lift in both directions (an absent key inherits the rule's color, an explicit `null` does not), independent colors through the file, and a `null` written as a key rather than omitted, and the global switch effect: the shipped default for a config that predates it, a chosen effect/easing/unified duration surviving the round trip, hostile values falling back while an absurd duration clamps, a `0` duration kept rather than defaulted, and drift inside `transition` reported instead of silently dropped.
249
+ - **`check:node`** — the built node half driven through its real RPC handler with `DSH_HOME` redirected to a throwaway directory, and `globalThis.fetch` replaced by a stub — the festival art lives on a CDN now, and this suite must not depend on one being up, nor assert today's bytes of a file it does not own. It covers: the holiday block through the shared sanitizer; nothing fetched merely for loading the plugin or reading the config; the art fetched on demand and cached under `holiday-cache/` keyed by **asset** rather than slot; a second read answered from disk with no second transfer; two simultaneous cold reads sharing one transfer; a dead first mirror falling through to the second with both mirrors pinned to the same commit; every mirror down yielding "no bytes" rather than a throw, with nothing cached from a failed fetch; a mirror answering 200 with a non-image rejected rather than cached; a corrupt cache entry discarded and re-downloaded; a holiday slot refusing writes, deletes and URL fetches while a file dropped into it is ignored and no `modelbg-h-*` file is ever written; an ordinary rule slot still writing, listing and deleting; a hand-edited config unable to redirect a holiday at another rule's image; the per-image color shape — the read-time lift in both directions (an absent key inherits the rule's color, an explicit `null` does not), independent colors through the file, and a `null` written as a key rather than omitted; and the global switch effect — the shipped default for a config that predates it, a chosen effect/easing/unified duration surviving the round trip, hostile values falling back while an absurd duration clamps, a `0` duration kept rather than defaulted, and drift inside `transition` reported instead of silently dropped. The recommended profile is covered end to end from the node half's side: the manifest and the config fetched in that order and sanitized on arrival, the request set DERIVED from the rules with the holiday slots dropped from it, one request per wallpaper with the bytes sniffed rather than trusted from a header, a dead jsDelivr falling through to raw for a single file, and every way it can fail reported as a value rather than a throw — every mirror down, a mirror answering 500, a 200 carrying HTML, a manifest whose version is not a number, a profile written for a newer shape refused before a single wallpaper is asked for, a config that is not JSON, a config that is a JSON array, a config naming more images than the cap, and a mirror announcing an oversized body; plus the strict half (a named wallpaper the host does not have is an error, not a short profile), the two naming refusals that happen before any request at all (a holiday slot, and a slot name that is not a slot), and the reset: the config and every rule image gone, `holiday-cache/` untouched, a read afterwards yielding a fresh config rather than an error, and the profile then writing into the emptied store.
223
250
 
224
251
  ## FAQ
225
252
 
@@ -270,23 +297,23 @@ Two things can hold it, and both are on the card: the effect is **Instant**, or
270
297
 
271
298
  ## Recent Optimizations
272
299
 
273
- > Only the two most recent releases are listed here; older ones (v0.7.2 and earlier) live in [CHANGELOG.md](./CHANGELOG.md).
300
+ > Only the two most recent releases are listed here; older ones (v0.7.4 and earlier) live in [CHANGELOG.md](./CHANGELOG.md).
274
301
 
275
- ### v0.7.4
302
+ ### v0.7.6
276
303
 
277
- - **The controls inside the dialog change colour on the wallpaper's clock instead of jumping when it finishes (a fix)** — on a theme-colour change the holiday switch's fill and the active **Slide in** / **Standard** chips held the **previous** colour for the whole switch and took the new one only once the background had finished: a three-second change that reads as "the interface ignores it, then hard-cuts at the end". The cause was this plugin's own doing: the fade armed the **full token list** on three elements, two of which (the settings dialog and the trajectory root) only **re-declare** the three layer tokens and inherit the rest. A transition on an *inherited* token targets the value that element saw when the fade was armed — still the old one, because the ancestor is itself interpolating — so it becomes a damped copy of the animation and drags its whole subtree behind it (measured: 113/255 of the way at t = 1500 ms on a 1200 ms transition, against frame-for-frame for a consumer with no transition of its own). The transition list is now per scope, and a scope only ever gets the tokens it declares.
278
- - **The surfaces painted from a colour of the plugin's own fade too now (a fix)** — the settings dialog's plate, the file-preview panel and the Cordis panel are not painted from a registered alias token but from a variable the plugin owns and writes inline on `<html>`, and an unregistered variable cannot interpolate at all: they snapped in one frame while every surface around them faded. A fade scope can now name a **standard property** (`background-color`), and each paint rule and its fade now share one selector constant so the two halves cannot drift apart. Measured over the real emitted CSS, one 3000 ms `ease` change: the plate, the right panel and the Cordis surface already read their **final** colour at the first sample and never moved, while all five surfaces now sit at exactly the same fraction of the way — 80.2% at t = 1500 ms, which is `ease(0.5)`.
304
+ - **The rules page turns from a stack of self-growing cards into one tab strip and one editor** — every rule used to be a card that expanded itself, so the page was as tall as the sum of the cards and only got shorter once the user collapsed them by hand. Now every rule is a tab and exactly one editor is mounted, so the height is a function of one card plus the strip, whatever the rule count. The tabs carry what only the card head used to show (the number, a thumbnail of the first image, the match string, and whether the rule is the one in use and whether it rotates); the strip scrolls sideways rather than wrapping, at a fixed 34px with an elided match label, so switching rules cannot reflow the row. **The `+` and the "which rule" menu sit outside the scroller** — inside it, the only control that can add a rule scrolled away from the end of a long list. The match tester moved to the profile page: it answers a question about the configuration as a whole, and on the rules page it pushed the strip a whole card down. The two decisions behind the strip are pure functions now (`src/client/rules-view.ts`), pinned by the new `check:rules`, which also pins the page structure the layout rests on.
305
+ - **The rotation card's controls were reshuffled, at your request** — the Next button and the "On screen 2 / 3" readout moved outside the rotation panel and are **always there**: that readout is the only place on the card that says which picture is showing, and it used to vanish together with the rotation switch. The only test left is `rotateNow`'s own — the active rule has two or more pictures — and below that the button is disabled and the readout is omitted rather than printing "0 / 0". **"Also step on a model switch" joined the order row** (all three answer "when does a picture change"); **"1 hour" is gone** from the dwell-time presets (the custom field still takes 3600 s); **the row of eight fixed preset swatches is deleted** (the wheel, the numeric inputs, Extract from this image, the eyedropper and Follow system theme all stay — the row was a shortcut into the same setter); and **the whole rotation block moved into the second column**, below the theme colour, where in the collapsed single-column card it used to be the tallest block on the card, sitting between the pictures and the colour controls that edit them.
306
+ - **The festival art stops shipping in the package: fetched on the day, cached by asset** — the two holiday wallpapers (228 KB + 500 KB) were two thirds of the entire tarball, for a picture looked at one day a year, with their binary history kept in `.git` forever on top of that. They now live in the project's own [`HarmlessFunny/assets`](https://github.com/HarmlessFunny/assets): the download is gated on the **calendar**, so a profile that is not on a holiday fetches nothing at all, and the first read on the day caches the file under `holiday-cache/` — every boot after that, and every year after that, comes off disk. The reference is **pinned to a commit, never a branch** — runtime art has to be immutable, or a later reshuffle of the documentation screenshots could silently kill a shipped feature on a day nobody is watching — with jsDelivr first and raw second, and with both mirrors unreachable the holiday simply does not take over and the model rules keep painting (the "does this slot actually have bytes" gate was written for exactly that). The cache is keyed by **asset** name and sits deliberately outside the slot namespace, so neither "a holiday slot is read-only" nor "the store never holds a `modelbg-h-*` file" moved an inch; a cache entry is **validated**, and one that is not an image is treated as a miss and re-downloaded. `globalThis.fetch` is replaced by a stub throughout `check:node`, so the suite does not need a CDN to be up.
307
+ - **A recommended profile you can install in one click** — the Config tab gained a third card that downloads the author's own configuration from the same assets repository and writes it in place of yours (see [Recommended profile](#recommended-profile)). **It is hosted in the store's own shape** — a `preset.json` shape gate, a readable `theme-config.json`, and one raw file per slot — which is what makes it editable where it lives: the config is 6.5 KB of JSON you can edit on GitHub, replacing a wallpaper is replacing a file, and publishing it is a `cp` out of the data directory. It was first written as a single theme file with every wallpaper inlined as base64, at 2.9 MB where 99.8% of the bytes were a picture; that is not a thing anyone can hand-edit, and the format was changed for exactly that reason. The plugin fetches the manifest and the config, derives the file list from `rules[].images[].slot` (no second place to keep in sync), and fetches the wallpapers one request each, four at a time — showing `3/7` on the button while it runs — so each file gets its own mirror fallback and its own validation. Applied through the same `applyThemeFile` a hand-picked file goes through, assembled on the client into the shape `exportTheme` writes; it is the only action in the panel that destroys anything, so it is the only one that asks, and the download happens **before** the question so the confirmation can state what is actually in hand (7 rules · 7 wallpapers · 2.9 MB). **The store is emptied first, deliberately:** `nextSlot` hands slots out as `m1`, `m2`, … — a counter, not a random token — so a downloaded profile and the store it lands in collide on those names as a matter of course, and "replace everything with this" is only true if the everything is actually gone; that is also why there is no undo. The wipe runs **after** the download and never before it, because an unreachable CDN must not cost the user their configuration on the way to a profile that never arrived, and a host too old to write this build's config shape is refused before anything is deleted. **One missing wallpaper fails the whole attempt and names the file** — a profile that quietly arrived short a picture is precisely the silent failure that pinning the festival art to a commit exists to prevent, so the loud failure is what makes a branch reference acceptable here; the two holiday slots a config legally names are dropped from the request set rather than asked for, since the store can never hold a `modelbg-h-*` file. `holiday-cache/` is left alone, and nothing about the profile is written to the store or cached to disk — the whole point of the button is to fetch what is there *now*.
308
+ - **The published package drops from 1.1 MB to about 220 kB** — the documentation screenshots (`example_img/`, three webp files, 220 KB) moved into the same assets repository and both READMEs reference them by absolute jsDelivr URL (an absolute URL keeps the images on the npm page unbroken just as well, without putting binaries in the tarball or their history in `.git`); `screenshots.json`, which nothing consumes, is deleted; and `lib/client.js.map` (a 624 KB source map, useless to anyone installing the package) is no longer published, though it stays in the repository. Thirteen files became ten.
279
309
 
280
- ### v0.7.3
310
+ ### v0.7.5
281
311
 
282
- - **The switch is a setting now: four effects, an easing and one duration, on the Config page** — every wallpaper change this plugin performs (a model switch, a rotation step, the manual **Next image**, a holiday taking over) went through one hard-coded cross-fade whose length was read off a field no control could reach. **Cross-fade**, **instant**, **zoom** and **slide** all ride the two layers that already existed — zoom and slide use `transform` on the incoming layer only, a property the wallpaper layer had never used, so neither costs a byte of extra transfer — instant and a `0` s duration are the same hard cut, and **reduced motion** is a veto rather than a shorter animation.
283
- - **The card carries a preview swatch, and that is not decoration** — a wallpaper fading into itself is invisible, so replaying the effect on the picture already on screen would show nothing at all: the one way this feature could look broken while working. The swatch replays the chosen effect at the length a real switch would use right now.
284
- - **There is exactly one duration, and "Follow each rule" is gone** — the option named a value the interface cannot set (`rotate.fadeMs` had been in the shape since 0.7.0 and no control ever reached it), so it was a choice between a real setting and a phantom one. The slider IS the setting. A hand-edited config is not dropped in silence: `legacyFadeMs` folds the first deliberate value it finds into the global duration on the first read (a value equal to the shipped 320 ms is ignored, and an explicit `durationMs` wins), and the field is exempt from the drift warning because it is being migrated, not drifting.
285
- - **The interface colour now changes over the same clock as the wallpaper** — a new theme colour is one rewrite of `body{--dsw-alias-…}`, which used to change every surface in a single frame while the picture behind it blended. The fade is a **registration** problem (an unregistered custom property cannot interpolate), so the plugin emits `@property` rules that turn the alias tokens into `<color>`s, plus a `transition` for the elements whose own declarations change with the palette — `body`, the settings dialog and the trajectory root. The two surfaces it paints **inline** (the app columns, the chat/trajectory cards) do not read those tokens at all, so they carry the same duration on `background-color`. The duration, the easing and both vetoes come from the **same** `transitionPlan` the wallpaper uses, and it is armed **only when the palette itself moved** — every other pass (a slider drag moving the alphas) writes the sheet with no transition and `none` inline, because a live transition there would make the surface lag behind the pointer.
286
- - **A light↔dark flip switches instantly, and the wallpaper keeps animating** — interpolating between the two palettes walks the interface through mid-tones whose ink is wrong in both directions, so a scheme flip is exactly the case a fade makes worse. The fade is armed only while the **scheme** stays put (light→light, dark→dark); the wallpaper is deliberately untouched by that rule, because one image fading into another has no scheme to get wrong.
287
- - **The ink is written synchronously now, and that was a real bug with exactly one writer (a fix)** — switching a rule to a dark theme colour could leave a panel's text on the **previous** palette while every surface followed the new one: dark ink on a dark panel, unreadable, cleared only by reloading, with nothing in the log. Measured from the reported screenshot rather than guessed: the panel was the plugin's dark `bg-layer-2` (`hsl(229,59%,27%)` = `rgb(28,43,109)`, composited at 87% panel opacity to the sampled `rgb(23,36,91)`) while the ink was the **light** branch token over it (`rgba(0,0,0,0.85)` on that navy is exactly the `rgb(3,6,14)` sampled from the glyph cores, and `rgba(0,0,0,0.7)` exactly the `rgb(7,10,27)` of the hint line). Every other surface has a second, synchronous writer, so only the token stylesheet could go stale — and its single writer was reachable only from a `requestAnimationFrame` callback wrapped in `catch {}`: a window that is not rendering swallows the frame while the id stays armed and parks every later update behind it, and a failure inside the write said nothing at all. A palette that moved is now written in the same task as the surfaces it has to agree with, the frame path is kept for what it was for (coalescing a slider drag), a frame outstanding for over a second is dropped instead of blocking forever, and the writer logs its failure.
288
- - **The config shape moved to 5 and an export to format 6** — a schema-4 sanitizer rebuilds the config from the keys it knows, so a stale host process would drop the effect, the easing and the duration on the first write after a refresh, silently and with nothing in the host log. The published `schema` moves with it, the hold-writes path catches such a host, and `warnUnknownConfigKeys` compares the fixed-shape `transition` block one level down.
289
- - **Gates** — `check:transition` joins `pnpm test`, which is now **six** checks; `check:repaint` pins the three lines the ink bug was made of (write synchronously on a palette change, do not swallow the failure, ask both halves of the fade question at both the token and the inline call sites); `check:node` covers the shipped default, the round trip, hostile values, a kept `0` duration, the drift check and the legacy fold; `check:ui` covers the new strings and classes and registers the chip labels in `TABLE_KEYS`.
312
+ - **The layout mode belongs to the picture, not the rule** — 适应 / 填充 / 拉伸 / 平铺 / 居中 was one value per rule, so a rotation through a landscape photo and a tall phone screenshot could not letterbox one and fill the other. It now lives on `images[].bgMode`, read back from the image being painted, and the chip row under the filmstrip edits the image selected there (disabled, not hidden, while a rule has no picture yet). `SCHEMA_VERSION` moves to **6** because both directions of this move are silent: a schema-5 host would drop a per-image mode on its first write (拉伸 coming back 适应), and a schema-6 read of a schema-5 config lifts the rule-level value onto every one of its images, so no existing wallpaper lands on the default. A holiday's image is 填充 from its definition, like the slot and the colours beside it; the framing stays yours.
313
+ - **The fade only animates what the palette in force writes (a fix)** — "有时候切换背景时(图上为浅色切换为浅色),白色色块会渐变为黑色色块…白色渐变完黑色后立马切到白色", with the white value measured as `237, 243, 254`. That number is the answer: `#edf3fe` = `--dsw-static-deepseek-50`, which is what the host's light scheme paints `--dsw-specific-bubble` with — the user message bubble — and its dark-scheme value is `#2c2c2e`. The fade had registered that name because it asks for the **dark** branch's token list (the superset) and hands the whole thing to the `:root` and `body` scopes, while the light branch writes only 35 of those 80 names: the other 45 (the bubble, the settings dropdown fills, the tip/tooltip/toast surfaces, the code-block header segments, the status colours) still belong to the **host**, and the plugin is what makes the host switch schemes for a moment — it disposes and re-registers its theme on every colour change, and the host's disposer resets the preference to `system`, which on a dark desktop paints the host's dark palette until that call finishes. With the reported 3000 ms switch, one frame of it became a three-second ramp. A scope's token is now emitted only when the palette in force writes it (the registrations still cover all 80 names, so the dark palette is unchanged), and a skin whose colour scheme already holds is not rebuilt at all. Measured on the reported palette: `body`'s transition list goes 80 → 35 names, the bubble and the dropdown fill are out of it, and `--dsw-alias-bg-base`, `--dsw-alias-bg-layer-2` and the code plate stay in.
314
+ - **The code block follows the palette, and its plate has an opacity of its own (a fix)** — "代码块背景有点问题": dark green ink on a near-black plate inside a **light** interface. The host's shiki sheet declares `--shiki-background: var(--dsw-alias-markdown-code-block)` on `:root`, while the token was declared on `body` only, so the root element had nothing to read but the fade registration's `initial-value` — the host's own colour, sampled once. The palette rule is emitted for `:root` too, `:root` joins the fade scopes, the plate and its banner are palette tints again, and a new **`code`** part drives both surfaces, each through its own variable, defaulting to opaque.
315
+ - **Four small ones** — the rotation switch no longer stays "on" when deleting images leaves fewer than two (the panel disables the switch there, so it could not be turned off either; `normalizeRule` enforces the same invariant for imports), "切换时长" is printed once instead of twice, and the profile footer's package name and the brand tile link to the repository, with `check:ui` comparing that one address against `package.json`.
316
+ - **Gates** — `pnpm test` is now **eight** checks: `check:code-plate` and `check:image-mode` join the suite, `check:rotation` gained the switch-and-count invariant, `check:ui` the repository-link agreement, `check:transition` the fade-ownership rule and `check:repaint` the two lines that arm the fade.
290
317
 
291
318
  ## Star History
292
319