dsh-background-by-model 0.7.9 → 0.8.0-test.1

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,120 +1,119 @@
1
1
  # Changelog
2
2
 
3
- > The full release history lives here; the **Recent optimizations** section of [README.md](./README.md#recent-optimizations) lists only the two most recent releases (v0.7.9 / v0.7.8).
3
+ > The full release history lives here; the **Recent optimizations** section of [README.md](./README.md#recent-optimizations) lists only the two most recent releases (v0.8.0 / v0.7.10).
4
4
 
5
- ## v0.7.9
6
-
7
- - **Every asset reference is pinned to one release tag of the assets repository (`v0.1.0`), and the three that had drifted apart now agree** — the README's three screenshots were read from the `main` branch, the festival art was pinned to a raw commit hash (`HOLIDAY_ASSETS_COMMIT = '58a9e107…'`), and the recommended profile followed `main` deliberately. Three references, three different answers to "which bytes is this build looking at", and only one of them was actually immune to the repository moving underneath it. `HOLIDAY_ASSETS_COMMIT` is now `HOLIDAY_ASSETS_REF = 'v0.1.0'` — renamed with the value, since it no longer names a commit — and `PRESET_ASSETS_REF` carries the same string, so one release of `HarmlessFunny/assets` is one version of every byte the plugin fetches: the festival art, the recommended profile and the README's images together, in a form a person can read, compare against the tags on GitHub and update in one place rather than a 40-character hash nobody can date.
5
+ ## v0.8.0
8
6
 
9
- - **The festival art's bytes do not move with the name.** `58a9e107` is an ancestor of `v0.1.0` and both `dsh-background-by-model/holiday/*.webp` files are **byte-identical** between the two revisions (the same blobs, `46ccfc89…` and `dc5080e4…`), so the download is the same download — the diff between the revisions is the profile arriving (seven `preset/modelbg-*` files, `preset.json` and `theme-config.json`, none of which existed at that commit) plus the three screenshots being re-shot. What did change is that the raw mirror carries the tag as well (`raw.githubusercontent.com/HarmlessFunny/assets/v0.1.0/…`), which is what keeps "both mirrors serve the same revision" true by VALUE rather than by convention.
7
+ - **The bundle is built when the tarball is packed, instead of being committed** — `lib/` was ~1 MB of generated JavaScript in every release and effectively all of this repository's growth, while keeping it in step with `src/` was something a person had to remember. There is no `prepublishOnly` hook here, so a stale bundle shipped in silence and nothing in either flow would have noticed. It is now gitignored and built by `prepack`, which npm runs for both `npm pack` and `npm publish`, so what is published is always built from the tagged source. Nothing changes for anyone installing the package: the same three files arrive, built a minute earlier.
8
+ - **The `github:` install route is gone, deliberately** — `dsh plugin --profile web add github:HarmlessFunny/dsh-background-by-model` fetched a tree that no longer contains `lib/` and has nothing in it that builds one, so it could only produce a plugin that would not load. npm is the only source now and the `npx` form follows it; the third method, a working copy built from source, says up front that the build is a step you have to run.
9
+ - **Releases are published from GitHub Actions over OIDC, so the versions carry build provenance** — the npm token is out of the release path entirely, and npm attaches a provenance attestation binding each version to the commit and the workflow that produced it. Every version up to v0.7.10 was uploaded by hand and can claim nothing of the kind; from here they are checkable rather than merely asserted.
10
+ - **The README and this file are cut back to the register of the v0.3.x entries** — a feature is introduced in a sentence, the ten checks are one line each, and the FAQ keeps all fifteen questions with each answer cut to the sentence carrying the fact. Nothing a user needs was removed; what went was the narration of how each change was arrived at.
10
11
 
11
- - **The recommended profile gives up "correct it without a release", deliberately** — it followed a branch because its case for immutability was the opposite of the festival art's: a profile fails LOUDLY (the user is looking at the button and gets an error naming the missing slot) where a holiday fails silently on a day nobody is watching, so pinning bought nothing there and a branch bought the ability to fix a bad recommendation by editing one JSON file. That is what is traded away. A branch also bought a plugin whose bytes could change under a user between the build and the click, and with one tag covering the screenshots, the art and the profile, no fetched byte can do that any more. The price is named rather than left to be discovered: correcting one JSON file now costs a tag in `assets`, a new value in `src/index.ts` and a release on npm — and a tag is not a commit. It **can** be re-pointed, while jsDelivr serves a version reference as immutable, so re-pointing one takes a purge request instead of a push, which makes a correction a new tag rather than a force-push of the old one. That is exactly why `presetKey` survives this change: an edge that has not been purged can still answer with the previous bytes of the same URL, so two fetches of "the profile" are still not guaranteed to be one revision, and a press that resumes must not glue them together.
12
+ ## v0.7.10
12
13
 
13
- - **Gates** — `check:node` had pinned the old design in two assertions, and its `revision()` helper could not see a tag at all: it matched a 40-character SHA, which is the only reason the raw mirror's reference could be lifted out without an `@`. It now matches beside the repository name (`HarmlessFunny/assets@<ref>` in the jsDelivr shape, `HarmlessFunny/assets/<ref>/` in the raw one), so the two mirrors stay comparable, and both assertions require `v0.1.0` — the holiday art's became "pinned to the assets release tag, never to a branch" and the profile's became "read at the assets release tag, not at a branch", replacing the assertion that used to pin the branch on purpose. `pnpm test` is ten checks, all passing, and `lib/` is that run's output.
14
-
15
- ## v0.7.8
14
+ - **The festival art's cache is keyed by the release the plugin pins, so an updated plugin is answered by a download instead of by the previous generation's bytes** — the cache lived at `holiday-cache/<asset>` and `readHolidayAsset` returned any file that existed and was a valid image, with no version comparison, no network and no log line. Pointing a build at a new release of the assets repository therefore changed the URL it would have fetched and nothing about what it returned. `holidayCachePath` now carries the tag (`holiday-cache/v0.1.0/mid-autumn.webp`), which makes the release part of the file's identity: a build pinning a new tag looks in a directory nobody has written to, so the first holiday after an update is a cache miss, and `ls holiday-cache/` answers which generations are on this disk.
15
+ - **A superseded generation is dropped, after the new art is on disk and never before it.** A versioned key on its own would leave every generation lying there forever — about 730 KB each, including assets a later release stops shipping at all. `pruneHolidayCache` runs at the end of a successful cache write and removes the generation directories that are not this build's tag, so a pinned release that turns out to be unreachable cannot cost a user the art they already had. It is best-effort, and `resetStore` still leaves every generation alone.
16
+ - **Gates** — `check:node` derives both URL assertions and the cache path from one `REF`, and pins that art missing from the pinned generation is re-downloaded rather than read stale, that a superseded generation is gone once the new art has landed, and that the generation in use is left alone. `pnpm test` is ten checks, all passing.
16
17
 
17
- - **A fresh install opens on one blank rule instead of the "no rules yet" screen** — the report was "图一是刚安装上的情况,什么也没有,我希望刚安装上就如图二一样创建一条空白的规则" (image one is a fresh install: nothing there; I want it to create one blank rule, like image two). The cause was two shapes being read as the same thing: with no config file at all `migrateLegacy` answered `rules: []`, and `normalizeConfig` read that empty array as "the user deleted every rule". Only a **missing** `rules` key now means "nothing has ever been configured", and it is answered with one blank rule — id `r1`, the id `nextRuleId` mints for an empty list, so it is rule 1, the fallback and the tab the panel selects, with nothing downstream having to recognise it as seeded. A key that IS there and empty is still the user's own deletion and stays empty: a count cannot make that distinction, and without it the rule grows back on every read. The factory itself moved into `src/schema.ts` (`blankRule`), and the shipped default, `normalizeConfig` and the client's "add rule" now share one shape — the client's `newRule` is gone with it. The seeded rule is deliberately blank rather than half-built: with no picture and no colour of its own, `ruleCanPaint` makes matching skip it until something is filled in, instead of letting an empty background take the fallback away from rule 1.
18
+ ## v0.7.9
18
19
 
19
- - **The placeholder was not "too bright": the declaration never applied** — the report was "这个输入框的 placeholder 太亮了,让人以为真的有文字在里面" (this input's placeholder is too bright, it makes you think there really is text in there). `--dsw-alias-label-quaternary` is defined NOWHERE in the harness theme: an undefined custom property drops the declaration at computed-value time, and `color` is inherited, so the placeholder fell back to `inherit` — the input's own `label-primary`, the brightest ink in the panel, inside a box the user had not typed a character into. All three inputs that carry a placeholder (the rule's match string, the image URL, the rotation's custom seconds) now share one rule reading `--dsw-alias-label-caption`: one step weaker, and the same token `PLACEHOLDER_RULE` (`src/client/wallpaper.ts`) already hands the composer, so the plugin says one thing about what "empty" looks like. The dead name was in two more places: `.dab-brand-tag` wrote "quaternary with a fallback", so it has always painted tertiary and dropping the dead name changes no colour; `.dab-pick-n`'s `--dsw-mono` was missing a letter — the plugin's own variable is `--dab-mono`.
20
+ - **Every asset reference is pinned to one release tag of the assets repository (`v0.1.0`), and the three that had drifted apart now agree** — the README's screenshots read `main`, the festival art was pinned to a raw commit hash (`HOLIDAY_ASSETS_COMMIT`), and the recommended profile followed `main` deliberately: three references, three answers to "which bytes is this build looking at". It is now `HOLIDAY_ASSETS_REF = 'v0.1.0'`, with `PRESET_ASSETS_REF` carrying the same string, so one release of `HarmlessFunny/assets` is one version of every byte the plugin fetches — the art, the profile and the screenshots together — in a form a person can read, compare against the tags on GitHub and update in one place.
21
+ - **The festival art's bytes do not move with the name.** `58a9e107` is an ancestor of `v0.1.0` and both `holiday/*.webp` files are byte-identical between the two revisions, so the download is the same download. What did change is that the raw mirror carries the tag as well, which keeps "both mirrors serve the same revision" true by value rather than by convention.
22
+ - **The recommended profile gives up "correct it without a release", deliberately** — it followed a branch because its case for immutability was the opposite of the festival art's: a profile fails loudly (the user is looking at the button) where a holiday fails silently on a day nobody is watching. That is what is traded away: correcting one JSON file now costs a tag in `assets`, a new value in `src/index.ts` and a release on npm. And because jsDelivr serves a version reference as immutable, a re-pointed tag takes a purge request instead of a push — which is exactly why `presetKey` survives, since an edge that has not been purged can still answer with the previous bytes of the same URL.
23
+ - **Gates** — `check:node`'s `revision()` helper matched a 40-character SHA and could not see a tag at all; it now matches beside the repository name (`assets@<ref>` and `assets/<ref>/`), so the two mirrors stay comparable, and both assertions require `v0.1.0` instead of pinning a branch. `pnpm test` is ten checks, all passing.
20
24
 
21
- - **The recommended profile now retries per file, and a failed attempt hands what it fetched to the next one** — a file used to get exactly two chances (one per mirror), so a single 502, a reset connection or a CDN hiccup on the sixth of seven wallpapers threw away the six good downloads behind it, and the user's only recourse — pressing the button again — started from zero. Every file now gets up to three rounds, waiting 400 ms and then 800 ms between them (`PRESET_ATTEMPTS` / `presetRetryDelayMs` in `src/preset.ts`), and an attempt that fails holds the bytes it did get (`presetHeld`): the next press re-fetches the manifest and the config first (two small files) and reuses those bytes only while the config comes back **the same value** (`presetKey`, a canonical form with object keys sorted — a config the wire rebuilt in another key order must not cost the hold for nothing) — which turns "the sixth file failed" into one file to fetch instead of seven. Three failures are NOT retried, each for its own reason: 404/403 is "the file is not there" (the profile follows a branch, so an author renaming a file has to reach the slot **fast** and loudly rather than after two more rounds), oversized is "the bytes are the problem and the next round serves the same bytes", and a timeout is "sixty seconds of silence is not weather" (both mirrors were already asked inside the same round — and that is what keeps the worst case for one file at three minutes instead of six: a round in which BOTH mirrors were silent is over immediately, at the same two minutes this cost before there were rounds at all, and only a round mixing a silent mirror with a transient one can carry a minute into the next round). **The strictness is unchanged**: the first file that cannot be had still fails the whole attempt and still names the slot — what got cheaper is the retry, not the failure. The counter on the button now describes the **profile** rather than this attempt's transfers, so a resumed attempt opens on 6/7 and finishes on 7/7 (which is also why this change touches **no** interface code). The festival art deliberately does NOT get the rounds: it is fetched during boot, before the first wallpaper is painted, where a round's delay lands straight on the first frame — and it has a cache, with every later read trying again, while a button has no later read of its own.
25
+ ## v0.7.8
22
26
 
23
- - **Gates** — `pnpm test` is **ten** checks now (the new `check:preset`). `check:node` gains: a data directory with no config file reads as exactly one rule; that rule is blank (no match string, no colour, no image); it is rule 1, and therefore the fallback; and a rule list written explicitly empty still reads back empty — the shape of the fresh-install rule, and the distinction that keeps it from being seeded back. The assertion after `resetStore` was rewritten to the same meaning, since it used to pin the old behaviour ("an emptied store reads as an empty config"). The retry itself is pinned **by request count**: with both mirrors answering 503 once, the same file is asked again and arrives (three lines in the log — jsDelivr, raw, jsDelivr again); a file the host does not have still costs exactly two requests (one per mirror, no waiting); and a permanently broken mirror costs exactly six (three rounds over two mirrors — the retry has a budget). `check:ui` gains section 6: every `var(--dsw-…)` the UI reads must be a token the installed theme actually publishes — with the theme bundle absent (peer dependencies not installed) the section skips with a notice, because that is a fact about this host and not about this repository; this class of failure reports itself nowhere, the declaration is dropped and the property quietly falls back to an inherited value. The new `check:preset` pins the policy and the wiring on both sides: each of the six failure reasons judged on its own (the three terminal ones are the three that can be decided locally and cheaply, and a timeout is not retried because both mirrors were already asked inside the same round), a wait that grows and stays bounded, a revision key that treats two key orders of one value as one revision while the image list's ORDER counts (it is the rotation), a resume that hands back a copy rather than the held record itself, and the two halves actually using it — the node half looping over the round budget and asking the policy, the client sizing its pool by what is missing rather than by the whole slot list, holding what arrived on failure and clearing it on success, and applying the size cap **before** a file is kept (applied after, the attempt that blows it holds every slot, and the resume then has nothing left to fetch and passes an oversized profile straight through).
27
+ - **A fresh install opens on one blank rule instead of the "no rules yet" screen** — with no config file at all `migrateLegacy` answered `rules: []`, and `normalizeConfig` read that empty array as "the user deleted every rule". Only a **missing** `rules` key now means "nothing has ever been configured", and it is answered with one blank rule — id `r1`, so it is rule 1, the fallback and the tab the panel selects. A key that is there and empty is still the user's own deletion and stays empty. The factory moved into `src/schema.ts` (`blankRule`) and is shared by the shipped default, the sanitizer and the client's "add rule".
28
+ - **The placeholder was not "too bright": the declaration never applied** — `--dsw-alias-label-quaternary` is defined nowhere in the harness theme, so the declaration was dropped at computed-value time and the placeholder fell back to `inherit`, the input's own `label-primary`. All three placeholder inputs now read `--dsw-alias-label-caption`, the token `PLACEHOLDER_RULE` already hands the composer. Two more dead names went with it: `.dab-brand-tag`'s dead alias, and `.dab-pick-n`'s `--dsw-mono` for `--dab-mono`.
29
+ - **The recommended profile retries per file, and a failed attempt hands what it fetched to the next one** — a file used to get exactly two chances (one per mirror), so a single 502 on the sixth of seven wallpapers threw away the six good downloads behind it. Every file now gets up to three rounds, waiting 400 ms and then 800 ms, and an attempt that fails holds the bytes it did get, reusing them only while the config comes back as the same value (`presetKey`). 404/403, oversized and timeout are not retried, each for its own reason. The strictness is unchanged — the first file that cannot be had still fails the whole attempt and names the slot — and the button's counter now describes the profile, so a resumed attempt opens on 6/7.
30
+ - **Gates** — `pnpm test` is ten checks (the new `check:preset`). `check:node` pins the fresh-install rule and the distinction that keeps it from being seeded back, and pins the retry **by request count**: three lines for one transient 503, two requests for a file the host does not have, six for a permanently broken mirror. `check:ui` gains a section requiring every `var(--dsw-…)` the UI reads to be a token the installed theme actually publishes, skipping with a notice when the theme bundle is absent.
24
31
 
25
32
  ## v0.7.7
26
33
 
27
- - **A fresh install starts on the author's own profile, and it needs no network** — the shipped defaults on the Interface tab now ARE the numbers the recommended profile carries (`preset/theme-config.json` in the [assets repository](https://github.com/HarmlessFunny/assets)): main background `0%`, left panel `10%`, right panel `45%`, cards `100%`, code `81%`, inputs `88%` with `60 px` of blur, settings `100%`, conversation text `35%`, trajectory `73%`, every other blur `0`. So the panel is worth looking at before a single byte is fetched, and the profile's one click adds rules and pictures instead of changing the numbers underneath them. Every one of those values used to be chosen per key for **safety** rather than for looks — `bg` at 0.85 so the host surface stayed visible under the wallpaper, `code` fully opaque because "a code block is read, not looked through", `input` blur at 0 because a blur costs a compositor layer — which is why a new install opened on a pale approximation of the host rather than on a configuration anyone would actually run. Two consequences are named rather than discovered later: the main background starts at `0%`, so with no wallpaper yet the window is the host's own canvas (the palette and its ink are unaffected — they come from the theme colour, not from this alpha), and `check:code-plate` now pins the profile's `81%` instead of "defaults to opaque". The constants live in `src/schema.ts` under a note saying they are copied from a live data directory, so changing the profile's numbers means moving them together; the README's Interface section says the same.
28
-
29
- - **`rightbarOpacity` keeps its "never owned" state while a fresh install starts owned** — the preview panel's `null` means "follow `opacities.bg`", and that is not a theoretical state: **every** release so far has written it, because a panel the user never dragged is persisted as an explicit `null`. The new sanitizer (`normalizeRightbarOpacity`) therefore reads `null` back as itself and hands the shipped `45%` only to a key that is not there at all. Treating `null` as "missing" would have silently repainted the preview panel of every existing configuration whose panel was following the main background — exactly the kind of silent migration the config shape's versioning exists to prevent. A fresh install starts owned at `45%`; `null` is now reachable only by hand, since the UI has never had a control that goes back to it.
30
-
31
- - **The festival switch ships OFF** — a first install paints what the model rules say and nothing else, and the single switch on the Config page is how the feature is asked for. It shipped ON before, on the argument that the point of the feature is that the background changes by itself on the day; that is the right answer for a configuration somebody has already chosen — which is why the recommended profile still turns it **ON** — but not for a plugin that has just been installed and has no wallpaper of its own yet, where the first thing a new user would see on 中秋节 is a festival picture nobody asked for. The sanitizer is now "a real boolean is honoured, anything else takes the shipped default" rather than "only an explicit `false` turns it off": the absent field of a config written before the feature existed, and junk from a hand-edit, both mean OFF. The per-holiday switches inside the block are untouched — they are details of a feature that is already switched on, not the switch itself.
32
-
33
- - **The percentage pill in each Interface card is gone** — the number was already on the slider row directly below it, so the card head is the icon and the name again and `.dab-part-badge` goes with it, stylesheet rule included.
34
-
35
- - **Gates** — `pnpm test` is still nine checks; the defaults above moved `check:code-plate` (the code plate ships on the profile's `81%`) and `check:node`, whose new assertions are written **by value** so that a constant changed without the profile — or the profile changed without the constants — fails a check instead of drifting out of step: the appearance numbers a config with no file at all comes up with, an explicit `null` preview panel reading back as itself, a number of its own surviving, a hand-edit that is not a number landing on the shipped `45%`, and the festival switch through both booleans and a non-boolean.
34
+ - **A fresh install starts on the author's own profile, and it needs no network** — the shipped Interface defaults now ARE the numbers `preset/theme-config.json` carries: main background `0%`, left panel `10%`, right panel `45%`, cards `100%`, code `81%`, inputs `88%` with `60 px` of blur, settings `100%`, conversation text `35%`, trajectory `73%`. Every one of those values used to be chosen per key for **safety** rather than for looks, which is why a new install opened on a pale approximation of the host. The constants live in `src/schema.ts` under a note that they are copied from a live data directory.
35
+ - **`rightbarOpacity` keeps its "never owned" state while a fresh install starts owned** — the preview panel's `null` means "follow `opacities.bg`", and every release so far has written it, because a panel the user never dragged is persisted as an explicit `null`. `normalizeRightbarOpacity` therefore hands the shipped `45%` only to a key that is not there at all; treating `null` as missing would have silently repainted the preview panel of every existing configuration.
36
+ - **The festival switch ships OFF** — a first install paints what the model rules say and nothing else, and the single switch on the Config page is how the feature is asked for. The recommended profile still turns it **ON**. The sanitizer is now "a real boolean is honoured, anything else takes the shipped default", so the absent field of a config written before the feature existed and junk from a hand-edit both mean OFF.
37
+ - **The percentage pill in each Interface card is gone** — the number was already on the slider row directly below it, so the card head is the icon and the name again and `.dab-part-badge` goes with it.
38
+ - **Gates** — `pnpm test` is still nine checks; the new defaults moved `check:code-plate` and `check:node`, whose assertions are written **by value** so that a constant changed without the profile — or the profile changed without the constants — fails a check instead of drifting out of step.
36
39
 
37
40
  ## v0.7.6
38
41
 
39
- - **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.
40
-
41
- - **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.
42
-
43
- - **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.
44
-
45
- - **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.
46
-
47
- - **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.)
42
+ - **The rules page turns from a stack of self-growing cards into one tab strip and one editor** — every rule is a tab and exactly one editor is mounted, so the page 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, the match string, and the two states worth knowing unopened (whether it is in use, and whether it rotates or is off); the strip scrolls sideways rather than wrapping, for the same reason. The `+` and the "which rule" menu sit **outside** the scroller, because inside, 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, which is what it is about. The two decisions behind the strip are pure and now live in `src/client/rules-view.ts`, pinned by the new `check:rules`.
43
+ - **The rotation card's controls were reshuffled, at your request** — the Next button and the "On screen 2 / 3" readout moved out of the rotation switch's gate (that readout is the only place the card says which picture is showing, and it vanished with the switch); "also step on a model switch" joined the order row, where all three answer "when does a picture change"; "1 hour" is gone from the dwell presets; the row of eight fixed swatches is deleted with its only readers (the `PALETTE` table and the `.dab-swatch*` rules); and the whole rotation block moved into the second column, below the theme colour, out of the collapsed card's worst spot.
44
+ - **The festival art stops shipping in the package: it is fetched on the day, and cached by asset** — the two wallpapers (228 KB + 500 KB) were two thirds of the tarball, for a picture that is looked at one day a year. They now live in [`HarmlessFunny/assets`](https://github.com/HarmlessFunny/assets), 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; `cdn.jsdelivr.net` is tried first with raw as the fallback, both at the same revision; a failed download is a normal state, so the model rules keep painting; and a cache entry that is not an image is treated as a miss rather than trusted. The cache stays keyed by **asset** name, deliberately outside the slot namespace, so `writeImage` still refuses a holiday slot.
45
+ - **A recommended profile, installed in one click** — a third card on the Config page downloads the author's own configuration from the same assets repository: `preset/preset.json` (the shape gate), `preset/theme-config.json` (6.5 KB of readable JSON) and one `preset/modelbg-<slot>` file per wallpaper. It was first written as a single theme file with every wallpaper inlined as base64 — 2.9 MB in which 99.8% of the bytes were a picture, and not a document anyone can hand-edit. The request set is derived from the config rather than from a second manifest, so every slot name is checked against `SLOT_RE` and the count is capped; the two holiday slots a config legally names are dropped rather than requested; and one missing wallpaper fails the whole attempt and names the slot. It remains the only destructive action in the panel: the download happens before the question, and the wipe only after the download succeeds.
46
+ - **The published package drops from 1.1 MB to about 220 kB** — the documentation screenshots moved to the same assets repository and both READMEs reference them by absolute jsDelivr URL (which is what putting them in `files` was for), `screenshots.json` is deleted since nothing consumes it, and `lib/client.js.map` (624 KB) is no longer published though it stays in the repository. Ten files, about 220 kB packed / 690 kB unpacked, against thirteen files and 1.1 MB / 2.0 MB before.
48
47
 
49
48
  ## v0.7.5
50
49
 
51
- - **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.
52
- - **`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.
53
- - **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.
54
- - **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.
55
- - **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.
56
- - **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.
57
- - **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.
50
+ - **The layout mode belongs to the picture, not the rule** — 适应 / 填充 / 拉伸 / 平铺 / 居中 was one value per rule covering every picture of a rotation, so a landscape photo and a tall screenshot could not be framed differently. The mode now lives on `images[].bgMode`, read back from the image being painted, and the chip row under the filmstrip edits the image selected there. The row is **disabled** rather than hidden on a rule with no picture. README and README.zh were corrected everywhere they claimed the mode was the rule's.
51
+ - **`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; 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. `bgMode` joined `LEGACY_RULE_KEYS`, so the host log does not report the field it is deliberately consuming as drift.
52
+ - **A holiday's image is 填充 by definition** — full-bleed festival art must not letterbox. The mode comes 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.
53
+ - **The fade only animates what the palette in force writes (a fix)** — reported as a white block fading to black and snapping back, with the white value measured as `237, 243, 254`. That number is the answer: `#edf3fe` is `--dsw-static-deepseek-50`, the host's light-scheme user bubble, and the fade sheet had registered it because `fadeTokenNames()` asks for the **dark** branch's token list while the light branch writes only 35 of its 80 names. The other 45 still belong to the host, which changes them on a schedule of its own — and this plugin is what provoked it, by disposing and re-registering its theme on every colour change. A scope's token is now emitted only when the palette **in force** writes it, and a skin whose `colorScheme` already holds is not rebuilt at all.
54
+ - **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 host's shiki sheet substitutes `var(--dsw-alias-markdown-code-block)` where it is DECLARED, on `:root`, while the plugin declared those tokens on `body` only, so on the root element there was nothing to read but the host's own colour. The palette rule is now emitted for `:root` as well, `:root` joins the fade scopes, and the plate and its banner are palette tints again. A new **`code`** opacity part drives both surfaces, each through its own plugin-owned variable; it defaults to opaque, which is what a code block is.
55
+ - **Four small ones (a fix)** — with rotation on, deleting images until one was left kept the switch reading "on" while the panel disabled it below two images, so it could not be turned off either; 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. "切换时长" was printed twice, as a section heading and again as the slider's label. And the profile footer's package name and the brand tile are links to the project's repository, both reading the one address in `repo.ts`, which `check:ui` compares against `package.json`.
56
+ - **Gates** — `pnpm test` is now **eight** checks. `check:code-plate` and `check:image-mode` join the suite; `check:rotation` gains the switch-and-count invariant, `check:ui` the repository-link agreement, `check:transition` the ownership rule, and `check:repaint` the two lines that arm the fade.
58
57
 
59
58
  ## v0.7.4
60
59
 
61
- - **The controls inside the dialog now change colour on the wallpaper's clock instead of jumping when it finishes (a fix)** — on a theme-colour change, the controls in the settings dialog (the holiday switch's fill, the **Slide in** / **Standard** chips) held the **previous** colour for the whole duration of the switch and only took the new one once the background had finished: a three-second change that reads as "the interface ignores it, then hard-cuts at the end". Measured in a real session (a probe reading `getComputedStyle` every 100 ms): `body`'s `--dsw-alias-brand-primary` was interpolating correctly — starting at 930 ms, done at 3000 ms, and its midpoint `rgb(199,131,30)` is exactly what `ease` produces at 53% of the time — while the **active chip's own `background-color`** was `rgb(53,152,177)` at every single sample across those three seconds, bit-for-bit the OLD value rather than a blend, and only reached `rgb(230,126,0)` after 4063 ms (just after body's animation ended) through a ~150 ms ramp.
62
- - **What holds is an intermediate element, not "inheritance cannot see an animated value"** — that was the first hypothesis, and it is wrong twice over. WPT has [custom-property-animation-inherited-used-by-standard-property](https://github.com/web-platform-tests/wpt/blob/master/css/css-properties-values-api/animation/custom-property-animation-inherited-used-by-standard-property.html), which requires exactly this to work ("animating an inherited CSS variable on a parent is reflected on a standard property using that variable as a value on a child"), and an isolated repro agrees: a consumer that merely inherits, with no transition of its own, follows the declaring element **frame for frame**. The real cause is this plugin's own doing: it 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 everything else. Give an inherited token a transition and that element's transition targets the value it 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. Same isolated setup, one 1200 ms transition on the declaring element: the consumer without its own transition follows frame for frame, while the intermediate that was given the same transition is only 113/255 of the way at t = 1500 ms.
63
- - **The fix: the transition list is per scope, and a scope only gets the tokens it declares** — `paletteFadeCss` now takes `scopes: { selector, tokens }[]` instead of `selectors: string[]`: `body` carries the full list (everything else inherits from it), and the dialog and the trajectory root each carry only the three layer tokens they re-declare. That is not a "do less work" optimisation; it is the entire reason the shape exists — a scope handed a name it does not declare copies the lag above into its whole subtree. The emitted CSS goes from `body,.dlg,[data-conversation-composer-overlay]{transition:…full list…}` to `body{…full list…}` plus two rules listing the layer tokens only.
64
- - **Gates** — `check:transition` gained two: **"a scope is never given a token it only inherits"** (the dialog's list must never contain a name it only inherits, such as `--dsw-alias-label-primary`) and "a scope with nothing of its own emits no rule". The existing "every registered token is in the transition list" is kept (`body` still carries all of them), so this fix cannot quietly regress into "every scope gets the full list".
65
- - **One phenomenon recorded that is NOT the cause**: the probe also shows the fade sheet being rewritten without its `transition` a frame or so after it is armed, by the next non-palette apply (the `animate=false` branch of `applyPaletteFade`) — and **a transition that has already started is not cancelled by removing the declaration** (in that same measurement body's transition ran its full 3000 ms while the sheet no longer contained `transition`). So it is not what froze the dialog; but it does mean an element whose transition would only start later gets nothing at all, which is a note for whoever touches this next.
66
- - **Three surfaces were still snapping, and they are the ones painted from a colour of the plugin's OWN (a fix)** — the follow-up report ("还有几个元素的背景色是直接换的", with the interface transparency at maximum) is not the bug above, and the three frames it came with say which surfaces: classifying every pixel of the middle frame against the first one and the last (already at the final colour / still on the first one) puts the **settings dialog's own plate** — visible as the left rail, the title row and the gaps around the cards — on the final colour while every `.dab-card` inside it is still on the first one. A card that fades while the plate behind it does not is the signature of a different paint path: the plate is not painted from a registered alias token but from `--dsh-any-bg-settings-surface`, a plugin-owned variable written inline on `<html>` with the settings alpha folded in, and an **unregistered** variable cannot interpolate at all — there is no token to register and none to transition. The same shape exists twice more: the file-preview panel (`--dsh-any-bg-rightbar`, read by the three selectors `RIGHTBAR_STYLE_RULE` paints) and the Cordis panel (`--dsh-any-op-menu-cordis`, which re-declares `--dsw-specific-menu` on it).
67
- - **The fix: a scope may name a STANDARD property, and the paint rule and the fade share one selector** — `PaletteFadeScope` gained `properties?: string[]`, emitted into the same `transition` list as the tokens and deliberately **not** filtered against the registered names (a property name is not something the host publishes). `fadeScopes()` carries `background-color` for the settings plate and for the file-preview panel, and `--dsw-specific-menu` for the Cordis panel — the third case of the same kind, reached through the token it re-declares because that token IS one of ours. Both paint rules and their fade scopes now read one shared constant each (`RIGHTBAR_SEL`, `CORDIS_PANEL_SEL`), so the half that paints and the half that fades cannot drift apart. Measured in Chromium against the real emitted CSS, one 3000 ms `ease` change between the frames' own colours: with the old CSS the plate, the right panel and the Cordis surface already read their **final** colour at the first sample and never moved, while the card ramped `rgba(217,233,237)` → `rgba(255,255,255)` and the chip `rgb(87,167,188)` → `rgb(236,151,67)`; with the new CSS **all five sit at the same fraction of the way at every sample** — 80.2% at t = 1500 ms, which is exactly `ease(0.5)`: `rgba(239,234,221)` for the plate, `rgba(181,178,168)` for the right panel, both matching the card byte for byte on their own start→end spans. A `0` ms duration still disarms the property as well, so a slider drag stays glued to the pointer.
68
- - **Gates** — `check:transition` gained the second half of the fade shape: a scope carrying only a standard property still emits its rule, that property rides the same duration and easing as the tokens, a property is never registered as a colour, an unregistered token name no longer costs the scope its property, and `0` ms disarms it too. `check:repaint` gained the pairing itself: each of the three plugin-painted surfaces must be painted from its own variable **and** have a fade scope naming the same shared constant, so a fourth one added without a fade becomes a failing check rather than a screenshot nobody took.
69
-
70
- - **The switch itself became a setting: 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, a different rule winning) went through one hard-coded cross-fade whose length was read off a field no control could reach. The effect and its easing are global, because a rule has no notion of a transition: **cross-fade** (opacity), **instant**, **zoom** and **slide** (transform, on the incoming layer only — a property the wallpaper layer had never used, so neither new effect costs a byte of extra transfer). Instant and a `0` ms duration are the same hard cut rather than one of them being read as "unset", a `0` is kept as a real value rather than defaulted away, and `prefers-reduced-motion` is honoured as a **veto** rather than as a shorter animation. The decision is pure (`src/client/transition.ts`), which is what lets `check:transition` pin the failures that throw nothing: an effect whose start state equals its end state (visibly doing nothing), an effect that leaves the incoming layer scaled (which would crop every later wallpaper), and `fade` ceasing to be byte-for-byte the string earlier releases wrote.
71
- - **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 perfectly. The swatch replays the chosen effect at the duration a real switch would use right now, and picking an effect or an easing replays it at once.
72
- - **There is exactly one duration, and the per-rule choice is gone** — the card's default was "Follow each rule", which named a value the interface cannot set: `rotate.fadeMs` had existed in the shape since 0.7.0 and no control ever reached it, so the option was a choice between a real setting and a phantom one. The slider IS the setting now, and the field it deferred to is gone with it. The removal is not a silent loss for the one kind of config that could carry it: that field could only ever be hand-edited, so `legacyFadeMs` folds the first deliberate value it finds into the global duration when a config that predates the setting is first read — values equal to the shipped 320 ms are ignored, so an ordinary config keeps the ordinary default, and an explicit `durationMs` always wins over a legacy field sitting beside it. `rules[].rotate.fadeMs` is exempt from the drift warning for the same reason: it is being migrated, not drifting.
73
- - **The interface colour now changes over the same clock as the wallpaper** — a new theme colour is one rewrite of `body{--dsw-alias-…}`, which changed every surface in a single frame while the picture behind it blended: a snap in front of an animation. The fade is a **registration** problem, not a declaration one, because an unregistered custom property cannot interpolate: `src/client/palette-fade.ts` 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`, and the two surfaces that re-declare layer tokens instead of inheriting them (the settings dialog and the trajectory root). The two places the plugin paints **inline** (the app columns, and the chat/trajectory cards) do not read those tokens at all, so they carry the same duration on `background-color` directly. The duration, the easing and the vetoes come from the **same** `transitionPlan` the wallpaper uses, so "instant", a `0` ms duration and reduced motion cannot mean one thing for the picture and another for the interface. It is armed **only when the palette itself moved**: every other pass rewrites the same token block (a slider drag moves the alphas) and writes the sheet with no transition and `none` inline, because a live transition there would make the surface lag behind the pointer. A token the host does not publish is deliberately left unregistered — registration needs an `initial-value`, and a registered property that nothing else sets resolves to that value instead of to the `var(…, fallback)` chain the plugin's own stylesheet relies on, so registering it would replace a fallback with an invented colour.
74
- - **A light↔dark flip switches instantly, and the wallpaper keeps animating (this one is a fix)** — interpolating between the two palettes walks the whole interface through mid-tones where the ink is wrong in both directions — dark text half-way into a dark palette, and driven by the **previous** scheme's ink — 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); light↔dark switches at once, which is what it did before the fade existed. The wallpaper is deliberately untouched by that rule: one image fading into another has no scheme to get wrong, so it keeps whatever the switch effect says. `paletteFadeAllowed` is the whole rule, pure and pinned in `check:transition`, and `check:repaint` asserts the call site keeps **both** halves of the question (the palette moved **and** the scheme held), because the call site is where a later edit would quietly drop one — and the surfaces the plugin paints inline take the same answer, so they cannot end up animating while the tokens do not.
75
- - **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** (light) palette while every surface followed the new one: dark ink on a dark panel, unreadable, cleared only by reloading, and with nothing in the log to explain it. Measured from the reported screenshot rather than guessed: the panel was the plugin's own dark `bg-layer-2` — `hsl(229,59%,27%)` = `rgb(28,43,109)`, which at the configured 87% panel opacity composites to the sampled `rgb(23,36,91)` — while the label ink was the **light** branch token over it: `rgba(0,0,0,0.85)` on that navy is exactly `rgb(3,6,14)`, the value sampled from the glyph cores, and `rgba(0,0,0,0.7)` is exactly the `rgb(7,10,27)` sampled from the hint line. Two exact matches, one panel: the surface and the ink came from different palettes. The asymmetry is structural — every other surface has a second, synchronous writer (the dialog's layer tokens come from `applySettingsOverrides`, the columns' backgrounds from the view cards), so only the token stylesheet could go stale, and that one 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, parking every later update behind it, and a failure inside the write said nothing at all. Reloading was the only cure, which is exactly what the report said. A palette that moved is now written synchronously, in the same task as the surfaces it has to agree with, while the frame path is kept for what it was for (coalescing a slider drag, where the palette is already correct); a frame outstanding for more than a second is dropped instead of blocking forever, and the writer logs its failure instead of swallowing it.
76
- - **The config shape moved to 5 and an export to format 6, and an older host still cannot eat a profile** — 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 to explain it. The published `schema` therefore moves, the client's `>=` comparison puts such a host into the hold-writes path it already had, and `warnUnknownConfigKeys` compares the fixed-shape `transition` block one level down for the same reason. Theme exports move to format 6; 5 and older still import. That shape has never been released, so `5` still means what it meant when it was written — `SCHEMA_VERSION` guards a newer client against an older host process, and no host has ever announced 5 with the per-rule field.
77
- - **Gates** — `check:transition` (`scripts/transition-check.ts`) joins `pnpm test`, which is now **six** checks: it pins the cross-fade string byte-for-byte, the hard-cut semantics of `instant` and a `0` duration, the reduced-motion veto, the landing state of every effect, the palette-fade CSS shape (including that `0` ms emits the registrations and no transition rule) and the scheme-flip table. `check:repaint` gained the three lines this bug was made of — the palette write must be synchronous when the palette moves, must not swallow its failure, and must 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 `transition` drift check, the announced schema of 5, and the legacy fold (a hand-tuned 1200, a deliberate `0`, the unremarkable 320, and an explicit global value winning). `check:ui` covers the new strings and the two new classes (`.dab-tr-preview`, `.dab-tr-preview-in`), and registers the effect/easing chip labels in `TABLE_KEYS` — they are reached through `Record<TransitionEffect, …>` tables, which is the documented mechanism for a label the compiler already guarantees exists.
60
+ - **The controls inside the dialog now change colour on the wallpaper's clock instead of jumping when it finishes (a fix)** — on a theme-colour change, the controls in the settings dialog held the **previous** colour for the whole duration and only took the new one once the background had finished. Measured in a real session: `body`'s own token interpolated correctly across the three seconds, while the active chip's `background-color` was bit-for-bit the OLD value at every sample and only reached the new one after the animation ended.
61
+ - **What holds is an intermediate element, not "inheritance cannot see an animated value"** — a consumer that merely inherits follows the declaring element frame for frame, which an isolated repro confirms. The plugin armed the **full token list** on three elements, two of which only re-declare the three layer tokens: give an inherited token a transition and that element's transition targets the value it saw when the fade was armed, so it becomes a damped copy of the animation and drags its whole subtree behind it.
62
+ - **The fix: the transition list is per scope, and a scope only gets the tokens it declares** — `paletteFadeCss` takes `scopes: { selector, tokens }[]` instead of a list of selectors: `body` carries the full list, and the dialog and the trajectory root each carry only the three layer tokens they re-declare. That is not a "do less work" optimisation; a scope handed a name it does not declare copies the lag above into its whole subtree.
63
+ - **Three surfaces were still snapping, and they are the ones painted from a colour of the plugin's OWN (a fix)** — classifying every pixel of the middle frame against the first and the last puts the settings dialog's own plate on the final colour while every card inside it is still on the first. A card that fades while the plate behind it does not is a different paint path: the plate is painted from `--dsh-any-bg-settings-surface`, a plugin-owned variable written inline, and an **unregistered** variable cannot interpolate at all. The same shape exists twice more — the file-preview panel and the Cordis panel.
64
+ - **The fix: a scope may name a STANDARD property, and the paint rule and the fade share one selector** — `PaletteFadeScope` gained `properties?: string[]`, emitted into the same `transition` list and deliberately not filtered against the registered names. `fadeScopes()` carries `background-color` for the settings plate and the file-preview panel and `--dsw-specific-menu` for the Cordis panel, and both paint rules and their fade scopes now read one shared constant each, so the half that paints and the half that fades cannot drift apart. Measured in Chromium against the real emitted CSS, all five surfaces sit at the same fraction of the way at every sample — 80.2% at t = 1500 ms, exactly `ease(0.5)`.
65
+ - **One phenomenon recorded that is NOT the cause** — the probe also shows the fade sheet being rewritten without its `transition` a frame or so after it is armed, and a transition that has already started is not cancelled by removing the declaration. It is not what froze the dialog, but it does mean an element whose transition would only start later gets nothing at all.
66
+ - **Gates** — `check:transition` gained the two halves of the fade shape: a scope is never given a token it only inherits, a scope with nothing of its own emits no rule, a scope carrying only a standard property still emits its rule and rides the same duration and easing, and `0` ms disarms the property too. `check:repaint` gained the pairing itself: each plugin-painted surface must be painted from its own variable **and** have a fade scope naming the same shared constant, so a fourth one added without a fade becomes a failing check rather than a screenshot nobody took.
67
+
68
+ ## v0.7.3
69
+
70
+ - **The switch itself became a setting: four effects, an easing and one duration, on the Config page** — every wallpaper change this plugin performs went through one hard-coded cross-fade whose length was read off a field no control could reach. The effects are **cross-fade**, **instant**, **zoom** and **slide** (the last two transform the incoming layer only, so neither costs a byte of extra transfer); instant and a `0` ms duration are the same hard cut rather than one being read as "unset", a `0` is kept as a real value, and `prefers-reduced-motion` is honoured as a **veto** rather than as a shorter animation. The decision is pure (`src/client/transition.ts`), which is what lets `check:transition` pin the failures that throw nothing.
71
+ - **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 perfectly. The swatch replays the chosen effect at the duration a real switch would use right now.
72
+ - **There is exactly one duration, and the per-rule choice is gone** — the card's default was "Follow each rule", which named `rotate.fadeMs`, a field that had existed in the shape since 0.7.0 and that no control ever reached. The slider IS the setting now, and `legacyFadeMs` folds the first deliberate value it finds into the global duration when a config that predates the setting is first read; an explicit `durationMs` always wins over a legacy field sitting beside it.
73
+ - **The interface colour now changes over the same clock as the wallpaper** — the fade is a **registration** problem, not a declaration one, because an unregistered custom property cannot interpolate: `src/client/palette-fade.ts` emits `@property` rules that turn the alias tokens into `<color>`s, plus a `transition` for the elements whose own declarations change with the palette. It is armed **only when the palette itself moved**, because a live transition on a slider drag would make the surface lag behind the pointer, and a token the host does not publish is deliberately left unregistered.
74
+ - **A light↔dark flip switches instantly, and the wallpaper keeps animating (this one is a fix)** — interpolating between the two palettes walks the whole interface through mid-tones where the ink is wrong in both directions, so the fade is armed only while the **scheme** stays put. The wallpaper is deliberately untouched by that rule: one image fading into another has no scheme to get wrong, so it keeps whatever the switch effect says.
75
+ - **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, cleared only by reloading, with nothing in the log. Every other surface has a second, synchronous writer, so only the token stylesheet could go stale — and its one writer was reachable only from a `requestAnimationFrame` callback wrapped in `catch {}`, where a window that is not rendering swallows the frame. A palette that moved is now written synchronously, in the same task as the surfaces it has to agree with, and the writer logs its failure instead of swallowing it.
76
+ - **The config shape moved to 5 and an export to format 6, and an older host still cannot eat a profile** — 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, silently. Theme exports move to format 6; 5 and older still import.
77
+ - **Gates** — `check:transition` joins `pnpm test`, which is now **six** checks. `check:repaint` gained the three lines this bug was made of, `check:node` the announced schema and the legacy fold, and `check:ui` the new strings and the two new classes.
78
78
 
79
79
  ## v0.7.2
80
80
 
81
- - **The filmstrip IS the control: drag to reorder, double-click to show** — the order used to be reachable only through a pair of ↑ / ↓ arrows and a "make first" button in the row below, which is a second and worse way to say "put this one there" when the five thumbnails already show the order they are in. Grab any tile and drop it where it belongs: the drop point is a 2px bar drawn INSIDE the tile it precedes, because a bar that took up room would push around the very tiles the pointer is being measured against. Which picture is on screen is the strip's OTHER gesture, and it changes no order and writes nothing at all — the image being shown is runtime state, not a setting, so it is not saved and the next boot starts at the rule's first image again. A double-click on a tile of a rule that is not painting says why (reusing "only the active rule can step the background") instead of leaving a double-click that looks broken, and both gestures are named in the tile's tooltip.
82
- - **The order and "which one is on screen" no longer interfere with each other (this one is a fix)** — both old actions (`moveRuleImage`, `setCurrentImage`) ended by moving the image index onto the image they had just moved, so tidying the list swapped the wallpaper as well: with the rotation off the index is sticky, which is exactly the state this was met in ("even with the rotation off, the background can change when I adjust the order, and it can happen by dragging or by the buttons"). `moveRuleImageTo(id, slot, to)` replaces both of them: it remembers the SLOT being painted before the splice and puts the index back on it afterwards. A reorder is not a change of subject — the three buttons left with the two uncalled actions behind them, and `check:repaint`'s list follows.
83
- - **The tile that is on screen lost its ring and kept its dot** — `.is-sel` already rings the tile that is open for editing, so a second ring put two "selected" tiles in a row of near-identical thumbnails, which is the one question a filmstrip must not leave open. Being on screen is the dot, and nothing else.
84
- - **Every image of a batch upload gets its own color** — a theme color belongs to an IMAGE, so an upload carrying several pictures has several palettes to fill, and the extraction was addressed by POSITION: `addRuleImages` appended the whole batch and then asked for a color for `rule.images[rule.images.length - 1]` alone. That is the right answer for the one-file upload this action grew up with, and silently the wrong one for every batch — the pictures before the last kept no color of their own and stayed on the system theme, with nothing in the interface to say why. It is now addressed by the slots the batch itself just appended, so which pictures get themed no longer depends on where they landed in the rule, nor on which one the card happens to select.
85
- - **Copy trimmed, and the framing button moved** — the "Rules" heading and the priority sentence under it, and the sentence explaining the automatic extraction, are gone (the panel's title introduces the rules and the `fallback` badge on the first card states the priority; the switch and its own label stay). The labels lost their nouns: `Image 1 / 5` for `当前第 1 / 5`, and `Add` / `Replace` / `Remove` for `添加图片` / `替换这张` / `移除图片`. `Edit position` (`编辑位置`) moved out of the image-action row to the end of the layout row — what it opens edits how the picture fills the area, which is what those five modes are about — and it stays last rather than between two chips because the modes are mutually exclusive and it is not a sixth one. It still appears only in **fit**, because dragging the framing is what fit means.
86
- - **Gates** — `check:repaint` gained the batch section (the batch form must walk every slot it was handed, an upload must record the slots of its own batch and theme all of them, and the positional `maybeAutoExtract(id, rule.images[…])` must not come back), and `moveRuleImageTo` joins the actions required to sample the winner and close with `repaintIfMoved` while the two deleted actions leave it. `check:ui` covers the new strings and the new class (`.dab-strip-bar`, plus the `is-dragging` state), and requires the three keys nothing renders any more to leave both dictionaries in the same commit.
81
+ - **The filmstrip IS the control: drag to reorder, double-click to show** — the order used to be reachable only through a pair of ↑ / ↓ arrows and a "make first" button in the row below, which is a second and worse way to say "put this one there". The drop point is a 2px bar drawn INSIDE the tile it precedes, because a bar that took up room would push around the very tiles the pointer is being measured against. Which picture is on screen is the strip's OTHER gesture, and it writes nothing at all — the image being shown is runtime state, not a setting, so the next boot starts at the rule's first image again.
82
+ - **The order and "which one is on screen" no longer interfere with each other (this one is a fix)** — both old actions ended by moving the image index onto the image they had just moved, so tidying the list swapped the wallpaper as well. `moveRuleImageTo(id, slot, to)` replaces both: it remembers the SLOT being painted before the splice and puts the index back on it afterwards.
83
+ - **The tile that is on screen lost its ring and kept its dot** — `.is-sel` already rings the tile open for editing, so a second ring put two "selected" tiles in a row of near-identical thumbnails. Being on screen is the dot, and nothing else.
84
+ - **Every image of a batch upload gets its own color** — a theme color belongs to an IMAGE, and the extraction was addressed by POSITION: `addRuleImages` appended the whole batch and then asked for a color for the last entry alone, so every picture before it kept no color of its own and stayed on the system theme. It is now addressed by the slots the batch itself just appended.
85
+ - **Copy trimmed, and the framing button moved** — the "Rules" heading and the two explanatory sentences are gone (the panel's title and the `fallback` badge already say it), the labels lost their nouns (`Image 1 / 5`, `Add` / `Replace` / `Remove`), and `Edit position` moved to the end of the layout row — what it opens edits how the picture fills the area, which is what those five modes are about.
86
+ - **Gates** — `check:repaint` gained the batch section, and `moveRuleImageTo` joins the actions required to sample the winner and close with `repaintIfMoved` while the two deleted actions leave it. `check:ui` covers the new strings and the new class.
87
87
 
88
88
  ## v0.7.1
89
89
 
90
- - **The theme color moved down to the image** — a rule is a rotation through several pictures, and pictures collected for different moods do not share one accent. Every image now carries its own `color: [h, s, l] | null`, and the whole color section — wheel, numeric inputs, inspiration palette, **Extract from this image**, eyedropper and **Follow system theme** — edits the image selected in the filmstrip. The controls gained one line that names their target ("Applies to the selected image: 2 / 3") and the filmstrip marks the images that already carry a color of their own (with the hex on hover), because a section that silently retargets is exactly how "I changed the color and nothing happened" starts. No new control and no layout change: the filmstrip was already the way to pick a picture, and it was already where framing is chosen.
91
- - **A rule's own `color` now means exactly one thing: what an image-less rule paints** — it is deliberately NOT a default for the rule's pictures. An image with no color of its own follows the **system theme** rather than inheriting the rule's, which is what makes a rotation through a green picture, a red one and a system-themed one expressible at all: with inheritance, clearing a picture's color would only hand it back to the rule's, and the clear button would look broken (there would also be no way to reach the system theme for a rule that has both a color and pictures). Clearing one image's color therefore clears only that image; the rule keeps its own, ready for the state where it is the only thing left to paint. The panel's two branches follow the same rule: with an image to address, the controls edit the image; with `images: []`, they edit the rule.
92
- - **Nothing loses its theme on upgrade, and the distinction is one key** — an image entry written before this release has no `color` key at all, and that is the whole test `normalizeImage` uses: a missing key means "this picture never had a color of its own", so the rule's color is lifted onto it (once, in the shared sanitizer, so both halves lift identically and the file heals on the first write); an explicit `null` means "cleared on purpose" and is never re-inherited over. That is also why every write emits the key even when the value is null — writing "no color" as an absent key would re-inherit the rule's color on every single load. A config predating the field therefore looks identical after the upgrade, and the pre-0.7 single-`slot` shape goes through the same lift (synthesized rather than read).
93
- - **A rotation step re-emits the interface palette, and that is the bug this feature would otherwise ship with** — the step only repainted the wallpaper, so a per-image color existed in the config and in the panel while the interface behind it kept the previous picture's palette until *something else* re-ran an apply: dragging the color wheel, or switching models. That is the symptom this plugin already had once, verbatim ("it only updates when I touch the color wheel"), so the palette apply is now one function (`applyPalette`) called by both a model switch and every step of the rotation — the timer's tick and the manual **Next image** button share the single `paintImage` funnel that paints a step. `pnpm check:repaint` fails if either `paintImage` or `applyActive` stops going through it, or if the palette stops reading the color in force (`activeColor`: the painted image's color, else the rule's).
94
- - **Holidays carry their palette per image too** — a festival's fixed color is written on the holiday *and* on its image, and `normalizeHolidayRule` forces both from the definition on every read (a hand-edited value in either place is replaced). The shape is therefore ready for a holiday that rotates through several pieces of art with a palette each, as a change to `HOLIDAYS`, its assets and the node half's slot→asset map rather than a second code path in the render layer.
95
- - **The config shape moved to 4, and an older host still cannot eat a profile** — `images[].color` is unknown to a schema-3 `normalizeImage`, which REBUILDS each image entry from the keys it knows: the color would live in memory until the next reload and then be gone, silently and with nothing in the host log to explain it. `SCHEMA_VERSION = 4` is published on every `read`, and the client's `>=` comparison puts a host announcing less into the hold-writes path, which logs once and says so in the panel ("restart DSH"). Theme exports move to format 5; 4 and older still import, with the lift above supplying the colors they never had.
96
- - **A rule with no picture has exactly one upload target** — the big empty preview tile *is* the button now (a real `<button>`, so it is focusable and keyboard-reachable; click it or drop files onto it), and the filmstrip is not rendered at all while the rule holds nothing: it contained nothing but a second "add image" tile, and a filmstrip is a list of pictures — a state with none does not have one. The drop handler moves up with it rather than disappearing, because hiding the strip would otherwise take drag & drop away from the state every rule starts in. Nothing else moved: the labelled **Add images** / **From URL** buttons stay where they were, the empty-state sentences are unchanged, and with pictures present the preview is still deliberately inert (dropping on the strip adds, and *replace* stays an explicit labelled action, which is what keeps a preview from swallowing a click the user did not mean).
97
- - **Gates** — `check:node` gained the per-image color shape end to end through the real RPC handler: the lift in both directions (an absent key inherits the rule's color, an explicit `null` does not), independent colors surviving the round trip in order, holidays forcing both copies, the announced schema moving to 4, and `null` landing on disk as a key rather than as an omission. `check:repaint` gained the palette-on-a-rotation-step section above, and `setImageColor` — the new write path — is now one of the actions it requires to sample the winner and close with `repaintIfMoved`. `check:ui` covers the three new strings and the two new classes (`.dab-strip-dot`, `.dab-rule-thumb-add`), as it covers every other one.
90
+ - **The theme color moved down to the image** — a rule is a rotation through several pictures, and pictures collected for different moods do not share one accent, so every image now carries its own `color: [h, s, l] | null` and the whole color section edits the image selected in the filmstrip. The controls gained one line naming their target ("Applies to the selected image: 2 / 3") and the filmstrip marks the images that already carry a color of their own, because a section that silently retargets is exactly how "I changed the color and nothing happened" starts. No new control and no layout change.
91
+ - **A rule's own `color` now means exactly one thing: what an image-less rule paints** — it is deliberately NOT a default for the rule's pictures. An image with no color of its own follows the **system theme** rather than inheriting the rule's, which is what makes a rotation through a green picture, a red one and a system-themed one expressible at all. Clearing one image's color therefore clears only that image; the rule keeps its own, ready for the state where it is the only thing left to paint.
92
+ - **Nothing loses its theme on upgrade, and the distinction is one key** — an image entry written before this release has no `color` key at all, and that missing key is the whole test: it means "this picture never had a color of its own", so the rule's color is lifted onto it once in the shared sanitizer. An explicit `null` means "cleared on purpose" and is never re-inherited over, which is why every write emits the key even when the value is null.
93
+ - **A rotation step re-emits the interface palette, and that is the bug this feature would otherwise ship with** — the step only repainted the wallpaper, so a per-image color existed in the config and in the panel while the interface behind it kept the previous picture's palette until something else re-ran an apply. The palette apply is now one function (`applyPalette`) called by both a model switch and every step of the rotation. `pnpm check:repaint` fails if either path stops going through it.
94
+ - **Holidays carry their palette per image too** — a festival's fixed color is written on the holiday *and* on its image, and `normalizeHolidayRule` forces both from the definition on every read. The shape is therefore ready for a holiday that rotates through several pieces of art, as a change to `HOLIDAYS` and its assets rather than a second code path in the render layer.
95
+ - **The config shape moved to 4, and an older host still cannot eat a profile** — `images[].color` is unknown to a schema-3 `normalizeImage`, which REBUILDS each image entry from the keys it knows, so the color would live in memory until the next reload and then be gone. `SCHEMA_VERSION = 4` is published on every `read`, and a host announcing less lands in the hold-writes path. Exports move to format 5.
96
+ - **A rule with no picture has exactly one upload target** — the big empty preview tile *is* the button now (a real `<button>`, so it is focusable and keyboard-reachable; click it or drop files onto it), and the filmstrip is not rendered at all while the rule holds nothing, because a filmstrip is a list of pictures. The drop handler moves up with it rather than disappearing, and with pictures present the preview is still deliberately inert.
97
+ - **Gates** — `check:node` gained the per-image color shape end to end through the real RPC handler, `check:repaint` the palette-on-a-rotation-step section, and `check:ui` the new strings and the two new classes.
98
98
 
99
99
  ## v0.7.0
100
100
 
101
101
  - **Multi-image rotation, per rule — and off by default** — a rule now owns an ordered list of images (`images: [{ slot, bgState }]`) and can cycle them on its own rhythm: 10 s / 30 s / 1 min / 5 min / 30 min / 1 h, or any custom dwell from 5 s to 24 h, **in order** or **shuffle**. It ships OFF because rotating spends real bandwidth, memory and battery, and a default that quietly animates a wallpaper nobody asked to animate is a default that decided for the user.
102
- - **A second trigger that needs no timer at all** — *also step on a model switch* advances the image once every time the model changes and lands on that rule. For a rule that should simply look different each time, that is the whole feature: nothing is scheduled, nothing wakes up, and the image only changes when the user does something.
103
- - **Shuffle never lands on the image already on screen** — the picker is uniform over every index *except* the current one, because a tick that leaves the picture unchanged is indistinguishable from a tick that never fired — the one failure this feature could produce with no error anywhere. `pnpm check:rotation` walks the whole support of the roll for lists of 2, 3 and 7 images rather than sampling it once, and also pins the sequential walk's wrap, out-of-range and fractional indices, and that "rotating" is never claimed for a rule with fewer than two images or a non-finite interval.
104
- - **One timer for the plugin, aimed at the active rule, re-armed by signature** — a timer per rule would burn wakeups on rules that are not painting anything, and only one rule can be on screen. It is (re)armed only when the signature of its own inputs changes — rule id, enabled flag, interval, order, image count, tab visibility — so the dozens of full re-applies a slider drag triggers can never restart the dwell (with a naive "re-arm on apply", dragging the opacity slider for 30 seconds would mean the rotation never advances during it). A hidden tab stops the rotation with the pixels and resumes on a fresh interval, instead of painting three times into a tab nobody is looking at.
105
- - **The picture changes; the rule does not** — theme color, layout mode, wallpaper opacity and blur stay the rule's, and the transition is the same two-layer cross-fade a model switch already used. A rule can ask for a longer blend than a switch, or `fadeMs: 0` for a hard cut — which is kept as a real value rather than read as "absent".
106
- - **Framing moved onto the image, where it belongs** — a crop is a property of a picture: two wallpapers of different aspect ratios share the rule's mode, color, opacity and blur, but one `fit` framing computed for the first is simply wrong for the second. Each image carries its own `bgState` and the background editor edits the image selected in the strip.
107
- - **The single thumbnail became a filmstrip** — add several images at once (**multi-select**, **drag & drop**, or **one URL per line**, fetched sequentially so N downloads do not interleave with no progress to show), click one to edit it, reorder it, promote it to **first** (which is what the rule paints when nothing rotates), **replace this image** in place (which keeps its position in the rotation, so it is labelled apart from adding), or remove it.
108
- - **A rule may hold NO image, and that has to be a real state** — the first cut of this feature refused to remove a rule's last picture and cleared its bytes "instead", which left the entry itself behind: a blank tile at position 1 of the filmstrip that could never be deleted, so the next upload landed at position 2 and the rule kept painting nothing while the preview showed an empty frame. Removing the last image now empties the list. A brand-new rule starts in that state too (no slot is allocated until the first picture), and a hand-edited list whose entries are all unusable lands in it rather than being conjured back into a broken slot. Only a rule that names no image anywhere — no list, no legacy slot — is dropped by the sanitizer.
109
- - **A rule with no image left keeps painting from its theme color** — an empty list is not "this rule is off", which is what the first cut made of it: usability only asked whether an image was there, so a rule whose pictures had all been removed **stopped matching altogether** — its own theme color, the wheel, the swatches and the clear-to-system-theme button all became decoration (the reported symptom was "the theme color does nothing") — and the models it used to match were quietly handed to the **next** rule: empty the fallback rule and another model's wallpaper appears. Usability is now *an image, or a theme color of its own* (`ruleCanPaint`): such a rule still matches, still serves as the fallback, and simply paints the interface instead of a wallpaper; only a rule with neither (a freshly added one) is skipped. The card's copy now says which of the three real states it is in — no image but a color, neither, or an image whose bytes have not arrived — where one sentence used to claim "this rule has no image" for all three, and the "extracts from this image" hint no longer appears when there is no image to extract from.
110
- - **Every edit that can move the winner takes effect at once, and only an edit that cannot may skip the repaint** — `color`, `enabled` and `match` all feed which rule wins, but each write path asked its own narrow question ("is this the rule that is painting right now?") before re-running the resolution, so only an edit to the rule that was *already* winning repainted. The edits that are not already winning are exactly the ones that **create** the winner: the first picture of a rule that was empty (which is what makes it paintable at all — see `ruleCanPaint`), and the first or auto-extracted color landing on it. Those landed in the config and in the panel's own readout while the interface behind it kept the previous rule, which was reported as "the update is never timely, it only appears when I move the color wheel or switch models" — dragging the wheel or a model switch was what finally re-ran the resolution. Every path now samples the winner before its mutation and compares it after (`repaintIfMoved`, over the pure `shouldRepaint` in `src/client/repaint.ts`), which also repairs a tracker that had gone stale: an edit to a rule that *is* the winner repaints even if the page never recorded it as active. `pnpm check:repaint` walks that decision's truth table — both directions of the winner moving, and the cases that must stay cheap — and fails if any of the nine write paths in the entry file — the auto-extraction that lands after an upload included — stops asking it. The live check that pinned this down drove a throwaway rule through the exact sequence (create empty → move to the top → upload one picture → touch nothing): with the old test the paint never moved until something else did, with this one it moved in ~280 ms, and the picture's own auto-extracted color came with it.
111
- - **An older host process can no longer eat a profile — the check is a version comparison, which is why the shape moved to 3** — the browser half and the node half are loaded separately, so updating the package and merely refreshing the page can pair a NEW client bundle with a host process still running the PREVIOUS node half. That older sanitizer knows nothing about `images` and drops every rule that has one — silently, on the first slider drag. The node half therefore publishes the config shape it writes (`schema` on every `read`), and a client that finds an older answer **holds every config write** (one funnel, `canWriteConfig` in `src/client/rpc.ts`), logs once, and says so in the panel: "restart DSH". Edits that silently do not persist are worse than edits that visibly wait for a restart; edits that silently delete rules are worse than both. During 0.7.0's own development this gate did let one through: the version was 2, and the running 0.7 node half reported `schema: 2` while being a build from **before the empty-rule fix**, so a write that emptied a rule was allowed through and the host erased those rules from disk. The number therefore now means "**an empty image list is legal**" (`SCHEMA_VERSION = 3`), not merely "rules carry `images`": any host announcing less than that lands in the hold-writes state.
112
- - **An emptied rule and an upload after it** — the picture you add is the picture you are then looking at (the card moves its selection to the first newly added image, and the strip, the big preview and the position readout all follow), instead of leaving the panel showing whatever used to be selected.
113
- - **Only the images that are needed are loaded** — boot hydrates each rule's **first** image, which is exactly what it hydrated per rule before multi-image existed, plus today's holiday and the image the current model paints. The rest arrive when a card is expanded or when the rotation is about to need one. Hydrating every image of every rule would mean twenty full-size data URLs over this plugin's RPC channel before the first frame on a five-rule setup with four pictures each; the slot list the store reports is likewise no longer hydrated, because an orphaned slot nothing references has no card to appear on.
114
- - **An older host process can no longer eat a profile** — see the version-comparison entry above: the shape's version number is what a stale host is judged by, and it now stands for "an empty image list is legal" as well.
115
- - **Older configs still load, and older exports still import** — `normalizeRule` accepts both shapes: a rule's single `slot`/`bgState` pair is lifted into `images: [{ slot, bgState }]` on read, duplicates within a rule are dropped, and an unusable slot is dropped. Theme exports move to **format version 4** and now carry, and restore, every image of every rule rather than just the first; version 3 files still import.
102
+ - **A second trigger that needs no timer at all** — *also step on a model switch* advances the image once every time the model changes and lands on that rule. Nothing is scheduled, nothing wakes up, and the image only changes when the user does something.
103
+ - **Shuffle never lands on the image already on screen** — the picker is uniform over every index *except* the current one, because a tick that leaves the picture unchanged is indistinguishable from a tick that never fired, which is the one failure this feature could produce with no error anywhere. `pnpm check:rotation` walks the whole support of the roll for lists of 2, 3 and 7 images rather than sampling it once.
104
+ - **One timer for the plugin, aimed at the active rule, re-armed by signature** — a timer per rule would burn wakeups on rules that are not painting anything, and only one rule can be on screen. It is re-armed only when the signature of its own inputs changes (rule id, enabled flag, interval, order, image count, tab visibility), so the dozens of full re-applies a slider drag triggers can never restart the dwell. A hidden tab stops the rotation with the pixels and resumes on a fresh interval.
105
+ - **The picture changes; the rule does not** — theme color, layout mode, wallpaper opacity and blur stay the rule's, and the transition is the same two-layer cross-fade a model switch already used. `fadeMs: 0` is kept as a real value rather than read as "absent".
106
+ - **Framing moved onto the image, where it belongs** — a crop is a property of a picture: two wallpapers of different aspect ratios share the rule's mode, color, opacity and blur, but one `fit` framing computed for the first is simply wrong for the second. Each image carries its own `bgState`, and the background editor edits the image selected in the strip.
107
+ - **The single thumbnail became a filmstrip** — add several images at once (multi-select, drag & drop, or one URL per line, fetched sequentially so N downloads do not interleave with no progress to show), click one to edit it, reorder it, promote it to **first**, **replace this image** in place (which keeps its position in the rotation, so it is labelled apart from adding), or remove it.
108
+ - **A rule may hold NO image, and that has to be a real state** — the first cut refused to remove a rule's last picture and cleared its bytes "instead", which left the entry behind: a blank tile at position 1 that could never be deleted. Removing the last image now empties the list, a brand-new rule starts in that state, and a hand-edited list whose entries are all unusable lands in it rather than being conjured back into a broken slot.
109
+ - **A rule with no image left keeps painting from its theme color** — the first cut made an empty list mean "this rule is off", so a rule whose pictures had all been removed stopped matching altogether and the models it used to match were quietly handed to the **next** rule. Usability is now *an image, or a theme color of its own* (`ruleCanPaint`): such a rule still matches, still serves as the fallback, and simply paints the interface instead of a wallpaper. The card's copy now says which of the three real states it is in.
110
+ - **Every edit that can move the winner takes effect at once, and only an edit that cannot may skip the repaint** — `color`, `enabled` and `match` all feed which rule wins, but each write path asked its own narrow question ("is this the rule that is painting right now?"), so the edits that are not already winning — exactly the ones that **create** the winner — landed in the config and the readout while the interface kept the previous rule. Every path now samples the winner before its mutation and compares it after (`repaintIfMoved`), and `pnpm check:repaint` fails if any of the nine write paths stops asking it.
111
+ - **An emptied rule and an upload after it** — the picture you add is the picture you are then looking at: the card moves its selection to the first newly added image, and the strip, the big preview and the position readout all follow.
112
+ - **Only the images that are needed are loaded** — boot hydrates each rule's **first** image, which is exactly what it hydrated per rule before multi-image existed, plus today's holiday and the image the current model paints. The rest arrive when a card is expanded or when the rotation is about to need one; hydrating everything would mean twenty full-size data URLs over this plugin's RPC channel before the first frame.
113
+ - **An older host process can no longer eat a profile — the check is a version comparison, which is why the shape moved to 3** — the browser half and the node half are loaded separately, so updating the package and merely refreshing the page can pair a NEW client bundle with a host process still running the PREVIOUS node half, and that older sanitizer knows nothing about `images` and drops every rule that has one. The node half therefore publishes the shape it writes, and a client that finds an older answer **holds every config write**, logs once and says so in the panel: "restart DSH". Edits that silently do not persist are worse than edits that visibly wait for a restart; edits that silently delete rules are worse than both. The number means "**an empty image list is legal**" (`SCHEMA_VERSION = 3`), not merely "rules carry `images`", so any host announcing less than that lands in the hold-writes state.
114
+ - **Older configs still load, and older exports still import** — `normalizeRule` accepts both shapes: a rule's single `slot`/`bgState` pair is lifted into `images: [{ slot, bgState }]` on read, duplicates within a rule are dropped, and an unusable slot is dropped. Theme exports move to **format version 4** and carry every image of every rule rather than just the first; version 3 files still import.
116
115
  - **Per-image slots are independent, and removing a rule releases only what it owns** — a rule with three images owns three `modelbg-<slot>` files; `nextSlot` scans every slot in the config (rules *and* holiday entries) so a second image can never collide with an existing one, and deleting a rule reclaims each of its slots only when nothing else references it.
117
- - **The node half reports nested drift too, and migration writes the new shape** — `warnUnknownConfigKeys` now compares `rules[].rotate` and `rules[].images[]` one level deeper, which is where a field that only the client bundle knows would otherwise disappear in silence, while the legacy `slot`/`bgState` names are explicitly exempt because the sanitizer lifts them on purpose. The pre-0.3 migration writes an image list directly instead of leaving a legacy shape on disk until the first write.
116
+ - **The node half reports nested drift too, and migration writes the new shape** — `warnUnknownConfigKeys` now compares `rules[].rotate` and `rules[].images[]` one level deeper, which is where a field that only the client bundle knows would otherwise disappear in silence, while the legacy `slot`/`bgState` names are explicitly exempt because the sanitizer lifts them on purpose.
118
117
 
119
118
  ## v0.6.0
120
119