# Changelog

> 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.2 / v0.8.1).

## v0.8.2

- **The dsh dependency floor moves to the host's own version (`>=0.2.1-alpha.2`)** — `engines.dsh`, the five `dsh-client-*` peers and `dsh-home-paths` still asked for the 0.1.7 generation while the harness in use is 0.2.1-alpha.2. Those five are the packages the host injects into the browser half, so the declaration is what says which generation this code was written against — with the old floor an install could pair it with the previous one. `dsh-invariants` cannot follow (npm has no 0.2.1-alpha.2 of it, and a 0.2.x host does not ship it), so it keeps `>=0.1.7-rc.2`; `@deepseek-ai/cordis` is untouched. The lockfile resolves the dsh family to 0.2.1-alpha.2, and `pnpm test` is ten checks, all green against it.

## v0.8.1

- **The wallpaper layer gains a brightness slider, next to opacity and blur** — the two existing sliders could take a picture down and frost it, but nothing could lift one: an underexposed photo, a night shot or a dim monitor meant editing the file before it went in. The new **背景亮度 / Background brightness** slider sits between them (`0–200%`, `100%` untouched, double-click to reset) and belongs to the rule, exactly like the other two, so each model's wallpaper keeps its own exposure. A festival wallpaper's brightness is yours as well, like its opacity and framing; its slot, mode and colors stay the definition's.
- **The two effects that share `filter` are composed in ONE place (a fix waiting to happen)** — brightness and blur are one CSS property, so a second assignment anywhere would have left whichever ran last and silently dropped the other. There is now a single `wpFilter()` that builds the string from the active rule (brightness first, blur second, and the neutral brightness left out so an untouched wallpaper keeps the empty `filter` it always had), and both the full apply and the live drag write through it. The old standalone `setWpBlur` helper had no callers and is now the parameterless `setWpFilter`.
- **`brightness` is optional in the shape on purpose** — every config written before this release lacks the key, and the absence has to keep meaning "the picture as it was saved" all the way to the render layer: `normalizeRule` leaves the key off rather than baking a default in (so an upgrade never rewrites a rule list to state what its owner was already looking at), and `rBrightness` is the one place the absence becomes `100`. A literal `undefined` there would have been copied over the live rule by `Object.assign`, which copies keys and not values — the slider reading 100 while the layer held nothing.
- **`SCHEMA_VERSION` moves to 7** — the field is new on the node side too, and a schema-6 sanitizer rebuilds each rule from the keys it knows, so a brightness the user just dragged would survive in memory until the next reload and then be gone in silence. Nothing has to be lifted the other way: an absent key already means untouched. A theme file exported by this build carries version 7 in the same change, and a v6 profile still imports.
- **Every page the centre column shows gets ONE opacity slider (插件页面)** — 插件, 自动化任务 and the pages a plugin registers of its own (上下文洞察) each bring their own root element and class name, so the item could not be a selector: it is whatever the main slot has mounted, which is what lets one row cover a page that does not exist yet. It is located as the child of `[data-slot="main"]` — a `display:contents` anchor with no box of its own, so the walk steps through box-less elements rather than settling on the first child — and the chat view, the slot's other occupant, is excluded by its own scrollport marker. Opacity only: the frost behind the column is the main background's blur. It ships at 0, where the plugin writes nothing at all.
- **A page is tinted through a variable, never an inline style** — the surface is a plugin-owned `--dsh-any-bg-page` read by a rule the plugin already injects, and a page carries only a `data-dab-page` attribute that is dropped again at zero. Nothing is stashed, so nothing can be restored wrong: `.t-XoWW_page`'s own `background: var(--dsw-alias-bg-base)` simply applies again, and the rule's fallback is that same host token, so a mark that outlived its variable could not leave a transparent hole.
- **The interface parts are found by name, not by position** — the frame grew a bottom row and a drag handle after the overlay, which quietly turned "the three children before the overlay" into "the centre column, the collapsed right column and the bottom row": the part sliders wrote where nobody looks and the conversation frame stopped answering its slider. Each column is now named by the class suffix its CSS module keeps across builds, and the right column by the host's own `data-rightbar-col`, with the positional answer only as the `??` fallback.
- **The Interface page loses every blur slider, and the capability behind them** — ten surfaces each carried a `0–60 px` frost, and none of them did anything worth keeping: what a blurred column frosted was the wallpaper behind it, which the wallpaper layer's own blur already does per rule, and what a blurred panel frosted was a backdrop nobody reads once the panel is translucent. The sliders, the `blurs` field, the underlays, the four plugin-owned CSS variables and the whole `setBlurs` channel are gone together, so the page is opacity and nothing else. The wallpaper's own 背景模糊 is untouched: a rule field, on the other page, and the one doing the visible work.
- **`SCHEMA_VERSION` lands on 9: a field added, then one removed** — the page tint is new, and a schema-7 sanitizer rebuilds the config from the keys it knows, so an alpha dragged onto a page would be gone at the next reload in silence (8). The per-surface blur then left the shape entirely, and that step reads the same mechanism backwards: a schema-8 sanitizer would re-add `blurs` from its own defaults — input & controls at 60 px — for a build that no longer has the slider (9). Neither direction needs a lift: an absent `pageOpacity` is the untouched zero, and an absent `blurs` is the state a schema-9 build wants.
- **Gates** — `check:node` pins the brightness round trip (the 200 % ceiling, the 0 floor, junk falling back to 100), the absent key surviving a write, and a config that still carries `blurs` loading with the field dropped in silence — a migration, not drift. `check:image-mode` pins the single composer behind all four filter assignments and the page item (the slot anchor, the box-less walk, the conversation's marker, the shipped zero, both apply sites), and its closing section requires the blur field, its writers, its channel and its dictionary key to be absent together with the announced shape past 8. `pnpm test` is ten checks, all passing.

## v0.8.0

- **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.
- **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.
- **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.
- **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.

## v0.7.10

- **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.
- **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.
- **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.

## v0.7.9

- **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.
- **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.
- **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.
- **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.

## v0.7.8

- **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".
- **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`.
- **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.
- **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.

## v0.7.7

- **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.
- **`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.
- **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.
- **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.
- **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.

## v0.7.6

- **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`.
- **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.
- **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.
- **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.
- **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.

## v0.7.5

- **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.
- **`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.
- **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.
- **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.
- **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.
- **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`.
- **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.

## v0.7.4

- **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.
- **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.
- **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.
- **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.
- **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)`.
- **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.
- **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.

## v0.7.3

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## v0.7.2

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## v0.7.1

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## v0.7.0

- **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.
- **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.
- **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.
- **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.
- **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".
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## v0.6.0

- **Holiday backgrounds — one switch, not a settings block** — On 中秋节 and 国庆节 the wallpaper switches to that holiday's own image, with that holiday's own fixed theme color, and switches back the next morning. It ships ON, and the **only** UI it has is a single switch on the **Config** page. There is deliberately no card per holiday, no thumbnail and no image picker — the panels that briefly lived there made the feature read as something the user is expected to configure, which is exactly what it is not.
- **The switch is the off-ramp, and only an explicit `false` takes it down** — a config written before the feature existed, or one whose switch was never touched, keeps working.
- **A holiday with nothing to paint falls through instead of blanking the background** — the override needs all three of: the switch, a window that contains today, and actual bytes in that holiday's slot. The last one is the point — a switched-on holiday with no image leaves the model rules in charge.
- **中秋 is lunar, 国庆 is fixed, and both are read in Asia/Shanghai** — 中秋 is 农历八月十五 and moves across the Gregorian calendar (2024-09-17, 2025-10-06, 2026-09-25, 2027-09-15 …); National Day is the fixed October 1–7 window. The calendar comes from the browser's own ICU (no dependency at all). The zone is **pinned rather than taken from the machine**: the lunar day rolls over at Beijing midnight, so reading "today" off a UTC clock puts the festival on the wrong day for most of the planet — the day-boundary behaviour is pinned by a test at Beijing 23:59 / 00:01.
- **A leap eighth month cannot drag the festival a month late** — ICU marks a leap month by suffixing `bis`, so an exact `month === '8'` test is what keeps 中秋 on the **regular** eighth month. Checked against every leap-eighth-month year in 1900–2100 (1900, 1957, 1976, 1995, 2052, 2071, 2090): in 2052 the plugin fires on **2052-09-07**, never on the `8bis` fifteenth that falls in October that year.
- **中秋 wins the overlap with 国庆** — the two do collide (2025-10-06 was both). A single precise day is the better answer than one day inside a seven-day window.
- **The wallpapers ship inside the package, at one twelfth of the source art: 8.37 MB → 729 KB** — native resolution, WebP q80 (`中秋节` 1856 KB → 228 KB, `国庆节` 6716 KB → 500 KB). The tarball goes **401 KB → 1.12 MB**, and because the files are already-compressed images gzip cannot shrink them further — that is the entire cost of the feature, and the reason they are not shipped raw (raw would be a 21× heavier install, and every boot would then push a 6.7 MB base64 payload through the plugin's own RPC channel).
- **The wallpapers belong to the package, and nothing can replace them** — a holiday slot is read-only: the node half serves the bundled asset and never consults the data directory for it, and writing, deleting or URL-fetching into such a slot is refused. A file planted there is ignored by every read, which is what keeps the egg an egg instead of a setting.
- **An unused holiday costs nothing — and neither does an unused DAY** — with the override on by default and invisible, boot fetches only the holiday that can actually paint *today*; on any other day the 729 KB is never read at all. The rollover tick fetches the art if midnight lands *on* a holiday, since boot had no reason to. The node half looks the bundled file up lazily rather than loading it with the plugin.
- **The holiday theme colors are constants, not something read off the art** — 中秋 paints in `#384A77` and 国庆 in `#FFF6EB`: quoted as hex in the holiday definition, beside its slot and its wallpaper, converted to the rule shape's `[h, s, l]` by the shared schema, and forced on every read — so a hand-edited `color` in `theme-config.json` is replaced by the definition's on load. The extraction pass that decoded a full-size festival photo to re-derive a hue that was always going to be the same one is gone, and with it the "has this image been tried yet" bookkeeping; the palette is now a design decision that costs nothing at boot.
- **Theme exports carry rules only** — the holiday wallpapers are not in `dsh-background-by-model-theme.json`. They ship with the package, so exporting them would add ~970 KB of base64 to every file to move bytes that every install already has, into slots that refuse them anyway.
- **Two dependency-free checks now guard the parts that fail silently** — `pnpm check:holiday` (`scripts/holiday-check.ts`) pins the two windows, the Beijing day boundary, the leap-eighth-month behaviour, and the four gates `pickHoliday` applies before a holiday may take over; `pnpm check:ui` (`scripts/ui-strings-check.ts`) fails on a `t('…')` key missing from either dictionary, on a `dab-…` class with no rule in the stylesheet, and on a dictionary entry nothing references any more. A third, `pnpm check:node` (`scripts/node-half-check.mjs`), drives the BUILT node half through its real RPC handler with `DSH_HOME` pointed at a throwaway directory — so it covers the persistence layer without ever touching a real `~/.dsh`: the holiday block through the shared sanitizer, the packaged wallpapers served out of an empty slot, a holiday slot refusing writes, deletes and URL fetches while a file dropped into it is ignored, an ordinary rule slot still writing and deleting, and a hand-edited config unable to point a holiday at another rule's image. All three run on plain `node` (≥ 22.6 strips the types itself), so they need no dependency, no transformer and no browser, and `pnpm test` now runs them. The decision itself — `pickHoliday` — was extracted out of the plugin's apply closure into `src/holiday.ts` precisely so it could be tested at all.
- **`check:ui` immediately retired ten strings that had been dead since earlier releases** — `close`, `ruleMatchLabel`, `ruleFraming`, `ruleFramingLocked`, `colorHint`, `swatchTitle`, `hexCaption`, `extractColor`, `extractNoWp`, and the `holidayPreview` this release had just added.

## v0.5.5

- **The color preview field prints its own hex, so it stops reading as a fourth text input** — The live preview swatch sits at the bottom of the same column as the H/S/L (R/G/B) number fields, at the same width and behind the same 1px border, and it was filled with the current color but carried no text at all. On a light color — the reported case was `#BADEE8`, L 82% — a blank bordered rectangle under three editable fields reads as one more field, and there is nothing to type into it. It now prints `#RRGGBB` in monospace, centered on the color itself, with its ink picked by **Rec.709 luma**, the same brightness measure the wallpaper extractor already uses, so a light fill gets dark ink and a dark one light ink and the value never disappears into its own background. The `scale(1.02)` hover went with the old reading: it advertised an interaction the field never had, which is most of why it looked clickable.
- **The hex caption above the HSL/RGB toggle is gone** — the preview field now prints that same value on the color itself, and printing it twice only added a second thing to explain.
- **The clear-color button is named for its action, not for the state it lands in** — It read **System theme** while its action was to clear the rule's custom color, so it read as a label for whatever control sat beside it rather than as a control. It is now **Follow system theme** (`跟随系统主题`), carrying the host's own appearance glyph (the sun, migrated from `IconLightOutline16`). This is the same label-versus-action mismatch fixed on the other side of this state in 0.5.3, where the no-color button became **Custom color**; the pair now names the two destinations instead of the two states.

## v0.5.4

- **A brand-filled control takes its label from the host's on-brand ink, not from `brand-text`** — Following the system theme painted the active layout chip, the **+ Add rule** button and the settings brand tile from the host's own `--dsw-alias-brand-primary`, which is a **contrast ink rather than a hue**: near-black (`#0f1115`) in the host's light scheme, near-white (`#f9fafb`) in its dark one. Their labels read `--dsw-alias-brand-text`, which the host defines as that very same color in both schemes and never uses in its own CSS. On a dark system that made them filled boxes with invisible labels — white primary button, white active chip, white icon tile (measured at exactly `#f9fafb`) — and they were only ever readable while the plugin owned the palette and generated its own brand pair. All four now read `--dsw-alias-label-primary-inverted`, the host's own ink for a brand/contrast fill: `#fff` on the light scheme's black fill, `#353638` on the dark scheme's white one.
- **The toggle's on-state knob was the same bug one size smaller** — a hard-coded `#fff` on a `--dsw-alias-brand-primary` track, i.e. a white knob on the white track the dark scheme paints there.
- **The plugin's own dark palette had the mirror of it** — that branch deliberately keeps its brand fill at 50%+ lightness, while `--dsw-alias-label-primary-foreground` — the token the **host's** own primary buttons label themselves with over `--dsw-alias-button-primary-fill` — was hard-coded white. It now flips with the fill (one shared verdict with `brand-text`), and the light branch re-emits `label-primary-inverted` so the plugin's palette owns the pair instead of inheriting it from whichever scheme the host happens to be in.

## v0.5.3

- **One palette at a time: clearing the theme color no longer leaves the previous skin behind** — The three redirects inside the settings dialog (`--dsw-alias-bg-layer-*` → plugin-owned variables) have **no host fallback**, and neither do the trajectory view's scope or the AppFrame columns' inline backgrounds; all of them were only ever cleaned up at teardown. Switching a rule back to **System theme** therefore left a dark interface with a light dialog and white-on-white text. Every surface is now driven by one palette source: the rule's own tokens while it has a color, and the host's own tokens read back out of the cascade and re-emitted with your alpha when it has none, handed back on the way out. The no-fallback redirects are gated on `html[data-dab-themed]`, so they are only live while the variables they read are actually written.
- **Following the system theme keeps the wallpaper and the Interface sliders** — Clearing the color used to drop the whole token set, and the host's opaque `--dsw-alias-bg-base` then buried the wallpaper while every opacity/blur slider (main background, sidebar, cards, input, settings panel, trajectory view, right panel) silently stopped working. The surfaces are now the host's own colors times your opacity, so the wallpaper keeps showing through.
- **Following the system theme hands the host's dark flag back** — While it owns a color the plugin forces `body[data-ds-dark-theme]` (removing it outright for a light palette), and the host only rewrites that flag when it projects a **theme snapshot** — it does not watch the attribute. A flag dropped by a light skin therefore sticks: the readback captures the host's light palette the moment the color is cleared, and the interface only turned dark once an unrelated slider drag re-ran an apply. The switch now hands the flag back synchronously in the host's own boolean form, using the scheme the host resolved (`ctx.theme`'s `active.colorScheme`, falling back to `html[data-ds-theme-source]` plus `prefers-color-scheme`); the one-second watchdog re-reads the host palette while a rule has no color, and the handback side no longer sits behind the 60 ms skin debounce (which made it read the plugin's own skin on its way out).
- **The no-color button no longer does the opposite of its label** — It read **System theme** while its action was to install a hard-coded dark blue `#1D3463` (the same label means "clear → follow the system theme" one state over). It is now **Custom color**, and it runs the *Extract from this image* pass first, falling back to the seed swatch only when the image has nothing vivid.
- **The token fingerprint is only recorded after the stylesheet write succeeds**, so a failed write is retried instead of pinning the interface to the old palette until another key happens to change.
- **Fixed the mojibake dash in the `package.json` description**, which is what the npm page shows.

## v0.5.0

- **The host self-check is gone.** Removed together with everything that fed it: the settings page's **Host check** tab, the contract table (`src/host-contracts.ts`), the browser probe (`src/client/judge.ts`), the installed-file scan (`src/host-scan.ts`), the `hostCheck` RPC, the `pnpm scan` script and its test suite. The plugin keeps only what it does: rules, wallpapers and interface opacity/blur.
- **`0.5.0` is implemented identically in `0.5.1` and `0.5.2`.** Those two are publisher-side noise: the first `npm publish` returned HTTP 202 and the version took a few minutes to appear on the registry, which was mistaken for a failure and re-published twice before the async publish landed. All three are live, byte-identical (12 files, same code), and `latest` pointed at `0.5.2` at that point. Install any of them — pin `0.5.2` if you want that tag's target by name.

## v0.4.5

- **`optionalWhen` finally means what its name says** — The field is documented as "the check runs while this surface is on screen", but the probe was evaluating the exact opposite: an anchor that was *not* in the DOM produced *n/a*, and one that *was* produced the failure. Two of the three dock contracts and both conversation-view contracts were therefore testing the wrong state — which is how a healthy dock came back as `停靠空位 失效` with "no element matches `[data-dockkit-empty]`". The helper now reads the anchor as a positive predicate, and every anchor was renamed to say what it means (`dockHasTab`, `dockHasNoTab`, `chatHasMessages`, `paneFloated`, …) so an anchor named for an absence can no longer invert a check silently. Four tests pin the resulting state table in both directions.
- **A contract about the message column stops firing on a session that has no messages** — `[data-chat-flow]` is rendered only when the session *has* messages, so its absence is the normal state of a brand-new session (the host's `data-content-phase="hero"` screen) and of the trajectory tab. The anchor was "the conversation scrollport exists", which is true in all three states, so the check reported a rename on the hero screen and on **轨迹** — the second failure the panel showed. It is now keyed on `[data-chat-turn]`, the host's own mark on every message row, which is the one thing that is present exactly when a column should exist. Measured: hero → 0 flow / 0 turn rows; after one message → 1 flow / 4 turn rows; trajectory tab → 0 flow / 0 turn rows.
- **`visible` — presence is not the same question as "on screen"** — A selector check marked `visible: true` now also requires the element to be on screen, so an element the host parks with `visibility:hidden` cannot satisfy a contract about a surface the user is looking at. The dock's empty seat is deliberately *not* marked: the host keeps it rendered and hidden as tabs open and close, so asking whether it is visible would be asking about a state the host uses both ways.
- **One more anchor rule, written down** — An anchor must name the state in which its attribute is **expected**, never a neighbour of it that happens to appear at the same time. All four false-red bugs in this feature have been the same mistake: a mounted surface that correctly renders nothing for the attribute to sit on.

## v0.4.4

- **The dock contracts stop asking a stylesheet question about JS attributes** — `dockkit.content` and `dockkit.float` were `rule` checks ("does a host stylesheet mention this literal?"). The host sets both attributes from JavaScript — `data-dockkit-content` on a tab host that has a tab, `data-dockkit-float` on a pane the user has floated — and never writes a CSS rule for either, so that question could only ever be answered "no". Both are now **presence** checks, guarded by the host's own tab marker (`data-dockkit-tab`) and by float mode respectively, so a dock with no tab open reads as *n/a* rather than as a rename. `dockkit.empty` — the empty seat the host renders in place of a populated tab host — is a presence check too. Verified against a live host: with no tab open `data-dockkit-tab`/`data-dockkit-content` are absent; clicking the dock's `+` makes both appear (tab 0→1, content 0→1, empty 1→0), and the checks then answer instead of skipping.
- **Anchors no longer depend on combinators** — The populated-tab anchor started out as `[data-dockkit-strip-tabs] > *`, which a DOM stand-in cannot answer and which let the fixture and the browser disagree. It is now a plain attribute selector.

## v0.4.3

- **The Host check tab stops crying wolf** — Its first real run on a healthy 0.1.7 install reported **9 failures out of 28 contracts** (7 tokens "no longer declared", 2 attributes gone). Every one was the *probe reading the wrong place*, not the host: the tokens are published as `body{--dsw-…}` while the probe asked `getComputedStyle(document.documentElement)`, and `data-plugin` — which the host's own CSS modules set on every `<style>` they inject — was being used to mean "this plugin's sheet", so the check skipped the entire host theme and then called the tokens renamed. Both are fixed, and both now have tests that fail if they come back: the token root is a named part of `ProbeEnv`, and reading the plugin's own sheets goes through a marker the plugin writes itself (`data-dab-side`), via `getAttribute` rather than `dataset` — `dataset` only exposes the camel-cased key, so a probe indexing it by the dashed name works in a fixture and silently returns nothing in a browser.
- **`rule` checks now say which side they are about** — A token contract asserts two different things: that the host still *declares* the name, and that it *resolves to a value*. The first can only be answered from the host's rules, the second only from the live cascade, and the one entry whose other half is this plugin's own stylesheet (`.dab-card`) is now marked `side: 'own'` explicitly instead of being indistinguishable from a host rule. Every re-emitted token carries both checks, so a rename is reported as a renamed token rather than as an empty value.
- **A readable-but-empty host stylesheet is a failure, not a blind spot** — The old code only asked whether *some* sheet was readable, so a host sheet that was readable yet mentioned nothing made every token check pass. Readability is now tracked per side.

## v0.4.2

- **New "Host check" tab — the plugin now reports what broke, instead of just breaking** — A plugin that styles someone else's interface depends on values it does not own: the host's session and model services, the elements it hangs backdrop filters on, and the design tokens it re-emits. Every one of those can be renamed between dsh releases, and all three 0.1.7 breakages were *silent* — nothing threw, the interface simply stopped following the settings. Settings → **Host check** now evaluates each of those touch points against the running build — the live DOM, the host's own stylesheets and the Cordis services — and shows, per point, whether it holds, what its loss looks like on screen, the literal it depends on, the host file that should define it and the plugin file that reads it. **Copy diagnostics** turns both halves into one markdown block for an issue. A surface that is merely off screen (the conversation view while the settings dialog is open, the file-preview panel before a file has been opened) is reported as *n/a*, never as a failure.
- **The same table is checked offline, with no browser** — `pnpm scan` (`scripts/host-contract-scan.mjs`) walks the installed dsh packages and fails non-zero the moment a contract target is gone, so a host rename turns into a red build right after `npm i -g @deepseek-ai/dsh@latest`, before anyone looks at the interface. It also prints the host version it found and whether it satisfies this plugin's floor.
- **One contract table, three consumers** — `src/host-contracts.ts` declares every touch point exactly once; the browser probe, the node scan and the tests all read it, so a newly added selector cannot be forgotten by the check that is supposed to notice it changing. `pnpm test` covers the table, the probe (against a fixture written in the shape of a host DOM), the UI stylesheet and the version floor.
- **A broken contract is no longer invisible in a log** — Two and a half seconds after boot, if any contract fails, the browser console gets one line per failure naming the literal, the plugin file that reads it and the host file expected to define it.

## v0.4.1

- **The right panel stops painting a permanent frosted plate** — dsh 0.1.7 no longer paints the file-preview panel itself: the container became the slide / fullscreen viewport, stays mounted at its full width for the whole session, and collapsing it only slides the dock out (`transform` + `visibility`, never `display`). A background plus a backdrop-filter on that container therefore covered the empty right half of the frame whenever the panel was closed — the frosted plate 0.1.7 showed. The card now paints the elements the host actually paints (the dock's tab hosts: `[data-dockkit-content]`, `[data-dockkit-empty]`) and rides the host's own open/close and slide transitions; the old panel selector survives behind a `:has()` guard for older hosts, and the backdrop-filter is gated on `[data-sidebar-right-open]` so a collapsed panel is never filtered.
- **Switching models switches the background again** — 0.1.7's `sessions.list` snapshot no longer publishes `current`, so the plugin could not tell which session was on screen: no session id, no `modelSelection` projection, empty model text, and every model fell through to rule 1. The session id now comes from the host's own `uiSession.current.key` binding, with the legacy `list.current` and a `retainedBy.mainView` row as fallbacks — all optional, so the lookup also works while `uiSession` mounts late. Model switches remain instant, because they ride the per-session `modelSelection` projection subscription rather than a poll.
- **Menu group headings stop reading as white bands** — 0.1.7 split the menu surface in two: the shared `MenuSurface` material paints the new `--dsw-menu-surface-fill`, and every other overlay paints `--dsw-specific-menu`, which the host aliases to it. The plugin re-emitted only the latter, so a model-selector panel kept the host glass while its sticky provider headings took the plugin's near-opaque palette colour. Both tokens are now generated from one expression and read one plugin-owned variable, so the card slider fades them together and the alias can no longer break.
- **The card slider's label says what it owns** — It covers menus as well as cards, so the Chinese label changed from 「对话框中选项面板」 to 「卡片与菜单面板」 and the English one from `Cards & panels` to `Cards & menus`.
- **Declared host raised to `0.1.7-rc.2`** — Every `@deepseek-ai/dsh-*` peer and `engines.dsh` now require 0.1.7-rc.2 or later. The host's compatibility check matches those peers against its own runtime version (`includePrerelease`), so an older host reports the plugin as incompatible by name instead of running it silently. The code's own version fallbacks are unchanged.

## v0.4.0

- **Color extraction now measures brightness the way a display does** — The auto light/dark band was decided from the average HSV *value* (the max channel), which reads every saturated pixel as bright: pure blue is `v = 1.0` while its Rec.709 luma is `0.072`. A dark, saturated wallpaper was therefore pushed into the light band and got a light palette built on top of a dark picture. The extractor now averages Rec.709 luma — the same measure the wallpaper verdict always used.
- **Choosing an image now themes the rule** — Picking or replacing a rule's image extracts its theme color automatically (global toggle, on by default). It only ever fills a rule that has *no* color, so a color you picked — or deliberately cleared — is never overwritten; because the decode is asynchronous the rule is re-checked on arrival, so a color chosen while it ran wins.
- **Match test card replaces the "Active now" readout** — The status card answered "what is active" and went stale between switches; the tester answers the better question one step earlier. Type any model name (the field follows the detected model until you type) and it reports which rule would match, or that the fallback would. The diagnostics it could not express — the value being *host default* rather than this session's model, and a sessions service that is not up yet — moved into the card as hints, so a broken lookup still cannot masquerade as a correct read.
- **Config** — The one new persisted field (`autoExtract`) landed in the shared schema, so both halves sanitize it identically.

## v0.3.3

- **The config shape is declared once, for both halves** — The node half (which sanitizes every write to disk) and the browser half (the UI's own view of the same file) used to spell the shape out separately: the key lists appeared 6 times across the two halves plus two sets of defaults, so a field added to one side only was silently dropped by the other side's sanitizer — the slider stayed live in memory, `writeConfig` wrote a config without it, and the next load fell back to the default. That is exactly how `blurs.rightbar` / `rightbarOpacity` were lost in 0.3.2. The types, the key lists, the defaults and the pure normalizers now live in one shared module (`src/schema.ts`) that both halves import, and both run the *same* sanitizer, so the UI and the disk copy can no longer disagree.
- **Unknown config fields are now reported** — While sanitizing a write, the node half logs one warning per key the shared shape does not declare (`ignoring unknown config field "blurs.produced" (declared in one half only?)`), so this class of drift shows up in the host log instead of quietly discarding a setting. Legacy pre-0.3 top-level keys are exempt (the migration consumes them).
- **No behaviour change** — Defaults, clamps, migration and the persisted file stay byte-for-byte what they were; this release only removes the duplication.

## v0.3.2

- **New "File preview panel" card** — The column that slides in from the right when you open a file now has opacity and blur sliders of its own. Its only surface was `--dsw-alias-bg-base` — the very token the Main background slider rewrites — so it used to be an unnamed second copy of the main background with no control of its own. The new card's opacity **follows Main background by default** and only takes over on the first drag, so nothing changes on upgrade; its blur stacks on top of the main-background blur. While the panel is closed it is merely slid off-screen, so the card costs nothing.
- **Host-side config keeps the new fields** — `theme-config.json` gained `rightbarOpacity` and `blurs.rightbar`. A missing field (any config written before this version, including an imported one) means "follow the main background".

## v0.3.1

- **Docs and screenshots refreshed** — The screenshots now show the current three-tab UI: one config under DeepSeek and under Kimi, plus a single rule's editor. The seven old captures (still showing the removed Color tab and the old Background page) are gone, and the image folder dropped from 3.6 MB to 220 kB.
- **Screenshots ship inside the npm package** — `example_img/` is part of the published tarball now, so the README rendered on npm has no broken images.
- **Art credit** — The example wallpaper art comes from [ZipZipPipe](https://space.bilibili.com/4168597) on bilibili.

## v0.3.0

- **Model rules replaced the single global wallpaper** — The background is now driven by an ordered list of rules, each selected by a match string against the current session's model name (first match wins, rule 1 falls back). The match string is compared against the provider, model id and display name together.
- **Appearance moved from global to per rule** — Wallpaper, theme color, layout mode, framing, background opacity and background blur are now properties of each rule, so different models can look completely different.
- **New tab layout** — Settings now has **Interface** (global), **Model Background** (the rule list) and **Config** (import/export). The **Color** tab was removed with the global theme color, and the old **Background** tab became **Model Background**.
- **Live match readout** — The Model Background tab reports the current model and the rule it resolved to (for example `Current model deepseek-flash → matched · Rule 1`), or a hint when the model can't be detected.
- **Export format v3** — `dsh-background-by-model-theme.json` now carries the full rule set and every rule's image (base64 inlined).
- **Breaking removals** — Video wallpapers, generated dynamic backgrounds (mesh gradient / Shader / geometric patterns) and the global theme color are gone.
- **Automatic legacy migration** — A pre-0.3.0 config becomes rule 1 with an empty match string, inheriting the old appearance, and stale video files are cleaned up.
- **Smoother switching, with a cross-fade** — Wallpapers are painted from an object URL, so one image is decoded once instead of being re-decoded on every switch, and a rule change now cross-fades (320 ms: the new image fades in over the old one, which stays fully opaque so the backdrop never brightens mid-transition). The drag-time low-resolution wallpaper swap was removed: it re-decoded and re-scaled the whole photo on every change, and its low-res frame was overwritten by the next repaint anyway.
- **Fixed: switching the model did nothing** — three things stacked up: (1) the old code read `ctx.modelDirectories`, which is not visible from a plugin context (it is registered in ui-model-selection's own scope), so it silently fell back to the host-wide default model; (2) this plugin's client bundle loads **before** `api-session-controller`, so `ctx.get('sessions')` is normally still undefined during `apply()` — and the old code gave up at that point and never retried, which is why nothing happened at all; (3) the readout passed a global default off as this session's model. It now reads **this session's own durable model selection** (`ctx.sessions.binding(id).session.projections.faceOf('modelSelection')` — the same value the model picker renders), waits for the `sessions` service at 400 ms intervals (up to 2 minutes), keeps a 1.5 s safety poll and rebinds on session switches; it labels the value **host default** when only the host-wide default was available, and the readout names the failing hop (not mounted / no current session / no projection / projection empty).

## v0.2.4

- **dsh 0.1.5 persistence fixed** — The theme/wallpaper RPC channel is now registered directly in the plugin's own `webServer` scope as a prefix route (keeping the same Host/Origin auth fence), instead of through `connection.rpc.handle`, whose effect binds to the connection service's context and never mounted on some 0.1.5 hosts — requests that previously dropped to the SPA fallback with 405 and never reached the disk now persist again. Verified working on both 0.1.2 and 0.1.5.
- **Host compatibility declared** — Added `engines.dsh: ">=0.1.2-rc.1"` to declare which DeepSeek Harness host versions the plugin supports.
- **Dark badge tokens fixed (issue #9)** — In the dark preset, the `*-tertiary` badge surfaces (trajectory tool/context badges, connection pill, plan chip) were tinted nearly the same as their background, making light label text unreadable. They now use the native dark 800/900 steps, so bright text sits on a properly dark badge.
