dsh-background-by-model 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -160
- package/CHANGELOG.zh.md +181 -159
- package/LICENSE +21 -21
- package/README.md +297 -283
- package/README.zh.md +297 -283
- package/cordis.patch.yml +8 -8
- package/lib/client.js +704 -49
- package/lib/client.js.map +1 -1
- package/lib/index.js +86 -8
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,160 +1,180 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
> The full release history lives here; the two most recent releases (v0.7.
|
|
4
|
-
|
|
5
|
-
## v0.7.
|
|
6
|
-
|
|
7
|
-
- **The
|
|
8
|
-
- **
|
|
9
|
-
- **The
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- **The
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **The
|
|
22
|
-
- **
|
|
23
|
-
- **Gates** — `check:
|
|
24
|
-
|
|
25
|
-
## v0.7.
|
|
26
|
-
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
41
|
-
- **
|
|
42
|
-
- **
|
|
43
|
-
- **
|
|
44
|
-
|
|
45
|
-
## v0.
|
|
46
|
-
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **
|
|
50
|
-
-
|
|
51
|
-
- **
|
|
52
|
-
-
|
|
53
|
-
- **The
|
|
54
|
-
- **
|
|
55
|
-
- **
|
|
56
|
-
- **
|
|
57
|
-
- **
|
|
58
|
-
- **
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- **The
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
- **A
|
|
70
|
-
-
|
|
71
|
-
- **
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- **
|
|
76
|
-
- **
|
|
77
|
-
- **
|
|
78
|
-
- **
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
- **The
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
## v0.4
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
- **
|
|
91
|
-
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
- **
|
|
97
|
-
- **
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
-
|
|
110
|
-
- **A
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
- **
|
|
117
|
-
- **
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
-
|
|
123
|
-
- **
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
- **
|
|
130
|
-
- **
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
- **
|
|
136
|
-
- **
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
- **
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
- **
|
|
150
|
-
- **
|
|
151
|
-
- **
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
- **
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
> The full release history lives here; the two most recent releases (v0.7.4 / v0.7.3) are also listed in [README.md](./README.md#recent-optimizations).
|
|
4
|
+
|
|
5
|
+
## v0.7.4
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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".
|
|
11
|
+
- **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.
|
|
12
|
+
- **Three surfaces were still snapping, and they are the ones painted from a colour of the plugin's OWN (a fix)** — the follow-up report ("还有几个元素的背景色是直接换的", with the interface transparency at maximum) is not the bug above, and the three frames it came with say which surfaces: classifying every pixel of the middle frame against the first one and the last (already at the final colour / still on the first one) puts the **settings dialog's own plate** — visible as the left rail, the title row and the gaps around the cards — on the final colour while every `.dab-card` inside it is still on the first one. A card that fades while the plate behind it does not is the signature of a different paint path: the plate is not painted from a registered alias token but from `--dsh-any-bg-settings-surface`, a plugin-owned variable written inline on `<html>` with the settings alpha folded in, and an **unregistered** variable cannot interpolate at all — there is no token to register and none to transition. The same shape exists twice more: the file-preview panel (`--dsh-any-bg-rightbar`, read by the three selectors `RIGHTBAR_STYLE_RULE` paints) and the Cordis panel (`--dsh-any-op-menu-cordis`, which re-declares `--dsw-specific-menu` on it).
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
- **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.
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **The interface colour now changes over the same clock as the wallpaper** — a new theme colour is one rewrite of `body{--dsw-alias-…}`, which changed every surface in a single frame while the picture behind it blended: a snap in front of an animation. The fade is a **registration** problem, not a declaration one, because an unregistered custom property cannot interpolate: `src/client/palette-fade.ts` emits `@property` rules that turn the alias tokens into `<color>`s, plus a `transition` for the elements whose own declarations change with the palette — `body`, and the two surfaces that re-declare layer tokens instead of inheriting them (the settings dialog and the trajectory root). The two places the plugin paints **inline** (the app columns, and the chat/trajectory cards) do not read those tokens at all, so they carry the same duration on `background-color` directly. The duration, the easing and the vetoes come from the **same** `transitionPlan` the wallpaper uses, so "instant", a `0` ms duration and reduced motion cannot mean one thing for the picture and another for the interface. It is armed **only when the palette itself moved**: every other pass rewrites the same token block (a slider drag moves the alphas) and writes the sheet with no transition and `none` inline, because a live transition there would make the surface lag behind the pointer. A token the host does not publish is deliberately left unregistered — registration needs an `initial-value`, and a registered property that nothing else sets resolves to that value instead of to the `var(…, fallback)` chain the plugin's own stylesheet relies on, so registering it would replace a fallback with an invented colour.
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **Gates** — `check:transition` (`scripts/transition-check.ts`) joins `pnpm test`, which is now **six** checks: it pins the cross-fade string byte-for-byte, the hard-cut semantics of `instant` and a `0` duration, the reduced-motion veto, the landing state of every effect, the palette-fade CSS shape (including that `0` ms emits the registrations and no transition rule) and the scheme-flip table. `check:repaint` gained the three lines this bug was made of — the palette write must be synchronous when the palette moves, must not swallow its failure, and must ask both halves of the fade question at both the token and the inline call sites. `check:node` covers the shipped default, the round trip, hostile values, a kept `0` duration, the `transition` drift check, the announced schema of 5, and the legacy fold (a hand-tuned 1200, a deliberate `0`, the unremarkable 320, and an explicit global value winning). `check:ui` covers the new strings and the two new classes (`.dab-tr-preview`, `.dab-tr-preview-in`), and registers the effect/easing chip labels in `TABLE_KEYS` — they are reached through `Record<TransitionEffect, …>` tables, which is the documented mechanism for a label the compiler already guarantees exists.
|
|
24
|
+
|
|
25
|
+
## v0.7.2
|
|
26
|
+
|
|
27
|
+
- **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.
|
|
28
|
+
- **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.
|
|
29
|
+
- **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.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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.
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
## v0.7.1
|
|
35
|
+
|
|
36
|
+
- **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.
|
|
37
|
+
- **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.
|
|
38
|
+
- **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).
|
|
39
|
+
- **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).
|
|
40
|
+
- **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.
|
|
41
|
+
- **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.
|
|
42
|
+
- **A rule with no picture has exactly one upload target** — the big empty preview tile *is* the button now (a real `<button>`, so it is focusable and keyboard-reachable; click it or drop files onto it), and the filmstrip is not rendered at all while the rule holds nothing: it contained nothing but a second "add image" tile, and a filmstrip is a list of pictures — a state with none does not have one. The drop handler moves up with it rather than disappearing, because hiding the strip would otherwise take drag & drop away from the state every rule starts in. Nothing else moved: the labelled **Add images** / **From URL** buttons stay where they were, the empty-state sentences are unchanged, and with pictures present the preview is still deliberately inert (dropping on the strip adds, and *replace* stays an explicit labelled action, which is what keeps a preview from swallowing a click the user did not mean).
|
|
43
|
+
- **Gates** — `check:node` gained the per-image color shape end to end through the real RPC handler: the lift in both directions (an absent key inherits the rule's color, an explicit `null` does not), independent colors surviving the round trip in order, holidays forcing both copies, the announced schema moving to 4, and `null` landing on disk as a key rather than as an omission. `check:repaint` gained the palette-on-a-rotation-step section above, and `setImageColor` — the new write path — is now one of the actions it requires to sample the winner and close with `repaintIfMoved`. `check:ui` covers the three new strings and the two new classes (`.dab-strip-dot`, `.dab-rule-thumb-add`), as it covers every other one.
|
|
44
|
+
|
|
45
|
+
## v0.7.0
|
|
46
|
+
|
|
47
|
+
- **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.
|
|
48
|
+
- **A second trigger that needs no timer at all** — *also step on a model switch* advances the image once every time the model changes and lands on that rule. For a rule that should simply look different each time, that is the whole feature: nothing is scheduled, nothing wakes up, and the image only changes when the user does something.
|
|
49
|
+
- **Shuffle never lands on the image already on screen** — the picker is uniform over every index *except* the current one, because a tick that leaves the picture unchanged is indistinguishable from a tick that never fired — the one failure this feature could produce with no error anywhere. `pnpm check:rotation` walks the whole support of the roll for lists of 2, 3 and 7 images rather than sampling it once, and also pins the sequential walk's wrap, out-of-range and fractional indices, and that "rotating" is never claimed for a rule with fewer than two images or a non-finite interval.
|
|
50
|
+
- **One timer for the plugin, aimed at the active rule, re-armed by signature** — a timer per rule would burn wakeups on rules that are not painting anything, and only one rule can be on screen. It is (re)armed only when the signature of its own inputs changes — rule id, enabled flag, interval, order, image count, tab visibility — so the dozens of full re-applies a slider drag triggers can never restart the dwell (with a naive "re-arm on apply", dragging the opacity slider for 30 seconds would mean the rotation never advances during it). A hidden tab stops the rotation with the pixels and resumes on a fresh interval, instead of painting three times into a tab nobody is looking at.
|
|
51
|
+
- **The picture changes; the rule does not** — theme color, layout mode, wallpaper opacity and blur stay the rule's, and the transition is the same two-layer cross-fade a model switch already used. A rule can ask for a longer blend than a switch, or `fadeMs: 0` for a hard cut — which is kept as a real value rather than read as "absent".
|
|
52
|
+
- **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.
|
|
53
|
+
- **The single thumbnail became a filmstrip** — add several images at once (**multi-select**, **drag & drop**, or **one URL per line**, fetched sequentially so N downloads do not interleave with no progress to show), click one to edit it, reorder it, promote it to **first** (which is what the rule paints when nothing rotates), **replace this image** in place (which keeps its position in the rotation, so it is labelled apart from adding), or remove it.
|
|
54
|
+
- **A rule may hold NO image, and that has to be a real state** — the first cut of this feature refused to remove a rule's last picture and cleared its bytes "instead", which left the entry itself behind: a blank tile at position 1 of the filmstrip that could never be deleted, so the next upload landed at position 2 and the rule kept painting nothing while the preview showed an empty frame. Removing the last image now empties the list. A brand-new rule starts in that state too (no slot is allocated until the first picture), and a hand-edited list whose entries are all unusable lands in it rather than being conjured back into a broken slot. Only a rule that names no image anywhere — no list, no legacy slot — is dropped by the sanitizer.
|
|
55
|
+
- **A rule with no image left keeps painting from its theme color** — an empty list is not "this rule is off", which is what the first cut made of it: usability only asked whether an image was there, so a rule whose pictures had all been removed **stopped matching altogether** — its own theme color, the wheel, the swatches and the clear-to-system-theme button all became decoration (the reported symptom was "the theme color does nothing") — and the models it used to match were quietly handed to the **next** rule: empty the fallback rule and another model's wallpaper appears. Usability is now *an image, or a theme color of its own* (`ruleCanPaint`): such a rule still matches, still serves as the fallback, and simply paints the interface instead of a wallpaper; only a rule with neither (a freshly added one) is skipped. The card's copy now says which of the three real states it is in — no image but a color, neither, or an image whose bytes have not arrived — where one sentence used to claim "this rule has no image" for all three, and the "extracts from this image" hint no longer appears when there is no image to extract from.
|
|
56
|
+
- **Every edit that can move the winner takes effect at once, and only an edit that cannot may skip the repaint** — `color`, `enabled` and `match` all feed which rule wins, but each write path asked its own narrow question ("is this the rule that is painting right now?") before re-running the resolution, so only an edit to the rule that was *already* winning repainted. The edits that are not already winning are exactly the ones that **create** the winner: the first picture of a rule that was empty (which is what makes it paintable at all — see `ruleCanPaint`), and the first or auto-extracted color landing on it. Those landed in the config and in the panel's own readout while the interface behind it kept the previous rule, which was reported as "the update is never timely, it only appears when I move the color wheel or switch models" — dragging the wheel or a model switch was what finally re-ran the resolution. Every path now samples the winner before its mutation and compares it after (`repaintIfMoved`, over the pure `shouldRepaint` in `src/client/repaint.ts`), which also repairs a tracker that had gone stale: an edit to a rule that *is* the winner repaints even if the page never recorded it as active. `pnpm check:repaint` walks that decision's truth table — both directions of the winner moving, and the cases that must stay cheap — and fails if any of the nine write paths in the entry file — the auto-extraction that lands after an upload included — stops asking it. The live check that pinned this down drove a throwaway rule through the exact sequence (create empty → move to the top → upload one picture → touch nothing): with the old test the paint never moved until something else did, with this one it moved in ~280 ms, and the picture's own auto-extracted color came with it.
|
|
57
|
+
- **An older host process can no longer eat a profile — the check is a version comparison, which is why the shape moved to 3** — the browser half and the node half are loaded separately, so updating the package and merely refreshing the page can pair a NEW client bundle with a host process still running the PREVIOUS node half. That older sanitizer knows nothing about `images` and drops every rule that has one — silently, on the first slider drag. The node half therefore publishes the config shape it writes (`schema` on every `read`), and a client that finds an older answer **holds every config write** (one funnel, `canWriteConfig` in `src/client/rpc.ts`), logs once, and says so in the panel: "restart DSH". Edits that silently do not persist are worse than edits that visibly wait for a restart; edits that silently delete rules are worse than both. During 0.7.0's own development this gate did let one through: the version was 2, and the running 0.7 node half reported `schema: 2` while being a build from **before the empty-rule fix**, so a write that emptied a rule was allowed through and the host erased those rules from disk. The number therefore now means "**an empty image list is legal**" (`SCHEMA_VERSION = 3`), not merely "rules carry `images`": any host announcing less than that lands in the hold-writes state.
|
|
58
|
+
- **An emptied rule and an upload after it** — the picture you add is the picture you are then looking at (the card moves its selection to the first newly added image, and the strip, the big preview and the position readout all follow), instead of leaving the panel showing whatever used to be selected.
|
|
59
|
+
- **Only the images that are needed are loaded** — boot hydrates each rule's **first** image, which is exactly what it hydrated per rule before multi-image existed, plus today's holiday and the image the current model paints. The rest arrive when a card is expanded or when the rotation is about to need one. Hydrating every image of every rule would mean twenty full-size data URLs over this plugin's RPC channel before the first frame on a five-rule setup with four pictures each; the slot list the store reports is likewise no longer hydrated, because an orphaned slot nothing references has no card to appear on.
|
|
60
|
+
- **An older host process can no longer eat a profile** — see the version-comparison entry above: the shape's version number is what a stale host is judged by, and it now stands for "an empty image list is legal" as well.
|
|
61
|
+
- **Older configs still load, and older exports still import** — `normalizeRule` accepts both shapes: a rule's single `slot`/`bgState` pair is lifted into `images: [{ slot, bgState }]` on read, duplicates within a rule are dropped, and an unusable slot is dropped. Theme exports move to **format version 4** and now carry, and restore, every image of every rule rather than just the first; version 3 files still import.
|
|
62
|
+
- **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.
|
|
63
|
+
- **The node half reports nested drift too, and migration writes the new shape** — `warnUnknownConfigKeys` now compares `rules[].rotate` and `rules[].images[]` one level deeper, which is where a field that only the client bundle knows would otherwise disappear in silence, while the legacy `slot`/`bgState` names are explicitly exempt because the sanitizer lifts them on purpose. The pre-0.3 migration writes an image list directly instead of leaving a legacy shape on disk until the first write.
|
|
64
|
+
|
|
65
|
+
## v0.6.0
|
|
66
|
+
|
|
67
|
+
- **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.
|
|
68
|
+
- **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.
|
|
69
|
+
- **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.
|
|
70
|
+
- **中秋 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.
|
|
71
|
+
- **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.
|
|
72
|
+
- **中秋 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.
|
|
73
|
+
- **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).
|
|
74
|
+
- **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.
|
|
75
|
+
- **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.
|
|
76
|
+
- **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.
|
|
77
|
+
- **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.
|
|
78
|
+
- **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.
|
|
79
|
+
- **`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.
|
|
80
|
+
|
|
81
|
+
## v0.5.5
|
|
82
|
+
|
|
83
|
+
- **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.
|
|
84
|
+
- **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.
|
|
85
|
+
- **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.
|
|
86
|
+
|
|
87
|
+
## v0.5.4
|
|
88
|
+
|
|
89
|
+
- **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.
|
|
90
|
+
- **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.
|
|
91
|
+
- **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.
|
|
92
|
+
|
|
93
|
+
## v0.5.3
|
|
94
|
+
|
|
95
|
+
- **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.
|
|
96
|
+
- **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.
|
|
97
|
+
- **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).
|
|
98
|
+
- **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.
|
|
99
|
+
- **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.
|
|
100
|
+
- **Fixed the mojibake dash in the `package.json` description**, which is what the npm page shows.
|
|
101
|
+
|
|
102
|
+
## v0.5.0
|
|
103
|
+
|
|
104
|
+
- **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.
|
|
105
|
+
- **`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.
|
|
106
|
+
|
|
107
|
+
## v0.4.5
|
|
108
|
+
|
|
109
|
+
- **`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.
|
|
110
|
+
- **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.
|
|
111
|
+
- **`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.
|
|
112
|
+
- **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.
|
|
113
|
+
|
|
114
|
+
## v0.4.4
|
|
115
|
+
|
|
116
|
+
- **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.
|
|
117
|
+
- **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.
|
|
118
|
+
|
|
119
|
+
## v0.4.3
|
|
120
|
+
|
|
121
|
+
- **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.
|
|
122
|
+
- **`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.
|
|
123
|
+
- **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.
|
|
124
|
+
|
|
125
|
+
## v0.4.2
|
|
126
|
+
|
|
127
|
+
- **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.
|
|
128
|
+
- **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.
|
|
129
|
+
- **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.
|
|
130
|
+
- **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.
|
|
131
|
+
|
|
132
|
+
## v0.4.1
|
|
133
|
+
|
|
134
|
+
- **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.
|
|
135
|
+
- **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.
|
|
136
|
+
- **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.
|
|
137
|
+
- **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`.
|
|
138
|
+
- **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.
|
|
139
|
+
|
|
140
|
+
## v0.4.0
|
|
141
|
+
|
|
142
|
+
- **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.
|
|
143
|
+
- **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.
|
|
144
|
+
- **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.
|
|
145
|
+
- **Config** — The one new persisted field (`autoExtract`) landed in the shared schema, so both halves sanitize it identically.
|
|
146
|
+
|
|
147
|
+
## v0.3.3
|
|
148
|
+
|
|
149
|
+
- **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.
|
|
150
|
+
- **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).
|
|
151
|
+
- **No behaviour change** — Defaults, clamps, migration and the persisted file stay byte-for-byte what they were; this release only removes the duplication.
|
|
152
|
+
|
|
153
|
+
## v0.3.2
|
|
154
|
+
|
|
155
|
+
- **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.
|
|
156
|
+
- **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".
|
|
157
|
+
|
|
158
|
+
## v0.3.1
|
|
159
|
+
|
|
160
|
+
- **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.
|
|
161
|
+
- **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.
|
|
162
|
+
- **Art credit** — The example wallpaper art comes from [ZipZipPipe](https://space.bilibili.com/4168597) on bilibili.
|
|
163
|
+
|
|
164
|
+
## v0.3.0
|
|
165
|
+
|
|
166
|
+
- **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.
|
|
167
|
+
- **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.
|
|
168
|
+
- **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**.
|
|
169
|
+
- **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.
|
|
170
|
+
- **Export format v3** — `dsh-background-by-model-theme.json` now carries the full rule set and every rule's image (base64 inlined).
|
|
171
|
+
- **Breaking removals** — Video wallpapers, generated dynamic backgrounds (mesh gradient / Shader / geometric patterns) and the global theme color are gone.
|
|
172
|
+
- **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.
|
|
173
|
+
- **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.
|
|
174
|
+
- **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).
|
|
175
|
+
|
|
176
|
+
## v0.2.4
|
|
177
|
+
|
|
178
|
+
- **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.
|
|
179
|
+
- **Host compatibility declared** — Added `engines.dsh: ">=0.1.2-rc.1"` to declare which DeepSeek Harness host versions the plugin supports.
|
|
180
|
+
- **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.
|