# Changelog > The full release history lives here; the two most recent releases (v0.7.5 / v0.7.4) are also listed in [README.md](./README.md#recent-optimizations). ## v0.7.5 - **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. - **`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. - **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. - **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. - **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. - **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. - **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. ## 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 (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. - **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. - **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. - **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". - **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. - **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 `` 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). - **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. - **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. - **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. - **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. - **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. - **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 ``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. - **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. - **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. - **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. - **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` tables, which is the documented mechanism for a label the compiler already guarantees exists. ## 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" 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. - **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. - **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. - **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. - **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. - **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. ## 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. 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. - **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. - **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). - **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). - **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. - **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. - **A rule with no picture has exactly one upload target** — the big empty preview tile *is* the button now (a real `