dsh-mpkg-wallpaper 3.7.1 → 3.7.3

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/README.en.md CHANGED
@@ -36,13 +36,25 @@ A plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
36
36
  - Appearance tab also has: floating cards, etc. (the clock is a runtime-compat item — old configs still show it, but there is no settings toggle)
37
37
 
38
38
  **🧩 dsh-better-sidebar adaptation (shown when that plugin is detected)**
39
- - When [dsh-better-sidebar](https://github.com/) is installed, an **adaptation section** appears in the "Appearance / Other" tabs with a master toggle + sub-toggles:
40
- - **Floating double-layer fix** (bsFloat): makes the floating sidebar's inner `pane/tabBar` background transparent so no double solid rectangle appears when floating
39
+ - When dsh-better-sidebar is installed, an **adaptation section** appears in the **"Other" tab** (not "Appearance") with a master toggle + sub-toggles:
40
+ - **Floating double-layer fix** (bsFloat): 14px rounded shell with `overflow:hidden` (clips inner right angles / active-tab pill), transparent inner `pane/tabBar/terminalWrap`, **zero outer margin** (the panel's left/right are aligned by better-sidebar's own ResizeObserver — margins shift it 8px and leave a 3.6px sliver when collapsed), and the host resize strip is moved inside the panel (host default `top:-4px` gets half-clipped once the shell has rounded corners)
41
41
  - **Reveal level** (bsReveal + bsRevealAlpha slider): how much wallpaper shows through the better-sidebar surface (higher = more transparent)
42
42
  - **Follow theme / Aqua** (bsAlpha / bsAqua): better-sidebar panel follows the theme base / the unified-fog color
43
43
  - **Bottom panel avoidance** (bsBottomAvoid): the bottom panel stays aligned with the DSH center column (handled by better-sidebar's own ResizeObserver — no manual offset)
44
44
  - Font follow (bsFont) and other sub-toggles
45
45
  - The host `/ping` endpoint auto-detects whether better-sidebar is installed; the section is hidden when it is not
46
+ - **Version-aware adaptation**: the host also reports the installed better-sidebar version (e.g. `0.19.1`) and the
47
+ client writes `body[data-mpw-bs-version]`, so version-specific rules can be gated with
48
+ `[data-mpw-bs-version^="…"]` (e.g. the 0.16+ floating-window reveal; 0.19 removed floating windows upstream, so
49
+ that rule simply stops matching). Panel-level rules additionally carry the 0.19 stable attribute hooks
50
+ `[data-dsh-bottom-panel]` / `[data-dsh-pane]`, so a CSS-module hash change cannot silently break them.
51
+ **2026-09-17: two "looks adapted but never actually ran" root causes were fixed** (`/ping` always returned a
52
+ `null` version; version probing only ran when the settings panel was opened). Criteria, real-machine evidence and
53
+ reproduction commands: [`docs/BETTER-SIDEBAR-COMPAT.md`](docs/BETTER-SIDEBAR-COMPAT.md); the 0.19.1 DOM contract:
54
+ [`docs/BETTER-SIDEBAR-DOM-CONTRACT-0.19.1.md`](docs/BETTER-SIDEBAR-DOM-CONTRACT-0.19.1.md).
55
+ Regression: `node tools/better-sidebar-compat-test.mjs` (41 assertions, including a mutation case that must turn
56
+ red when the old code shape comes back, plus an anchor canary against the installed version; wired into
57
+ `tools/check.sh` step 10).
46
58
 
47
59
  **⏯️ Playback control & power saving**
48
60
  - **Pause / Play button**: when the current wallpaper is a video/web type, the settings page shows a **Pause/Play** button (click to freeze the image, click again to resume). The paused state is **synced in real-time** (the button follows the actual video state); **adjusting unrelated settings (mute/brightness/blur etc.) does NOT trigger a replay** — the root cause ("video.src" compared as an absolute URL to a relative one → every settings apply reloaded the media source) has been fixed.
@@ -57,7 +69,7 @@ A plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
57
69
  - Based on CSS `backdrop-filter`: semi-transparent + blur + edge highlight (**no longer the WebGL refraction version** — WebGL was removed in v3.6.0, see below). Four toggles:
58
70
  - **lgTest (test mode)**: keeps only wallpaper + floating + layout, and overrides no DSH token (otherwise a translucent base makes the chat box transparent without blur)
59
71
  - **lgComposer / lgSidebar / lgHeader**: add a liquid-glass overlay to the message-bubble area / sidebar / title bar respectively (the sidebar can only be semi-transparent + edge highlight because of the settings dialog's render hierarchy — it **cannot** use backdrop-filter, or it would squash the settings dialog into the sidebar — historical pitfall)
60
- - **History**: early versions used real WebGL refraction (`lib/liquid-glass/` library + `liquid-glass-bundle.js` 107KB); v3.6.0 removed the WebGL runtime (unstable + large) in favor of pure CSS. `lib/liquid-glass/*.js`, `liquid-glass-bundle.js`, `tools/liquid-demo/` **remain in the package but are no longer referenced at runtime** (leftover dead files, can be cleaned later).
72
+ - **History**: early versions used real WebGL refraction (`lib/liquid-glass/` library + `liquid-glass-bundle.js` 107KB); v3.6.0 removed the WebGL runtime (unstable + large) in favor of pure CSS. `lib/liquid-glass/*.js` and `liquid-glass-bundle.js` **stay in the repo but have been moved off the publish surface** (negative `files` patterns `!lib/liquid-glass/**` + `!lib/liquid-glass-bundle.js` ⇒ **not shipped in the npm package**, P-127) and are **no longer referenced by the client** (`lgModule` is declared but never assigned). The host-side static route is still a **live path** (`/api/mpkg-wallpaper/lg/<file>.js` really `readFileSync`s `lib/liquid-glass/<file>`; in-repo development/demos are unaffected), and the `tools/liquid-demo/` demo page uses that same mount (P-122). `tools/liquid-demo/` stays in the repo only and is **not shipped** (`files` excludes `tools/`). So they are **not dead files**; **being off the publish surface means those 10 files are no longer distributed ⇒ the MIT attribution obligation surface disappears**, while the repo keeps all 10 and still records their 10 sha256 sums (`THIRD-PARTY.md` §1.3; numbers and gates: `docs/PUBLISH-SURFACE-LIQUID-GLASS.md` P-127).
61
73
 
62
74
  **🎬 Lens & picture**
63
75
  - Lens zoom (10–2000%) & pan, brightness (50–150%), light sharpen, Deep diving background box
@@ -92,6 +104,9 @@ A plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
92
104
  - **Scene cache is byte-bounded**: layer cache has a 128MB byte budget + count cap double-guard; scene.pkg is only read in full on a cache miss (stat-first)
93
105
  - **Listeners/timers register once**: storage listener, 60s slot check, inline-style watcher, etc. are de-duplicated — repeated apply/RTC reconnects never accumulate
94
106
  - **Lazy loading prevents OOM**: time-variation wallpapers extract only the current slot; hybrid streams large files with minimal memory
107
+ - **The audio list no longer waits for the whole package (2026-09-15, user report #1)**: a `scene.pkg` keeps its **directory table at the start of the file**, so "which audio tracks does this package have" needs only that table plus a 16-byte magic sniff per candidate entry (track bytes never enter memory). Two new entry points: `/custom-scene-audio?folder=` and `/library-scene-audio?ltoken=`, returning `{count,tracks:[{path,size,mime,refs}],stats}` (measured on the 22.5MB hina package: **2.3ms / 107KB read**; 0.5ms on a cache hit), and `/raw` now supports **Range/206** (it always returned 200 + the whole body before, so a renderer could not even fetch just the index). Measured with `tools/audio-scan-bench.mjs` (11 real packages, median of 3): whole-package read 1.7–379ms ⇒ index read 1.3–6.9ms cold / 0.4–0.9ms warm, with the track list **item-for-item identical** to this repository's own spec `docs/AUDIO-TRACK-SPEC.md` (11/11 packages, including FLAC/OggS/ID3/ftyp and scene.json layer refs). The track-detection/collection section was clean-room rewritten against that spec on 2026-09-16 (provenance: `THIRD-PARTY.md`).
108
+ - **Scene video probing is now "index-first" (2026-09-15, user report #1 ⑥c)**: `ensureSceneVideo` used to sit on the **critical path of applying a wallpaper** — `readFileSync` of the whole package plus full mipmap decompression of every `.tex` (the client even sets a 4s timeout for this step). It now reads only the **directory table** plus a **prefix** of each candidate entry (`.tex`: walk the header + first mipmap record and read the first 12 payload bytes to test `ftyp`; when mip0 is LZ4 only the first sequence is decompressed; standalone video entries win and are the only ones read; it stops after confirming 2 embedded videos), falling back to a full read whenever anything is uncertain. **The selection result, the on-disk cache file name (same hash formula) and its content sha256 are item-for-item identical to before.** Measured across 11 real packages: **2894ms → 532ms cold / 7ms warm**; the 7 no-video packages **1772ms → 18ms**; the 69.5MB Kel'thuzad-class package 771ms → 4ms; all 213 corpus `.tex` prefix decisions are correct. Bench `tools/scene-video-bench.mjs`, gate `tools/scene-video-test.mjs` (26 assertions).
109
+ ⚠ **Which path**: this changes the **DSH plugin host** path (`/custom-scene-video-check`). The `:8899` renderer's 🔊 audio panel does **not** go through the plugin — its perceived improvement comes from renderer-side scheduling (see `docs/AUDIO-SCAN-FAST.md` §0).
95
110
  - **Weak-device throttling**: heavy compositing (full-screen backdrop-filter over streaming video) is globally throttled; for extreme WebView combos, Edge / desktop browsers still give the best experience
96
111
 
97
112
  **🌐 Browser compatibility (tested reference)**
@@ -112,7 +127,7 @@ A plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
112
127
  | **mpkg (scene)** | 🟡 Partial | static-frame / layer composite / preview animation (below); slots with video textures auto-switch |
113
128
  | **Time-variation wallpaper** | ✅ Full | multi-slot auto-switch + manual lock, lazy loading to avoid OOM |
114
129
  | **Video (mp4/webm)** | ✅ Full | plays directly |
115
- | **Web (HTML)** | 🟡 Experimental | sandboxed iframe; **web wallpapers with settings are wired into the plugin (below)**; **interactive-feature wallpapers not adapted yet** |
130
+ | **Web (HTML)** | 🟡 Experimental | **sandboxed iframe + a WE API shim injected before author scripts** (property / audio / media / slideshow callbacks work); **web wallpapers with settings are wired into the plugin (below)**; **interactive-feature wallpapers not adapted yet** |
116
131
  | **Scene raw folder (scene.pkg)** | 🟡 Partial | same as mpkg scene |
117
132
  | **Application (.exe)** | ❌ Excluded | safety: never read/executed |
118
133
 
@@ -146,12 +161,35 @@ The plugin offers these partial solutions (chosen automatically by scene content
146
161
 
147
162
  ## Web Wallpapers (Experimental)
148
163
 
149
- - HTML wallpapers load full-screen in a **sandboxed iframe** (`allow-scripts` isolation; **mute toggle, default on**; **webUrl is persisted** — auto-recovers after refresh / route changes / RTC reconnects without losing config; refresh the page manually if it ever freezes)
164
+ - HTML wallpapers load full-screen in a **sandboxed iframe**; **webUrl is persisted** — auto-recovers after refresh / route changes / RTC reconnects without losing config (refresh the page manually if it ever freezes)
165
+ - **Two loading modes** (pick one in the confirm dialog; the mode is stored inside webUrl and survives reloads):
166
+ - **Sandbox mode (default, recommended)**: the host injects a **WE API shim** as the first `<script>` in the entry HTML's `<head>`
167
+ (`window.wallpaperPropertyListener`, `wallpaperRegisterAudioListener`, `wallpaperRegisterMedia*Listener`,
168
+ `wallpaperRequestRandomFileForProperty`, …), so wallpapers that rely on the WE API actually run. The iframe's `sandbox`
169
+ is **`allow-scripts` only** (opaque origin) ⇒ wallpaper scripts **cannot reach the DSH UI or local storage**.
170
+ Trade-off: the parent cannot read the frame's DOM either, so mute / speed / pause are executed in-frame by the shim.
171
+ - **Compatibility mode (same-origin)**: equivalent to the previous bare iframe
172
+ (`allow-scripts allow-same-origin allow-pointer-lock`). Required by the "Web wallpaper options" of Live2D-style
173
+ wallpapers (resolution / language / volume, written to the frame's same-origin `localStorage`).
174
+ - **Property / media wiring**: on mount the wallpaper's `project.json` `general.properties` defaults (overlaid with your
175
+ saved `propEdits`) are pushed to the shim; media files in the wallpaper folder become the slideshow pool
176
+ (`__mpw-list.json`). Mute / speed / pause are delivered in-frame via postMessage.
177
+ - **Type detection is content-first, not declaration-first**: `general.type` is only a hint —
178
+ a package that claims `web` but ships `scene.pkg` is treated as a **scene**, and one that claims `scene`/`video`
179
+ but only has `index.html` is treated as a **web** wallpaper (four states: web / scene / video / unknown;
180
+ `application/exe` is always excluded).
150
181
  - **Risk preflight**: auto-classified during scan; badges shown in the list and confirm dialog:
151
- - **⚠heavy animation**: Spine/L2D skeletal wallpapers — may freeze on low-end devices
182
+ - **⚠heavy animation**: Spine/L2D skeletal wallpapers — may freeze on low-end devices (use compatibility mode for these)
152
183
  - **🌐external**: depends on external SDK/CDN (e.g. miHoYo event pages) — may fail to load
184
+ - **Author script errors never break the plugin**: the in-frame shim catches listener exceptions, global `error` events and
185
+ unhandled promise rejections, and rate-limits reports to the parent (`console.warn` + `/diag`).
153
186
  - Tested: webm-video-based web wallpapers (light) work; Spine skeletal ones depend on device performance; **Live2D portraits with a `loadJson.json` are wired into the plugin options** (above)
154
187
 
188
+ > Details (detection table, per-attribute sandbox rationale, shim API / control protocol, file-URL rewriting,
189
+ > error boundary, known limitations, diff against the reference implementation):
190
+ > [`docs/WEB-WALLPAPER.md`](docs/WEB-WALLPAPER.md) (Chinese).
191
+ > Regression: `node tools/web-wallpaper-test.mjs` (wired into step 5 of `bash tools/check.sh`, all assertions green).
192
+
155
193
  ## Settings Tabs (8 total)
156
194
 
157
195
  - **Source**: master switch, hybrid, mpkg file, image/video files, custom folder (can point at the workshop root), local library (Steam scan), switching/rotation, **time-variation slot lock**
@@ -163,6 +201,235 @@ The plugin offers these partial solutions (chosen automatically by scene content
163
201
  - **Liquid glass**: lgTest / lgComposer / lgSidebar / lgHeader (CSS version, **experimental, not recommended**; with a separate demo page, see the "Liquid glass" section above)
164
202
  - **Other**: power-saving 3-tier (hidden/blur/battery), new style/sharpen/round-compat, update check/apply, **backup & restore**, restore all defaults, submit feedback; the better-sidebar **adaptation section** appears here when that plugin is installed (clock is a runtime-compat item, no settings toggle)
165
203
 
204
+ ## P-66 panel robustness fixes (2026-09-15)
205
+
206
+ > Context: the 9 UI issues reported in that round were confirmed to belong to the **webwallgl test bench (:8901)**,
207
+ > not to the DSH plugin panel. Under that scope this round keeps **only two genuine plugin bugs that are independent
208
+ > of that UI and independently reproducible**; every other interface change was reverted (after the revert
209
+ > `lib/client.js` is byte-identical to the synced copy). Regression: `node tools/panel-fixes-test.mjs`
210
+ > (wired into step 2 of `tools/check.sh`).
211
+
212
+ | Real bug | Root cause | Fix | Repro / assertion |
213
+ | --- | --- | --- | --- |
214
+ | **The render error boundary itself is broken, swallowing the real cause** | The outer `catch (err)` of `MpkgSectionImpl` called `h(...)`, but `h` is a `const` declared **inside the outer `try` block** (block scope is not visible in `catch`) → the boundary throws `h is not defined`, so the user sees "壁纸引擎设置区渲染异常:h is not defined" and the **real error is lost** | That catch now uses `react.createElement` | `node tools/panel-fixes-test.mjs --client <pre-fix copy>` → red (shows `h is not defined`); against the current code → green (shows the real error `boom-body`) |
215
+ | **zh/en dictionaries had different key sets** | `en` was missing 18 keys (`glass.title/desc`, `glassWindow*`, `glass.accent*`, `glass.color*`, `glass.alpha`, `glass.reset`, `flipX/Y*`, `themeColor*`, `rightSidebarBlur.overridden`) → the English UI printed raw keys; the 10 `clock.*` keys existed only in `en` → the Chinese UI showed English; plus two hardcoded Chinese strings (`"当前状态: "`, `"(已重挂)"`) | Both dictionaries now carry **identical key sets** (additions only; the exact count is printed by `node tools/panel-fixes-test.mjs`); the two hardcoded strings go through `t()` (visible Chinese text unchanged) | Same test: identical key sets / all static `t("k")` keys present in both / zero Chinese in the English render / zero raw keys in the Chinese render |
216
+
217
+ ## Two on-device bugs fixed at the root (2026-09-16): header frost / right-side timeline
218
+
219
+ | Bug | Root cause (evidence) | Fix | Rollback switch | Regression |
220
+ | --- | --- | --- | --- | --- |
221
+ | **Header frost "never shows up"** | The first statement of `syncHeaderFrost()` calls `normalizeSection(...)`, but that helper was declared **inside another function's body** ⇒ every call threw `ReferenceError`, which the function's own `catch {}` swallowed ⇒ the frost layer was never injected and the diagnostic `reason` stayed empty (on-device diag: `injected:false`, `reason:""`) | Hoisted the normalisation helpers to **module scope**; the catch now writes the exception into `hdrFrostState.reason` (no more silence); the injected frost element and the header's translucent background are now applied **as a pair**, with the background colour coming from our own `--mpw-hdr-frost-bg`; when `wanted=false` we **clean up** instead of leaving a `blur:none` stub layer (a stub suppresses the pseudo-element fallback and kills frost entirely) | `?hdrfrost=legacy` (old gating), `?hdrfrost=off` (fully off), `?hdrblur=pseudo\|element` (A/B) | `node tools/frost-rail-test.mjs` |
222
+ | **Right-side timeline (turn-navigation rail) turns transparent with a wallpaper** | The rail marks are `.eGxaPq_mark::before`; their colours come from `--dsw-alias-border-l4` (`#00000029` / `#fff3`, i.e. 16%/20% alpha) and `--dsw-alias-label-*`. The plugin ① overrode `--dsw-alias-label-*` with `var(--mpw-aqua-ink, inherit)` and self-referencing fallbacks — DSH defines those tokens on `body`, not on `html`, so on `body` they became **guaranteed-invalid** ⇒ the active/preview marks' `background` resolved to unset = transparent; ② a bare `html body { --dsw-specific-sidebar-fill: transparent }` rewrote a host token **globally** and made the chat surfaces transparent ⇒ a 16%-alpha mark painted over the wallpaper is invisible | Token overrides are now **scoped** to an explicit whitelist of containers; the aqua/text-colour overrides are gated by `data-mpw-*` and no longer use `inherit` or self-references; the marks get a theme-derived contrast colour from **our own namespace**, applied only to whitelisted `.eGxaPq_*` nodes (host tokens untouched, no `!important`) | `?railink=off` (disable the contrast compensation), `?sbfill=wide` (restore the old global sidebar-fill override) | `node tools/frost-rail-test.mjs` |
223
+
224
+ > Details and the diagnostic field table: [`docs/HEADER-FROST.md`](docs/HEADER-FROST.md), [`docs/TIMELINE-RAIL-TOKEN.md`](docs/TIMELINE-RAIL-TOKEN.md).
225
+
226
+ ## Style-scope guard: why this class of bug cannot come back (2026-09-17, MASTER-TODO §5 item 2)
227
+
228
+ Both bugs above share one mechanism: **nothing owned selector scope**, so a style edit could hit host UI
229
+ without anyone noticing locally. Gate step 12 turns that into a machine-checkable, red-on-regression rule
230
+ (`node tools/style-scope-guard.mjs`):
231
+
232
+ * **It uses the real artifact**: `buildCss` is never re-implemented. `tools/_stub.mjs` loads `lib/client.js`
233
+ in Node and calls the plugin's own `__mpwBuildCss(patch)` over **600+ setting combinations** (the run prints
234
+ the exact count; 615 this round: defaults / each boolean alone / all 512 combinations of the 9 core switches /
235
+ the `bsCompat` family / numeric 0 and 100 / no wallpaper / lgTest), then parses every generated rule, including `@media` / `@supports` nesting.
236
+ * **Verdict**: every selector must hit our own markers (`.mpw*` / `[data-mpw*]` / `#mpw-*`) or a **registered**
237
+ host/third-party scope. The `bsCompat` block that deliberately targets third-party DOM
238
+ (`[data-dsh-better-sidebar] …`) is allowed **only because it is declared** in the allow-list with a reason and a
239
+ `docs/*.md:line` pointer (pointers are verified at runtime; a rotted pointer fails the gate). Bare element
240
+ selectors (`button{…}`), a bare `*`, `:root` overriding host tokens, host tokens set to transparent/inherit,
241
+ unregistered `!important` token overrides, touching the host turn-navigation rail without our own gate,
242
+ `[data-dsh-panel-host]`, or making the header border transparent ⇒ **red**.
243
+ * **It proves it can discriminate**: `node tools/style-scope-guard.mjs --selftest` copies `lib/client.js` into a
244
+ temp dir and injects 15 mutations (plus a negative control that must still pass), asserting RED/REVIEW/PASS for each.
245
+ * Criteria, the allow-list ledger and "how to register a new entry":
246
+ [`docs/STYLE-SCOPE-GUARD.md`](docs/STYLE-SCOPE-GUARD.md).
247
+
248
+ ## Surface token namespace: top bar / sidebar / panels / timeline rail read one `--mpw-*` set (2026-09-18, MASTER-TODO §5 item 1 / P0-3)
249
+
250
+ The requirement reads: "the four surfaces must use **one token namespace** (`--mpw-*`) and
251
+ **never override host tokens** ⇒ structurally eliminate the class of bugs where we break a new host
252
+ feature." Gate step 12's second check makes that mechanical (`node tools/token-namespace-test.mjs`):
253
+
254
+ * **One source**: host tokens are consumed into `--mpw-surface-*` only inside `emitSurfaceTokens()`; the single
255
+ `body{…}` block in the output is the definition point for every surface value, and the four surfaces'
256
+ rules **only** write `var(--mpw-surface-*)`.
257
+ * **Why `body` and not `:root`**: DSH defines `--dsw-static-*` / `--dsw-alias-*` on **`body`** (not on `html`).
258
+ A `var()` inside a custom property is resolved **on the element where it is declared**, so declaring the
259
+ SSOT on `:root` makes it guaranteed-invalid and **inherit that invalidity to every descendant** (consumers
260
+ all fall back to `unset` = transparent). That is the very mechanism behind the historical "timeline rail went
261
+ transparent" bug; a dedicated assertion plus a mutation guard it.
262
+ * **Only one host-token override left**: `buildSidebarFillCss()` (`--dsw-specific-sidebar-fill`, scoped to the
263
+ sidebar allow-list, only in the "sidebar translucent" feature's respective state). The registry
264
+ (`HOST_OVERRIDE_REGISTRY`) demands token + selector + value shape + **activation condition** per entry:
265
+ all 39 override declarations in the output must be registered, and **none may appear in combinations where
266
+ the feature is off**.
267
+ * **Equivalence evidence**: using `git HEAD`'s `lib/client.js` as *before*, 606 setting combinations ×
268
+ light/dark × default/gated states are compared on the four surfaces' **effective values** (tiny cascade
269
+ model + recursive `var()` substitution) ⇒ 25,428 keys match exactly. This is a refactor, not a redesign.
270
+ * Inventory (which token belongs to which surface / which host token is consumed / the registry with reasons /
271
+ known deviations / how to add a token): [`docs/TOKEN-NAMESPACE.md`](docs/TOKEN-NAMESPACE.md).
272
+
273
+ ## Switch-wiring audit: no more "the toggle clicks but nothing happens" (2026-09-18)
274
+
275
+ **Real incident**: the CSS for "Accent colour" and "Dark-background text readability" (aquaTextEnhance) was
276
+ wrapped together inside `if (aquaOn(section))`, so **turning on only those two switches generated no rules at
277
+ all** — the toggle was clickable, had no effect, and logged nothing (both blocks' own comments claimed they did
278
+ not depend on Aqua, contradicting the implementation — very hard to spot by reading). Fixed, plus a general
279
+ check: `node tools/switch-wiring-test.mjs` (gate step 2):
280
+
281
+ * every boolean switch must **change the `buildCss` output** in at least one of three contexts
282
+ (default / rich / all-others-on); runtime-only switches must be registered with a `reason`;
283
+ * non-boolean features (`accent` / `aquaTextEnhance` must change the output); `themeColor` is
284
+ "always-emitted CSS + runtime attribute gate", so the check asserts the gate rules exist instead;
285
+ * switches **proven dead** go into `KNOWN_DEAD` and are listed on every run (two-way assertion: fixing one
286
+ requires deleting its entry). **The table is now empty** (`lgCss`/`sessionFollow` fixed, `glassWindow` retired
287
+ and deleted); **retired** switches are instead guarded by section A0 ("0 dangling references in the source +
288
+ 0 orphan key in either dictionary"), each with a permanent mutation that must go red.
289
+ That list drove the first fix: **`lgCss` (pure CSS/SVG liquid glass) never ran at
290
+ all** — the block referenced `bdSupported` while the `const` was declared after it, i.e. a same-scope TDZ
291
+ `ReferenceError` swallowed by the outer `catch { /* liquid glass failure must not affect other styles */ }`
292
+ (the catch is kept; it now only fires on real failures). The criteria are two-way: `lgCss:true` must emit the
293
+ glass block (`mix-blend-mode: screen` + `url(#mpw-lg-warp)`), `lgCss:false` must not, and the two must not be
294
+ byte-identical; a mutation restoring the TDZ order must turn all three red. The second entry is fixed too: **`sessionFollow` (new-chat button follows panel opacity)** — the setting page had a
295
+ toggle and copy but nothing read `section.sessionFollow`; implemented per the **user-visible copy** (on = follow
296
+ that opacity, off = **back to the host's original colour**), default unchanged, asserted in
297
+ `tools/switch-wiring-test.mjs` section A4 (three two-way assertions in both default and unified-blur contexts;
298
+ the "remove the read" mutation must go red). The third, `glassWindow`, was handled under the user's policy of
299
+ **"no copy that is visible but unclickable"**: it had **no toggle row (nobody could see it) and no read point
300
+ (nothing to click)**, while the feature its copy promised is already delivered by `settingsBlur` (settings-panel
301
+ blur) plus `dialogBlur`/`popoverBlur` ⇒ its copy and field were **deleted** rather than wired (wiring would add
302
+ a second switch for the same element and make the settings panel a backdrop root). Six deletion sites
303
+ (2 i18n lines each in zh/en + the `lgTest` preset default + the reset defaults + `BACKUP_FIELDS` + the import
304
+ `boolFields`; the export/sanitise lists are deleted **in lockstep** so an imported backup cannot fall through to
305
+ the "unregistered type" passthrough) — asserted in section **A0** of `tools/switch-wiring-test.mjs`
306
+ (0 hits in the source + 0 hits in both dictionaries) with the permanent mutation
307
+ `retired-glasswindow-copy-restored` required to go red; the default-context CSS is **sha256-identical**
308
+ before and after. See [`docs/TOKEN-NAMESPACE.md`](docs/TOKEN-NAMESPACE.md) §3b. **`bsCompat` (the better-sidebar adaptation master switch) now defaults to on** (ruled 2026-09-18): the
309
+ bottom-panel float adaptation is settled on real devices, so a default of off meant nobody ever saw it.
310
+ Existing users are migrated **only if they never set it explicitly**; anyone who turned it off by hand is
311
+ **never overridden** (the write path stamps a `bsCompatUserSet` marker; the migration itself does not).
312
+ Asserted by `node tools/bs-compat-default-test.mjs` (gate step 10, 15 assertions + 3 mutations).
313
+ Liquid glass also gained a **`?lgcss=off` kill switch** now that it actually runs (registered in the renderer repo's diagnostics table;
314
+ `node tests/diag-flag-check.mjs` reports 149==149).
315
+ **The header's share of the refraction lives on a pseudo-element**
316
+ (`html body[data-mpw-hdr-frost-el] .wSkVaW_header::before`, `z-index:0`): putting it on `.wSkVaW_header` itself
317
+ turns the header into a **backdrop root** ⇒ floating panels inside the header lose their backdrop sampling
318
+ (frosting fails, text behind shows through sharply) — a real-device regression that `tools/css-matrix.mjs`
319
+ assertion 3 caught with 40 problems. A pseudo-element is not an ancestor of those panels, so sampling still
320
+ works; it is gated on the JS-injected frost layer existing (that layer already raises the header's direct
321
+ children to `z-index:1`, so `z-index:0` lands between background and content), and it is only emitted when the
322
+ header is frosted anyway (`(headerBlur || unifyTint) && headerBg`, matching css-matrix assertion 7).
323
+ Neither assertion was relaxed.
324
+ * discrimination proof: reverting either gate back under `aquaOn` must turn the audit red.
325
+
326
+ ## Pre-commit gate (a few seconds, 2026-09-19 / P-129)
327
+
328
+ The audit above (plus the panel-rendering regression) is now available as a **few-second pre-commit** — four
329
+ seconds before a commit is enough to stop the two failure classes that **have really happened here several
330
+ times**: "the switch isn't wired" and "the panel no longer renders".
331
+
332
+ ```sh
333
+ cd dsh-mpkg-wallpaper
334
+ git config core.hooksPath .githooks # install (this clone only; git config --unset core.hooksPath to remove)
335
+ git commit --no-verify # bypass once
336
+ MPW_SKIP_PRECOMMIT=1 git commit -m … # bypass once (the script's own explicit escape hatch, always exit 0)
337
+ ```
338
+
339
+ * **What runs**: `tools/panel-smoke.mjs` (panel rendering + language dictionaries, ~0.3 s) and
340
+ `tools/switch-wiring-test.mjs` (every switch must really change the output + retired switches with 0 dangling
341
+ references + 7 discrimination mutations, ~2.5 s), **serially**, **2.8–3.9 s** total across repeated runs.
342
+ * **What does not run**: the 12 steps of `tools/check.sh` (step 3 is the 1115-combination CSS matrix, step 9
343
+ onwards needs headless Firefox) stay **out** of pre-commit — a minute-long gate in pre-commit only makes people
344
+ afraid to commit. The full gate is still `bash tools/check.sh` (or CI).
345
+ * **It does not block development**: installing is explicit (not installing changes nothing); if the staged
346
+ changes touch no artifact path (no `lib/`, `tools/`, `package.json`) it **skips**; a missing `node` or script
347
+ prints one line and exits 0; on failure it prints the red lines verbatim and tells you how to bypass — it never
348
+ edits files and never auto-fixes.
349
+ * **Details, every measured output and the unverified items**: [`docs/PRE-COMMIT.md`](docs/PRE-COMMIT.md).
350
+
351
+ ## Video-wallpaper transcoding: a **misjudgement** + resource caps (2026-09-17, item 1)
352
+
353
+ > User report: "I'm not using transcoding, my wallpaper is a **video-class mpkg**, the
354
+ > **decode FPS cap is unlimited** and **resolution is the original** — I changed nothing.
355
+ > Is this a bug?" Measured: an `ffmpeg -threads 1 -filter_threads 1 … -i
356
+ > ~/.dsh-mpkg-wallpaper/transcodes/src_1789….bin` process **resident, RSS ≈ 690MB** —
357
+ > the plugin was transcoding the wallpaper the user was **currently playing**.
358
+
359
+ **Verdict: the transcode was a misjudgement (bug).** Evidence: that `src_*.bin` is
360
+ `h264 High L5.2 + aac / MP4` (read with `ffprobe` — any modern browser plays it directly),
361
+ while `settings.json` has `fpsCap=0 / resMax=0` (the user enabled nothing) ⇒ the trigger was
362
+ the client's automatic fallback on `video.error` (`code 3/4`) to `/transcode?fps=24`.
363
+ `code 3/4` only means "this frame failed to decode", **not** "the browser cannot decode this codec".
364
+
365
+ **Fix**: a new **playability gate** (`/probe`, metadata-only, never spawns ffmpeg; verdict =
366
+ codec/container allow-list plus deterministic gaps such as h264+opus-in-MP4 and HEVC Main10;
367
+ "cannot tell" leaves behaviour unchanged) — directly-playable sources are served as-is and only
368
+ genuinely unsupported ones get transcoded. Three real bugs fixed along the way: the old
369
+ `direct-spec` read-through **ignored the codec** (HEVC was served directly → black screen), the
370
+ byte-cap eviction **started from the newest entry** (deleting the artifact it had just produced →
371
+ permanent cache miss), and **cancellation kept retrying other encoders** (a new ffmpeg spawned
372
+ while the user was switching wallpapers). Caps now live in one place: 12 artifacts / **512MB**,
373
+ concurrency **1**, 30s queue, 15min per job, **transcodes default to 1920 wide** (measured 4K
374
+ 656MB → 1080p 275MB), **1GB available-memory admission** (refuse rather than swap the machine to
375
+ death), plus a startup prune with a log line. The three states (direct / transcoding / cached)
376
+ are logged and exposed as `window.__mpwWallpaperState` — no more silent 690MB.
377
+
378
+ > Details, verdict table, memory measurements and "measured-and-rejected options":
379
+ > [`docs/TRANSCODE-RESOURCE.md`](docs/TRANSCODE-RESOURCE.md); fallback switch
380
+ > `?mpwtranscode=legacy` (old behaviour) / `aggressive` (probe even user-set caps);
381
+ > regression: `node tools/transcode-limit-test.mjs` (43 assertions, wired into `tools/check.sh`).
382
+ > Debug switches (URL parameters, effective after a refresh, nothing written to settings):
383
+ > `?hdrfrost=legacy|off`, `?hdrblur=pseudo|element`, `?railink=off`, `?sbfill=wide`.
384
+ > After updating, refresh once and hit the diagnostic/report button — the `headerFrost` section of `diag-*.json`
385
+ > shows exactly which link of the chain is broken (`hostHasHeader` / `injected` / `px` / `computed.headerBg` /
386
+ > `computed.frostElBackdrop` / `reason`).
387
+
388
+ ## Folder / file picker: behaviour contract & shortcuts (2026-09-17, item 13)
389
+
390
+ > User report: "inside the choose-folder feature, when I scroll the mouse up and down the view sometimes
391
+ > jumps back to the very top, and sometimes it locks at the top — you have never fixed this bug."
392
+
393
+ **Root cause (evidence-based; full write-up in [`docs/DIR-PICKER-SCROLL.md`](docs/DIR-PICKER-SCROLL.md))**:
394
+ the old "restore the scroll position afterwards" code was **dead code** — `dirScrollRef.current` was only ever
395
+ assigned `{anchorIdx, anchorOff}` and **never `ratio`**, so `if (ratio === void 0 || ratio === null) return;`
396
+ always returned early and neither the anchor compensation nor the ratio restore ever ran. On top of that the
397
+ scroller had no `overscroll-behavior: contain` (wheel chaining scrolled the host settings panel), and nothing
398
+ restored the user's `scrollTop` after React recreated the list node (a fresh node starts at `scrollTop = 0`).
399
+ Together these produce the reported "jump to / lock at the top".
400
+
401
+ **Fix (no new third-party dependency)**: the selector now **owns its scroll position** — the container remembers
402
+ the user's `scrollTop` per path (the scroll handler only writes a ref, never state) and writes it back
403
+ **synchronously before paint** in a `useLayoutEffect` (idempotent, so it can never fight the user's own
404
+ scrolling; all timing-window hacks removed). Scroll containers carry `overscroll-behavior: contain` +
405
+ `overflow-anchor: none`, row keys are `full path + directory name` (incremental updates instead of a full
406
+ rebuild), the dialog element has a stable key, and **there is no `focus()`/`autoFocus` anywhere**.
407
+
408
+ | Contract | Meaning |
409
+ |---|---|
410
+ | **Scroll position preserved** | Across refresh / filtering / shorter lists / 500-item directories / host re-renders / **the container node being recreated**, the position stays put (no single frame at the top) |
411
+ | **Never steals focus** | Opening the dialog focuses **the list container itself** with `focus({preventScroll:true})` (so it can never be scrolled into view); every row is `tabindex="-1"` with `mousedown` default prevented, so **no row ever becomes `document.activeElement`** (the active row only changes highlight + `aria-activedescendant`); re-renders never move focus |
412
+ | **Per-directory memory** | Directory A at 60 and directory B at 20 keep their own positions |
413
+ | **No wheel chaining** | Reaching the list boundary never scrolls the settings panel behind it |
414
+ | **Row-level incremental updates** | A refresh only adds/removes the diff (row key = full path; the test asserts row node uids stay the same) — never a full rebuild |
415
+ | **A missing anchor never means 0** | A remembered offset outside the new range is **clamped** (never reset to 0); a missing/invalid value (`null`/`""`/`NaN`) counts as "no anchor" and claims the current position |
416
+
417
+ **Shortcuts** (a visible hint is shown inside the dialog). Click the list (or `Tab` into it) first:
418
+
419
+ | Key | Action |
420
+ |---|---|
421
+ | `↑` / `↓` | Move the active row (no focus stealing; minimal `block:"nearest"` scroll only on key press) |
422
+ | `Home` / `End` | First / last directory |
423
+ | `Enter` | Open the active directory; **with no row selected it means "Choose this folder"** |
424
+ | `Backspace` / `Alt`+`↑` | Parent directory |
425
+ | `Esc` | Close the dialog |
426
+
427
+ **Regression gate**: `node tools/dir-picker-test.mjs` (**57 assertions**; group A is source-level and turns
428
+ **9/10 red** against the old `git show HEAD:lib/client.js`, proving the assertions have discriminating power;
429
+ group B runs a slice of the production implementation against a fake DOM + mini React).
430
+ The behaviour contract is aligned item-by-item with the test bench (8901/8902):
431
+ `docs/DIR-PICKER-SCROLL.md` §5 ↔ `vendor-ref/ww-pages/PATCH-NOTES.md` §10.5.
432
+
166
433
  ## Installation
167
434
 
168
435
  Published on npm (`dsh-mpkg-wallpaper`). Pick one:
@@ -184,7 +451,7 @@ pnpm --dir $DSH_HOME/profiles/<profile> add dsh-mpkg-wallpaper
184
451
  ### Option 3: Git clone (developers / offline)
185
452
 
186
453
  ```bash
187
- git clone https://github.com/XHR666/dsh-mpkg-wallpaper.git $DSH_HOME/profiles/node_modules/dsh-mpkg-wallpaper
454
+ git clone https://github.com/XHR666/dsh-mpkg-wallpaper.git $DSH_HOME/profiles/<profile>/node_modules/dsh-mpkg-wallpaper
188
455
  # then register in the profile's cordis.patch.yml:
189
456
  # - insert:
190
457
  # - id: dsh-mpkg-wallpaper
@@ -196,24 +463,91 @@ git clone https://github.com/XHR666/dsh-mpkg-wallpaper.git $DSH_HOME/profiles/no
196
463
 
197
464
  Uninstall: `dsh plugin --profile web remove dsh-mpkg-wallpaper`.
198
465
 
466
+ ### Option 4: single-file bundle (offline / drop-in; **host half only**)
467
+
468
+ If you would rather not have DSH resolve a package (no npm/pnpm network), inline the host half into one
469
+ self-contained ESM file and register that file:
470
+
471
+ ```bash
472
+ cd /path/to/dsh-mpkg-wallpaper
473
+ node tools/build-bundle.mjs # output: dist/dsh-mpkg-wallpaper.bundle.mjs (~342KB, sha256 printed)
474
+ node tools/build-bundle.mjs --check # parity vs. source: exports / 39 routes / ping JSON shape (20 assertions)
475
+ node tools/bundle-equivalence-test.mjs # full equivalence gate: same route assertions on source and bundle (38)
476
+ ```
477
+
478
+ Copy `dist/dsh-mpkg-wallpaper.bundle.mjs` anywhere (e.g. `~/.dsh/plugins/`), register it by **absolute path**
479
+ in the profile's `cordis.patch.yml`, then restart `dsh web`:
480
+
481
+ ```yaml
482
+ # $DSH_HOME/profiles/<profile>/cordis.patch.yml
483
+ - insert:
484
+ - id: dsh-mpkg-wallpaper
485
+ name: /absolute/path/dsh-mpkg-wallpaper.bundle.mjs # ← the .mjs file itself
486
+ ```
487
+
488
+ **What this mode does and does not load** (code facts, not guesses):
489
+
490
+ | Item | Option 4 behaviour | Evidence |
491
+ | --- | --- | --- |
492
+ | Host half (upload/streaming + Range, scene extraction, audio listing, settings persistence, diagnostics — 39 routes) | **Complete** (`lib/index.js` + `pkg-extract.js` + `web-wallpaper.js` all inlined; external deps are node builtins only) | `node tools/build-bundle.mjs --check`: route table equal, 39/39 |
493
+ | `GET /api/mpkg-wallpaper/ping` | Same key set as source (`ok`, `version`, `betterSidebar`, `betterSidebarVersion`) | same `--check` run, section ③ |
494
+ | **Client half (settings panel / wallpaper layer / frost)** | **Not loaded.** The single file only exports the host surface (`apply`/`inject`/`__mpwTest`) | DSH discovers client halves **per package**: it scans host Loader entries for packages declaring `dsh.client` and resolves `exports["./client"]` (`@deepseek-ai/dsh-client-modules/lib/index.js:66-70,153-165,650-658`). A bare `.mjs` has no package.json ⇒ no `dsh.client` declaration |
495
+ | `GET /api/mpkg-wallpaper/lg/*` (legacy WebGL hosting route, no client caller) | **404** unless a `liquid-glass/` directory sits next to the bundle; `cp -r lib/liquid-glass <bundle dir>/` makes it byte-identical to source | that route locates `liquid-glass/` via `import.meta.url` (`lib/index.js:3304`); asserted in both layouts by `--check` |
496
+ | `ping.version` | `null` when the bundle's **parent** directory has no `package.json` (version display only) | same `new URL('../package.json', import.meta.url)` (`lib/index.js:1622`); equals source when a companion `package.json` is present |
497
+ | "Check for updates / one-click update" | `update-check` returns 500 without a companion `package.json`; `update-apply` writes files **next to/above the bundle** ⇒ **do not use one-click update in Option 4** | `lib/index.js:1801/1812/1847-1849` |
498
+ | Uninstall | delete the `.mjs` and its line in `cordis.patch.yml` | — |
499
+
500
+ > Bottom line: **Option 4 is a host-side-only, degraded install** (great for offline/emergency use or for reusing
501
+ > the routes from another host). Use Options 1–3 for the full UI. The artifact is **not committed**
502
+ > (`dist/` is gitignored: it is a pure derivative of `lib/*.js` and two builds are byte-identical, asserted in
503
+ > `tools/bundle-equivalence-test.mjs` §②; generate it at release time).
504
+
505
+ ## Degraded behaviour without a Wallpaper Engine install (missing WE / non-Windows)
506
+
507
+ "WE installed" means the Steam build of Wallpaper Engine (appid **431960**). The host locates it with
508
+ `locateWallpaperEngine()` (`lib/index.js:303-327`): Windows registry `HKCU\Software\Valve\Steam\SteamPath` →
509
+ common Steam dirs (`C:\Program Files (x86)\Steam`, `D:\Steam`, …) → non-Windows Steam dirs
510
+ (macOS `~/Library/Application Support/Steam`, Linux/Android `~/.local/share/Steam`, WSL `/mnt/c/...`) →
511
+ any library listed in `steamapps/libraryfolders.vdf` containing 431960 → and it only accepts a library where
512
+ `<lib>/steamapps/common/wallpaper_engine/wallpaper32.exe` exists. **If nothing matches it returns `null`**, and
513
+ everything downstream follows the degraded paths below (this Linux box takes exactly that path):
514
+
515
+ | Situation | Actual behaviour (with code location) |
516
+ | --- | --- |
517
+ | WE not installed (or `wallpaper32.exe` not found) | `GET /api/mpkg-wallpaper/steam-inventory` returns **200 `{ok:true, installDir:null, wallpapers:[]}`** — not an error, no 500 (`lib/index.js:2967-2968`) |
518
+ | User clicks "scan local wallpaper library" | List stays empty plus an error line **"Wallpaper Engine install not found (requires Windows + Steam Wallpaper Engine)"** (`lib/client.js:8781` tests `!d.installDir`, string at `lib/client.js:11555`); an empty list also shows "No usable wallpapers found (or not a Windows environment)" (`lib/client.js:10419/11553`). **The scan itself does not fail** — it just returns nothing |
519
+ | WE installed but `projects/myprojects`, `projects/defaultprojects` and `steamapps/workshop/content/431960` are all absent | each root is checked separately (`scan()` starts with `if (!existsSync(root)) return`, `lib/index.js:2976-2977`) ⇒ empty list, and the "install not found" message is **not** shown (because `installDir` is non-null); the UI only shows "No usable wallpapers found (or not a Windows environment)" / an empty rotation group. **Not implemented**: there is no *dedicated* message for "WE installed but its asset dirs are missing" (the existing copy lumps it together with "not Windows"); to diagnose, inspect `installDir`/`wallpapers` from `steam-inventory` |
520
+ | Non-Windows / mobile | same as "WE not installed": `installDir=null` (the registry branch returns null when `process.platform !== 'win32'`, `lib/index.js:282-284`); all Steam probe paths are plain strings, so `existsSync` is simply false — no side effects |
521
+ | WE native playlists (`config.json` → `general.playlists`) | only when `installDir` exists and `config.json` parses; otherwise `playlists` is absent ⇒ after its first scan the client writes `rotSeeded:true` and **stops re-seeding** the rotation list (`lib/client.js:8782-8793`), so user-defined rotations are not overwritten on every scan |
522
+ | **Still works without WE** (the degradation is not "everything breaks") | (1) manual folder picking: `/list-dirs` + `/custom-dir` browse any drive/folder and use it as the wallpaper source; (2) importing `.mpkg` directly (hybrid mode streams from the host, no 600MB cap); (3) URL / local-file web and video wallpapers; (4) scene extraction, audio listing, settings persistence and diagnostics do not depend on WE at all |
523
+ | Host half entirely unavailable (Option 4 missing / port closed) | the client's `/ping` probe fails ⇒ falls back to **browser-only mode**: status line "Host unavailable (fell back to browser-only mode, 600MB cap)" (`lib/client.js:10203/11603`) and the library button reports "Host unavailable — cannot scan the local library" (`lib/client.js:8774/11554`); assets above 600MB cannot be handled in browser-only mode (see Limitations) |
524
+ | `ffmpeg` missing (video transcoding; unrelated to WE) | `GET /api/mpkg-wallpaper/ffmpeg-check` returns **200 `{ok:true, found:false, source:null, path:null, version:null}`** (`lib/index.js:3089-3090`); the client shows "not installed" and only starts the download chain when the user clicks; directly playable videos are never transcoded |
525
+
526
+ > In one line: **no WE install = you lose the "auto-discover the local library" convenience channel**; the plugin
527
+ > still works. Every degraded path returns an empty list with explicit copy and keeps the manual folder/upload
528
+ > channels — nothing fails silently and nothing returns 500.
529
+
199
530
  ## Limitations
200
531
 
201
532
  - **Scene wallpapers cannot be fully dynamic on the web** (see [Scene wallpaper adaptation](#scene-wallpaper-adaptation)); mpkg adjustable options are read-only (apply changes in the WE app)
202
- - **Web wallpapers are experimental**: heavy animation / external dependencies may freeze or fail (preflight tags + refresh recovery); **web wallpapers with built-in options are partly wired in — wallpapers with interactive features are not adapted yet**
533
+ - **Web wallpapers are experimental**: heavy animation / external dependencies may freeze or fail (preflight tags + refresh recovery).
534
+ In sandbox mode (default) wallpaper scripts **cannot reach the DSH UI or local storage**, but the Live2D-style
535
+ "web wallpaper options" need compatibility mode; the audio-spectrum channel is wired but the plugin has no spectrum
536
+ source yet; **wallpapers with interactive features are not adapted yet** (the wallpaper layer receives no pointer events)
203
537
  - **Huge assets** (pure-browser mode): standalone video >600MB, video textures >250MB, images >200MB unsupported; **hybrid mode** has no such limit
204
538
  - Scene static-frame / layer-composite **first extraction takes a few seconds** (longer for 8K textures); afterwards served from cache
205
539
 
206
540
  <!-- ## Screenshots
207
541
 
208
- <!-- ![Sidebar collapsed · new session](screenshots/dhsw1.jpg) -->
542
+ <!-- screenshot reference removed -->
209
543
 
210
544
  <!-- *The dynamic wallpaper fills the whole UI. Sidebar collapsed, chat box centered with frosted blur; the sidebar is fully transparent so the wallpaper shows through cleanly.* -->
211
545
 
212
- <!-- ![Sidebar expanded](screenshots/dshw2.jpg) -->
546
+ <!-- screenshot reference removed -->
213
547
 
214
548
  <!-- *After adjusting panel opacity and unified blur: most UI areas are opacity-adjustable, the sidebar is semi-transparent with the wallpaper faintly visible behind.* -->
215
549
 
216
- <!-- ![Settings page](screenshots/dshw3.jpg) -->
550
+ <!-- screenshot reference removed -->
217
551
 
218
552
  <!-- *The wallpaper settings page. Beyond the screenshot, nearly everything is adjustable: unified blur, UI blur (dialogs/panels/popups/popovers/mask/sidebar frost), lens zoom & pan, wallpaper flip, theme color, sidebar/title-bar visibility, sharpen, and scene layer compositing with time-frame switching.* -->
219
553
 
@@ -229,10 +563,11 @@ Please include:
229
563
 
230
564
  ## Security
231
565
 
232
- - **No passive outbound network requests by default**: the plugin never **actively** contacts external networks; everyday wallpaper playback only talks to the **local DSH host** (127.0.0.1). The only exceptions are **user-initiated** actions: Check update / Apply update accesses GitHub (`raw.githubusercontent.com`, `api.github.com`); downloading ffmpeg accesses GitHub Releases / the npm binary mirror (`registry.npmmirror.com`) — both fire only after the user clicks, never automatically. User-entered network image URLs and resources loaded by web wallpapers themselves are also external access.
566
+ - **No passive outbound network requests by default**: the plugin never **actively** contacts external networks; everyday wallpaper playback only talks to the **local DSH host** (127.0.0.1). The only exceptions are **user-initiated** actions: Check update / Apply update accesses GitHub (`raw.githubusercontent.com`, `api.github.com`); downloading ffmpeg accesses GitHub Releases / the npm binary mirror (`registry.npmmirror.com`) — "Apply update"/"download ffmpeg" fire only after the user clicks; **opening the settings panel silently checks the version once after 0.8s** (it only lights up the "update available" badge — no popup, no download, no local data uploaded). User-entered network image URLs and resources loaded by web wallpapers themselves are also external access.
233
567
  - **No secrets**: no paths, keys, tokens or personal info in the source
234
568
  - **Open-source deps only**: DSH's own react + official slots/locale APIs; the scene.pkg extractor is adopted from [elysia395/dsh-wallpaper-engine](https://github.com/elysia395/dsh-wallpaper-engine) (MIT, credited in the file header)
235
- - References: [dsh-bg-image](https://github.com/lyh9712/dsh-bg-image) (MIT, template), [unmpkg](https://github.com/aqnya/unmpkg) (GPL-3.0, mpkg format reference), [repkg](https://github.com/notscuffed/repkg) (GPL, .tex format research)
569
+ - **Web wallpaper sandbox**: in the default mode the wallpaper iframe is an **opaque origin** (`sandbox="allow-scripts"`, no `allow-same-origin`), so author scripts **cannot read the host DOM / `localStorage` / cookies** and cannot strip their own sandbox. Frame and parent only talk via `postMessage` (op whitelist + parent-window source check). Cross-origin reads of wallpaper assets are allowed **only for `Origin: null`** (the sandboxed frame); ordinary websites get no CORS header. Author script errors are caught in-frame and rate-limited (`console.warn` + `/diag`) without affecting the plugin.
570
+ - References: [dsh-bg-image](https://github.com/lyh9712/dsh-bg-image) (MIT, template), [unmpkg](https://github.com/aqnya/unmpkg) (GPL-3.0, mpkg format reference), [repkg](https://github.com/notscuffed/repkg) (**MIT**, .tex format research — this line previously said GPL by mistake; corrected 2026-09-17 from the upstream `LICENSE` text plus the project owner's confirmation, see `../docs/COPYING-RULES.md` §6/§9.10)
236
571
  - Data boundary: all parsing happens locally; localStorage only stores the background and settings; settings are additionally stored at `~/.dsh-mpkg-wallpaper/settings.json`
237
572
 
238
573
  ## File Structure
@@ -244,21 +579,40 @@ dsh-mpkg-wallpaper/
244
579
  ├── LICENSE # MIT license
245
580
  ├── lib/
246
581
  │ ├── index.js # host: upload/streaming + Steam discovery + custom folders + scene routes + settings persistence
582
+ │ ├── web-wallpaper.js # web wallpapers: content-first type detection + WE API shim source + entry-HTML injection + CORS policy (MIT, self-written)
247
583
  │ ├── client.js # browser: mpkg parsing + settings page + bg DOM + blur suite + library + time-variation/web options + playback control/power saving
248
584
  │ ├── pkg-extract.js# scene.pkg static-frame/layer extraction (PKG+LZ4+TEX, MIT, from elysia395)
249
- │ ├── liquid-glass/ # WebGL liquid-glass library (**leftover, no runtime ref**; CSS version since v3.6.0)
250
- │ └── liquid-glass-bundle.js # liquid-glass bundle (107KB, **unused dead file**, ships by redundancy)
251
- ├── tools/ # mpkg/tex/mdl reverse-engineering + lg build/inline scripts + liquid-demo page (for developers)
252
- ├── screenshots/ # effect screenshots
585
+ │ ├── liquid-glass/ # WebGL liquid-glass library (**leftover, not shipped**; no runtime ref; CSS version since v3.6.0)
586
+ │ └── liquid-glass-bundle.js # liquid-glass bundle (107KB, **not shipped**; not referenced by the client; byte-rebuildable from the kept sources, sha256 db50361c…)
587
+ ├── tools/ # gates/tests/benches + lg build/inline scripts + liquid-demo page (for developers)
588
+ │ # audio scan: audio-scan-bench.mjs (timing table) / audio-scan-test.mjs (spec assertions · no full inflate · cache)
589
+ │ # scene-audio-route-test.mjs (/raw Range + probe route + security)
590
+ │ # web wallpapers: web-wallpaper-test.mjs (detection / sandbox / injection order / shim API diff / error boundary / no GPL)
591
+ │ # single-file install: build-bundle.mjs (inlines lib/index.js + relative deps into one ESM; `--check` for source parity)
592
+ │ # bundle-equivalence-test.mjs (gate step 11: same route assertions on source and bundle + mutation controls)
593
+ │ # style scope: style-scope-guard.mjs (gate step 12: every injected CSS rule must hit .mpw*/[data-mpw*];
594
+ │ # host/third-party scopes must be registered in the allow-list with a reason + docs pointer)
595
+ │ # pre-commit: pre-commit.sh (a few seconds: panel-smoke + switch-wiring, with path filtering and
596
+ │ # bypass hatches; paired with ../.githooks/pre-commit — see README / docs/PRE-COMMIT.md)
597
+ │ # note: the research-era Python tools (unmpkg/tex2png/mdl_explorer/xref) were
598
+ │ # **deleted (GPL lineage unresolved, 2026-09-16)** — see `../docs/COPYING-RULES.md` §6
599
+ ├── dist/ # build output (**not committed**, gitignored): dsh-mpkg-wallpaper.bundle.mjs (Option 4, generated on demand)
600
+ ├── docs/ # developer notes (not shipped): WEB-WALLPAPER.md (web-wallpaper spec / sandbox / API table / limits), RELEASE.md (release preconditions + commands) etc.
601
+ ├── screenshots/ # (moved out of the repo)
253
602
  ├── README.md # Chinese
254
- └── README.en.md # English
603
+ ├── README.en.md # English
604
+ └── THIRD-PARTY.md # third-party provenance / clean-room record (shipped, MIT-side attribution)
255
605
  ```
256
- > Note: `lib/liquid-glass/` and `lib/liquid-glass-bundle.js` still ship in the npm package because of `files: ["lib"]`, but the **client no longer references them** (WebGL was removed in v3.6.0 in favor of the CSS version).
606
+ > Note: `lib/liquid-glass/` and `lib/liquid-glass-bundle.js` **have been moved off the publish surface** (negative `files` patterns `!lib/liquid-glass/**` + `!lib/liquid-glass-bundle.js`, P-127 ⇒ **not in the npm package**; measured 22 files/1 626 235 B → 12 files/1 389 781 B), yet **all of them stay in the repo**, and the **client no longer references them** (WebGL was removed in v3.6.0 in favor of the CSS version); the host still keeps the `/api/mpkg-wallpaper/lg` route (live path, no callers; in-repo development/demos unaffected, an npm-installed copy returns 404 by design). Local backups such as `lib/client.js.bak-*` are likewise excluded by negative `files` patterns (`!lib/**/*.bak*`) and are **not shipped**; both are guarded by **two-way** machine assertions in section ⑨ of `tools/integrity-check.mjs` ("not on the publish surface" **and** "none of them may disappear from the repo").
607
+ > Why `dist/` is **not committed**: it is a pure derivative of `lib/*.js` (an inlined artifact) that duplicates tracked sources byte for byte, and two builds are byte-identical (machine-asserted in `tools/bundle-equivalence-test.mjs` §②); committing it would only create drift ("edited lib, forgot to rebuild") . By contrast `lib/liquid-glass-bundle.js` **is** committed because it *used to be* a **runtime input** (base64 written into `lib/client.js` by `tools/inline-lg-bundle.mjs`) — ⚠ **measured 2026-09-18: that link is dead**: neither `LG_BUNDLE_B64` nor `LG_BUNDLE_SRC` exists in `lib/client.js` (the only `B64` hit is a comment), and both inline tools now fail with "missing placeholder". It is therefore currently **neither referenced by the client nor a runtime input**; it **stays in the repo** (off the npm package since P-127) and can be rebuilt **byte-identically** from the kept sources. `dist/` is also outside the `files` whitelist, so the npm package never carries it.
257
608
 
258
609
  ## Acknowledgements
259
610
 
611
+ > **Naming note (2026-09-18):** the renderer product this plugin integrates with is now called **WEwebLoader**; the upstream project is still **WebWallGL** (`oneincase/webwallgl`, MIT) — its attribution and licence are unchanged.
612
+
260
613
  - [Bil812](https://github.com/Bil812) — proposed wallpaper tint, adaptive text and unified full-screen mask in [PR #2](https://github.com/XHR666/dsh-mpkg-wallpaper/pull/2) and maintains a fork; those ideas were absorbed as the "Aqua" experiment mode (toggles, off by default)
261
614
  - [elysia395/dsh-wallpaper-engine](https://github.com/elysia395/dsh-wallpaper-engine) — the scene.pkg static-frame extractor (MIT); `lib/pkg-extract.js` is adopted from this project; its "settings persist to host file" and "Edge canvas-compat rendering" ideas are also borrowed
615
+ - [oneincase/webwallgl](https://github.com/oneincase/webwallgl) — sandboxed-iframe + WE API shim approach for web wallpapers (MIT): the **API list and semantics** of `lib/web-wallpaper.js` were studied against that project (**no code copied**; diff list in `docs/WEB-WALLPAPER.md` §10, ledger in `../docs/COPYING-RULES.md` §4)
262
616
  - [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) community — listing & promotion
263
617
 
264
618
  ## Rendering Feasibility Research
@@ -266,3 +620,5 @@ dsh-mpkg-wallpaper/
266
620
  - Full scenes (incl. Live2D puppets) can only be rendered by the proprietary engine: the WE app's native library (embedded Chromium + proprietary puppet renderer); the open-source [we-layerd](https://github.com/Aromatic05/we-layerd) (Rust) bundles the official renderer but is **Linux Wayland only**
267
621
  - There is no mature WE scene renderer for browsers (pixeltris/wallpaper-engine-web is gone) — **independent of OS, no browser can render Live2D scenes directly**; the official renderer .so is closed-source, so it cannot be compiled to WASM
268
622
  - This plugin's path: **static-frame extraction + layer compositing + (time-variation) mpkg-way slot switching** (see [Scene wallpaper adaptation](#scene-wallpaper-adaptation)); for full dynamics use "render externally to video → video wallpaper"
623
+
624
+ > Screenshots were moved out of the repository (they contain personal UI content): `../Delete/plugin-screenshots/`. Re-add sanitized copies under `docs/media/` if you want them shown.