dsh-custom-theme 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sparrived
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,479 @@
1
+ # dsh-custom-theme
2
+
3
+ User-editable CSS themes for the DeepSeek Harness Web GUI and Desktop app.
4
+
5
+ This ports the one UI-customization capability DSH does not have: a theme
6
+ **directory** a user can edit or extend. DSH ships `light`, `dark` and `system`,
7
+ plus an in-process `ctx.theme.register()` seam for code-authored token
8
+ overrides, but it has no way for a user to supply CSS.
9
+
10
+ ## What it does
11
+
12
+ | Half | File | Owns |
13
+ | --- | --- | --- |
14
+ | Host | `src/index.mjs` | `$DSH_HOME/themes/` and `$DSH_HOME/backgrounds/` — seeds `gov`, `monokai-pro` and `one-dark`, re-syncs them when the seed generation advances, serves both directories over `/dsh-custom-theme/` |
15
+ | Browser | `lib/client.js` | Its own settings page (**主题与背景**), registered into `settings.section` with theme, colour-scheme, conversation-stream, working-text and background controls. A theme's tokens go to the official theme runtime as an override layer and its base palette to `ctx.theme.setTheme`; the `<style>` element carries only its non-token rules. Backgrounds are written as inline `!important` properties on the painted surface plus one injected rule per picture layer, since a `::before` layer cannot be styled inline |
16
+
17
+ Served routes:
18
+
19
+ | Route | Response |
20
+ | --- | --- |
21
+ | `GET /dsh-custom-theme/themes` | `{ themes: [{ id, bundled }], dir }` |
22
+ | `GET /dsh-custom-theme/theme/<id>.css` | The stylesheet text |
23
+ | `GET /dsh-custom-theme/backgrounds` | `{ backgrounds: [{ name, url }], dir }` |
24
+ | `GET /dsh-custom-theme/background/<name>` | The image bytes |
25
+
26
+ Adding a theme is dropping a `.css` file into the theme directory and pressing
27
+ **Rescan**; the file only needs to override `--dsw-alias-*` custom properties.
28
+ Seeded ids come first in the picker, extra ids after them in alphabet order.
29
+
30
+ The three seeded files are managed: they are written when the directory is first
31
+ created and rewritten when the bundled palettes change, so an edit inside
32
+ `gov.css` will not survive that. Put a customised palette in a copy under its own
33
+ name — any id other than the three is never touched.
34
+
35
+ ```css
36
+ /* $DSH_HOME/themes/my-theme.css */
37
+ :root {
38
+ --dsw-alias-bg-base: #1b1d23;
39
+ --dsw-alias-brand-primary: #7aa2f7;
40
+ --dsw-alias-label-primary: #c0caf5;
41
+ }
42
+ ```
43
+
44
+ A theme states its **palette**, not its `!important` fights: the plugin reads the
45
+ token declarations out of the stylesheet and hands them to the official theme
46
+ runtime as a token override layer, so the presenter applies them the same way it
47
+ applies its own palette. `!important` on a token declaration is therefore
48
+ unnecessary — the plugin de-escalates it on the way in, so a leftover one from an
49
+ older theme is harmless.
50
+
51
+ ### The working text
52
+
53
+ While a turn runs, that label is `chat.deepDiving` (「深度求索中」) from the shell's
54
+ `chat` namespace. `ctx.locale.register` throws for a namespace and locale pair that
55
+ already exist — there is no override layer — and no slot carries the label on its
56
+ own, so replacing the row is the only way to reword it.
57
+
58
+ The replacement is **opt-in**. It registers `conversation.chat.node` / key
59
+ `turn-process` at `priority: -1` (the lowest live entry renders), and only when at
60
+ least one phrase is configured; with an empty list the shipped row is left exactly
61
+ in place. Opting in rather than replacing by default is deliberate:
62
+
63
+ - A plugin cannot render the official component — it is not exported — so a default
64
+ replacement would mean reproducing a shell build this plugin cannot read.
65
+ - The installed shell is not the published source. Reading the live row showed its
66
+ finished label carries an elapsed time and reads 「已完成,用时 …」, and its label
67
+ font size follows the primary content size; the vendored sources say otherwise on
68
+ both counts. A copy would have missed both, silently.
69
+ - The translate seat a replacement row receives does not interpolate parameters: a
70
+ template carrying a placeholder comes back with the slot empty. The replacement
71
+ therefore uses parameter-free keys only, and does not re-attach an elapsed time.
72
+
73
+ Fidelity is guarded by `test/browser/working-row.mjs`, which captures the shipped
74
+ row's computed geometry before a phrase is configured and compares the replacement
75
+ against it property by property.
76
+
77
+ ### The settings page
78
+
79
+ The controls live on a settings page of their own rather than as a card inside
80
+ General. That is a single `settings.section` registration: it creates both the nav
81
+ row and the panel. The row sits at `order: 30`, after the built-in sections, and
82
+ its label is the `nav` key of this plugin's locale namespace, re-read on every
83
+ projection so it follows a locale change. The shell chooses the nav glyph from the
84
+ entry id and falls back to a generic settings gear for an id it does not know, so
85
+ the page cannot supply its own icon.
86
+
87
+ The shell renders one settings section at a time, which would put the official
88
+ appearance row out of sight while this page is open. The page therefore carries
89
+ its own light/dark/system control for the same preference. Both it and the
90
+ official row write through `ctx.theme.setTheme`, and this control follows
91
+ `theme/change`, so the two cannot disagree — and a theme with a light/dark pair
92
+ stays switchable without leaving the page.
93
+
94
+ ### The conversation stream
95
+
96
+ | Control | Route |
97
+ | --- | --- |
98
+ | Text size | `ctx.theme.setFontSize(px)` — the official runtime's own preference, 12–17. The shell persists it, so this plugin writes it and reads it back from `ThemeSnapshot.fontSize`. |
99
+ | Line spacing | Adds px to `--dsh-content-font-delta`, the delta the shell derives from the font size and folds into every content line height. At 0 the shell's own value is left untouched. |
100
+ | Text font / code font | `--dsw-font-family` and `--ds-font-family-code`, picked from a preset list rather than typed: **跟随官方默认** (declare nothing), then the system, Microsoft YaHei, Noto Sans SC and Georgia stacks for text, and Cascadia Mono, JetBrains Mono and Sarasa Mono SC for code. A stack that is not one of them still shows up as its own option, so a value written by an earlier version is never silently reset. |
101
+
102
+ Two details worth keeping:
103
+
104
+ Line spacing rides the shell's delta rather than pinning an absolute
105
+ `line-height`, because every content surface computes its own base
106
+ (`calc(24px + delta)` on the assistant flow, `calc(22px + delta)` on the user
107
+ bubble) — one delta moves them together.
108
+
109
+ The declarations are emitted at raised specificity (`html:root` / `html body`)
110
+ rather than relying on source order. The shell installs its palette styles at
111
+ boot and may do so after this plugin runs, so an equal-specificity `:root` or
112
+ `body` rule would lose depending on who ran last.
113
+
114
+ There is no streaming-fade control. Nothing in the client renders streamed text as
115
+ per-chunk elements and no chunk timestamp reaches CSS, so a fade could only be
116
+ faked as a single mask animation per render — visibly wrong, so it is not offered.
117
+
118
+ ### One palette, or a light/dark pair
119
+
120
+ A theme may carry a single palette, or one set per colour scheme:
121
+
122
+ ```css
123
+ :root { --dsw-alias-bg-base: #f0f7ff; } /* light */
124
+ body[data-ds-dark-theme] { --dsw-alias-bg-base: #0a1520; } /* dark */
125
+ ```
126
+
127
+ Any dark selector the shell could be expected to write counts: `data-ds-dark-theme`
128
+ is what this shell sets on `body`, `[data-theme="dark"]` is the convention
129
+ Deeptop's own theme files use, and `@media (prefers-color-scheme: dark)` is read
130
+ the same way. All three bundled themes carry both sets, ported from the two-set
131
+ files they came from, so 浅色/深色 moves each of them between its own light and
132
+ dark palette.
133
+
134
+ With a pair the theme adapts: the official choice keeps deciding which set
135
+ applies, and the theme stays selected across the switch. A token declared in only
136
+ one of the two sets reaches both, so a pair may override as few or as many tokens
137
+ as it likes.
138
+
139
+ With a single palette the theme states one look, so the plugin also selects the
140
+ base palette that matches it — otherwise every token the theme does *not* override
141
+ keeps the colour of whichever scheme the user last picked, which is what leaves a
142
+ light theme with dark composer and menu surfaces. The scheme is the luma of
143
+ `--dsw-alias-bg-base`, read by handing the value to the browser, so a hex, a named
144
+ colour, `hsl()`, `oklch()`, `color-mix()` or a `var()` naming a shell token all
145
+ work. A directive overrides the reading when a theme wants to be explicit, and a
146
+ base colour the browser accepts but cannot reduce to a luma leaves the appearance
147
+ preference where the user put it rather than guessing:
148
+
149
+ ```css
150
+ /* dsh:color-scheme dark */
151
+ :root {
152
+ --dsw-alias-bg-base: #101418;
153
+ }
154
+ ```
155
+
156
+ Selecting a single-palette theme therefore also moves the official appearance
157
+ preference, and like any other appearance choice that survives a restart. Moving
158
+ that preference to the other scheme afterwards **unloads the theme** instead of
159
+ keeping it: its layer carries one set of values for both modes, so letting the
160
+ base palette flip underneath it would leave a window split across the two — every
161
+ token the theme declares in one scheme's colours and everything it does not in the
162
+ other's. The page's selector falls back to its built-in entry to match the window.
163
+
164
+ ## Background images
165
+
166
+ Drop an image into the background directory (`$DSH_HOME/backgrounds` by default),
167
+ press **Rescan**, and pick it in the **Background image** row. Served extensions
168
+ are `.png .jpg .jpeg .webp .gif .avif .bmp`, up to 16 MiB each.
169
+
170
+ Each of six zones holds its own image, picture opacity, blur, fit and position:
171
+
172
+ | Zone | Painted on |
173
+ | --- | --- |
174
+ | Whole app | The frame, and every zone below it |
175
+ | Title bar | The shell's header |
176
+ | Sidebar | The sidebar column |
177
+ | Conversation | The main column |
178
+ | Composer | The composer seat |
179
+ | Tool panel | The tool-panel column, while the dock is open |
180
+
181
+ The zones are the shell's own layout boxes, found by the stable part of their
182
+ CSS-module class names (`_frame`, `_sidebarCol`, `_centerCol`) and by `data-*`
183
+ hooks where the shell provides them (`[data-composer-seat]`, `[data-rightbar-col]`).
184
+ The picture is
185
+ **not** painted on that box directly: the shell paints the visible surface from a
186
+ component root nested a few levels below it, often under a zero-size wrapper, so
187
+ the plugin descends to the deepest opaque element covering the box and paints
188
+ that. Each painted surface is tagged `data-dct-zone`, which makes the target
189
+ visible in the inspector and gives the browser test something to assert on; the
190
+ picture layer itself is addressed by a generated `data-dct-layer`, because two
191
+ zones can resolve to the same surface and an element holds one value per attribute.
192
+
193
+ Three of those anchors — the header, the composer seat and the tool-panel column —
194
+ have a transparent background of their own and take their colour from an ancestor,
195
+ so the panel fill is built from the nearest opaque ancestor instead. Without that the
196
+ picture would show at full strength whatever the fill is set to.
197
+
198
+ **Image opacity** is the picture's own alpha, and it is a channel of its own: the
199
+ picture is painted on a separate layer at that alpha, so it no longer shares a
200
+ gradient with the panel fill. It is bounded to `0.05`–`0.45` with a default of
201
+ `0.18`, the same range and default Deeptop's model uses, so a picture can never
202
+ obscure the shell's own surfaces. Because the panel fill is read from the live
203
+ surface, the composite follows the active theme. A fully covered frame is
204
+ deliberately left unpainted — otherwise the same image would show twice through the
205
+ column fills and read stronger than configured.
206
+
207
+ **Blur** softens the picture alone, `0`–`16` px with a default of `0`, the range and
208
+ default Deeptop's model uses. It is applied to the picture layer, never to an
209
+ element that holds text, so the shell's content stays sharp at every setting. At `0`
210
+ the declaration is dropped entirely rather than written as `blur(0px)`.
211
+
212
+ **Panel fill** follows Deeptop's per-zone defaults: the whole-app frame stays fully
213
+ opaque, while the title bar (94%), sidebar (92%), conversation (91%), composer (91%)
214
+ and tool panel (92%) let the app backdrop show faintly through. The value is bounded
215
+ to 0–100 and only written below 100, so a zone at 100 keeps whatever alpha the
216
+ shell's own colour already had. It applies whether or not that zone has a picture,
217
+ and it is always read from the zone's own entry — the global entry supplies only the
218
+ picture, its alpha and its blur. The fill is built from the nearest **opaque**
219
+ ancestor colour, because a translucent one is either the shell's own panel fill or an
220
+ override this plugin wrote on an earlier pass, and neither is a stable basis.
221
+
222
+ ### How the picture is stacked
223
+
224
+ The picture is **not** an element's `background-image`. It is a `::before` layer on
225
+ the painted surface, so the stack from the bottom reads: the shell's surface colour →
226
+ the panel fill → the picture at its own alpha, blurred if asked → the shell's own
227
+ content. Only a separate box can carry the picture's alpha and blur without fading or
228
+ smearing the text that shares the surface element, which is what makes the two
229
+ controls above possible at all.
230
+
231
+ Three declarations arrange that, all written by the plugin rather than assumed of the
232
+ shell:
233
+
234
+ - `::before { position: absolute; inset: 0; z-index: -1 }` sizes the layer to the
235
+ surface and puts it under the shell's content. A layer at `z-index: 0` or `auto`
236
+ would be a positioned element drawn **over** the shell's normal-flow content and
237
+ would cover the text.
238
+ - `isolation: isolate` on the surface creates the stacking context that keeps it
239
+ there. Without it the layer's `z-index: -1` escapes to the nearest ancestor
240
+ stacking context and can be hidden behind a background painted at that level. It
241
+ creates a stacking context without setting a `z-index` and without affecting
242
+ layout, so the shell's own layering is left alone.
243
+ - `position: relative`, written **only when the surface is `static`**, gives the
244
+ absolutely positioned layer something to be laid out against. See the risk note
245
+ below.
246
+
247
+ The plugin writes only inline `!important` declarations and injected layer rules, and
248
+ removes exactly the properties, attributes and rules it added, so switching zones or
249
+ clearing a zone restores the shell's own styling — including the layer stylesheet,
250
+ which is emptied on every pass.
251
+
252
+ ## Install
253
+
254
+ This package is a DSH **bundle**: it declares `dsh.bundle.patch`, so installing it
255
+ into a profile contributes the `custom-theme` row. The Host plugin and the browser
256
+ half ride that one row, so one install brings up both halves — there is nothing to
257
+ enable separately.
258
+
259
+ Every path below ends with `dsh plugin --profile <name> remove dsh-custom-theme`,
260
+ which removes both the dependency and the layer.
261
+
262
+ ### From a local checkout
263
+
264
+ ```sh
265
+ dsh plugin --profile <name> add /path/to/dsh-custom-theme # links the checkout
266
+ dsh --profile <name> --dump-config # shows a "# == dsh-custom-theme" layer
267
+ dsh --profile <name> --no-open
268
+ ```
269
+
270
+ ### From GitHub
271
+
272
+ ```sh
273
+ dsh plugin --profile <name> add github:Sparrived/dsh-custom-theme
274
+ ```
275
+
276
+ Pin a commit when you want a later push to be unable to change what runs:
277
+
278
+ ```sh
279
+ dsh plugin --profile <name> add github:Sparrived/dsh-custom-theme#<sha>
280
+ ```
281
+
282
+ A git install fetches **source, not build artifacts**, which is the step where a
283
+ TypeScript plugin arrives without its compiled `lib/` and fails to load — the
284
+ official guide's *"installing from GitHub: the build-script catch"* section is
285
+ about exactly that case, and its fix is a `prepare` script plus an `allowBuilds`
286
+ grant. **This package does not need either.** `src/index.mjs` (Host) and
287
+ `lib/client.js` (browser) are hand-written JavaScript that Node and the browser
288
+ load directly, so there is no build step for `prepare` to run and nothing to
289
+ allowlist: the install completes with no code-execution prompt.
290
+
291
+ ### From a tarball, or from npm
292
+
293
+ Both ship the same prebuilt code:
294
+
295
+ ```sh
296
+ pnpm pack # -> dsh-custom-theme-0.1.0.tgz
297
+ dsh plugin --profile <name> add ./dsh-custom-theme-0.1.0.tgz
298
+ ```
299
+
300
+ ```sh
301
+ dsh plugin --profile <name> add dsh-custom-theme # after an npm release
302
+ ```
303
+
304
+ For the maintainer, publishing a release is:
305
+
306
+ ```sh
307
+ git tag v0.1.0 && git push origin v0.1.0
308
+ gh release create v0.1.0 --title v0.1.0 --notes-file CHANGELOG.md
309
+ pnpm publish --access public # optional; needs npm auth
310
+ ```
311
+
312
+ ### Configuring the directories
313
+
314
+ `themesDir` and `backgroundsDir` can be overridden on the row; otherwise
315
+ `$DSH_HOME/themes` and `$DSH_HOME/backgrounds` are used:
316
+
317
+ ```yaml
318
+ - id: custom-theme
319
+ name: 'dsh-custom-theme'
320
+ config:
321
+ themesDir: D:/themes/dsh
322
+ backgroundsDir: D:/wallpapers/dsh
323
+ ```
324
+
325
+ ### Desktop app
326
+
327
+ Install from the **Plugins** page, which uses Desktop's bundled pnpm and is the GUI
328
+ equivalent of `dsh plugin --profile desktop`; point it at the checkout or the
329
+ tarball, then restart the app. Booting the `desktop` profile from the CLI is refused
330
+ by design — *"profile desktop is managed exclusively by the Electron application"* —
331
+ so the Plugins page owns that profile.
332
+
333
+ ### Local development without installing
334
+
335
+ A `--patch` overlay row pointing at the source file is enough, because the client
336
+ module system resolves the owning `package.json` by walking up from the entry
337
+ file — the browser half attaches even though the row is a `file://` specifier:
338
+
339
+ ```sh
340
+ dsh --profile <name> --patch dev.overlay.yml --no-open
341
+ ```
342
+
343
+ `dev.overlay.yml` names `./src/index.mjs`, a path the loader resolves against the
344
+ overlay file itself, so a fresh clone works without editing it.
345
+
346
+ The browser test drives a real browser against a booted instance, so it needs the
347
+ token from the printed URL:
348
+
349
+ ```sh
350
+ set DCT_TOKEN=<token from the "dsh web: http://…/?token=…" line>
351
+ node test/browser/appearance.mjs
352
+ ```
353
+
354
+ `DCT_BASE` (default `http://127.0.0.1:3080`), `DCT_CDP_PORT` and `DCT_SHOT_DIR`
355
+ override the target, the debugging port and where screenshots land. The test
356
+ imports `test/browser/driver.mjs`, a small CDP driver over Node's built-in
357
+ `WebSocket`; no browser automation dependency is installed.
358
+
359
+ ## Verify
360
+
361
+ Verified against `dsh` 0.2.0-rc.2 on Windows:
362
+
363
+ - `node --test "test/**/*.test.mjs"` — 14 tests, all passing: id and image-name
364
+ whitelists, ordering, directory resolution, seeding, re-sync on a new seed
365
+ generation, both asset routes, both listings, and traversal, extension and
366
+ method rejection.
367
+ - `node test/browser/appearance.mjs` — 33 steps in a real headless Edge, all
368
+ passing. It boots the app, asserts the controls are absent from the chat view and
369
+ still absent once Settings opens, then opens the plugin's own page from the nav
370
+ and drives it. It asserts on rendered state: each bundled theme paints its light
371
+ set and then follows the page's colour-scheme control into its dark set without
372
+ being dropped, the empty option restores a built-in token, a saved theme and
373
+ background both re-apply on boot before Settings is opened. For backgrounds it
374
+ checks that the picture really is on the layer and not on the surface element, that
375
+ the layer's own alpha is the configured picture opacity while the panel fill stays
376
+ at its per-zone percentage, that a blur lands on the picture layer and nowhere a
377
+ text-bearing element could inherit it, that both new controls clamp at Deeptop's
378
+ ceilings, and that clearing a zone removes its inline properties, its attributes and
379
+ every injected layer rule. Finally, with the panel closed, since the panel is
380
+ portalled over the whole window, it asserts that the image is what
381
+ `elementFromPoint` actually finds at a point inside each zone — which is what keeps
382
+ the negative-`z-index` layer from silently disappearing behind a surface.
383
+ - `node test/browser/working-row.mjs` — 5 steps against a session that already has
384
+ turns. It reads the shipped row's computed geometry, asserts the replacement is
385
+ absent while no phrase is configured, configures one, then compares the
386
+ replacement against the captured geometry property by property and asserts no
387
+ placeholder leaked into its label.
388
+ - A theme stating one palette was driven by hand across the official schemes: the
389
+ plugin drops it rather than half-applying it, which no bundled theme exercises
390
+ because all three carry a pair. That path is covered by the host tests only
391
+ through the seeding and serving assertions.
392
+ - Composed into a real profile (`--dump-config`), then booted: the row loads,
393
+ `$DSH_HOME/themes/` is seeded, `GET /dsh-custom-theme/themes` returns `200`
394
+ with `{"themes":[{"id":"gov","bundled":true},…]}`, `GET /dsh-custom-theme/theme/gov.css`
395
+ returns `200 text/css`, an unknown id returns `404`, and
396
+ `GET /dsh-custom-theme/backgrounds` plus the image route serve a dropped-in
397
+ `.png` as `image/png` while `evil.txt` and `missing.png` return `404`.
398
+ - The browser half composes into `window.__DSH_BOOT__` as
399
+ `{"id":"dsh-custom-theme","url":"plugins/??dsh-custom-theme/client.js&rev=…","rev":"…","inject":["@deepseek-ai/dsh-client-ui-settings"]}`,
400
+ and that URL serves `200 text/javascript` containing the
401
+ `settings.section` registration. The `inject` field is
402
+ `dsh.client.inject` reaching the boot graph.
403
+ - Both halves were driven from a `--patch` row as well as a bare-package row,
404
+ and `config.themesDir` was confirmed to redirect the directory: dropping a
405
+ `solarized.css` into it made the listing return
406
+ `{"id":"solarized","bundled":false}` and the stylesheet serve `200 text/css`.
407
+
408
+ Still to confirm by hand, in the running app: the Appearance rows render in
409
+ Settings → General, selecting a theme repaints immediately, and disabling the
410
+ row removes the rows and the palette together.
411
+
412
+ ## Known limitations
413
+
414
+ - **A configured phrase drops the elapsed time on finished turns.** The seat a
415
+ replacement row receives does not interpolate parameters, so the shipped
416
+ `{duration}` template would render with an empty slot; the replacement uses only
417
+ parameter-free keys instead. Leave the phrase list empty to keep the shipped row,
418
+ elapsed time included.
419
+ - **The running phrase itself is verified by hand.** It only renders while a turn is
420
+ live, which the browser suite does not start; the suite proves the opt-in swap, the
421
+ geometry match and the finished-turn label. The rotation is driven by a
422
+ `setInterval` on the configured interval.
423
+
424
+ - **The page cannot choose its nav icon.** `settings.section` has no icon field;
425
+ the shell maps the entry id to a glyph and falls back to a generic settings gear
426
+ for an id it does not know, which is what this page gets.
427
+ - **One settings section renders at a time.** The official appearance row is on
428
+ the General section, so it is off-screen while this page is open. The page's own
429
+ colour-scheme control covers that case; it drives the same preference.
430
+ - **Which custom theme is selected lives in `localStorage`.** The tokens and the
431
+ base palette go to the official runtime, but the official preference field
432
+ accepts only the three built-in ids, so `setTheme` does not persist a custom
433
+ selection and the plugin restores it from `localStorage` on boot. Durable
434
+ cross-device persistence would need a Schemastery `Config` on the Host row plus
435
+ `ctx.configForms`.
436
+ - **A theme colours the whole window, buttons and menus included.** There is no
437
+ longer a surface a token theme cannot reach: the tokens ride the official
438
+ runtime, and the base palette follows the theme — for a pair, by the appearance
439
+ preference choosing one of the theme's two sets; for a single palette, by the
440
+ plugin selecting the set that palette belongs to. Reducing the theme's claimed
441
+ coverage is not possible by accident — ask for fewer tokens and you get the
442
+ base palette for the rest.
443
+ - **Background zones bind to the shell's DOM skeleton.** The six zone anchors are
444
+ found by the stable half of the shell's CSS-module class names (`_frame`,
445
+ `_sidebarCol`, `_centerCol`) and, where the shell offers one, by a `data-*` hook
446
+ (`[data-composer-seat]`, `[data-rightbar-col]`, and `header` for the title bar).
447
+ Every class the shell hashes keeps its authored name as a suffix, so a rebuild
448
+ does not move them; only renaming those classes or restructuring the layout
449
+ would. `test/browser/appearance.mjs` asserts that each zone paints with the
450
+ global image, so a moved anchor breaks loudly in the test rather than silently
451
+ for a user.
452
+ - **A painted surface is given `position: relative` when it was `static`.** The
453
+ picture layer is absolutely positioned, so a `static` surface has to become a
454
+ positioned one for the layer to be laid out against it. That changes the
455
+ containing block for any absolutely positioned descendant the shell has inside
456
+ that surface, which is the one way this feature can move shell layout. It is done
457
+ only where `static` was actually computed, so a surface the shell already
458
+ positions is never touched, and both are removed when the zone is cleared. The
459
+ alternative — leaving the surface `static` — lets the picture escape the element
460
+ it is meant to fill, so it is not an option.
461
+ - **A picture layer needs a `::before` free on the surface.** The shell currently
462
+ uses no `::before` content on any of the six zone surfaces (verified in a live
463
+ window: zero elements in the whole shell style one with content), so the layer
464
+ gets that pseudo-element to itself. A future shell build that starts using
465
+ `::before` on one of these surfaces would collide with it; the plugin's rule sets
466
+ `content`, `position`, `inset`, `z-index` and the picture, so the shell's own
467
+ `::before` content would be replaced rather than merged.
468
+ - **No Schemastery `Config` schema**, so `config` is read defensively and never
469
+ validated. That is also why the package carries no peer dependencies at all.
470
+ - **No live file watching.** A new or edited file needs **Rescan**, and an edited
471
+ *active* theme needs re-selecting.
472
+ - **The CSS targets the official DOM**, which is pre-1.0 and changes. Themes
473
+ built on `--dsw-*` alias tokens survive layout changes far better than themes
474
+ that style class names directly.
475
+ - **`body { font-family }` in `gov.css` is a deliberate whole-app restyle**, not
476
+ a token override; drop that block if only the palette is wanted.
477
+ - **Browser halves are not sandboxed.** This plugin runs in the same realm and
478
+ document as the shell; so does every client plugin, including the official
479
+ theme package.
@@ -0,0 +1,14 @@
1
+ # dsh-custom-theme — the rows this package contributes to a profile.
2
+ #
3
+ # The row is mounted from the BARE package name on purpose: `client-modules`
4
+ # attaches a package's browser half to the row whose specifier is the bare
5
+ # package name, and a row mounted from a subpath export never carries a half.
6
+ #
7
+ # Host and browser halves ride this one row because they own a single concern —
8
+ # the theme directory and the picker that reads it. Disabling the row therefore
9
+ # removes the Appearance row, the palette, and the route together, which is the
10
+ # supported coarse switch.
11
+
12
+ - insert:
13
+ - id: custom-theme
14
+ name: 'dsh-custom-theme'