dsh-theme-gallery 0.1.2

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/lib/client.js ADDED
@@ -0,0 +1,4229 @@
1
+ /**
2
+ * Browser half of the theme gallery.
3
+ *
4
+ * ## What changed, and why it matters
5
+ *
6
+ * An earlier revision sourced the theme list from the `theme-gallery` settings
7
+ * namespace and declared `settingsScope` as a **hard** dependency. That service
8
+ * is provided by `@deepseek-ai/dsh-client-ui-settings`, which itself waits on
9
+ * `remote.settings` — so under the desktop composition it never arrived, the
10
+ * fiber stayed `pending` forever, and the boot-complete check refused to start
11
+ * the app:
12
+ *
13
+ * web boot: 1 entry did not activate
14
+ * dsh-theme-gallery: pending (waiting for service: settingsScope)
15
+ *
16
+ * Cordis' array-form `inject` makes every entry required, so a service you do
17
+ * not control is a service that can brick the app. This revision needs no
18
+ * settings domain at all:
19
+ *
20
+ * - the theme list is `ctx.theme.getTheme().themes` — the official registry;
21
+ * - selecting is `ctx.theme.setTheme(id)`, and ui-theme persists that
22
+ * preference itself, in the `ui-theme` namespace it owns;
23
+ * - the picker lives in the left sidebar, not in Settings.
24
+ *
25
+ * The only hard requirements are `slots` and `locale`, both shipped by
26
+ * statically-composed UI packages (`dsh-client-ui-layout` consumes them, so they
27
+ * exist in every web composition).
28
+ *
29
+ * ## Where the picker lives
30
+ *
31
+ * `sidebar.panellist` (a list slot: one icon per panel) plus `main` (a **keyed**
32
+ * slot addressed by the same id). That pairing is this app's plugin mechanism —
33
+ * one plugin is one sidebar entrance plus one main-column page — so it has room
34
+ * for previews, descriptions and whatever this gallery grows into.
35
+ *
36
+ * ## Two conversational states
37
+ *
38
+ * A skin covers the screen; a screen covered by a gradient is what makes a long
39
+ * conversation tiring to read. So once the transcript has messages, the centre
40
+ * column gets a lightened card. Detection is DOM-derived because DSH exposes no
41
+ * public "does this session have messages" API; if it stops matching, the plugin
42
+ * degrades to the idle look and never breaks the UI.
43
+ *
44
+ * ## Why the skin itself needs no injected stylesheet
45
+ *
46
+ * The official frame already paints the sidebar column AND the Windows caption
47
+ * row with `var(--dsw-specific-sidebar-fill)`, and the centre with
48
+ * `var(--dsw-alias-bg-base)`. Themes set that token to a gradient, so the screen
49
+ * is covered by tokens alone. Only the reading card needs one injected rule,
50
+ * scoped to the official `[data-windows-titlebar] .centerCol` anchor.
51
+ *
52
+ * This file is a lazy-CJS factory, the bundle format the client module system
53
+ * loads (`window.__ModuleLoader__.load({ id, factory })`); the module body lives
54
+ * inside the factory closure so it runs at materialization, not at script load.
55
+ * The registration contract was type-checked against the real official packages
56
+ * — see ../types/client-panel.ts.
57
+ */
58
+ window.__ModuleLoader__.load({
59
+ id: 'dsh-theme-gallery',
60
+ factory: (require) => {
61
+ var module = { exports: {} }
62
+ var exports = module.exports
63
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
64
+
65
+ // Value requires only. Like the official ui-theme bundle, these are NOT
66
+ // listed in `dsh.client.inject`: they resolve from the client's platform
67
+ // module seed (`dsh-client-store`) or the shell (`react/jsx-runtime`).
68
+ const { defineStore } = require('@deepseek-ai/dsh-client-store')
69
+ const { jsx, jsxs } = require('react/jsx-runtime')
70
+
71
+ /** Panel id shared by the sidebar icon and the main-column page. */
72
+ const PANEL_ID = 'theme-gallery'
73
+
74
+ /** Locale namespace this plugin owns its copy in. */
75
+ const NS = 'theme-gallery'
76
+
77
+ /**
78
+ * Display names for the themes the theme plugin ships with itself.
79
+ *
80
+ * They carry no `label` — the official Appearance row localizes them from its
81
+ * own dictionaries — so the gallery names them here instead of showing a bare
82
+ * `light` / `dark` id on a card.
83
+ */
84
+ const BUILT_IN_LABELS = { light: '浅色', dark: '深色', system: '跟随系统' }
85
+
86
+ /**
87
+ * Built-in ids the gallery leaves to the official Appearance row.
88
+ *
89
+ * `light` is exactly what "follow the system" resolves to on a light desktop,
90
+ * so offering it beside that row is duplicate UI. `dark` stays because it is a
91
+ * distinct choice a user may want without a full skin.
92
+ */
93
+ const OMITTED_IDS = new Set(['light'])
94
+
95
+ /** Body attribute publishing the reading state; the injected rule reads it. */
96
+ const READING_ATTRIBUTE = 'data-dsh-theme-reading'
97
+
98
+ /**
99
+ * Reading-state defaults, tuned in `tools/theme-bench`.
100
+ *
101
+ * One shared setting rather than one per theme: the gallery does not own the
102
+ * theme definitions (the official registry does), so per-theme reading values
103
+ * would need a side table keyed by theme id. These values were chosen against
104
+ * both bundled skins and are the ones the bench exports as its defaults.
105
+ */
106
+ const READING = { bg: '#FFFFFF', alpha: 0.62, blur: 3, maxWidth: 640 }
107
+
108
+ /**
109
+ * Themes this package contributes, inlined from lib/themes/*.json.
110
+ *
111
+ * Someone has to put them into the registry, and on this side that is this
112
+ * plugin: the browser half is where `ctx.theme` lives. The picker then lists
113
+ * whatever the registry holds, so themes contributed by other plugins appear
114
+ * beside these without either plugin knowing about the other.
115
+ *
116
+ * Regenerate after editing those files: `node scripts/embed-themes.mjs`.
117
+ */
118
+ const BUNDLED_THEMES = [
119
+ {
120
+ "id": "meng-hai-you-yu",
121
+ "label": "梦海游鱼",
122
+ "description": "梦幻海洋,游鱼作伴 —— 复刻自电商新零售系统管理后台的默认主题",
123
+ "colorScheme": "light",
124
+ "tokens": {
125
+ "--dsw-alias-bg-base": {
126
+ "light": "linear-gradient(to bottom,#F7FBFF 0%,#EDF5FD 55%,#E2EEFA 100%)",
127
+ "dark": "linear-gradient(to bottom,#F7FBFF 0%,#EDF5FD 55%,#E2EEFA 100%)"
128
+ },
129
+ "--dsw-alias-bg-layer-1": {
130
+ "light": "#FBFDFF",
131
+ "dark": "#FBFDFF"
132
+ },
133
+ "--dsw-alias-bg-layer-2": {
134
+ "light": "#FFFFFF",
135
+ "dark": "#FFFFFF"
136
+ },
137
+ "--dsw-alias-bg-layer-3": {
138
+ "light": "#FFFFFF",
139
+ "dark": "#FFFFFF"
140
+ },
141
+ "--dsw-alias-bg-overlay": {
142
+ "light": "#E1EFFB",
143
+ "dark": "#E1EFFB"
144
+ },
145
+ "--dsw-alias-bg-skeleton": {
146
+ "light": "#177CB014",
147
+ "dark": "#177CB014"
148
+ },
149
+ "--dsw-alias-bg-module-platform": {
150
+ "light": "#E0EFFB",
151
+ "dark": "#E0EFFB"
152
+ },
153
+ "--dsw-alias-bg-multi-select": {
154
+ "light": "#E0EFFB",
155
+ "dark": "#E0EFFB"
156
+ },
157
+ "--dsw-alias-border-l1": {
158
+ "light": "#177CB00F",
159
+ "dark": "#177CB00F"
160
+ },
161
+ "--dsw-alias-border-l2": {
162
+ "light": "#177CB021",
163
+ "dark": "#177CB021"
164
+ },
165
+ "--dsw-alias-border-l3": {
166
+ "light": "#177CB02E",
167
+ "dark": "#177CB02E"
168
+ },
169
+ "--dsw-alias-border-l4": {
170
+ "light": "#177CB03D",
171
+ "dark": "#177CB03D"
172
+ },
173
+ "--dsw-alias-brand-primary": {
174
+ "light": "#177CB0",
175
+ "dark": "#177CB0"
176
+ },
177
+ "--dsw-alias-brand-primary-invert": {
178
+ "light": "#FFFFFF",
179
+ "dark": "#FFFFFF"
180
+ },
181
+ "--dsw-alias-brand-text": {
182
+ "light": "#177CB0",
183
+ "dark": "#177CB0"
184
+ },
185
+ "--dsw-alias-label-primary": {
186
+ "light": "#16384F",
187
+ "dark": "#16384F"
188
+ },
189
+ "--dsw-alias-label-primary-bluish": {
190
+ "light": "#16384F",
191
+ "dark": "#16384F"
192
+ },
193
+ "--dsw-alias-label-secondary": {
194
+ "light": "#2E4A63",
195
+ "dark": "#2E4A63"
196
+ },
197
+ "--dsw-alias-label-tertiary": {
198
+ "light": "#5A7B96",
199
+ "dark": "#5A7B96"
200
+ },
201
+ "--dsw-alias-label-caption": {
202
+ "light": "#5A7B96",
203
+ "dark": "#5A7B96"
204
+ },
205
+ "--dsw-alias-label-dimmed": {
206
+ "light": "#8FAAC0",
207
+ "dark": "#8FAAC0"
208
+ },
209
+ "--dsw-alias-label-primary-dimmed": {
210
+ "light": "#2E4A63",
211
+ "dark": "#2E4A63"
212
+ },
213
+ "--dsw-alias-label-primary-foreground": {
214
+ "light": "#FFFFFF",
215
+ "dark": "#FFFFFF"
216
+ },
217
+ "--dsw-alias-label-primary-inverted": {
218
+ "light": "#FFFFFF",
219
+ "dark": "#FFFFFF"
220
+ },
221
+ "--dsw-alias-link": {
222
+ "light": "#B8860B",
223
+ "dark": "#B8860B"
224
+ },
225
+ "--dsw-alias-interactive-bg-hover": {
226
+ "light": "#2B74B512",
227
+ "dark": "#2B74B512"
228
+ },
229
+ "--dsw-alias-interactive-bg-active": {
230
+ "light": "#2B74B529",
231
+ "dark": "#2B74B529"
232
+ },
233
+ "--dsw-alias-interactive-bg-hover-solid": {
234
+ "light": "#E0EFFB",
235
+ "dark": "#E0EFFB"
236
+ },
237
+ "--dsw-alias-interactive-bg-hover-accent": {
238
+ "light": "#06527929",
239
+ "dark": "#06527929"
240
+ },
241
+ "--dsw-alias-button-primary-fill": {
242
+ "light": "#177CB0",
243
+ "dark": "#177CB0"
244
+ },
245
+ "--dsw-alias-button-primary-hover": {
246
+ "light": "#2B74B5",
247
+ "dark": "#2B74B5"
248
+ },
249
+ "--dsw-alias-button-primary-dimmed": {
250
+ "light": "#177CB047",
251
+ "dark": "#177CB047"
252
+ },
253
+ "--dsw-alias-button-ghost-active-fill": {
254
+ "light": "#2B74B524",
255
+ "dark": "#2B74B524"
256
+ },
257
+ "--dsw-alias-button-ghost-active-border": {
258
+ "light": "#2B74B5",
259
+ "dark": "#2B74B5"
260
+ },
261
+ "--dsw-alias-button-ghost-active-hover": {
262
+ "light": "#2B74B53D",
263
+ "dark": "#2B74B53D"
264
+ },
265
+ "--dsw-alias-button-info-fill": {
266
+ "light": "#4C8DAE",
267
+ "dark": "#4C8DAE"
268
+ },
269
+ "--dsw-alias-button-info-hover": {
270
+ "light": "#177CB0",
271
+ "dark": "#177CB0"
272
+ },
273
+ "--dsw-alias-button-elevated-fill": {
274
+ "light": "#FFFFFF",
275
+ "dark": "#FFFFFF"
276
+ },
277
+ "--dsw-alias-button-floating-fill": {
278
+ "light": "#FFFFFF",
279
+ "dark": "#FFFFFF"
280
+ },
281
+ "--dsw-alias-button-floating-hover": {
282
+ "light": "#F5FAFF",
283
+ "dark": "#F5FAFF"
284
+ },
285
+ "--dsw-alias-button-contrast-fill": {
286
+ "light": "#16384F",
287
+ "dark": "#16384F"
288
+ },
289
+ "--dsw-alias-markdown-code-block": {
290
+ "light": "#EAF5FF",
291
+ "dark": "#EAF5FF"
292
+ },
293
+ "--dsw-alias-markdown-code-block-banner": {
294
+ "light": "#D6E9F8",
295
+ "dark": "#D6E9F8"
296
+ },
297
+ "--dsw-alias-markdown-inline-code": {
298
+ "light": "#E0EFFB",
299
+ "dark": "#E0EFFB"
300
+ },
301
+ "--dsw-alias-markdown-citation": {
302
+ "light": "#E0EFFB",
303
+ "dark": "#E0EFFB"
304
+ },
305
+ "--dsw-alias-markdown-tag": {
306
+ "light": "#E0EFFB",
307
+ "dark": "#E0EFFB"
308
+ },
309
+ "--dsw-alias-markdown-placeholder": {
310
+ "light": "#D6E9F8",
311
+ "dark": "#D6E9F8"
312
+ },
313
+ "--dsw-alias-markdown-code-segment-selected": {
314
+ "light": "#2B74B524",
315
+ "dark": "#2B74B524"
316
+ },
317
+ "--dsw-alias-markdown-code-segment-unselected": {
318
+ "light": "#EAF5FF",
319
+ "dark": "#EAF5FF"
320
+ },
321
+ "--dsw-alias-scrollbar-bg-l1": {
322
+ "light": "#2B74B529",
323
+ "dark": "#2B74B529"
324
+ },
325
+ "--dsw-alias-scrollbar-bg-l2": {
326
+ "light": "#2B74B538",
327
+ "dark": "#2B74B538"
328
+ },
329
+ "--dsw-alias-scrollbar-hover-l1": {
330
+ "light": "#2B74B552",
331
+ "dark": "#2B74B552"
332
+ },
333
+ "--dsw-alias-scrollbar-hover-l2": {
334
+ "light": "#2B74B566",
335
+ "dark": "#2B74B566"
336
+ },
337
+ "--dsw-alias-tooltip-bg": {
338
+ "light": "#065279",
339
+ "dark": "#065279"
340
+ },
341
+ "--dsw-alias-toast-bg": {
342
+ "light": "#FFFFFF",
343
+ "dark": "#FFFFFF"
344
+ },
345
+ "--dsw-alias-state-error-primary": {
346
+ "light": "#C0566A",
347
+ "dark": "#C0566A"
348
+ },
349
+ "--dsw-alias-state-error-secondary": {
350
+ "light": "#DB5A6B",
351
+ "dark": "#DB5A6B"
352
+ },
353
+ "--dsw-alias-state-success-primary": {
354
+ "light": "#21A675",
355
+ "dark": "#21A675"
356
+ },
357
+ "--dsw-alias-state-success-secondary": {
358
+ "light": "#177CB0",
359
+ "dark": "#177CB0"
360
+ },
361
+ "--dsw-alias-state-success-tertiary": {
362
+ "light": "#9CC3E4",
363
+ "dark": "#9CC3E4"
364
+ },
365
+ "--dsw-alias-state-warn-primary": {
366
+ "light": "#CA6924",
367
+ "dark": "#CA6924"
368
+ },
369
+ "--dsw-alias-state-warn-secondary": {
370
+ "light": "#E09A4E",
371
+ "dark": "#E09A4E"
372
+ },
373
+ "--dsw-alias-state-warn-tertiary": {
374
+ "light": "#F3E3CB",
375
+ "dark": "#F3E3CB"
376
+ },
377
+ "--dsw-alias-state-warn-label": {
378
+ "light": "#8A4A12",
379
+ "dark": "#8A4A12"
380
+ },
381
+ "--dsw-alias-state-business-primary": {
382
+ "light": "#177CB0",
383
+ "dark": "#177CB0"
384
+ },
385
+ "--dsw-alias-state-business-tertiary": {
386
+ "light": "#4C8DAE",
387
+ "dark": "#4C8DAE"
388
+ },
389
+ "--dsw-specific-sidebar-fill": {
390
+ "light": "linear-gradient(to bottom,#EAF5FF 0%,#DDF0FF 26%,#CDE9FB 52%,#C7E7FA 62%,#CDEEFC 70%,#C4E7FA 80%,#B9DCF3 90%,#B0D9F0 100%)",
391
+ "dark": "linear-gradient(to bottom,#EAF5FF 0%,#DDF0FF 26%,#CDE9FB 52%,#C7E7FA 62%,#CDEEFC 70%,#C4E7FA 80%,#B9DCF3 90%,#B0D9F0 100%)"
392
+ }
393
+ },
394
+ "reading": {
395
+ "colorScheme": "light",
396
+ "bg": "#FFFFFF",
397
+ "alpha": 0.62,
398
+ "blur": 3,
399
+ "maxWidth": 640
400
+ },
401
+ "accent": "#FFD166",
402
+ "ambient": {
403
+ "kind": "dream",
404
+ "bubbles": 9,
405
+ "motes": 5,
406
+ "fish": 3
407
+ }
408
+ },
409
+ {
410
+ "id": "shan-qing-ting-cai",
411
+ "label": "山青婷彩",
412
+ "description": "青山叠翠,蜓舞生姿 —— 复刻自电商新零售系统管理后台同名主题",
413
+ "colorScheme": "light",
414
+ "tokens": {
415
+ "--dsw-alias-bg-base": {
416
+ "light": "linear-gradient(to bottom,#F7FCF9 0%,#EDF7F1 55%,#E4F2EA 100%)",
417
+ "dark": "linear-gradient(to bottom,#F7FCF9 0%,#EDF7F1 55%,#E4F2EA 100%)"
418
+ },
419
+ "--dsw-alias-bg-layer-1": {
420
+ "light": "#FBFEFC",
421
+ "dark": "#FBFEFC"
422
+ },
423
+ "--dsw-alias-bg-layer-2": {
424
+ "light": "#FFFFFF",
425
+ "dark": "#FFFFFF"
426
+ },
427
+ "--dsw-alias-bg-layer-3": {
428
+ "light": "#FFFFFF",
429
+ "dark": "#FFFFFF"
430
+ },
431
+ "--dsw-alias-bg-overlay": {
432
+ "light": "#E3F1E8",
433
+ "dark": "#E3F1E8"
434
+ },
435
+ "--dsw-alias-bg-skeleton": {
436
+ "light": "#2F7D5E14",
437
+ "dark": "#2F7D5E14"
438
+ },
439
+ "--dsw-alias-bg-module-platform": {
440
+ "light": "#E4F3EA",
441
+ "dark": "#E4F3EA"
442
+ },
443
+ "--dsw-alias-bg-multi-select": {
444
+ "light": "#E4F3EA",
445
+ "dark": "#E4F3EA"
446
+ },
447
+ "--dsw-alias-border-l1": {
448
+ "light": "#2F7D5E0F",
449
+ "dark": "#2F7D5E0F"
450
+ },
451
+ "--dsw-alias-border-l2": {
452
+ "light": "#2F7D5E21",
453
+ "dark": "#2F7D5E21"
454
+ },
455
+ "--dsw-alias-border-l3": {
456
+ "light": "#2F7D5E2E",
457
+ "dark": "#2F7D5E2E"
458
+ },
459
+ "--dsw-alias-border-l4": {
460
+ "light": "#2F7D5E3D",
461
+ "dark": "#2F7D5E3D"
462
+ },
463
+ "--dsw-alias-brand-primary": {
464
+ "light": "#2F7D5E",
465
+ "dark": "#2F7D5E"
466
+ },
467
+ "--dsw-alias-brand-primary-invert": {
468
+ "light": "#FFFFFF",
469
+ "dark": "#FFFFFF"
470
+ },
471
+ "--dsw-alias-brand-text": {
472
+ "light": "#2F7D5E",
473
+ "dark": "#2F7D5E"
474
+ },
475
+ "--dsw-alias-label-primary": {
476
+ "light": "#1F4638",
477
+ "dark": "#1F4638"
478
+ },
479
+ "--dsw-alias-label-primary-bluish": {
480
+ "light": "#1F4638",
481
+ "dark": "#1F4638"
482
+ },
483
+ "--dsw-alias-label-secondary": {
484
+ "light": "#3C6B57",
485
+ "dark": "#3C6B57"
486
+ },
487
+ "--dsw-alias-label-tertiary": {
488
+ "light": "#5C8474",
489
+ "dark": "#5C8474"
490
+ },
491
+ "--dsw-alias-label-caption": {
492
+ "light": "#5C8474",
493
+ "dark": "#5C8474"
494
+ },
495
+ "--dsw-alias-label-dimmed": {
496
+ "light": "#8AA79B",
497
+ "dark": "#8AA79B"
498
+ },
499
+ "--dsw-alias-label-primary-dimmed": {
500
+ "light": "#3C6B57",
501
+ "dark": "#3C6B57"
502
+ },
503
+ "--dsw-alias-label-primary-foreground": {
504
+ "light": "#FFFFFF",
505
+ "dark": "#FFFFFF"
506
+ },
507
+ "--dsw-alias-label-primary-inverted": {
508
+ "light": "#FFFFFF",
509
+ "dark": "#FFFFFF"
510
+ },
511
+ "--dsw-alias-link": {
512
+ "light": "#D97BA4",
513
+ "dark": "#D97BA4"
514
+ },
515
+ "--dsw-alias-interactive-bg-hover": {
516
+ "light": "#3E9B7A14",
517
+ "dark": "#3E9B7A14"
518
+ },
519
+ "--dsw-alias-interactive-bg-active": {
520
+ "light": "#E88BB02E",
521
+ "dark": "#E88BB02E"
522
+ },
523
+ "--dsw-alias-interactive-bg-hover-solid": {
524
+ "light": "#E4F3EA",
525
+ "dark": "#E4F3EA"
526
+ },
527
+ "--dsw-alias-interactive-bg-hover-accent": {
528
+ "light": "#E88BB03D",
529
+ "dark": "#E88BB03D"
530
+ },
531
+ "--dsw-alias-button-primary-fill": {
532
+ "light": "#2F7D5E",
533
+ "dark": "#2F7D5E"
534
+ },
535
+ "--dsw-alias-button-primary-hover": {
536
+ "light": "#3E9B7A",
537
+ "dark": "#3E9B7A"
538
+ },
539
+ "--dsw-alias-button-primary-dimmed": {
540
+ "light": "#2F7D5E47",
541
+ "dark": "#2F7D5E47"
542
+ },
543
+ "--dsw-alias-button-ghost-active-fill": {
544
+ "light": "#E88BB029",
545
+ "dark": "#E88BB029"
546
+ },
547
+ "--dsw-alias-button-ghost-active-border": {
548
+ "light": "#E88BB0",
549
+ "dark": "#E88BB0"
550
+ },
551
+ "--dsw-alias-button-ghost-active-hover": {
552
+ "light": "#E88BB047",
553
+ "dark": "#E88BB047"
554
+ },
555
+ "--dsw-alias-button-info-fill": {
556
+ "light": "#4C9B78",
557
+ "dark": "#4C9B78"
558
+ },
559
+ "--dsw-alias-button-info-hover": {
560
+ "light": "#3E9B7A",
561
+ "dark": "#3E9B7A"
562
+ },
563
+ "--dsw-alias-button-elevated-fill": {
564
+ "light": "#FFFFFF",
565
+ "dark": "#FFFFFF"
566
+ },
567
+ "--dsw-alias-button-floating-fill": {
568
+ "light": "#FFFFFF",
569
+ "dark": "#FFFFFF"
570
+ },
571
+ "--dsw-alias-button-floating-hover": {
572
+ "light": "#F4FBF7",
573
+ "dark": "#F4FBF7"
574
+ },
575
+ "--dsw-alias-button-contrast-fill": {
576
+ "light": "#1F4638",
577
+ "dark": "#1F4638"
578
+ },
579
+ "--dsw-alias-markdown-code-block": {
580
+ "light": "#EAF7F0",
581
+ "dark": "#EAF7F0"
582
+ },
583
+ "--dsw-alias-markdown-code-block-banner": {
584
+ "light": "#DCEEE2",
585
+ "dark": "#DCEEE2"
586
+ },
587
+ "--dsw-alias-markdown-inline-code": {
588
+ "light": "#E4F3EA",
589
+ "dark": "#E4F3EA"
590
+ },
591
+ "--dsw-alias-markdown-citation": {
592
+ "light": "#E4F3EA",
593
+ "dark": "#E4F3EA"
594
+ },
595
+ "--dsw-alias-markdown-tag": {
596
+ "light": "#E4F3EA",
597
+ "dark": "#E4F3EA"
598
+ },
599
+ "--dsw-alias-markdown-placeholder": {
600
+ "light": "#DCEEE2",
601
+ "dark": "#DCEEE2"
602
+ },
603
+ "--dsw-alias-markdown-code-segment-selected": {
604
+ "light": "#E88BB029",
605
+ "dark": "#E88BB029"
606
+ },
607
+ "--dsw-alias-markdown-code-segment-unselected": {
608
+ "light": "#EAF7F0",
609
+ "dark": "#EAF7F0"
610
+ },
611
+ "--dsw-alias-scrollbar-bg-l1": {
612
+ "light": "#2F7D5E29",
613
+ "dark": "#2F7D5E29"
614
+ },
615
+ "--dsw-alias-scrollbar-bg-l2": {
616
+ "light": "#2F7D5E38",
617
+ "dark": "#2F7D5E38"
618
+ },
619
+ "--dsw-alias-scrollbar-hover-l1": {
620
+ "light": "#2F7D5E52",
621
+ "dark": "#2F7D5E52"
622
+ },
623
+ "--dsw-alias-scrollbar-hover-l2": {
624
+ "light": "#2F7D5E66",
625
+ "dark": "#2F7D5E66"
626
+ },
627
+ "--dsw-alias-tooltip-bg": {
628
+ "light": "#1F4638",
629
+ "dark": "#1F4638"
630
+ },
631
+ "--dsw-alias-toast-bg": {
632
+ "light": "#FFFFFF",
633
+ "dark": "#FFFFFF"
634
+ },
635
+ "--dsw-alias-state-error-primary": {
636
+ "light": "#C65B6A",
637
+ "dark": "#C65B6A"
638
+ },
639
+ "--dsw-alias-state-error-secondary": {
640
+ "light": "#DB5A6B",
641
+ "dark": "#DB5A6B"
642
+ },
643
+ "--dsw-alias-state-success-primary": {
644
+ "light": "#21A675",
645
+ "dark": "#21A675"
646
+ },
647
+ "--dsw-alias-state-success-secondary": {
648
+ "light": "#2F7D5E",
649
+ "dark": "#2F7D5E"
650
+ },
651
+ "--dsw-alias-state-success-tertiary": {
652
+ "light": "#9DCDBA",
653
+ "dark": "#9DCDBA"
654
+ },
655
+ "--dsw-alias-state-warn-primary": {
656
+ "light": "#B97F3A",
657
+ "dark": "#B97F3A"
658
+ },
659
+ "--dsw-alias-state-warn-secondary": {
660
+ "light": "#D9A45E",
661
+ "dark": "#D9A45E"
662
+ },
663
+ "--dsw-alias-state-warn-tertiary": {
664
+ "light": "#EFDCC0",
665
+ "dark": "#EFDCC0"
666
+ },
667
+ "--dsw-alias-state-warn-label": {
668
+ "light": "#8A5A22",
669
+ "dark": "#8A5A22"
670
+ },
671
+ "--dsw-alias-state-business-primary": {
672
+ "light": "#E88BB0",
673
+ "dark": "#E88BB0"
674
+ },
675
+ "--dsw-alias-state-business-tertiary": {
676
+ "light": "#F8A8C2",
677
+ "dark": "#F8A8C2"
678
+ },
679
+ "--dsw-specific-sidebar-fill": {
680
+ "light": "linear-gradient(to bottom,#EAF7F0 0%,#D8EFE4 20%,#C9E8DA 36%,#BFE2D2 50%,#B4DCCA 62%,#A8D2BE 74%,#9CC9B2 86%,#90C0A8 100%)",
681
+ "dark": "linear-gradient(to bottom,#EAF7F0 0%,#D8EFE4 20%,#C9E8DA 36%,#BFE2D2 50%,#B4DCCA 62%,#A8D2BE 74%,#9CC9B2 86%,#90C0A8 100%)"
682
+ }
683
+ },
684
+ "reading": {
685
+ "colorScheme": "light",
686
+ "bg": "#FFFFFF",
687
+ "alpha": 0.62,
688
+ "blur": 3,
689
+ "maxWidth": 640
690
+ },
691
+ "accent": "#E88BB0",
692
+ "ambient": {
693
+ "kind": "shan",
694
+ "petals": 7
695
+ }
696
+ }
697
+ ]
698
+
699
+ /**
700
+ * Marks the ambient nodes this run created.
701
+ *
702
+ * The layer lives on the document body, so parentage can no longer identify
703
+ * ownership. Anything found without this stamp is debris from an earlier build —
704
+ * and because the shell keeps its DOM across a plugin reload, such nodes outlive
705
+ * the code that made them.
706
+ */
707
+ const AMBIENT_OWNER = 'theme-gallery'
708
+
709
+ /**
710
+ * Ambient scenery drawn inside the sidebar column.
711
+ *
712
+ * Ported from the source admin system, where each theme carried its own
713
+ * animation component mounted into a `left-sidebar-theme-container`. The
714
+ * artwork is reproduced here (mountains, mist, water, dragonflies, falling
715
+ * petals for 山青婷彩; corner glow, rising bubbles, swaying seaweed for
716
+ * 梦海游鱼) with two deliberate changes:
717
+ *
718
+ * - sizes are expressed in **percentages and em**, not the source system's
719
+ * fixed 223 px sidebar width, so the scene scales to whatever width the
720
+ * user drags the sidebar to;
721
+ * - nothing here carries colour of its own beyond the ported artwork, and the
722
+ * layer is `pointer-events:none` / `z-index:0`, so it can never intercept a
723
+ * click or cover a menu item. The source system has the same rule, and a
724
+ * bug note there records opaque mountains hiding the bottom menu rows.
725
+ *
726
+ * `#dsh-theme-ambient` is a seat this plugin injects into the sidebar column;
727
+ * `syncAmbient()` fills it, and each theme chooses its scene by `ambient.kind`.
728
+ */
729
+ const AMBIENT_CSS = [
730
+ /* The layer, arranged exactly like the working `dsh-theme-firefly` ambient layer:
731
+ a full-viewport fixed element styled by a CLASS, appended to `document.body`, at
732
+ `z-index: 60`.
733
+ Every other arrangement of this layer failed to paint on this machine — precise
734
+ inline geometry, maximum z-index, `documentElement` as the parent, inline styles
735
+ per element. The firefly plugin uses this one and renders, so this is now the
736
+ layer's arrangement. The scenery is placed INSIDE it (`.dsh-ambient-scene`). */
737
+ '#dsh-theme-ambient{position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:60}',
738
+ '.dsh-ambient-scene{overflow:hidden}',
739
+ /* The scene roots carry their own positioning inline too; the selectors below are
740
+ kept only where a rule cannot be inlined (`@keyframes`) or where they must reach
741
+ a shell element. */
742
+ '.ZTP-Xa_sidebarCol{position:relative}',
743
+
744
+ /* BRING-UP PROBE — remove with the rest of the diagnostics.
745
+ The control layer, copied rule-for-rule from the working `dsh-theme-firefly`
746
+ plugin: geometry and stacking from a class, motion from `@keyframes`, and only
747
+ the per-element random values inline. */
748
+ '.dsh-amb-control{position:fixed;inset:0;pointer-events:none;z-index:60;overflow:hidden}',
749
+ /* BRING-UP PROBE — the scene box inside the proven layer. No `pointer-events` here on
750
+ purpose: the layer above already disables it, and this box is the thing being
751
+ tested rather than a decoration that must stay click-through. */
752
+ '.dsh-amb-control-scene{overflow:hidden}',
753
+
754
+ /* The scenery's own geometry. These live here rather than inline because the scene
755
+ markup is built as a string, and they must reach it wherever it is mounted — which
756
+ is now inside the proven layer rather than a layer of its own. */
757
+ '.dsh-amb-control-scene .sta-mountains{position:absolute;left:0;right:0;bottom:0;height:46%;z-index:3}',
758
+ '.dsh-amb-control-scene .sta-mountains svg{display:block;width:100%;height:100%}',
759
+ '.dsh-amb-control-scene .sta-mist{position:absolute;height:1.6em;border-radius:1000px;z-index:4;',
760
+ 'background:linear-gradient(90deg,transparent,rgba(255,255,255,.75),transparent);',
761
+ 'filter:blur(5px);opacity:.85;animation:dsh-amb-mist 26s ease-in-out infinite alternate}',
762
+ '.dsh-amb-control-scene .sta-mist-1{width:62%;top:56%;left:12%}',
763
+ '.dsh-amb-control-scene .sta-mist-2{width:44%;top:61%;left:38%;opacity:.6;',
764
+ 'animation-duration:32s;animation-delay:-9s}',
765
+ '.dsh-amb-control-scene .sta-pond{position:absolute;left:0;right:0;bottom:0;height:13%;z-index:5;',
766
+ 'background:linear-gradient(to bottom,rgba(104,178,150,.62),rgba(66,141,113,.78))}',
767
+ '.dsh-amb-control-scene .sta-pond-line{position:absolute;top:0;left:0;right:0;height:1.2px;opacity:.6;',
768
+ 'background:linear-gradient(90deg,transparent,rgba(255,255,255,.9),transparent)}',
769
+ '.dsh-amb-control-scene .sta-ripple{position:absolute;z-index:6;width:.55em;height:.55em}',
770
+ '.dsh-amb-control-scene .sta-ripple span{position:absolute;inset:0;',
771
+ 'border:1.6px solid rgba(217,123,164,.9);border-radius:50%;',
772
+ 'animation:dsh-amb-ring 3.2s ease-out infinite}',
773
+ '.dsh-amb-control-scene .sta-ripple span:nth-child(2){animation-delay:1.6s}',
774
+ '@keyframes dsh-amb-ring{0%{transform:scale(.4);opacity:.8}100%{transform:scale(4.2);opacity:0}}',
775
+ '.dsh-amb-control-scene .sta-dfly{position:absolute;z-index:8;will-change:transform}',
776
+ '.dsh-amb-control-scene .sta-dfly svg{display:block;width:100%;height:auto}',
777
+ '.dsh-amb-control-scene .sta-bob{animation:dsh-amb-bob .9s ease-in-out infinite}',
778
+ '.dsh-amb-control-scene .sta-dfly-2 .sta-bob{animation-duration:1.1s;animation-delay:-.4s}',
779
+ /* The flight paths are bounded INSIDE the sidebar. The previous ones swept up to
780
+ 5.4em to the right, which carried the dragonfly out of the 280px column and under
781
+ the main column, where it was hidden — the drift is now horizontal-left-biased and
782
+ half the amplitude. */
783
+ '@keyframes dsh-amb-hover1{0%{transform:translate(0,0)}18%{transform:translate(1.4em,.7em)}',
784
+ '38%{transform:translate(2.4em,-.5em)}55%{transform:translate(1.2em,.5em)}',
785
+ '70%{transform:translate(-.5em,-.9em)}100%{transform:translate(0,0)}}',
786
+ '@keyframes dsh-amb-hover2{0%{transform:translate(0,0)}25%{transform:translate(-1.5em,-1.2em)}',
787
+ '50%{transform:translate(-2.4em,.5em)}75%{transform:translate(-.7em,1.2em)}100%{transform:translate(0,0)}}',
788
+ '@keyframes dsh-amb-bob{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-2.5px) rotate(-2deg)}}',
789
+ '.dsh-amb-control-scene .sta-petals{position:absolute;inset:0;z-index:2;pointer-events:none}',
790
+ '.dsh-amb-control-scene .sta-petal{position:absolute;top:-1em;',
791
+ 'border-radius:60% 40% 55% 45%/60% 55% 45% 40%;opacity:1;animation:dsh-amb-fall linear infinite;',
792
+ 'box-shadow:0 0 3px rgba(217,123,164,.45)}',
793
+ '@keyframes dsh-amb-fall{0%{transform:translate(0,-1em) rotate(0);opacity:0}8%{opacity:.9}',
794
+ '35%{transform:translate(-1.2em,5em) rotate(140deg)}70%{transform:translate(.9em,10em) rotate(280deg)}',
795
+ '100%{transform:translate(-.5em,15em) rotate(380deg);opacity:0}}',
796
+ '@keyframes dsh-amb-mist{from{transform:translateX(0)}to{transform:translateX(11%)}}',
797
+ /* 梦海游鱼 */
798
+ '.dsh-amb-control-scene .dof-glow{position:absolute;inset:0;z-index:2}',
799
+ '.dsh-amb-control-scene .dof-corner{position:absolute;top:0;left:-30%;width:150%;height:40%;',
800
+ 'background:radial-gradient(ellipse at 32% 50%,rgba(255,255,255,.55),rgba(255,255,255,0) 62%);',
801
+ 'filter:blur(12px);-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 45%);',
802
+ 'mask-image:linear-gradient(to bottom,transparent 0,#000 45%);',
803
+ 'animation:dsh-amb-wash 18s ease-in-out infinite alternate}',
804
+ '.dsh-amb-control-scene .dof-wash{position:absolute;left:-20%;width:140%;height:30%;filter:blur(12px);',
805
+ 'opacity:.5;background:linear-gradient(100deg,transparent,rgba(255,255,255,.8),transparent);',
806
+ '-webkit-mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);',
807
+ 'mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);',
808
+ 'animation:dsh-amb-wash 24s ease-in-out infinite alternate}',
809
+ '.dsh-amb-control-scene .dof-wash-1{top:14%}',
810
+ '.dsh-amb-control-scene .dof-wash-2{top:34%;opacity:.34;animation-duration:31s;animation-delay:-8s}',
811
+ '@keyframes dsh-amb-wash{from{transform:translateX(0)}to{transform:translateX(9%)}}',
812
+ '.dsh-amb-control-scene .dof-bubbles{position:absolute;inset:0;z-index:6;pointer-events:none}',
813
+ // The ORIGINAL rising bubbles, restored exactly as they were: pale spheres with a white
814
+ // inset ring. An earlier revision replaced them with glowing motes, which was wrong —
815
+ // the motes are a SECOND effect, not a new look for these.
816
+ '.dsh-amb-control-scene .dof-bubble{position:absolute;bottom:-1em;border-radius:50%;',
817
+ 'background:radial-gradient(circle at 32% 30%,rgba(255,255,255,.95),rgba(190,232,246,.55));',
818
+ 'box-shadow:inset 0 0 0 1px rgba(255,255,255,.6);animation:dsh-amb-rise linear infinite}',
819
+ '@keyframes dsh-amb-rise{0%{transform:translate(0,0) scale(.6);opacity:0}12%{opacity:.85}',
820
+ '55%{transform:translate(1em,-7em) scale(1)}100%{transform:translate(-.6em,-12.5em) scale(.8);opacity:0}}',
821
+ // The glowing motes, added alongside the bubbles.
822
+ //
823
+ // This is the effect the firefly control layer had: a soft light-blue core with a halo,
824
+ // drifting upward. Both layers are drawn at once, so the scene shows outlined bubbles
825
+ // AND glowing motes — two separate effects, as requested.
826
+ '.dsh-amb-control-scene .dof-motes{position:absolute;inset:0;z-index:7;pointer-events:none}',
827
+ '.dsh-amb-control-scene .dof-mote{position:absolute;bottom:-1em;border-radius:50%;',
828
+ 'background:radial-gradient(circle at 34% 30%,#FFFFFF 0%,#D6F1FF 40%,rgba(122,205,255,.5) 72%,rgba(122,205,255,0) 100%);',
829
+ 'box-shadow:0 0 10px 3px rgba(122,205,255,.55),0 0 22px 6px rgba(122,205,255,.22);',
830
+ 'animation:dsh-amb-mote linear infinite}',
831
+ // A straighter, quicker climb than the bubbles, so the two read as distinct effects
832
+ // rather than one doubled-up stream.
833
+ '@keyframes dsh-amb-mote{0%{transform:translate(0,0) scale(.5);opacity:0}10%{opacity:.95}',
834
+ '50%{transform:translate(-.8em,-8em) scale(1)}100%{transform:translate(.5em,-13em) scale(.85);opacity:0}}',
835
+ // Swimming fish, ported from the source system's separate `FishAnimation.vue`.
836
+ // The component drove them through entering / bubbling / leaving phases in JavaScript;
837
+ // a static bundle has nowhere to run that, so the same artwork crosses the water
838
+ // continuously instead, on CSS animations.
839
+ '.dsh-amb-control-scene .dof-fish{position:absolute;left:0;z-index:6;pointer-events:none;',
840
+ 'will-change:transform}',
841
+ '.dsh-amb-control-scene .dof-fish svg{display:block;width:100%;height:auto}',
842
+ // Right-to-left fish are mirrored, so the nose leads in both directions.
843
+ '.dsh-amb-control-scene .dof-fish-flip svg{transform:scaleX(-1)}',
844
+ '.dsh-amb-control-scene .dof-fish-bob{animation:dsh-amb-fish-bob 2.4s ease-in-out infinite}',
845
+ '.dsh-amb-control-scene .dof-fish-tail{transform-origin:10px 10px;',
846
+ 'animation:dsh-amb-fish-tail .9s ease-in-out infinite alternate}',
847
+ // Crossings start well off one edge and finish well off the other, so a fish enters and
848
+ // leaves instead of appearing and vanishing mid-water.
849
+ '@keyframes dsh-amb-swim{0%{transform:translateX(-6em)}100%{transform:translateX(24em)}}',
850
+ '@keyframes dsh-amb-swim-back{0%{transform:translateX(24em)}100%{transform:translateX(-6em)}}',
851
+ '@keyframes dsh-amb-fish-bob{0%,100%{transform:translateY(0) rotate(0)}',
852
+ '50%{transform:translateY(-3px) rotate(-1.6deg)}}',
853
+ '@keyframes dsh-amb-fish-tail{from{transform:rotate(-9deg)}to{transform:rotate(9deg)}}',
854
+ '.dsh-amb-control-scene .dof-seaweed{position:absolute;left:0;right:0;bottom:0;height:34%;z-index:5}',
855
+ '.dsh-amb-control-scene .dof-seaweed svg{display:block;width:100%;height:100%}',
856
+ '.dsh-amb-control-scene .dof-blade{transform-origin:50% 100%;',
857
+ 'animation:dsh-amb-sway 6s ease-in-out infinite alternate}',
858
+ '.dsh-amb-control-scene .dof-blade-2{animation-duration:7.4s;animation-delay:-1.6s}',
859
+ '.dsh-amb-control-scene .dof-blade-3{animation-duration:5.2s;animation-delay:-2.8s}',
860
+ '.dsh-amb-control-scene .dof-blade-4{animation-duration:8.1s;animation-delay:-.9s}',
861
+ '.dsh-amb-control-scene .dof-blade-5{animation-duration:6.6s;animation-delay:-3.4s}',
862
+ '@keyframes dsh-amb-sway{from{transform:rotate(-3.5deg)}to{transform:rotate(3.5deg)}}',
863
+ '@media (prefers-reduced-motion:reduce){.dsh-amb-control-scene *{animation:none!important}}',
864
+
865
+
866
+ /* ── 山青婷彩 ─────────────────────────────────────────────────────── */
867
+ '#dsh-theme-ambient .sta-mountains{position:absolute;left:0;right:0;bottom:0;height:46%;z-index:3}',
868
+ '#dsh-theme-ambient .sta-mountains svg{display:block;width:100%;height:100%}',
869
+ '#dsh-theme-ambient .sta-mist{position:absolute;height:1.6em;border-radius:1000px;z-index:4;',
870
+ 'background:linear-gradient(90deg,transparent,rgba(255,255,255,.75),transparent);',
871
+ 'filter:blur(5px);opacity:.85;animation:dsh-amb-mist 26s ease-in-out infinite alternate}',
872
+ '#dsh-theme-ambient .sta-mist-1{width:66%;top:58.5%;left:13%}',
873
+ '#dsh-theme-ambient .sta-mist-2{width:48%;top:63%;left:40%;opacity:.6;animation-duration:32s;animation-delay:-9s}',
874
+ '@keyframes dsh-amb-mist{from{transform:translateX(0)}to{transform:translateX(11%)}}',
875
+
876
+ '#dsh-theme-ambient .sta-pond{position:absolute;left:0;right:0;bottom:0;height:14%;z-index:5;',
877
+ 'background:linear-gradient(to bottom,rgba(104,178,150,.62),rgba(66,141,113,.78))}',
878
+ '#dsh-theme-ambient .sta-pond-line{position:absolute;top:0;left:0;right:0;height:1.2px;opacity:.6;',
879
+ 'background:linear-gradient(90deg,transparent,rgba(255,255,255,.9),transparent)}',
880
+
881
+ '#dsh-theme-ambient .sta-ripple{position:absolute;z-index:6;width:.55em;height:.55em}',
882
+ '#dsh-theme-ambient .sta-ripple span{position:absolute;inset:0;border:1.6px solid rgba(232,139,176,.92);',
883
+ 'border-radius:50%;animation:dsh-amb-ring 3.2s ease-out infinite}',
884
+ '#dsh-theme-ambient .sta-ripple span:nth-child(2){animation-delay:1.6s}',
885
+ '@keyframes dsh-amb-ring{0%{transform:scale(.4);opacity:.8}100%{transform:scale(4.2);opacity:0}}',
886
+
887
+ '#dsh-theme-ambient .sta-dfly{position:absolute;z-index:8;width:6.4em;will-change:transform}',
888
+ '#dsh-theme-ambient .sta-dfly svg{display:block;width:100%;height:auto}',
889
+ '#dsh-theme-ambient .sta-dfly-1{top:30%;left:16%;animation:dsh-amb-hover1 11s ease-in-out infinite}',
890
+ '#dsh-theme-ambient .sta-dfly-2{top:52%;left:48%;width:4.4em;opacity:.95;',
891
+ 'animation:dsh-amb-hover2 13s ease-in-out infinite;animation-delay:-5s}',
892
+ '#dsh-theme-ambient .sta-bob{animation:dsh-amb-bob .9s ease-in-out infinite}',
893
+ '#dsh-theme-ambient .sta-dfly-2 .sta-bob{animation-duration:1.1s;animation-delay:-.4s}',
894
+ '@keyframes dsh-amb-hover1{0%{transform:translate(0,0)}18%{transform:translate(2.4em,.9em)}',
895
+ '38%{transform:translate(5.4em,-.6em)}55%{transform:translate(2.8em,.6em)}',
896
+ '70%{transform:translate(-1.1em,-1.3em)}100%{transform:translate(0,0)}}',
897
+ '@keyframes dsh-amb-hover2{0%{transform:translate(0,0)}25%{transform:translate(-2.8em,-1.7em)}',
898
+ '50%{transform:translate(-4.8em,.7em)}75%{transform:translate(-1.9em,1.7em)}100%{transform:translate(0,0)}}',
899
+ '@keyframes dsh-amb-bob{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-2.5px) rotate(-2deg)}}',
900
+
901
+ '#dsh-theme-ambient .sta-petals{position:absolute;inset:0;z-index:7;pointer-events:none}',
902
+ '#dsh-theme-ambient .sta-petal{position:absolute;top:-1em;border-radius:60% 40% 55% 45%/60% 55% 45% 40%;',
903
+ 'background:linear-gradient(135deg,#F9A8C8 0%,#E88BB0 55%,#CF6B96 100%);opacity:.9;animation:dsh-amb-fall linear infinite}',
904
+ /* Distances are measured against the BAND the seat occupies, not the viewport.
905
+ `vh` units were fine when the seat filled the column; in a band they carried
906
+ petals and bubbles straight past its bottom edge, where `overflow:hidden`
907
+ removed them mid-flight. The band is roughly a third of the viewport, so
908
+ these values cover it with a little margin. */
909
+ '@keyframes dsh-amb-fall{0%{transform:translate(0,-1em) rotate(0);opacity:0}8%{opacity:.9}',
910
+ '35%{transform:translate(-1.5em,10em) rotate(140deg)}70%{transform:translate(1.1em,20em) rotate(280deg)}',
911
+ '100%{transform:translate(-.6em,30em) rotate(380deg);opacity:0}}',
912
+
913
+ /* ── 梦海游鱼 ─────────────────────────────────────────────────────── */
914
+ '#dsh-theme-ambient .dof-glow{position:absolute;inset:0;z-index:2}',
915
+ '#dsh-theme-ambient .dof-corner{position:absolute;top:0;left:-30%;width:150%;height:40%;',
916
+ 'background:radial-gradient(ellipse at 32% 50%,rgba(255,255,255,.55),rgba(255,255,255,0) 62%);',
917
+ 'filter:blur(12px);-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 45%);',
918
+ 'mask-image:linear-gradient(to bottom,transparent 0,#000 45%);',
919
+ 'animation:dsh-amb-wash 18s ease-in-out infinite alternate}',
920
+ '#dsh-theme-ambient .dof-wash{position:absolute;left:-20%;width:140%;height:30%;filter:blur(12px);opacity:.5;',
921
+ 'background:linear-gradient(100deg,transparent,rgba(255,255,255,.8),transparent);',
922
+ '-webkit-mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);',
923
+ 'mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);',
924
+ 'animation:dsh-amb-wash 24s ease-in-out infinite alternate}',
925
+ '#dsh-theme-ambient .dof-wash-1{top:14%}',
926
+ '#dsh-theme-ambient .dof-wash-2{top:34%;opacity:.34;animation-duration:31s;animation-delay:-8s}',
927
+ '@keyframes dsh-amb-wash{from{transform:translateX(0)}to{transform:translateX(9%)}}',
928
+
929
+ '#dsh-theme-ambient .dof-bubbles{position:absolute;inset:0;z-index:6;pointer-events:none}',
930
+ '#dsh-theme-ambient .dof-bubble{position:absolute;bottom:-1em;border-radius:50%;',
931
+ 'background:radial-gradient(circle at 32% 30%,rgba(255,255,255,.95),rgba(190,232,246,.55));',
932
+ 'box-shadow:inset 0 0 0 1px rgba(255,255,255,.6);animation:dsh-amb-rise linear infinite}',
933
+ '@keyframes dsh-amb-rise{0%{transform:translate(0,0) scale(.6);opacity:0}12%{opacity:.85}',
934
+ '55%{transform:translate(1em,-7em) scale(1)}100%{transform:translate(-.6em,-12.5em) scale(.8);opacity:0}}',
935
+
936
+ '#dsh-theme-ambient .dof-seaweed{position:absolute;left:0;right:0;bottom:0;height:38%;z-index:5}',
937
+ '#dsh-theme-ambient .dof-seaweed svg{display:block;width:100%;height:100%}',
938
+ '#dsh-theme-ambient .dof-blade{transform-origin:50% 100%;animation:dsh-amb-sway 6s ease-in-out infinite alternate}',
939
+ '#dsh-theme-ambient .dof-blade-2{animation-duration:7.4s;animation-delay:-1.6s}',
940
+ '#dsh-theme-ambient .dof-blade-3{animation-duration:5.2s;animation-delay:-2.8s}',
941
+ '#dsh-theme-ambient .dof-blade-4{animation-duration:8.1s;animation-delay:-.9s}',
942
+ '#dsh-theme-ambient .dof-blade-5{animation-duration:6.6s;animation-delay:-3.4s}',
943
+ '@keyframes dsh-amb-sway{from{transform:rotate(-3.5deg)}to{transform:rotate(3.5deg)}}',
944
+ /* Fish rules for the PREVIEW page, which mounts the scene into a
945
+ `#dsh-theme-ambient` seat rather than the live `.dsh-amb-control-scene`
946
+ box. The live layer styles these classes in its own section above; the
947
+ preview would otherwise draw fish that never mirror, bob or beat their
948
+ tails — a lying preview. */
949
+ '#dsh-theme-ambient .dof-fish{position:absolute;left:0;z-index:6;pointer-events:none;',
950
+ 'will-change:transform}',
951
+ '#dsh-theme-ambient .dof-fish svg{display:block;width:100%;height:auto}',
952
+ '#dsh-theme-ambient .dof-fish-flip svg{transform:scaleX(-1)}',
953
+ '#dsh-theme-ambient .dof-fish-bob{animation:dsh-amb-fish-bob 2.4s ease-in-out infinite}',
954
+ '#dsh-theme-ambient .dof-fish-tail{transform-origin:10px 10px;',
955
+ 'animation:dsh-amb-fish-tail .9s ease-in-out infinite alternate}',
956
+ '#dsh-theme-ambient .dof-mote{position:absolute;bottom:-1em;border-radius:50%;',
957
+ 'background:radial-gradient(circle at 34% 30%,#FFFFFF 0%,#D6F1FF 40%,rgba(122,205,255,.5) 72%,rgba(122,205,255,0) 100%);',
958
+ 'box-shadow:0 0 10px 3px rgba(122,205,255,.55),0 0 22px 6px rgba(122,205,255,.22);',
959
+ 'animation:dsh-amb-mote linear infinite}',
960
+
961
+ /* Respect a user who has asked the system for less motion: the scenery
962
+ stays, the movement does not. */
963
+ '@media (prefers-reduced-motion:reduce){#dsh-theme-ambient *{animation:none!important}}',
964
+ ].join('\n')
965
+
966
+ /** Gallery page copy. */
967
+ const zh = {
968
+ title: '主题皮肤',
969
+ hint: '点一下即切换,选中会记入设置',
970
+ count: '{count} 个可选主题',
971
+ current: '当前',
972
+ applied: '已应用',
973
+ empty: '正在读取官方主题注册表…',
974
+ }
975
+ const en = {
976
+ title: 'Theme skins',
977
+ hint: 'Click to switch; the choice is remembered',
978
+ count: '{count} themes available',
979
+ current: 'Current',
980
+ applied: 'Applied',
981
+ empty: 'Reading the theme registry…',
982
+ }
983
+
984
+ /**
985
+ * Lighten a colour toward white by an alpha.
986
+ *
987
+ * Done in JS rather than CSS `color-mix` so the value needs no support check
988
+ * and the same string is available without reading computed styles.
989
+ * @param hex - `#rrggbb` or `#rgb`.
990
+ * @param alpha - 0..1: how much of the original colour survives over white.
991
+ * @returns an `rgb()` string, or the input when it is not a hex colour.
992
+ */
993
+ function lighten(hex, alpha) {
994
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex).trim())
995
+ if (m === null) return String(hex)
996
+ let h = m[1]
997
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
998
+ const mix = (c) => Math.round(255 - (255 - c) * alpha)
999
+ return `rgb(${mix(parseInt(h.slice(0, 2), 16))},${mix(parseInt(h.slice(2, 4), 16))},${mix(parseInt(h.slice(4, 6), 16))})`
1000
+ }
1001
+
1002
+ /** The gallery page's stylesheet, installed once and owned by this plugin. */
1003
+ const PAGE_CSS = [
1004
+ '.tg-page{padding:20px 24px;display:flex;flex-direction:column;gap:16px;height:100%;overflow:auto}',
1005
+ '.tg-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}',
1006
+ '.tg-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary)}',
1007
+ '.tg-hint{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
1008
+ '.tg-debug{font:11px/1.6 ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-tertiary);',
1009
+ 'background:var(--dsw-alias-bg-layer-3);border:.5px solid var(--dsw-alias-border-l3);',
1010
+ 'border-radius:8px;padding:8px 10px;word-break:break-all}',
1011
+ '.tg-warn{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}',
1012
+ '.tg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}',
1013
+ '.tg-card{display:flex;flex-direction:column;gap:8px;padding:12px;text-align:left;cursor:pointer;',
1014
+ 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);',
1015
+ 'border:.5px solid var(--dsw-alias-border-l3);border-radius:10px;font:inherit;transition:border-color .15s,background .15s}',
1016
+ '.tg-card:hover{background:var(--dsw-alias-interactive-bg-hover)}',
1017
+ '.tg-card[aria-pressed="true"]{border-color:var(--dsw-alias-brand-primary);',
1018
+ 'box-shadow:inset 0 0 0 1px var(--dsw-alias-brand-primary)}',
1019
+ '.tg-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px}',
1020
+ '.tg-name{font-size:13.5px;font-weight:600}',
1021
+ '.tg-badge{font-size:11px;padding:1px 7px;border-radius:999px;',
1022
+ 'background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}',
1023
+ '.tg-desc{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
1024
+ '.tg-strip{display:flex;height:6px;border-radius:999px;overflow:hidden;border:.5px solid var(--dsw-alias-border-l3)}',
1025
+ '.tg-strip span{flex:1}',
1026
+ ].join('')
1027
+
1028
+ /**
1029
+ * Mirror the registry into the page's store.
1030
+ *
1031
+ * Same shape as the official settings-store.ts, including why it passes no
1032
+ * explicit type arguments: the generics settle from `init` plus the untyped
1033
+ * draft parameter in one inference round.
1034
+ * @returns store handle used as the page registration's `store` seat.
1035
+ */
1036
+ function createGalleryStore() {
1037
+ return defineStore({
1038
+ init: () => ({ ids: [], labels: {}, descriptions: {}, swatches: {}, selected: 'system', status: '', revision: -1 }),
1039
+ actions: {
1040
+ sync: (draft, themes, selected, revision) => {
1041
+ if (revision <= draft.revision) return
1042
+ const labels = {}
1043
+ const descriptions = {}
1044
+ const swatches = {}
1045
+ for (const theme of themes) {
1046
+ // Built-in themes carry no label — the official Appearance row
1047
+ // localizes them from its own dictionaries — so name them here
1048
+ // rather than showing a bare `light` / `dark` id.
1049
+ labels[theme.id] = theme.label || BUILT_IN_LABELS[theme.id] || theme.id
1050
+ // A built-in theme derives its colours from the base palette rather
1051
+ // than declaring tokens, so its card gets a plain line.
1052
+ descriptions[theme.id] = theme.description
1053
+ || (theme.id in BUILT_IN_LABELS ? '' : '')
1054
+ // A card's colour strip comes from the theme's own tokens, so a
1055
+ // theme authored anywhere shows its identity without extra metadata.
1056
+ const tokens = theme.tokens || {}
1057
+ const pick = (name) => {
1058
+ const value = tokens[name]
1059
+ if (value === undefined || value === null) return undefined
1060
+ return typeof value === 'string' ? value : value[theme.colorScheme]
1061
+ }
1062
+ swatches[theme.id] = [
1063
+ pick('--dsw-alias-brand-primary'),
1064
+ pick('--dsw-alias-label-secondary'),
1065
+ pick('--dsw-alias-state-business-primary'),
1066
+ ].filter((value) => typeof value === 'string' && value !== '')
1067
+ }
1068
+ draft.ids = themes.map((theme) => theme.id)
1069
+ draft.labels = labels
1070
+ draft.descriptions = descriptions
1071
+ draft.swatches = swatches
1072
+ draft.selected = selected
1073
+ draft.revision = revision
1074
+ },
1075
+ /**
1076
+ * Record why the page has nothing to show.
1077
+ *
1078
+ * An empty picker is indistinguishable from a broken picker without
1079
+ * this, and the boot screen only ever says "failed" — never why.
1080
+ * @param draft - store draft.
1081
+ * @param status - one line describing the state.
1082
+ */
1083
+ note: (draft, status) => { draft.status = status },
1084
+ },
1085
+ })
1086
+ }
1087
+
1088
+ /**
1089
+ * Render one selectable theme card.
1090
+ * @param props - id, label, description, swatches, selected, onSelect.
1091
+ * @returns the card element.
1092
+ */
1093
+ function ThemeCard(props) {
1094
+ const { id, label, description, swatches, selected, applied, onSelect, t } = props
1095
+ return jsxs('button', {
1096
+ type: 'button',
1097
+ className: 'tg-card',
1098
+ 'aria-pressed': selected,
1099
+ onClick: () => { onSelect(id) },
1100
+ title: description || label,
1101
+ children: [
1102
+ jsxs('div', {
1103
+ className: 'tg-card-top',
1104
+ children: [
1105
+ jsx('span', { className: 'tg-name', children: label || id }),
1106
+ selected ? jsx('span', { className: 'tg-badge', children: applied }) : null,
1107
+ ],
1108
+ }),
1109
+ swatches.length > 0
1110
+ ? jsx('span', {
1111
+ className: 'tg-strip',
1112
+ children: swatches.map((colour, index) => jsx('span', { style: { background: colour } }, index)),
1113
+ })
1114
+ : null,
1115
+ description ? jsx('span', { className: 'tg-desc', children: description }) : null,
1116
+ ],
1117
+ })
1118
+ }
1119
+
1120
+ /**
1121
+ * Whether the gallery shows its diagnostic line.
1122
+ *
1123
+ * Opt-in through the URL hash — `#theme-gallery-debug` — so the page stays
1124
+ * clean in normal use while the one tool that actually located the token and
1125
+ * presenter faults stays one reload away. From outside the app, "selected but
1126
+ * nothing changed" cannot be split into "not registered", "not selected",
1127
+ * "registered with broken tokens", or "painted over by our own CSS"; this line
1128
+ * splits it by showing what the theme SERVICE believes next to what the
1129
+ * document actually resolves.
1130
+ * @returns true when the debug line should render.
1131
+ */
1132
+ function debugEnabled() {
1133
+ try {
1134
+ return typeof window !== 'undefined'
1135
+ && typeof window.location?.hash === 'string'
1136
+ && window.location.hash.includes('theme-gallery-debug')
1137
+ } catch {
1138
+ return false
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Read the theme state straight out of the document.
1144
+ *
1145
+ * It reads the live DOM rather than the theme service so a divergence between
1146
+ * the two becomes visible instead of being assumed away — that divergence is
1147
+ * exactly what a broken token or a stylesheet painting over the skin looks
1148
+ * like.
1149
+ * @param selected - the preference the page is showing as selected.
1150
+ * @returns one line describing the chain.
1151
+ */
1152
+ function themeDiagnostics(selected) {
1153
+ try {
1154
+ if (typeof document === 'undefined') return 'no document'
1155
+ const ctx = window.__DSH_THEME_DEBUG__ ?? {}
1156
+ const active = ctx.activeId === undefined ? '?' : String(ctx.activeId)
1157
+ const tokens = ctx.activeTokens === undefined ? '?' : String(ctx.activeTokens)
1158
+ const body = document.body
1159
+ const scheme = body === null ? '?' : (body.hasAttribute('data-ds-dark-theme') ? 'dark' : 'light')
1160
+ const background = body === null ? '?' : getComputedStyle(body).backgroundColor
1161
+ const brand = body === null
1162
+ ? '?'
1163
+ : getComputedStyle(body).getPropertyValue('--dsw-alias-brand-primary').trim() || '(unset)'
1164
+ const sidebar = body === null
1165
+ ? '?'
1166
+ : getComputedStyle(body).getPropertyValue('--dsw-specific-sidebar-fill').trim() || '(unset)'
1167
+ return `诊断 · 选中=${selected} · 服务内活动主题=${active} · 该主题 token=${tokens}`
1168
+ + ` · 配色=${scheme} · body 背景=${background}`
1169
+ + ` · brand=${brand} · sidebar-fill=${sidebar}`
1170
+ + ` · ${describeAccentLayer()}`
1171
+ + ` · 装饰=${describeAmbientReport()}`
1172
+ } catch (error) {
1173
+ return `诊断失败: ${String(error && error.message ? error.message : error)}`
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * Whether the scenery the active theme asks for actually reached the document.
1179
+ *
1180
+ * The plugin cannot see the app's console, and "the scenery did not appear" has
1181
+ * several indistinguishable causes. So the panel checks the one thing it can:
1182
+ * the active theme declares `ambient`, and the layer is missing, empty, or
1183
+ * unstyled. When that happens the reader is owed the reason on screen rather
1184
+ * than in a log they would have to know to open.
1185
+ * @param selected - the preference the page is showing as selected.
1186
+ * @returns a warning line, or null when nothing is wrong.
1187
+ */
1188
+ function ambientWarning(selected) {
1189
+ try {
1190
+ const wanted = bundledTheme(selected)?.ambient
1191
+ if (wanted === undefined) return null
1192
+ const report = ambientReport
1193
+ if (report === undefined) return `装饰未同步:syncSkin 尚未运行(期望 ${wanted.kind})`
1194
+ if (report.found === false) return `装饰未生效:${report.note}(期望 ${wanted.kind})`
1195
+ if (report.paintError !== undefined) return `装饰绘制抛错:${report.paintError}`
1196
+ if (report.children === 0) return `装饰层已插入但为空(期望 ${wanted.kind}):未能构建任何节点`
1197
+ // The layer deliberately lives on the document body now, so "inside the sidebar
1198
+ // column" is no longer a requirement — checking it produced a permanent false
1199
+ // alarm and hid the readings that mattered.
1200
+ if (report.css !== true) return '样式表未生效:AMBIENT_CSS 不在 document 中'
1201
+ if (report.size === '0x0') return `装饰层尺寸为 0:display=${report.display} position=${report.position}`
1202
+ if (report.html === '(空)') return '装饰层里没有内容:innerHTML 未被写入'
1203
+ if (report.probe === '未建出') return '探针未建出:说明绘制在探针之前就中断了'
1204
+ if (report.artSize === '0x0') return '素材高度塌缩为 0:百分比的参照物没有高度'
1205
+ return null
1206
+ } catch (error) {
1207
+ return `装饰自检失败: ${String(error && error.message ? error.message : error)}`
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * The one-line scenery status shown on the panel.
1213
+ *
1214
+ * Deliberately unconditional during bring-up, and deliberately dumb: it prints
1215
+ * the report whatever it says. The previous revision only spoke when a
1216
+ * self-check considered something wrong, so a check that passed while the
1217
+ * scenery was still invisible produced **silence** — the worst possible output
1218
+ * for a diagnostic. Printing the numbers always means the reader sees the
1219
+ * geometry even when the code's opinion of it is wrong.
1220
+ * @param selected - the preference the page is showing as selected.
1221
+ * @returns the line, or null when the active theme asks for no scenery.
1222
+ */
1223
+ function sceneryLine(selected) {
1224
+ try {
1225
+ if (bundledTheme(selected)?.ambient === undefined) return null
1226
+ const warning = ambientWarning(selected)
1227
+ const prefix = warning === null ? '装饰自检通过' : `⚠ ${warning}`
1228
+ return `${prefix} · ${describeAmbientReport()}`
1229
+ } catch (error) {
1230
+ return `装饰自检失败: ${String(error && error.message ? error.message : error)}`
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * Render the scenery report as one line.
1236
+ * @returns the report text.
1237
+ */
1238
+ function describeAmbientReport() {
1239
+ const report = ambientReport
1240
+ // The attempt log comes FIRST, because it answers the question the final state cannot:
1241
+ // whether the scenery was ever placed while the sidebar existed. Boot-time silence
1242
+ // leaves a perfectly healthy final report — the successful run happens later — so the
1243
+ // sequence is the only thing that shows the failure.
1244
+ const tried = `同步记录[${describeAmbientLog()}]`
1245
+ if (report === undefined) return `尚未同步(syncSkin 未运行) · ${tried}`
1246
+ if (report.found === false) return `未生效:${report.note} · ${tried}`
1247
+ if (report.note !== undefined) return `${report.kind} · ${report.note} · ${tried}`
1248
+ return `${report.kind} · 子元素=${report.children} · 座位=${report.size}`
1249
+ + ` · 挂载于=${report.parent}`
1250
+ + ` · 定位=${report.placement}`
1251
+ + ` · 内容=${report.html}`
1252
+ + ` · 子链=${report.kids}`
1253
+ + ` · 场景=${report.sceneSize} · 素材=${report.artSize}`
1254
+ + ` · 命中测试[${report.hitTest}]`
1255
+ + ` · 列几何[${report.columns}]`
1256
+ + ` · display=${report.display} · position=${report.position} · z-index=${report.zIndex}`
1257
+ + ` · ${tried}`
1258
+ }
1259
+
1260
+ /**
1261
+ * The 山青婷彩 scene: two mountain ridges with a mist band, water at the foot
1262
+ * with expanding ripples, two hovering dragonflies, and falling petals.
1263
+ *
1264
+ * Ported from the source system's `ShanQingTingCaiAnimation.vue` one-to-one —
1265
+ * same paths, same gradients, same hues — so the port is recognisably the same
1266
+ * artwork rather than an approximation. Gradient ids are prefixed `dsh-` so
1267
+ * two themes can never collide through a shared `<defs>` id.
1268
+ * @param petals - how many petals to seed.
1269
+ * @returns the scene element.
1270
+ */
1271
+ /* ---------------- scenery markup ----------------
1272
+ *
1273
+ * The scenes are HTML STRINGS, injected with `innerHTML`, rather than React
1274
+ * elements mounted into a root owned by this plugin.
1275
+ *
1276
+ * Two earlier attempts failed silently and cost real debugging time:
1277
+ * `createRoot` mounted nothing at all in the shipped app, and a hand-written
1278
+ * walker that created nodes itself also produced an empty seat — while a plain
1279
+ * pseudo-element on the same seat was visible, which proved the seat paints and
1280
+ * put the fault squarely in how the children were constructed.
1281
+ *
1282
+ * Markup removes the two things those approaches had to get right by hand:
1283
+ * the HTML parser switches to the SVG namespace on its own inside `<svg>`, and
1284
+ * attribute names are written as the SVG actually spells them (`stop-color`)
1285
+ * instead of being translated from JSX casing. Everything here is also plain
1286
+ * text, so it can be asserted without a DOM.
1287
+ *
1288
+ * Only attributes and hex/CSS values reach this builder — no text content, no
1289
+ * user input — and `sceneMarkup` refuses anything script-bearing regardless.
1290
+ */
1291
+ const SVG_TAGS = new Set(['svg', 'defs', 'linearGradient', 'stop', 'path', 'ellipse', 'g', 'circle'])
1292
+
1293
+ /**
1294
+ * Serialise one attribute.
1295
+ * @param name - the attribute name, already in its markup spelling.
1296
+ * @param value - its value.
1297
+ * @returns the attribute, or an empty string when it carries no value.
1298
+ */
1299
+ function attr(name, value) {
1300
+ const text = String(value)
1301
+ const safe = SVG_TAGS.has('svg') && (name.startsWith('on') || /^javascript:/i.test(text))
1302
+ ? ''
1303
+ : ` ${name}="${text.replace(/"/g, '&quot;')}"`
1304
+ return safe
1305
+ }
1306
+
1307
+ /**
1308
+ * Open a tag.
1309
+ * @param tag - element name.
1310
+ * @param attributes - attribute map; nullish values are dropped.
1311
+ * @returns the opening tag.
1312
+ */
1313
+ function open(tag, attributes) {
1314
+ let out = `<${tag}`
1315
+ for (const [name, value] of Object.entries(attributes ?? {})) {
1316
+ if (value === null || value === undefined) continue
1317
+ out += attr(name, value)
1318
+ }
1319
+ return `${out}>`
1320
+ }
1321
+
1322
+ /**
1323
+ * The 山青婷彩 scene: two mountain ridges, a mist band, water at the foot with
1324
+ * expanding ripples, two hovering dragonflies, and falling petals.
1325
+ *
1326
+ * Ported from the source system's \`ShanQingTingCaiAnimation.vue\` — same paths,
1327
+ * same gradients, same structure. Colours are darkened from the source values:
1328
+ * the originals sat behind a 223px sidebar and, at this width, the far ridge
1329
+ * landed on the sidebar's own gradient value and was invisible.
1330
+ * @param petals - how many petals to seed.
1331
+ * @returns the scene markup.
1332
+ */
1333
+ function shanAmbientScene(petals) {
1334
+ const count = Math.max(0, Math.min(20, petals ?? 7))
1335
+ let petalNodes = ''
1336
+ for (let n = 1; n <= count; n += 1) {
1337
+ const petalSize = 7 + ((n * 3) % 4)
1338
+ petalNodes += open('div', {
1339
+ class: 'sta-petal',
1340
+ // Everything inline: position, size, colour, shape and the animation timing.
1341
+ style: 'position:absolute;top:-1em;'
1342
+ + `left:${((n * 17) % 80) + 8}%;width:${petalSize}px;height:${petalSize - 1}px;`
1343
+ + 'background:linear-gradient(135deg,#F8BBD2 0%,#E88BB0 60%,#D97BA4 100%);'
1344
+ + 'border-radius:60% 40% 55% 45%/60% 55% 45% 40%;opacity:.9;'
1345
+ + `animation:dsh-amb-fall ${6 + ((n * 5) % 3)}s linear infinite;animation-delay:${-(n * 0.9)}s`,
1346
+ }) + '</div>'
1347
+ }
1348
+
1349
+ const mountains = open('div', {
1350
+ class: 'sta-mountains',
1351
+ // Inline, like the layer itself: if the ridge appears but stays flat, the
1352
+ // stylesheet is not being applied at all, which the computed readings could not
1353
+ // reveal because they were reading the layer's inline values.
1354
+ style: 'position:absolute;left:0;right:0;bottom:0;height:46%;z-index:3',
1355
+ })
1356
+ + open('svg', {
1357
+ viewBox: '0 0 223 190',
1358
+ preserveAspectRatio: 'none',
1359
+ // No background. A magenta fill lived here while the layer's ability to paint was
1360
+ // still in question; it is what the user saw as a bright pink block over the
1361
+ // sidebar, and the ridges below are the actual artwork.
1362
+ style: 'display:block;width:100%;height:100%',
1363
+ })
1364
+ + open('defs', {})
1365
+ + open('linearGradient', { id: 'dsh-sta-back', x1: '0', y1: '0', x2: '0', y2: '1' })
1366
+ + open('stop', { offset: '0', 'stop-color': '#8FBFAA' }) + '</stop>'
1367
+ + open('stop', { offset: '1', 'stop-color': '#74AE96' }) + '</stop>'
1368
+ + '</linearGradient>'
1369
+ + open('linearGradient', { id: 'dsh-sta-front', x1: '0', y1: '0', x2: '0', y2: '1' })
1370
+ + open('stop', { offset: '0', 'stop-color': '#3E8A66' }) + '</stop>'
1371
+ + open('stop', { offset: '1', 'stop-color': '#2B6E4F' }) + '</stop>'
1372
+ + '</linearGradient>'
1373
+ + '</defs>'
1374
+ + open('path', {
1375
+ d: 'M 0 78 Q 30 48 62 66 Q 96 34 128 60 Q 160 40 190 62 Q 208 50 223 58 L 223 190 L 0 190 Z',
1376
+ fill: 'url(#dsh-sta-back)',
1377
+ }) + '</path>'
1378
+ + open('path', {
1379
+ d: 'M 0 122 Q 36 92 70 110 Q 104 84 140 108 Q 176 90 223 116 L 223 190 L 0 190 Z',
1380
+ fill: 'url(#dsh-sta-front)',
1381
+ }) + '</path>'
1382
+ + '</svg>'
1383
+ + '</div>'
1384
+
1385
+ return open('div', {
1386
+ class: 'sta',
1387
+ // Fills the scene box — the sidebar's blank area — and no more.
1388
+ //
1389
+ // This was stretched to the full viewport during bring-up. That left it 1280x820
1390
+ // inside a 280x260 box, so every percentage-positioned child (the ridges at 46%
1391
+ // height, the dragonflies at 58%/74%) resolved against the SCREEN and landed
1392
+ // outside the box, where `overflow:hidden` removed them. Only the petals stayed
1393
+ // visible, because they start at the box's top edge and fall into it.
1394
+ style: 'position:absolute;inset:0;display:block',
1395
+ })
1396
+ + mountains
1397
+ + open('div', {
1398
+ class: 'sta-mist sta-mist-1',
1399
+ style: 'position:absolute;height:1.6em;width:66%;top:58.5%;left:13%;border-radius:1000px;z-index:4;'
1400
+ + 'background:linear-gradient(90deg,transparent,rgba(255,255,255,.75),transparent);'
1401
+ + 'filter:blur(5px);opacity:.85;animation:dsh-amb-mist 26s ease-in-out infinite alternate',
1402
+ }) + '</div>'
1403
+ + open('div', {
1404
+ class: 'sta-mist sta-mist-2',
1405
+ style: 'position:absolute;height:1.6em;width:48%;top:63%;left:40%;border-radius:1000px;z-index:4;'
1406
+ + 'background:linear-gradient(90deg,transparent,rgba(255,255,255,.75),transparent);'
1407
+ + 'filter:blur(5px);opacity:.6;animation:dsh-amb-mist 32s ease-in-out infinite alternate;'
1408
+ + 'animation-delay:-9s',
1409
+ }) + '</div>'
1410
+ + open('div', {
1411
+ class: 'sta-pond',
1412
+ style: 'position:absolute;left:0;right:0;bottom:0;height:14%;z-index:5;'
1413
+ + 'background:linear-gradient(to bottom,rgba(104,178,150,.62),rgba(66,141,113,.78))',
1414
+ })
1415
+ + open('div', {
1416
+ class: 'sta-pond-line',
1417
+ style: 'position:absolute;top:0;left:0;right:0;height:1.2px;opacity:.6;'
1418
+ + 'background:linear-gradient(90deg,transparent,rgba(255,255,255,.9),transparent)',
1419
+ }) + '</div>'
1420
+ + '</div>'
1421
+ + rippleMarkup('38%', '5.2%', '0s')
1422
+ + rippleMarkup('62%', '3.4%', '1.6s')
1423
+ + dragonflyBlock('1', 'sta-dfly-1', 'top:58%;left:6%;width:3.8em', 'dsh-amb-hover1 11s ease-in-out infinite', '0s')
1424
+ + dragonflyBlock('2', 'sta-dfly-2', 'top:74%;left:16%;width:2.6em;opacity:.95', 'dsh-amb-hover2 13s ease-in-out infinite', '-5s')
1425
+ + open('div', { class: 'sta-petals', style: 'position:absolute;inset:0;z-index:7;pointer-events:none' })
1426
+ + petalNodes + '</div>'
1427
+ + '</div>'
1428
+ }
1429
+
1430
+ /**
1431
+ * The dragonfly artwork, shared by both instances.
1432
+ *
1433
+ * Each copy carries its own \`<defs>\` and a suffixed gradient id, because the two
1434
+ * dragonflies are separate elements that animate independently and gradient ids
1435
+ * must stay unique across the document.
1436
+ * @param suffix - makes the gradient id unique per instance.
1437
+ * @returns the dragonfly markup.
1438
+ */
1439
+ function dragonflyMarkup(suffix) {
1440
+ const gradient = `dsh-sta-dfly-body-${suffix}`
1441
+ return open('svg', { viewBox: '0 0 100 70' })
1442
+ + open('defs', {})
1443
+ + open('linearGradient', { id: gradient, x1: '1', y1: '0', x2: '0', y2: '0' })
1444
+ + open('stop', { offset: '0', 'stop-color': '#1F6B4C' }) + '</stop>'
1445
+ + open('stop', { offset: '1', 'stop-color': '#2E8C66' }) + '</stop>'
1446
+ + '</linearGradient>'
1447
+ + '</defs>'
1448
+ + open('ellipse', { cx: '36', cy: '15', rx: '17', ry: '4.4', fill: 'rgba(150,200,222,0.5)', transform: 'rotate(-40 36 15)' }) + '</ellipse>'
1449
+ + open('ellipse', { cx: '38', cy: '24', rx: '15', ry: '4', fill: 'rgba(150,200,222,0.42)', transform: 'rotate(-14 38 24)' }) + '</ellipse>'
1450
+ + open('ellipse', {
1451
+ cx: '31', cy: '11', rx: '19', ry: '5', fill: 'rgba(214,242,248,0.7)',
1452
+ transform: 'rotate(-30 31 11)', stroke: 'rgba(255,255,255,0.55)', 'stroke-width': '0.6',
1453
+ }) + '</ellipse>'
1454
+ + open('ellipse', {
1455
+ cx: '34', cy: '22', rx: '16', ry: '4.6', fill: 'rgba(240,214,242,0.62)',
1456
+ transform: 'rotate(-6 34 22)', stroke: 'rgba(255,255,255,0.55)', 'stroke-width': '0.6',
1457
+ }) + '</ellipse>'
1458
+ + open('path', {
1459
+ d: 'M 27 32 C 42 39, 60 46, 84 55',
1460
+ stroke: `url(#${gradient})`, 'stroke-width': '3', fill: 'none', 'stroke-linecap': 'round',
1461
+ }) + '</path>'
1462
+ + open('circle', { cx: '84', cy: '55', r: '1.4', fill: '#17513C' }) + '</circle>'
1463
+ + open('ellipse', { cx: '27', cy: '30', rx: '6.5', ry: '5', fill: '#1F6B4C' }) + '</ellipse>'
1464
+ + open('circle', { cx: '18.5', cy: '27.5', r: '4.2', fill: '#17513C' }) + '</circle>'
1465
+ + open('circle', { cx: '16.2', cy: '25.8', r: '1.9', fill: '#0F3D2E' }) + '</circle>'
1466
+ + open('circle', { cx: '20.6', cy: '25.4', r: '1.9', fill: '#0F3D2E' }) + '</circle>'
1467
+ + open('circle', { cx: '15.6', cy: '25.2', r: '0.6', fill: '#DFF3EC' }) + '</circle>'
1468
+ + open('circle', { cx: '20', cy: '24.8', r: '0.6', fill: '#DFF3EC' }) + '</circle>'
1469
+ + '</svg>'
1470
+ }
1471
+
1472
+ /**
1473
+ * The 梦海游鱼 scene: a soft corner glow with two drifting light washes, rising
1474
+ * bubbles, a second layer of glowing motes, and swaying seaweed over stones,
1475
+ * all grounded on a water-floor band. The floor is what makes the scene read as
1476
+ * one piece the way shan's mountains do: it starts fully transparent (the
1477
+ * sidebar's own gradient shows through at the junction) and deepens downward,
1478
+ * so the elements emerge from the water instead of floating on it.
1479
+ *
1480
+ * Ported from \`DreamOceanAmbient.vue\`. The source also keeps a separate
1481
+ * cartoon-fish animation on top; that is a distinct component there and is not
1482
+ * part of this ambience.
1483
+ * @param bubbles - how many bubbles to seed.
1484
+ * @param motes - how many glowing motes to seed. A separate effect from the bubbles,
1485
+ * and deliberately seeded separately so either can be tuned without touching the other.
1486
+ * @returns the scene markup.
1487
+ */
1488
+ function dreamAmbientScene(bubbles, motes, fish) {
1489
+ const count = Math.max(0, Math.min(24, bubbles ?? 9))
1490
+ let bubbleNodes = ''
1491
+ for (let n = 1; n <= count; n += 1) {
1492
+ const size = 3 + ((n * 4) % 4)
1493
+ bubbleNodes += open('div', {
1494
+ class: 'dof-bubble',
1495
+ style: 'position:absolute;bottom:-1em;border-radius:50%;'
1496
+ + 'background:radial-gradient(circle at 32% 30%,rgba(255,255,255,.95),rgba(190,232,246,.55));'
1497
+ + 'box-shadow:inset 0 0 0 1px rgba(255,255,255,.6);'
1498
+ + `left:${((n * 23) % 86) + 6}%;width:${size}px;height:${size}px;`
1499
+ + `animation:dsh-amb-rise ${8 + ((n * 7) % 8)}s linear infinite;animation-delay:${-(n * 1.7)}s`,
1500
+ }) + '</div>'
1501
+ }
1502
+
1503
+ // The cartoon fish, from the source system's own `FishAnimation.vue`. They are a
1504
+ // distinct effect again — not bubbles and not motes — so they get their own container
1505
+ // and their own count.
1506
+ const fishCount = Math.max(0, Math.min(6, fish ?? 3))
1507
+ // size(em), top(%), duration(s), delay(s), direction. Three different depths, sizes and
1508
+ // speeds so they read as separate fish rather than one repeated sprite.
1509
+ const FISH_PLAN = [
1510
+ [2.4, 26, 34, -4, false],
1511
+ [1.7, 52, 46, -18, true],
1512
+ [1.3, 71, 40, -29, false],
1513
+ [2.0, 40, 52, -36, true],
1514
+ [1.5, 63, 38, -11, false],
1515
+ [1.1, 33, 48, -24, true],
1516
+ ]
1517
+ let fishNodes = ''
1518
+ for (let n = 0; n < fishCount; n += 1) {
1519
+ const [size, top, duration, delay, flip] = FISH_PLAN[n % FISH_PLAN.length]
1520
+ fishNodes += fishMarkup(String(n + 1), size, top, duration, delay, flip)
1521
+ }
1522
+
1523
+ // The glowing motes: a second, independent effect drawn over the bubbles.
1524
+ const moteCount = Math.max(0, Math.min(24, motes ?? 5))
1525
+ let moteNodes = ''
1526
+ for (let n = 1; n <= moteCount; n += 1) {
1527
+ const size = 5 + ((n * 3) % 4)
1528
+ moteNodes += open('div', {
1529
+ class: 'dof-mote',
1530
+ style: 'position:absolute;bottom:-1em;border-radius:50%;'
1531
+ + 'background:radial-gradient(circle at 34% 30%,#FFFFFF 0%,#D6F1FF 40%,'
1532
+ + 'rgba(122,205,255,.5) 72%,rgba(122,205,255,0) 100%);'
1533
+ + 'box-shadow:0 0 10px 3px rgba(122,205,255,.55),0 0 22px 6px rgba(122,205,255,.22);'
1534
+ + `left:${((n * 31) % 84) + 8}%;width:${size}px;height:${size}px;`
1535
+ + `animation:dsh-amb-mote ${9 + ((n * 5) % 7)}s linear infinite;animation-delay:${-(n * 2.1)}s`,
1536
+ }) + '</div>'
1537
+ }
1538
+
1539
+ return open('div', { class: 'dof', style: 'position:absolute;inset:0;display:block' })
1540
+ + open('div', { class: 'dof-glow', style: 'position:absolute;inset:0;z-index:2' })
1541
+ // The sunlight pool. It used to be centred ON the band's top edge at full
1542
+ // brightness, so the scene box's overflow clip cut it into a hard white line
1543
+ // against the un-lit middle of the sidebar — the boundary the user reported.
1544
+ // Now it starts AT the edge, is dimmer, and is masked to zero there; the
1545
+ // sidebar gradient's own light-pool stop (meng-hai-you-yu.json, 62–70%)
1546
+ // continues the bloom above the edge.
1547
+ + open('div', {
1548
+ class: 'dof-corner',
1549
+ style: 'position:absolute;top:0;left:-30%;width:150%;height:40%;'
1550
+ + 'background:radial-gradient(ellipse at 32% 50%,rgba(255,255,255,.55),rgba(255,255,255,0) 62%);'
1551
+ + 'filter:blur(12px);'
1552
+ + '-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 45%);'
1553
+ + 'mask-image:linear-gradient(to bottom,transparent 0,#000 45%);'
1554
+ + 'animation:dsh-amb-wash 18s ease-in-out infinite alternate',
1555
+ }) + '</div>'
1556
+ + open('div', {
1557
+ class: 'dof-wash dof-wash-1',
1558
+ style: 'position:absolute;left:-20%;width:140%;height:30%;top:14%;filter:blur(12px);opacity:.5;'
1559
+ + 'background:linear-gradient(100deg,transparent,rgba(255,255,255,.8),transparent);'
1560
+ + '-webkit-mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);'
1561
+ + 'mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);'
1562
+ + 'animation:dsh-amb-wash 24s ease-in-out infinite alternate',
1563
+ }) + '</div>'
1564
+ + open('div', {
1565
+ class: 'dof-wash dof-wash-2',
1566
+ style: 'position:absolute;left:-20%;width:140%;height:30%;top:34%;filter:blur(12px);opacity:.34;'
1567
+ + 'background:linear-gradient(100deg,transparent,rgba(255,255,255,.8),transparent);'
1568
+ + '-webkit-mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);'
1569
+ + 'mask-image:linear-gradient(to bottom,transparent,#000 22%,#000 78%,transparent);'
1570
+ + 'animation:dsh-amb-wash 31s ease-in-out infinite alternate;animation-delay:-8s',
1571
+ }) + '</div>'
1572
+ + '</div>'
1573
+ + open('div', { class: 'dof-bubbles', style: 'position:absolute;inset:0;z-index:6;pointer-events:none' })
1574
+ + bubbleNodes + '</div>'
1575
+ + open('div', { class: 'dof-fish-layer', style: 'position:absolute;inset:0;z-index:6;pointer-events:none' })
1576
+ + fishNodes + '</div>'
1577
+ + open('div', { class: 'dof-motes', style: 'position:absolute;inset:0;z-index:7;pointer-events:none' })
1578
+ + moteNodes + '</div>'
1579
+ + open('div', {
1580
+ class: 'dof-floor',
1581
+ style: 'position:absolute;left:0;right:0;bottom:0;height:20%;z-index:4;'
1582
+ + 'background:linear-gradient(to bottom,rgba(126,184,222,0) 0%,rgba(126,184,222,.4) 46%,rgba(84,152,199,.62) 100%)',
1583
+ }) + '</div>'
1584
+ + open('div', {
1585
+ class: 'dof-seaweed',
1586
+ style: 'position:absolute;left:0;right:0;bottom:0;height:38%;z-index:5',
1587
+ }) + seaweedMarkup() + '</div>'
1588
+ + '</div>'
1589
+ }
1590
+
1591
+ /**
1592
+ * The seaweed artwork, reproduced from the source system's paths.
1593
+ *
1594
+ * Five blades from three gradients, each animating on its own phase so the bed
1595
+ * sways rather than moving as one rigid shape, over three resting stones.
1596
+ * @returns the seaweed markup.
1597
+ */
1598
+ /**
1599
+ * One expanding ring on the water, positioned inline.
1600
+ * @param left - horizontal position.
1601
+ * @param bottom - vertical position.
1602
+ * @param delay - animation delay, so the rings do not pulse in unison.
1603
+ * @returns the ring markup.
1604
+ */
1605
+ function rippleMarkup(left, bottom, delay) {
1606
+ const ring = 'position:absolute;inset:0;border:1.6px solid rgba(232,139,176,.92);border-radius:50%;'
1607
+ + `animation:dsh-amb-ring 3.2s ease-out infinite;animation-delay:${delay}`
1608
+ return open('div', {
1609
+ class: 'sta-ripple',
1610
+ style: `position:absolute;z-index:6;width:.55em;height:.55em;left:${left};bottom:${bottom}`,
1611
+ }) + `<span style="${ring}"></span><span style="${ring}"></span></div>`
1612
+ }
1613
+
1614
+ /**
1615
+ * One hovering dragonfly: an outer element on its flight path and an inner one
1616
+ * bobbing on the wingbeat, so the two motions compose.
1617
+ * @param suffix - gradient id suffix.
1618
+ * @param className - the positioning class.
1619
+ * @param position - inline position.
1620
+ * @param flight - animation shorthand for the flight path.
1621
+ * @param delay - animation delay.
1622
+ * @returns the dragonfly markup.
1623
+ */
1624
+ function dragonflyBlock(suffix, className, position, flight, delay) {
1625
+ return open('div', {
1626
+ class: `sta-dfly ${className}`,
1627
+ style: `position:absolute;z-index:8;will-change:transform;${position};`
1628
+ + `animation:${flight};animation-delay:${delay}`,
1629
+ }) + open('div', {
1630
+ class: 'sta-bob',
1631
+ style: `animation:dsh-amb-bob ${suffix === '2' ? '1.1s' : '.9s'} ease-in-out infinite;`
1632
+ + `animation-delay:${suffix === '2' ? '-.4s' : '0s'}`,
1633
+ }) + dragonflyMarkup(suffix) + '</div></div>'
1634
+ }
1635
+
1636
+ /**
1637
+ * One seaweed blade. The sway is applied to the group so the blades move from their
1638
+ * base, and each blade animates on its own phase.
1639
+ * @param key - blade index.
1640
+ * @param d - path data.
1641
+ * @param gradient - gradient id.
1642
+ * @param width - stroke width.
1643
+ * @param opacity - blade opacity.
1644
+ * @returns the blade markup.
1645
+ */
1646
+ function bladeMarkup(key, d, gradient, width, opacity) {
1647
+ const durations = { 1: '6s', 2: '7.4s', 3: '5.2s', 4: '8.1s', 5: '6.6s' }
1648
+ const delays = { 1: '0s', 2: '-1.6s', 3: '-2.8s', 4: '-.9s', 5: '-3.4s' }
1649
+ return open('g', {
1650
+ class: `dof-blade dof-blade-${key}`,
1651
+ style: `transform-origin:50% 100%;animation:dsh-amb-sway ${durations[key]} ease-in-out infinite alternate;`
1652
+ + `animation-delay:${delays[key]}`,
1653
+ }) + open('path', {
1654
+ d, fill: `url(#${gradient})`, stroke: `url(#${gradient})`, 'stroke-width': width, opacity,
1655
+ }) + '</path></g>'
1656
+ }
1657
+
1658
+ function seaweedMarkup() {
1659
+ const blade = bladeMarkup
1660
+
1661
+ return open('svg', {
1662
+ viewBox: '0 0 140 120',
1663
+ preserveAspectRatio: 'none',
1664
+ style: 'display:block;width:100%;height:100%',
1665
+ })
1666
+ + open('defs', {})
1667
+ + open('linearGradient', { id: 'dsh-dof-weed-a', x1: '0', y1: '1', x2: '0', y2: '0' })
1668
+ + open('stop', { offset: '0', 'stop-color': '#1E6E93' }) + '</stop>'
1669
+ + open('stop', { offset: '0.55', 'stop-color': '#3E93BC' }) + '</stop>'
1670
+ + open('stop', { offset: '1', 'stop-color': '#A5DEF0', 'stop-opacity': '0.85' }) + '</stop>'
1671
+ + '</linearGradient>'
1672
+ + open('linearGradient', { id: 'dsh-dof-weed-b', x1: '0', y1: '1', x2: '0', y2: '0' })
1673
+ + open('stop', { offset: '0', 'stop-color': '#2B7FA6' }) + '</stop>'
1674
+ + open('stop', { offset: '0.6', 'stop-color': '#4FA3C6' }) + '</stop>'
1675
+ + open('stop', { offset: '1', 'stop-color': '#B8E2F2', 'stop-opacity': '0.85' }) + '</stop>'
1676
+ + '</linearGradient>'
1677
+ + open('linearGradient', { id: 'dsh-dof-weed-c', x1: '0', y1: '1', x2: '0', y2: '0' })
1678
+ + open('stop', { offset: '0', 'stop-color': '#4A78A8' }) + '</stop>'
1679
+ + open('stop', { offset: '0.6', 'stop-color': '#7FA9CE' }) + '</stop>'
1680
+ + open('stop', { offset: '1', 'stop-color': '#CBE2F4', 'stop-opacity': '0.8' }) + '</stop>'
1681
+ + '</linearGradient>'
1682
+ + '</defs>'
1683
+ + blade('1', 'M 22 120 C 12 96, 26 74, 18 48 C 15 38, 18 28, 24 20 C 20 34, 24 44, 30 60 C 36 80, 30 100, 32 120 Z', 'dsh-dof-weed-a', '2.2', '0.95')
1684
+ + blade('2', 'M 48 120 C 40 98, 54 80, 46 56 C 42 44, 48 34, 56 24 C 50 40, 56 52, 60 68 C 64 88, 56 104, 58 120 Z', 'dsh-dof-weed-b', '2', '0.92')
1685
+ + blade('3', 'M 74 120 C 68 102, 78 88, 72 68 C 69 58, 72 48, 78 40 C 74 52, 78 62, 82 76 C 86 94, 80 108, 82 120 Z', 'dsh-dof-weed-c', '1.8', '0.88')
1686
+ + blade('4', 'M 96 120 C 92 104, 102 90, 96 72 C 93 62, 96 54, 102 46 C 98 58, 102 68, 106 82 C 110 98, 102 110, 104 120 Z', 'dsh-dof-weed-a', '1.6', '0.85')
1687
+ + blade('5', 'M 118 120 C 114 108, 122 96, 117 82 C 115 74, 117 68, 121 62 C 118 72, 121 80, 124 92 C 127 104, 121 112, 123 120 Z', 'dsh-dof-weed-b', '1.4', '0.8')
1688
+ + open('ellipse', { cx: '30', cy: '119', rx: '14', ry: '4', fill: '#7FAFC6', opacity: '0.55' }) + '</ellipse>'
1689
+ + open('ellipse', { cx: '72', cy: '120', rx: '10', ry: '3.4', fill: '#8FB9CE', opacity: '0.5' }) + '</ellipse>'
1690
+ + open('ellipse', { cx: '108', cy: '119.5', rx: '12', ry: '3.6', fill: '#7FAFC6', opacity: '0.45' }) + '</ellipse>'
1691
+ + '</svg>'
1692
+ }
1693
+
1694
+ /**
1695
+ * One cartoon fish, swimming across the water.
1696
+ *
1697
+ * Ported from the source system's separate `FishAnimation.vue`. That component drives the
1698
+ * fish from JavaScript through entering / bubbling / leaving phases on a 60-second cycle,
1699
+ * but this skin is a static bundle with no component runtime to host a script — so the
1700
+ * same artwork swims continuously instead, on a CSS `@keyframes` cross, with the tail and
1701
+ * the whole body on separate animations so it reads as swimming rather than sliding.
1702
+ *
1703
+ * The artwork is reproduced shape for shape: the body curve, tail, dorsal fin, pectoral
1704
+ * fin, and the three-part eye. The colours are retuned into the theme's blue family —
1705
+ * the source values (`#38bdf8` body, `#1d4ed8` fins) sat outside the dream palette and
1706
+ * read as a sticker, the same treatment shan's mountains got when their source greens
1707
+ * were darkened for this sidebar.
1708
+ * @param suffix - unique id suffix, so several fish can coexist.
1709
+ * @param size - rendered width in em.
1710
+ * @param top - vertical position within the water, as a percentage.
1711
+ * @param duration - seconds for one crossing.
1712
+ * @param delay - animation delay, so the fish do not move in lockstep.
1713
+ * @param flip - whether this fish swims right-to-left instead.
1714
+ * @returns the fish markup.
1715
+ */
1716
+ function fishMarkup(suffix, size, top, duration, delay, flip) {
1717
+ const anim = flip ? 'dsh-amb-swim-back' : 'dsh-amb-swim'
1718
+ return open('div', {
1719
+ class: `dof-fish${flip ? ' dof-fish-flip' : ''}`,
1720
+ style: 'position:absolute;left:0;opacity:.94;'
1721
+ + `top:${top}%;width:${size}em;`
1722
+ + `animation:${anim} ${duration}s linear infinite;animation-delay:${delay}s`,
1723
+ })
1724
+ + open('div', {
1725
+ class: 'dof-fish-bob',
1726
+ style: `animation-duration:${(duration / 8).toFixed(2)}s`,
1727
+ })
1728
+ + open('svg', {
1729
+ viewBox: '0 0 50 18',
1730
+ preserveAspectRatio: 'xMidYMid meet',
1731
+ style: 'display:block;width:100%;height:auto;overflow:visible',
1732
+ })
1733
+ + open('g', { class: 'dof-fish-body' })
1734
+ // Body — recoloured into the theme's own blue family. The source values
1735
+ // (`#38bdf8` body, `#1d4ed8` fins) sat outside the dream palette and read
1736
+ // as a sticker pasted on the water; the same treatment shan's mountains
1737
+ // got when their source greens were darkened for this sidebar.
1738
+ + open('path', {
1739
+ d: 'M10 10 C20 5 35 5 45 10 C40 15 25 15 10 10 Z',
1740
+ fill: '#5FA5D6', stroke: '#2B6E9E', 'stroke-width': '1',
1741
+ }) + '</path>'
1742
+ // Tail
1743
+ + open('path', {
1744
+ d: 'M10 10 L5 7 L5 13 Z',
1745
+ fill: '#2B6E9E', stroke: '#2B6E9E', 'stroke-width': '1', class: 'dof-fish-tail',
1746
+ }) + '</path>'
1747
+ // Dorsal fin
1748
+ + open('path', {
1749
+ d: 'M20 7 L25 3 L30 7',
1750
+ fill: '#2B6E9E', stroke: '#2B6E9E', 'stroke-width': '1',
1751
+ }) + '</path>'
1752
+ // Pectoral fin
1753
+ + open('path', {
1754
+ d: 'M35 9 L40 12 L45 9',
1755
+ fill: '#5FA5D6', stroke: '#2B6E9E', 'stroke-width': '1',
1756
+ }) + '</path>'
1757
+ // Eye: white, pupil, highlight
1758
+ + open('circle', { cx: '40', cy: '8', r: '2', fill: '#FFFFFF' }) + '</circle>'
1759
+ + open('circle', { cx: '41', cy: '8', r: '1', fill: '#16384F' }) + '</circle>'
1760
+ + open('circle', { cx: '40.5', cy: '7.5', r: '0.5', fill: '#FFFFFF' }) + '</circle>'
1761
+ + '</g>'
1762
+ + '</svg>'
1763
+ + '</div></div>'
1764
+ }
1765
+
1766
+ /**
1767
+ * Apply a function on every tick until the thing it observes stops changing.
1768
+ *
1769
+ * This exists because the scenery has to be placed against a layout that is still being
1770
+ * built. The previous approach fired a fixed burst of animation frames plus three fixed
1771
+ * delays, all counted from the moment the plugin applied — the wrong clock entirely. The
1772
+ * shell mounts its sidebar whenever it is ready (after fonts, stores and window state),
1773
+ * which on this machine is later than 900ms; by then every retry had been spent and the
1774
+ * scenery stayed absent until something unrelated fired one more sync.
1775
+ *
1776
+ * So the wait is driven by the OBSERVED GEOMETRY rather than by elapsed time: sample it,
1777
+ * and once two consecutive samples agree, the shell has settled and the measurement can be
1778
+ * trusted. `apply` runs on every tick so the scenery keeps up while the layout moves, and
1779
+ * the loop stops as soon as it is stable — or after a bounded window, so a failed boot
1780
+ * cannot leave a timer running forever.
1781
+ *
1782
+ * The loop body is isolated from the DOM so it can be tested directly against a stub clock
1783
+ * and a stub sample: the bug it fixes is a timing bug, and a timing bug that cannot be
1784
+ * tested is how this one survived several rounds.
1785
+ * @param options - the loop's collaborators.
1786
+ * @param options.sample - returns a geometry fingerprint, or null when unavailable.
1787
+ * @param options.apply - runs each tick, before sampling.
1788
+ * @param options.setTimer - schedules a callback after a delay, returning a handle.
1789
+ * @param options.clearTimer - cancels a handle from `setTimer`.
1790
+ * @param options.now - current time in milliseconds.
1791
+ * @param options.intervalMs - delay between ticks.
1792
+ * @param options.maxMs - give up after this long.
1793
+ * @param options.stableTicks - consecutive equal samples required to call it settled.
1794
+ * @returns a handle with `stop()`, which cancels any pending tick.
1795
+ */
1796
+ function repeatUntilStable(options) {
1797
+ const {
1798
+ sample, apply, setTimer, clearTimer, now,
1799
+ intervalMs = 100,
1800
+ maxMs = 15000,
1801
+ stableTicks = 2,
1802
+ } = options
1803
+ const startedAt = now()
1804
+ let previous
1805
+ let stable = 0
1806
+ let handle
1807
+ let stopped = false
1808
+
1809
+ const tick = () => {
1810
+ if (stopped) return
1811
+ apply()
1812
+ const signature = sample()
1813
+ if (signature !== null && signature === previous) stable += 1
1814
+ else stable = 0
1815
+ previous = signature
1816
+ const settled = signature !== null && stable >= stableTicks
1817
+ if (settled || now() - startedAt > maxMs) return
1818
+ handle = setTimer(tick, intervalMs)
1819
+ }
1820
+
1821
+ handle = setTimer(tick, intervalMs)
1822
+
1823
+ return {
1824
+ stop() {
1825
+ stopped = true
1826
+ if (handle !== undefined) clearTimer(handle)
1827
+ handle = undefined
1828
+ },
1829
+ }
1830
+ }
1831
+
1832
+ /**
1833
+ * Pick the scene markup for a theme.
1834
+ *
1835
+ * Returns an empty string for a theme with no scenery, which is what clears the
1836
+ * seat — the scenery belongs to the skin, not to the app.
1837
+ * @param kind - the ambient kind.
1838
+ * @param options - the theme's ambient options.
1839
+ * @returns the markup.
1840
+ */
1841
+ function sceneMarkup(kind, options) {
1842
+ const markup = kind === 'shan'
1843
+ ? shanAmbientScene(options?.petals)
1844
+ : kind === 'dream'
1845
+ ? dreamAmbientScene(options?.bubbles, options?.motes, options?.fish)
1846
+ : ''
1847
+ // The builder is fed only attributes and hex values, but this is the one place
1848
+ // markup from outside this file could ever arrive, so it refuses anyway.
1849
+ if (/<script|\son[a-z]+\s*=/i.test(markup)) {
1850
+ throw new Error('theme-gallery: refusing scenery markup containing script or handlers')
1851
+ }
1852
+ return markup
1853
+ }
1854
+
1855
+ function sidebarColumn() {
1856
+ if (typeof document === 'undefined') return null
1857
+ return document.querySelector('[data-windows-titlebar] .ZTP-Xa_sidebarCol')
1858
+ || document.querySelector('.ZTP-Xa_sidebarCol')
1859
+ || document.querySelector('[class*="_sidebarCol"]')
1860
+ || document.querySelector('aside[class*="sidebar" i]')
1861
+ || null
1862
+ }
1863
+
1864
+ /**
1865
+ * Mount a React element into a plain DOM node owned by this plugin.
1866
+ *
1867
+ * The scenery cannot go through the slot system — the sidebar column has no
1868
+ * slot for scenery, and a slot component would be a child of the column rather
1869
+ * than a layer behind it. So this owns the seat, and is the ONLY place in this
1870
+ * plugin that touches the shell's DOM directly.
1871
+ */
1872
+ let ambientPaintError
1873
+
1874
+ /**
1875
+ * What the last scenery sync actually achieved, for the debug line.
1876
+ *
1877
+ * Kept because "the scenery did not appear" has several indistinguishable
1878
+ * causes from outside the app — sidebar not found, seat never created, a mount
1879
+ * that failed, or a seat with zero size — and only the live element separates
1880
+ * them.
1881
+ */
1882
+ let ambientReport
1883
+
1884
+ /**
1885
+ * The signature of the last scenery sync that actually touched the DOM.
1886
+ *
1887
+ * `syncAmbient` is invoked from a body-wide `MutationObserver` and also writes to the DOM,
1888
+ * so it can observe its own writes. Comparing this before doing any work is what breaks
1889
+ * that cycle — see the long note inside `syncAmbient`. Reset to `undefined` whenever the
1890
+ * sync bails out, so the next call is allowed to try again.
1891
+ */
1892
+ let lastAmbientFingerprint
1893
+
1894
+ /**
1895
+ * A rolling log of every attempt to place the scenery, newest last.
1896
+ *
1897
+ * The previous diagnostics reported only the FINAL state, which cannot distinguish
1898
+ * "never computed" from "computed wrongly and then corrected". That gap is exactly where
1899
+ * the boot-time bug lived: the scenery was synced while the sidebar did not exist yet,
1900
+ * bailed out, and nothing said so — the report simply showed the last, healthy run.
1901
+ *
1902
+ * Each entry records when an attempt happened, whether the column was found, and the
1903
+ * geometry that was actually used.
1904
+ */
1905
+ const AMBIENT_LOG_LIMIT = 12
1906
+ let ambientLog = []
1907
+
1908
+ /** When this module began, for readable relative timings in the log. */
1909
+ const bootAt = typeof Date.now === 'function' ? Date.now() : 0
1910
+
1911
+ /** When the page itself started, so a late first sync is visible as such. */
1912
+ const pageAt = (() => {
1913
+ try {
1914
+ const origin = typeof performance !== 'undefined' ? performance.timeOrigin : undefined
1915
+ return typeof origin === 'number' && origin > 0 ? origin : bootAt
1916
+ } catch {
1917
+ return bootAt
1918
+ }
1919
+ })()
1920
+
1921
+ /**
1922
+ * Milliseconds since the page began loading.
1923
+ *
1924
+ * The log used to be relative to PLUGIN start only, which hid the single most important fact
1925
+ * during the boot investigation: when the first sync actually happened. Every entry shown was
1926
+ * already tens of seconds old, so "did anything run during startup?" could not be answered
1927
+ * from the panel at all. Anchoring to the page makes a late start obvious.
1928
+ * @returns milliseconds since page start.
1929
+ */
1930
+ function sincePageStart() {
1931
+ try {
1932
+ return Math.max(0, Math.round(Date.now() - pageAt))
1933
+ } catch {
1934
+ return 0
1935
+ }
1936
+ }
1937
+
1938
+ /**
1939
+ * Record one scenery-sync attempt.
1940
+ * @param entry - the attempt, without its timestamp.
1941
+ */
1942
+ function noteAmbientAttempt(entry) {
1943
+ try {
1944
+ const now = Date.now()
1945
+ ambientLog.push({
1946
+ // `bootAt` is 0 only when `Date.now` is unavailable, and then 0 is the honest value.
1947
+ t: bootAt === 0 ? 0 : now - bootAt,
1948
+ page: sincePageStart(),
1949
+ ...entry,
1950
+ })
1951
+ if (ambientLog.length > AMBIENT_LOG_LIMIT) ambientLog = ambientLog.slice(-AMBIENT_LOG_LIMIT)
1952
+ } catch {
1953
+ // Diagnostics must never break the feature they are diagnosing.
1954
+ }
1955
+ }
1956
+
1957
+ /**
1958
+ * Record a lifecycle DECISION, which is not a sync attempt.
1959
+ *
1960
+ * The boot investigation kept stalling because the log held only sync attempts, so a gate that
1961
+ * silently refused to act left no trace whatsoever — `ensureSkinPainted` returning early
1962
+ * because `bootSettled` was still false, or `markBootSettled` never firing, were both
1963
+ * invisible. Decisions are now recorded alongside the attempts.
1964
+ *
1965
+ * A repeated state COLLAPSES into its existing line instead of appending another. These paths
1966
+ * run every frame, and a ring buffer of twelve identical entries buries the one line that
1967
+ * matters: during the boot investigation the first sync was tens of seconds earlier than
1968
+ * everything else, and by the time the panel was opened to read the log it had long been
1969
+ * pushed out by repeats.
1970
+ * @param label - a short decision label, e.g. `未上色·跳转一次`.
1971
+ * @param detail - optional extra context.
1972
+ */
1973
+ function noteAmbientEvent(label, detail) {
1974
+ try {
1975
+ const text = detail === undefined ? label : `${label}(${detail})`
1976
+ const last = ambientLog.length === 0 ? undefined : ambientLog[ambientLog.length - 1]
1977
+ if (last !== undefined && last.band === text) {
1978
+ // Same state: refresh the clock rather than adding a line, so "still here" reads as a
1979
+ // recent time and the rest of the buffer stays available for state CHANGES.
1980
+ last.t = bootAt === 0 ? 0 : Date.now() - bootAt
1981
+ last.page = sincePageStart()
1982
+ return
1983
+ }
1984
+ ambientLog.push({
1985
+ t: bootAt === 0 ? 0 : Date.now() - bootAt,
1986
+ page: sincePageStart(),
1987
+ column: true,
1988
+ band: text,
1989
+ })
1990
+ if (ambientLog.length > AMBIENT_LOG_LIMIT) ambientLog = ambientLog.slice(-AMBIENT_LOG_LIMIT)
1991
+ } catch {
1992
+ // Diagnostics must never break the feature they are diagnosing.
1993
+ }
1994
+ }
1995
+
1996
+ /**
1997
+ * Render the attempt log as one line.
1998
+ * @returns a compact, ordered summary of recent attempts.
1999
+ */
2000
+ function describeAmbientLog() {
2001
+ if (ambientLog.length === 0) return '(无记录)'
2002
+ return ambientLog
2003
+ .map((e) => {
2004
+ // Two clocks: `p` is since page start (when boot really began), `+` is since this plugin
2005
+ // mounted. Comparing them is what exposes a late start.
2006
+ const at = `p${e.page ?? '?'}/+${e.t}ms`
2007
+ if (e.column === false) return `${at} 无侧栏`
2008
+ return `${at} ${e.band === undefined ? '几何未测' : e.band}`
2009
+ })
2010
+ .join(' | ')
2011
+ }
2012
+
2013
+
2014
+ /**
2015
+ * Draw a scene into the seat.
2016
+ *
2017
+ * @param element - the scene element, or null to clear the seat.
2018
+ * @param seat - the seat node inside the sidebar column.
2019
+ */
2020
+ /**
2021
+ * Where the scenery band goes, in viewport coordinates.
2022
+ *
2023
+ * The band fills the sidebar's BLANK AREA: it stops above the account row and reaches
2024
+ * up roughly a third of the column.
2025
+ *
2026
+ * Two corrections are folded in here. A cap of 420px once put the band's top at 55% of
2027
+ * a 780px column — the middle of the sidebar rather than its lower third. And the band
2028
+ * used to run to the column's very bottom, which is where the account row lives: the
2029
+ * water band ended up over the signed-in user's avatar and name, and because this layer
2030
+ * is pointer-transparent the area stayed clickable, so it read as a broken menu rather
2031
+ * than a covered one.
2032
+ *
2033
+ * `footerHeight()` measures that reserved strip instead of hard-coding a value, so the
2034
+ * band follows the row if its size changes.
2035
+ * @param column - the sidebar column.
2036
+ * @returns the band box, or null when the column is unmeasurable.
2037
+ */
2038
+ function bandBox(column) {
2039
+ const rect = column.getBoundingClientRect()
2040
+ if (rect.width === 0 || rect.height === 0) return null
2041
+
2042
+ // Reserved strip at the bottom: the account row with the avatar and nickname.
2043
+ //
2044
+ // Clamped so the measurement can never consume the band. Early in boot the shell's
2045
+ // layout is not settled and this probe can read something far too tall; without the
2046
+ // clamp the band collapsed and the scenery silently disappeared until an unrelated
2047
+ // repaint brought it back — which is why the artwork only showed up after opening the
2048
+ // settings panel.
2049
+ const reserved = Math.min(footerHeight(column), Math.max(0, Math.round(rect.height * 0.25)))
2050
+
2051
+ // The band is placed by POSITION, not by a share of the height.
2052
+ //
2053
+ // Sizing it as a fraction of the column ("height = 30% of usable", "40%") kept the
2054
+ // right footprint but moved its CENTRE around: on a 780px column a 288px band spanned
2055
+ // 472..760, so its middle sat at y=616 — inside the sidebar's MIDDLE third, not the
2056
+ // bottom third the artwork is meant to occupy. The user's framing is the correct one:
2057
+ // the sidebar reads as three stacked regions, and the scenery belongs in the lowest
2058
+ // one.
2059
+ //
2060
+ // So the top edge is pinned to the start of the bottom third and the band extends down
2061
+ // to the account row. On a taller window the third starts lower, the band is taller,
2062
+ // and the artwork grows in place instead of drifting upward.
2063
+ const thirdStart = rect.top + (rect.height * 2) / 3
2064
+ const bottom = Math.min(rect.bottom - reserved, viewportBottomOf(rect))
2065
+ let top = Math.max(thirdStart, rect.top)
2066
+ // A very short column would leave nothing; fall back to a usable minimum.
2067
+ if (bottom - top < 150) top = Math.max(rect.top, bottom - 150)
2068
+ return {
2069
+ left: Math.round(rect.left),
2070
+ top: Math.round(top),
2071
+ width: Math.round(rect.width),
2072
+ height: Math.max(1, Math.round(bottom - top)),
2073
+ }
2074
+ }
2075
+
2076
+ /**
2077
+ * The lowest y the scenery may reach.
2078
+ *
2079
+ * On a short window the column's bottom edge can sit below the viewport, and artwork
2080
+ * placed there is simply not visible, so the visible bottom wins.
2081
+ * @param rect - the column's rectangle.
2082
+ * @returns the y coordinate to stop at.
2083
+ */
2084
+ function viewportBottomOf(rect) {
2085
+ if (typeof window === 'undefined') return rect.bottom
2086
+ return Math.min(rect.bottom, window.innerHeight)
2087
+ }
2088
+
2089
+ /**
2090
+ * Height of the strip at the column's bottom that scenery must not cover.
2091
+ *
2092
+ * That strip is the account row — the avatar and nickname the user is signed in as. The
2093
+ * artwork used to be drawn straight over it, and because this layer is
2094
+ * pointer-transparent the row stayed clickable, so it read as a broken menu rather than
2095
+ * a covered one.
2096
+ *
2097
+ * Measuring "the descendant nearest the bottom edge" does not work: the shell's own
2098
+ * full-height wrappers are flush with it, so the shortest distance is always 0. What
2099
+ * distinguishes the account row is its SHAPE — a short, full-width bar anchored to the
2100
+ * bottom — so that is what is matched.
2101
+ * @param column - the sidebar column.
2102
+ * @returns the reserved height in px, clamped to a sane range.
2103
+ */
2104
+ function footerHeight(column) {
2105
+ try {
2106
+ const rect = column.getBoundingClientRect()
2107
+ let reserve = 0
2108
+ for (const node of column.querySelectorAll('*')) {
2109
+ // Skip this plugin's own overlay, which is not part of the shell's layout.
2110
+ if (node.closest('#dsh-theme-ambient, .dsh-amb-control') !== null) continue
2111
+ const r = node.getBoundingClientRect()
2112
+ if (r.width < rect.width * 0.5) continue
2113
+ if (r.height < 28 || r.height > 96) continue
2114
+ const distance = rect.bottom - r.bottom
2115
+ if (distance > 24) continue
2116
+ const needed = r.height + distance
2117
+ if (needed > reserve) reserve = needed
2118
+ }
2119
+ // The reservation is kept as tight as the measurement allows. It only has to clear
2120
+ // the account row, and every pixel beyond that shows as a gap between the artwork
2121
+ // and the row — which is what "too much height reserved" describes. A small constant
2122
+ // padding is added rather than a proportional one, because the row is a fixed-height
2123
+ // control that does not grow with the window.
2124
+ const measured = reserve === 0 ? 52 : reserve
2125
+ return Math.max(56, Math.min(measured + 2, 72))
2126
+ } catch {
2127
+ return 60
2128
+ }
2129
+ }
2130
+
2131
+ /**
2132
+ * Whether this plugin's ambient stylesheet is actually in the document.
2133
+ *
2134
+ * The seat must never be created before it. `syncAmbient` runs on every shell
2135
+ * mutation, so without this guard it could create the seat during a window when
2136
+ * `AMBIENT_CSS` was not yet installed — leaving an UNSTYLED div in normal flow at
2137
+ * the bottom of the sidebar. That is exactly the stray block that appeared over
2138
+ * the main column and covered conversation text: an unstyled element joins the
2139
+ * layout instead of sitting behind it, and every later report still looks
2140
+ * healthy because the element does exist.
2141
+ *
2142
+ * A seat without its stylesheet is worse than no seat.
2143
+ * @returns true when the stylesheet element is in the document.
2144
+ */
2145
+ function ambientStylesheetReady() {
2146
+ if (typeof document === 'undefined') return false
2147
+ return document.querySelector('style[data-plugin-css="theme-gallery/ambient"]') !== null
2148
+ }
2149
+
2150
+ /**
2151
+ * Put the ambient stylesheet into the document, and keep it there.
2152
+ *
2153
+ * Self-healing on purpose. A one-shot install inside an effect assumed the sheet
2154
+ * would survive, and in the shipped app it did not: the report kept reading
2155
+ * `css=false`, which silently turned the guard above into a permanent no-op — the
2156
+ * guard was right, the sheet was simply never there. Because the guard bails out
2157
+ * while the sheet is absent, one failed install disabled the scenery for good.
2158
+ *
2159
+ * The document is searched for the sheet directly rather than trusting a cached
2160
+ * reference, because the failure being defended against is precisely "our
2161
+ * reference is stale".
2162
+ * @returns the stylesheet element, or null when there is no document.
2163
+ */
2164
+ function ensureAmbientStylesheet() {
2165
+ if (typeof document === 'undefined') return null
2166
+ const existing = document.querySelector('style[data-plugin-css="theme-gallery/ambient"]')
2167
+ // A sheet that is present but STALE is worse than none. The shell keeps its DOM
2168
+ // across a plugin reload, so the previous build's sheet survives — and a check that
2169
+ // only asks "is a sheet there?" happily reuses rules that no longer match this
2170
+ // build. That is exactly what happened: the layer was restructured, the old sheet
2171
+ // stayed, and every new rule was missing while the check insisted the sheet existed.
2172
+ if (existing !== null) {
2173
+ if (existing.textContent === AMBIENT_CSS) return existing
2174
+ existing.remove()
2175
+ }
2176
+
2177
+ const tag = document.createElement('style')
2178
+ tag.dataset.plugin = 'theme-gallery'
2179
+ tag.dataset.pluginCss = 'theme-gallery/ambient'
2180
+ tag.textContent = AMBIENT_CSS
2181
+ // `head` can be absent if this runs before the parser produced one; appending to
2182
+ // the document element still applies the rules.
2183
+ if (document.head !== null) document.head.append(tag)
2184
+ else document.documentElement.append(tag)
2185
+ return tag
2186
+ }
2187
+
2188
+ /**
2189
+ * Remove any ambient node that this run does not own.
2190
+ *
2191
+ * An earlier revision could leave a stray seat behind — unstyled, in the layout,
2192
+ * covering the main column — and because the shell keeps its DOM across a plugin
2193
+ * reload, such a node outlives the code that made it. Cleaning up on sight means a
2194
+ * fixed build repairs the previous build's damage instead of inheriting it.
2195
+ *
2196
+ * Seats are matched anywhere in the document now that the layer lives on the body,
2197
+ * so ownership is decided by an attribute this run stamps rather than by parentage.
2198
+ */
2199
+ function removeStrayAmbientSeats() {
2200
+ if (typeof document === 'undefined') return
2201
+ for (const seat of document.querySelectorAll('#dsh-theme-ambient')) {
2202
+ if (seat.dataset.ambientOwner === AMBIENT_OWNER) continue
2203
+ seat.remove()
2204
+ }
2205
+ }
2206
+
2207
+ /**
2208
+ * Remove every ambient node, ownership aside.
2209
+ *
2210
+ * Used when no scenery should exist at all: a theme without `ambient`, or a seat
2211
+ * whose placement can no longer be computed. Leaving a layer behind would paint one
2212
+ * theme's scenery over the next one's.
2213
+ */
2214
+ function removeAllAmbientSeats() {
2215
+ if (typeof document === 'undefined') return
2216
+ for (const seat of document.querySelectorAll('#dsh-theme-ambient')) seat.remove()
2217
+ }
2218
+
2219
+ /**
2220
+ * Depth counter for theme changes this plugin causes ITSELF.
2221
+ *
2222
+ * ── THE SELF-DRIVING LOOP THIS EXISTS TO STOP ───────────────────────────
2223
+ *
2224
+ * `ctx.theme.overrideTokens()` and `ctx.theme.setTheme()` both emit `theme/change`, and the
2225
+ * plugin subscribes to that event and calls `publish()`, which calls `syncSkin()`, which
2226
+ * calls back into `overrideTokens()`. Nothing in that cycle yields to the event loop, so it
2227
+ * is not a slow loop — it is a spin.
2228
+ *
2229
+ * The old brake was `stackedSkin === id`, and it could not work: `stackSkinTokens` clears
2230
+ * `stackedSkin` BEFORE calling `overrideTokens` (to dispose the previous layer), so the
2231
+ * handler that `overrideTokens` synchronously triggers sees `stackedSkin === undefined` and
2232
+ * stacks another layer — for ever. Measured on the real app: renderer RSS to 11 GB and ~2.7
2233
+ * cores of accumulated CPU, with main/host/GPU perfectly normal and no crash log, because
2234
+ * nothing throws.
2235
+ *
2236
+ * A depth counter is used rather than a boolean so nested emits (a disposer called while
2237
+ * stacking) are handled correctly: the flag is only clear once every emit has returned.
2238
+ * @type {number}
2239
+ */
2240
+ let selfEmitDepth = 0
2241
+
2242
+ /**
2243
+ * Run something that will emit `theme/change` as a consequence of this plugin's own action.
2244
+ *
2245
+ * The subscription installed later checks {@link selfEmitDepth} and declines to react, so an
2246
+ * action cannot be re-entered through the event it caused. That is the only reliable brake:
2247
+ * comparing values cannot distinguish "the service changed underneath me" from "I just
2248
+ * changed the service", and the whole defect was that distinction.
2249
+ * @param action - the action to run under the guard.
2250
+ * @returns whatever the action returns.
2251
+ */
2252
+ function emitting(action) {
2253
+ selfEmitDepth += 1
2254
+ try {
2255
+ return action()
2256
+ } finally {
2257
+ selfEmitDepth -= 1
2258
+ }
2259
+ }
2260
+
2261
+ /**
2262
+ * A kill switch, read from this bundle's own composition patch.
2263
+ *
2264
+ * Until now the only lever for stopping a misbehaving skin was removing the plugin from
2265
+ * `dsh.profile.bundles` — and on the desktop that list is not hand-maintained. The
2266
+ * application re-derives it from `dependencies` whenever a package is installed, enabled or
2267
+ * optimised, so a package declaring `dsh.bundle.patch` is written straight back and the
2268
+ * plugin returns on its own. There was no emergency brake on the plugin's side.
2269
+ *
2270
+ * With this, either layer can be switched off in `cordis.patch.yml` without touching the
2271
+ * bundle list, which means a broken skin can be defused without fighting the installer:
2272
+ *
2273
+ * - id: theme-gallery
2274
+ * name: dsh-theme-gallery
2275
+ * config:
2276
+ * ambient: false # 停掉氛围装饰层与皮肤恢复
2277
+ *
2278
+ * Anything other than an explicit `false` leaves both layers on, so an absent config behaves
2279
+ * exactly as before.
2280
+ * @returns whether the ambient and skin-restore layers may run.
2281
+ */
2282
+ function ambientEnabled() {
2283
+ try {
2284
+ const config = ctx?.config
2285
+ if (config === undefined || config === null) return true
2286
+ return config.ambient !== false
2287
+ } catch {
2288
+ // A config that cannot be read must not disable the feature.
2289
+ return true
2290
+ }
2291
+ }
2292
+
2293
+ let restoreDisabledReason
2294
+ /**
2295
+ * A global budget and cooldown for `setTheme`.
2296
+ *
2297
+ * The per-skin "one bounce" allowance only covered the `wanted === activeId` branch. The
2298
+ * other branch — a plain `setTheme(wanted)` taken whenever the service reports a different
2299
+ * theme — had no cooldown and no budget, and it runs on every publish. Because the shell's
2300
+ * `adopt()` puts the active id back to a built-in value, that branch could be taken
2301
+ * indefinitely: setTheme → theme/change → publish → setTheme.
2302
+ *
2303
+ * These counters bound the damage no matter which path asks: at most 6 calls per session and
2304
+ * at least 1 second apart. When the budget is gone the plugin stops asking and says so in the
2305
+ * panel, rather than burning the renderer to no effect.
2306
+ */
2307
+ const THEME_WRITE_BUDGET = 6
2308
+ const THEME_WRITE_COOLDOWN_MS = 1000
2309
+ let themeWrites = 0
2310
+ let lastThemeWriteAt = 0
2311
+
2312
+ /**
2313
+ * Ask the theme service for a skin, under the global budget.
2314
+ * @param id - the theme id to request.
2315
+ * @returns whether the request was actually made.
2316
+ */
2317
+ function requestTheme(id) {
2318
+ if (restoreDisabledReason !== undefined) return false
2319
+ if (themeWrites >= THEME_WRITE_BUDGET) {
2320
+ restoreDisabledReason = `已停止自动恢复皮肤(本次会话写主题 ${THEME_WRITE_BUDGET} 次上限已到)`
2321
+ noteAmbientEvent('主题写入预算耗尽', id)
2322
+ return false
2323
+ }
2324
+ if (Date.now() - lastThemeWriteAt < THEME_WRITE_COOLDOWN_MS) return false
2325
+ themeWrites += 1
2326
+ lastThemeWriteAt = Date.now()
2327
+ // Marked as self-caused so the subscription ignores the `theme/change` this produces.
2328
+ emitting(() => ctx.theme.setTheme(id))
2329
+ return true
2330
+ }
2331
+
2332
+ /**
2333
+ * Render the active theme's scenery into the sidebar column.
2334
+ *
2335
+ * Idempotent: the seat is created once and its contents are replaced whenever
2336
+ * the active theme (or its ambient config) changes, so re-running is cheap and
2337
+ * cannot stack duplicate scenes. Themes with no `ambient` get an empty seat —
2338
+ * the scenery belongs to the skin, so switching skins must remove it.
2339
+ * @param kind - the ambient kind, or undefined for none.
2340
+ * @param options - the theme's ambient options.
2341
+ */
2342
+ function syncAmbient(kind, options, record = true) {
2343
+ if (typeof document === 'undefined') return
2344
+ // The kill switch. `cordis.patch.yml` sets `config: { ambient: false }` to stop the whole
2345
+ // scenery layer, which is the only lever that works without editing the bundle list — the
2346
+ // desktop re-derives that list from `dependencies`, so removing the package does not stick.
2347
+ if (!ambientEnabled()) return
2348
+
2349
+ // ── THE IDEMPOTENCE TEST MUST COME BEFORE *ANY* WRITE ────────────────────
2350
+ //
2351
+ // This function is driven by a `MutationObserver` over the whole body, so anything it
2352
+ // writes schedules another call. The guard therefore has to be the FIRST thing that happens,
2353
+ // and the two calls below it write DOM:
2354
+ //
2355
+ // • `ensureAmbientStylesheet()` appends (or replaces) the `<style>` node;
2356
+ // • `removeStrayAmbientSeats()` removes nodes.
2357
+ //
2358
+ // Both used to run BEFORE the fingerprint test, which meant the test could never prevent a
2359
+ // write — every pass wrote at least the stylesheet's absence-check, the observer fired
2360
+ // again, and the cycle was self-sustaining regardless of the fingerprint. The test also
2361
+ // reset the fingerprint on its two bail-out paths, so those windows disabled it entirely.
2362
+ //
2363
+ // The order is now: measure cheaply (a rect read and a string) → decide → only then write.
2364
+ // The fingerprint is intentionally cheap — a few numbers and a markup length — because this
2365
+ // runs up to once per frame and anything geometry-derived would call `footerHeight`, which
2366
+ // walks every descendant of the sidebar.
2367
+ const column = sidebarColumn()
2368
+ if (column !== null) {
2369
+ const columnRect = column.getBoundingClientRect()
2370
+ const markup = sceneMarkup(kind, options)
2371
+ const fingerprint = [
2372
+ kind ?? '',
2373
+ JSON.stringify(options ?? {}),
2374
+ markup.length,
2375
+ Math.round(columnRect.width),
2376
+ Math.round(columnRect.height),
2377
+ ambientStylesheetReady() ? 'css' : 'nocss',
2378
+ ].join('|')
2379
+ if (fingerprint === lastAmbientFingerprint && ambientReport !== undefined) {
2380
+ if (record) noteAmbientAttempt({ kind: kind ?? '(无)', column: true, band: '未变化(跳过)' })
2381
+ return
2382
+ }
2383
+ lastAmbientFingerprint = fingerprint
2384
+ }
2385
+
2386
+ // Repair, then proceed. A one-shot install was assumed to survive and did not —
2387
+ // the report kept reading `css=false`, which quietly turned the guard below into
2388
+ // a permanent no-op. Re-adding the sheet here makes the scenery independent of
2389
+ // whatever removes the node.
2390
+ ensureAmbientStylesheet()
2391
+ removeStrayAmbientSeats()
2392
+ // A seat created before the stylesheet landed is a stray block in the layout,
2393
+ // not scenery. Remove it so the next pass can build a properly positioned one;
2394
+ // the un-styled state is the only one that can spill over the main column.
2395
+ if (!ambientStylesheetReady()) {
2396
+ removeAllAmbientSeats()
2397
+ if (record) noteAmbientAttempt({ kind, column: column !== null, note: '样式表未就绪' })
2398
+ ambientReport = { found: false, note: '等待氛围样式表就位(已清除无样式节点,避免其落入布局)' }
2399
+ return
2400
+ }
2401
+ if (column === null) {
2402
+ removeAllAmbientSeats()
2403
+ if (record) noteAmbientAttempt({ kind, column: false })
2404
+ ambientReport = { found: false, note: '未找到侧栏列(三个选择器全部落空)' }
2405
+ return
2406
+ }
2407
+
2408
+ const markup = sceneMarkup(kind, options)
2409
+
2410
+ if (markup === '') {
2411
+ removeAllAmbientSeats()
2412
+ if (record) noteAmbientAttempt({ kind, column: true, band: '无装饰' })
2413
+ ambientReport = { found: true, kind: '(none)', note: '该主题无装饰' }
2414
+ return
2415
+ }
2416
+
2417
+ // ── ONE RENDER PATH, NOT TWO ─────────────────────────────────────────────
2418
+ //
2419
+ // This used to draw the scene TWICE into two sibling containers: once into a
2420
+ // `#dsh-theme-ambient` seat, and again into the `.dsh-amb-control` layer that was added
2421
+ // while the painting defect was being investigated. The control layer is the one that
2422
+ // actually renders, so the seat was dead weight — except that its copy of the DOM was
2423
+ // real. Two copies of the same scene means two of every animated element, which is what
2424
+ // produced the duplicated dragonflies.
2425
+ //
2426
+ // The surviving container is `.dsh-amb-control`, whose arrangement is the one proven to
2427
+ // paint (see the comment on AMBIENT_CSS); the seat is no longer created and any seat left
2428
+ // over from an earlier build is swept away above.
2429
+ const placement = drawScene(column, markup, kind)
2430
+ if (record) noteAmbientAttempt({ kind, column: true, band: placement.band })
2431
+ ambientReport = placement.report
2432
+ }
2433
+
2434
+ /**
2435
+ * Draw the scene into the single ambient layer, and report what landed.
2436
+ *
2437
+ * The layer itself is a full-viewport fixed element styled by a CLASS — the arrangement copied
2438
+ * from the working `dsh-theme-firefly` plugin. Inside it, the scene box is positioned over the
2439
+ * sidebar's blank area. Splitting those two concerns is what makes both possible at once: the
2440
+ * layer takes the arrangement known to paint, and the artwork keeps the placement asked for.
2441
+ * @param column - the sidebar column to anchor the scene to.
2442
+ * @param markup - the scene markup.
2443
+ * @param kind - the ambient kind, for the report.
2444
+ * @returns the band description and the report.
2445
+ */
2446
+ function drawScene(column, markup, kind) {
2447
+ let wrap = document.querySelector('.dsh-amb-control')
2448
+ if (wrap === null) {
2449
+ wrap = document.createElement('div')
2450
+ wrap.className = 'dsh-amb-control'
2451
+ document.body.appendChild(wrap)
2452
+ }
2453
+ let box = wrap.querySelector(':scope > .dsh-amb-control-scene')
2454
+ if (box === null) {
2455
+ box = document.createElement('div')
2456
+ box.className = 'dsh-amb-control-scene'
2457
+ wrap.appendChild(box)
2458
+ }
2459
+
2460
+ // GEOMETRY every pass; CONTENT only when it changes.
2461
+ //
2462
+ // Re-assigning `innerHTML` rebuilt every scene node, which restarts all CSS animations from
2463
+ // zero — visible as the artwork snapping back to its starting position. Geometry has to be
2464
+ // rewritten because it is measured; the markup does not, because it is derived from the
2465
+ // theme alone.
2466
+ applySceneBox(box, column)
2467
+ const painted = box.dataset.ambientMarkup
2468
+ if (painted !== markup) {
2469
+ box.dataset.ambientMarkup = markup
2470
+ box.innerHTML = String(markup)
2471
+ }
2472
+
2473
+ const rect = box.getBoundingClientRect()
2474
+ return {
2475
+ band: `y${Math.round(rect.top)}..${Math.round(rect.bottom)}`,
2476
+ report: describeAmbient(wrap, kind, `场景=${Math.round(rect.width)}x${Math.round(rect.height)}`),
2477
+ }
2478
+ }
2479
+
2480
+ /**
2481
+ * Position the scene box over the sidebar's blank area.
2482
+ *
2483
+ * Split out from the painting so a resync can refresh the geometry without disturbing the
2484
+ * scene's DOM — and therefore without restarting its animations.
2485
+ * @param box - the scene box.
2486
+ * @param column - the sidebar column to anchor to.
2487
+ */
2488
+ function applySceneBox(box, column) {
2489
+ // The scenery occupies the sidebar's BLANK AREA, not its whole height.
2490
+ //
2491
+ // Full height put the water band at the column's bottom, which is where the account
2492
+ // row lives — the artwork ended up covering the signed-in user's avatar and name
2493
+ // (clicks still reached it, because the layer is pointer-transparent, so the menu
2494
+ // looked broken rather than covered). The bottom third is the region the request
2495
+ // named for the main artwork, and it stops short of the account row.
2496
+ const band = bandBox(column)
2497
+ const rect = column.getBoundingClientRect()
2498
+ const top = band === null ? Math.round(rect.top) : band.top
2499
+ const height = band === null ? Math.round(rect.height) : band.height
2500
+ box.setAttribute('style', [
2501
+ 'position:absolute',
2502
+ `left:${Math.round(rect.left)}px`,
2503
+ `top:${top}px`,
2504
+ `width:${Math.round(rect.width)}px`,
2505
+ `height:${height}px`,
2506
+ // No background of its own: anything opaque here would sit on top of the sidebar
2507
+ // and hide the shell's own menu. The box exists to give the artwork a frame, not to
2508
+ // be seen.
2509
+ 'overflow:hidden',
2510
+ ].join(';'))
2511
+ }
2512
+
2513
+
2514
+ /**
2515
+ * Name the topmost element at a point, and why it is on top.
2516
+ *
2517
+ * `elementFromPoint` alone said *who* wins; it did not say *why*, and this layer
2518
+ * kept losing to the shell's own containers even at the maximum z-index. That means
2519
+ * an ancestor stacking context decides the order, not the element's own `z-index`.
2520
+ * Walking the winner's ancestors and reporting the nearest one that establishes a
2521
+ * stacking context names the thing that actually has to be outranked.
2522
+ * @param x - viewport x.
2523
+ * @param y - viewport y.
2524
+ * @returns a short description.
2525
+ */
2526
+ function describeTopmost(x, y) {
2527
+ const hit = document.elementFromPoint(x, y)
2528
+ if (hit === null) return '空'
2529
+ const tag = hit.tagName === undefined ? '?' : hit.tagName.toLowerCase()
2530
+ const classes = typeof hit.className === 'string' && hit.className !== ''
2531
+ ? `.${hit.className.trim().split(/\s+/).slice(0, 2).join('.')}`
2532
+ : ''
2533
+ let node = hit
2534
+ let context = '无'
2535
+ let depth = 0
2536
+ while (node !== null && depth < 12) {
2537
+ const style = getComputedStyle(node)
2538
+ const positioned = style.position !== 'static'
2539
+ const opacity = Number(style.opacity)
2540
+ const owns = (positioned && style.zIndex !== 'auto')
2541
+ || (Number.isFinite(opacity) && opacity < 1)
2542
+ || style.isolation === 'isolate'
2543
+ || style.transform !== 'none'
2544
+ if (owns) {
2545
+ const owner = node === hit ? '自身' : node.tagName.toLowerCase()
2546
+ context = `${owner} z=${style.zIndex} pos=${style.position}`
2547
+ break
2548
+ }
2549
+ node = node.parentElement
2550
+ depth += 1
2551
+ }
2552
+ return `${tag}${classes}[${context}]`
2553
+ }
2554
+
2555
+ /**
2556
+ * Sample the points where the artwork should be visible.
2557
+ *
2558
+ * Every measurement kept agreeing that the scenery exists with the right size,
2559
+ * while nothing was visible. `getBoundingClientRect` reports GEOMETRY, and geometry
2560
+ * cannot tell "painted" from "painted and then covered". Hit testing can.
2561
+ * @param seat - the seat node.
2562
+ * @returns one short token per sample.
2563
+ */
2564
+ function hitTestAmbient(seat) {
2565
+ try {
2566
+ if (typeof document.elementFromPoint !== 'function') return 'elementFromPoint 不可用'
2567
+ const rect = seat.getBoundingClientRect()
2568
+ if (rect.width === 0 || rect.height === 0) return '座位尺寸为 0'
2569
+ const samples = [
2570
+ ['远山', rect.left + rect.width * 0.5, rect.bottom - rect.height * 0.30],
2571
+ ['近山', rect.left + rect.width * 0.5, rect.bottom - rect.height * 0.12],
2572
+ ['水面', rect.left + rect.width * 0.5, rect.bottom - rect.height * 0.05],
2573
+ // The layer's own top-left corner, where the probe sits. If the probe is not
2574
+ // visible, this point says who took its place.
2575
+ ['探针', rect.left + 20, rect.top + 20],
2576
+ ]
2577
+ return samples.map(([label, x, y]) => `${label}:${describeTopmost(x, y)}`).join(' ')
2578
+ } catch (error) {
2579
+ return `命中测试失败: ${String(error && error.message ? error.message : error)}`
2580
+ }
2581
+ }
2582
+
2583
+ /**
2584
+ * Describe the scenery layer as the document actually has it.
2585
+ *
2586
+ * A layer that is missing, empty, zero-sized or transparent all look identical
2587
+ * from outside the app, and each has a different cause. Reading the live element
2588
+ * is the only way to tell them apart.
2589
+ * @param seat - the seat node.
2590
+ * @param kind - the scene that was requested.
2591
+ * @returns the report.
2592
+ */
2593
+ function describeAmbient(seat, kind, placement) {
2594
+ try {
2595
+ const style = getComputedStyle(seat)
2596
+ const rect = seat.getBoundingClientRect()
2597
+ const column = seat.parentElement
2598
+ const columnRect = column === null ? null : column.getBoundingClientRect()
2599
+ const columnStyle = column === null ? null : getComputedStyle(column)
2600
+ // Measure the artwork itself, not just its container: a healthy-looking
2601
+ // 280x780 seat can still hold a scene that collapsed to zero height, and
2602
+ // the two need different fixes.
2603
+ const scene = seat.firstElementChild
2604
+ const sceneRect = scene === null ? null : scene.getBoundingClientRect()
2605
+ const art = seat.querySelector('.sta-mountains, .dof-seaweed')
2606
+ const artRect = art === null ? null : art.getBoundingClientRect()
2607
+ return {
2608
+ found: true,
2609
+ kind,
2610
+ children: seat.childElementCount,
2611
+ size: `${Math.round(rect.width)}x${Math.round(rect.height)}`,
2612
+ display: style.display,
2613
+ position: style.position,
2614
+ zIndex: style.zIndex,
2615
+ overflow: style.overflow,
2616
+ // Where the seat landed, relative to the column it was inserted into.
2617
+ // A large negative or oversized offset means it was placed outside the
2618
+ // visible box rather than covered.
2619
+ offsetInColumn: columnRect === null
2620
+ ? '?'
2621
+ : `${Math.round(rect.left - columnRect.left)},${Math.round(rect.top - columnRect.top)}`,
2622
+ columnPosition: columnStyle === null ? '?' : columnStyle.position,
2623
+ columnOverflow: columnStyle === null ? '?' : columnStyle.overflow,
2624
+ sceneSize: sceneRect === null ? '?' : `${Math.round(sceneRect.width)}x${Math.round(sceneRect.height)}`,
2625
+ artSize: artRect === null ? '?' : `${Math.round(artRect.width)}x${Math.round(artRect.height)}`,
2626
+ // The column clips its own overflow, so artwork painted outside the
2627
+ // column's box is invisible while every other reading looks healthy.
2628
+ artInsideColumn: artRect !== null && columnRect !== null
2629
+ && artRect.bottom > columnRect.top
2630
+ && artRect.top < columnRect.bottom,
2631
+ css: document.querySelector('style[data-plugin-css="theme-gallery/ambient"]') !== null,
2632
+ // The stylesheet is the other half of the mechanism: without it the seat
2633
+ // is a plain static div and every child collapses to nothing.
2634
+ inColumn: column !== null && column.className.includes('sidebarCol'),
2635
+ siblings: column === null ? -1 : column.childElementCount,
2636
+ // Who is on top where the artwork should be: the one reading that can
2637
+ // tell "never painted" apart from "painted and then covered".
2638
+ hitTest: hitTestAmbient(seat),
2639
+ // Whoever wins the hit test, measured. The previous round named `div.tg-page` —
2640
+ // this plugin's OWN panel — at every sample point, which would mean the scenery
2641
+ // IS painted and is then covered by the panel being looked at. Reporting the
2642
+ // winner's rectangle and the two column rectangles turns "covered" from a guess
2643
+ // into a comparison.
2644
+ columns: (() => {
2645
+ const out = []
2646
+ for (const selector of ['.ZTP-Xa_sidebarCol', '.ZTP-Xa_centerCol']) {
2647
+ const node = document.querySelector(selector)
2648
+ if (node === null) { out.push(`${selector.replace('.ZTP-Xa_', '')}=无`); continue }
2649
+ const r = node.getBoundingClientRect()
2650
+ out.push(`${selector.replace('.ZTP-Xa_', '')}=${Math.round(r.left)},${Math.round(r.top)}`
2651
+ + ` ${Math.round(r.width)}x${Math.round(r.height)}`)
2652
+ }
2653
+ // The winner at one point over the SIDEBAR, measured. The centre column starts
2654
+ // at x=280, so a panel inside it cannot legitimately cover x=0..280 — if this
2655
+ // reports a rectangle that does reach the sidebar, that is a finding in itself.
2656
+ const seatRect = seat.getBoundingClientRect()
2657
+ const sampleX = seatRect.left + 140
2658
+ const sampleY = seatRect.top + 140
2659
+ const top = document.elementFromPoint(sampleX, sampleY)
2660
+ if (top === null) {
2661
+ out.push(`采样(${Math.round(sampleX)},${Math.round(sampleY)})=空`)
2662
+ } else {
2663
+ const r = top.getBoundingClientRect()
2664
+ const s = getComputedStyle(top)
2665
+ const cls = typeof top.className === 'string' && top.className !== ''
2666
+ ? `.${String(top.className).split(' ')[0]}`
2667
+ : ''
2668
+ out.push(`采样(${Math.round(sampleX)},${Math.round(sampleY)})`
2669
+ + `=${top.tagName.toLowerCase()}${cls}`
2670
+ + `@${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}`
2671
+ + ` pos=${s.position} z=${s.zIndex} pe=${s.pointerEvents}`)
2672
+ }
2673
+ return out.join(' ')
2674
+ })(),
2675
+ placement,
2676
+ // A preview of what the layer actually CONTAINS. If the scene markup never
2677
+ // arrived, every geometry reading still looks healthy — the container has the
2678
+ // right size either way — so the content has to be reported, not assumed.
2679
+ html: (() => {
2680
+ const raw = seat.innerHTML
2681
+ return raw.length === 0 ? '(空)' : `${raw.length}字符`
2682
+ })(),
2683
+ // The first three descendants with their OWN computed geometry and paint state.
2684
+ // The seat can be perfectly sized and full of markup while every child is
2685
+ // zero-sized, hidden or clipped — and the parent's numbers look identical in all
2686
+ // three cases. The probe proves a simple element renders here; this reports the
2687
+ // complex one, which is where the difference must lie.
2688
+ kids: (() => {
2689
+ const out = []
2690
+ let node = seat.firstElementChild
2691
+ while (node !== null && out.length < 3) {
2692
+ const r = node.getBoundingClientRect()
2693
+ const s = getComputedStyle(node)
2694
+ const cls = String(node.className).split(' ')[0]
2695
+ out.push(`<${node.tagName.toLowerCase()}${cls === '' ? '' : `.${cls}`}`
2696
+ + ` ${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}`
2697
+ + ` pos=${s.position} disp=${s.display} vis=${s.visibility} op=${s.opacity}>`)
2698
+ node = node.firstElementChild
2699
+ }
2700
+ return out.length === 0 ? '(无子元素)' : out.join(' ')
2701
+ })(),
2702
+ // The seat's OWN box, inline-styled from `placeAmbient`. Its colour is the
2703
+ // one signal that separates "the layer paints" from "the layer's children
2704
+ // paint": the probe proved an overlay can be drawn, this proves THIS one is.
2705
+ seatBackground: style.backgroundColor,
2706
+ // WHERE the layer hangs. The whole defect came down to this: an identical
2707
+ // minimal element on `documentElement` rendered while the layer on `body`
2708
+ // painted nothing. Reporting the parent turns that into a reading instead of
2709
+ // something to be inferred from the code.
2710
+ parent: seat.parentElement === null
2711
+ ? '(无父节点)'
2712
+ : `${seat.parentElement.tagName.toLowerCase()}`
2713
+ + `${seat.parentElement.id === '' ? '' : `#${seat.parentElement.id}`}`,
2714
+ paintError: ambientPaintError,
2715
+ }
2716
+ } catch (error) {
2717
+ return {
2718
+ found: true,
2719
+ kind,
2720
+ note: `读取失败: ${String(error && error.message ? error.message : error)}`,
2721
+ }
2722
+ }
2723
+ }
2724
+
2725
+ /**
2726
+ * Resolve a bundled theme by id.
2727
+ *
2728
+ * Only this package's own themes are addressable: they are the ones whose
2729
+ * `accent` and `ambient` fields this plugin can trust. A theme contributed by
2730
+ * another plugin is simply skipped, which degrades to "no scenery, base accent"
2731
+ * rather than to an error.
2732
+ * @param id - the theme id.
2733
+ * @returns the bundled theme, or undefined.
2734
+ */
2735
+ function bundledTheme(id) {
2736
+ return BUNDLED_THEMES.find((theme) => theme.id === id)
2737
+ }
2738
+
2739
+ /**
2740
+ * Disposer of the active accent layer, when one is stacked.
2741
+ *
2742
+ * Declared BEFORE `syncAccent`, which reads it. The body runs top to bottom during
2743
+ * mount, so a `let` read above its own declaration throws a ReferenceError — and the
2744
+ * caller wraps this in a `try`, so the error was swallowed and the accent marker
2745
+ * silently never appeared. Same shape as the `paintAttempts` fault; see
2746
+ * `tests/check-tdz-order.mjs`, which now covers declarations that precede the function
2747
+ * reading them, not just declarations inside it.
2748
+ */
2749
+ let accentLayerDispose
2750
+
2751
+ /**
2752
+ * The plugin context, published for the factory-level helpers.
2753
+ *
2754
+ * `syncAccent` and `themeDiagnostics` are defined at factory scope but were written when they
2755
+ * lived inside the mount body, where `ctx` was a parameter. Extracting that body left them
2756
+ * without it, so their `ctx` reads threw — and because their callers wrap them in `try`, the
2757
+ * failure was invisible and the features simply never worked.
2758
+ *
2759
+ * Assigned once, by `applyGallery`, before anything can call those helpers.
2760
+ * @type {object|undefined}
2761
+ */
2762
+ let ctx
2763
+
2764
+ /**
2765
+ * Stack the active theme's accent colour over the selection states.
2766
+ *
2767
+ * The source system's design rules are explicit that an active marker takes the
2768
+ * theme's own characteristic colour rather than a colour invented for it, and
2769
+ * that the marker must be unmistakable: a coloured left bar **plus** coloured
2770
+ * text **plus** a 600 weight **plus** a translucent fill. In this shell those
2771
+ * states read from the `button-ghost-active-*` family, which is what the
2772
+ * sidebar entry and the selected conversation row both use.
2773
+ *
2774
+ * Two constraints matter more than the colours themselves:
2775
+ *
2776
+ * - **Only the ACTIVE theme may contribute.** `overrideTokens` layers compose
2777
+ * over whatever theme is active, so reading a fixed accent would paint
2778
+ * 山青婷彩's pink onto 梦海游鱼 and onto the built-in light/dark themes too.
2779
+ * The layer is withdrawn whenever the active theme is not one this package
2780
+ * contributed, so every other theme keeps its own selection colour.
2781
+ * - **The theme's brand colour is left alone.** Repointing `brand-primary` at a
2782
+ * warm accent would also repoint links, primary buttons and status chips —
2783
+ * far more than the marker this is about.
2784
+ *
2785
+ * Alpha is composed here because the token is a colour: the source stores the
2786
+ * active fill as `rgba(...,0.15)`-style values, and an 8-digit hex is how the
2787
+ * same intent is expressed in a token.
2788
+ * @param accent - the active theme's accent colour, or undefined for none.
2789
+ */
2790
+ function syncAccent(accent) {
2791
+ // A factory-level helper that needs the plugin context.
2792
+ //
2793
+ // These helpers used to live INSIDE the mount body, where `ctx` was a parameter in scope.
2794
+ // Extracting the body into `mountGallery(ctx)` left them at factory level, where `ctx` does
2795
+ // not exist at all — so every call threw `ReferenceError: ctx is not defined`, the caller's
2796
+ // `try` swallowed it, and the accent marker silently never appeared. The module-level `ctx`
2797
+ // below is assigned at mount and read here.
2798
+ if (ctx === undefined) return
2799
+ if (accentLayerDispose !== undefined) {
2800
+ accentLayerDispose()
2801
+ accentLayerDispose = undefined
2802
+ }
2803
+ if (typeof accent !== 'string' || accent === '') return
2804
+ accentLayerDispose = ctx.theme.overrideTokens('theme-gallery: accent', {
2805
+ '--dsw-alias-button-ghost-active-fill': { light: `${accent}29`, dark: `${accent}29` },
2806
+ '--dsw-alias-button-ghost-active-border': { light: accent, dark: accent },
2807
+ '--dsw-alias-button-ghost-active-hover': { light: `${accent}47`, dark: `${accent}47` },
2808
+ })
2809
+ }
2810
+
2811
+ /**
2812
+ * Report whether the accent token layer actually reached the theme snapshot.
2813
+ *
2814
+ * ── WHY THIS READING IS NEEDED ───────────────────────────────────────────
2815
+ *
2816
+ * `syncAccent` stacks three tokens (`button-ghost-active-fill` / `-border` / `-hover`) with
2817
+ * `ctx.theme.overrideTokens`. Whether that layer works had **never been verified**, and one
2818
+ * earlier attempt to verify it used the wrong evidence: the active workspace FOLDER icon
2819
+ * turns the accent colour, which was taken as proof — but that icon is painted by the skin's
2820
+ * own `--dsw-alias-state-business-primary` token and has nothing to do with the layer.
2821
+ *
2822
+ * The service makes this checkable without any guesswork: `composeActive` folds every
2823
+ * override layer into `snapshot.active.tokens` before publishing, so the composed value is
2824
+ * readable straight off `getTheme()`. If the layer is registered, the token IS there; if it
2825
+ * is not, the token is absent or still the built-in value.
2826
+ * @returns the reading, as one segment of the panel line.
2827
+ */
2828
+ function describeAccentLayer() {
2829
+ try {
2830
+ const theme = ctx?.theme
2831
+ if (theme === undefined) return '激活层[无 theme 服务]'
2832
+ const snapshot = theme.getTheme()
2833
+ const active = snapshot?.active
2834
+ const tokens = active?.tokens ?? {}
2835
+ const TOKEN = '--dsw-alias-button-ghost-active-border'
2836
+ const value = tokens[TOKEN]
2837
+ const layers = snapshot?.overrides === undefined
2838
+ ? '(服务未暴露)'
2839
+ : String(snapshot.overrides.size ?? snapshot.overrides.length ?? '?')
2840
+ // Its OWN key count too: layers folding into `active.tokens` is the mechanism, so a
2841
+ // token count that does not move when the layer is stacked also disproves it.
2842
+ return `激活层[id=${active?.id ?? '?'}`
2843
+ + ` 层数=${layers}`
2844
+ + ` token数=${Object.keys(tokens).length}`
2845
+ + ` ${TOKEN.replace('--dsw-alias-', '')}=${value === undefined ? '(缺失)' : String(value)}]`
2846
+ } catch (error) {
2847
+ return `激活层[读取失败: ${String(error && error.message ? error.message : error)}]`
2848
+ }
2849
+ }
2850
+
2851
+ /**
2852
+ * Render the main-column gallery page.
2853
+ *
2854
+ * `usePanelInfo` comes from the layout's GlobalStandardProps, so the page can
2855
+ * render nothing when another panel is selected without the shell having to
2856
+ * mount and unmount it.
2857
+ * @param props - composed slot props.
2858
+ * @returns the page element tree.
2859
+ */
2860
+ function ThemeGalleryPage({ t, setTheme, useStore, usePanelInfo }) {
2861
+ const info = usePanelInfo((s) => s.activePanelId)
2862
+ const ids = useStore((s) => s.ids)
2863
+ const labels = useStore((s) => s.labels)
2864
+ const descriptions = useStore((s) => s.descriptions)
2865
+ const swatches = useStore((s) => s.swatches)
2866
+ const selected = useStore((s) => s.selected)
2867
+ const status = useStore((s) => s.status)
2868
+ // The layout keeps this page registered whatever the selection is, so it
2869
+ // renders nothing while another panel owns the main column.
2870
+ if (info !== PANEL_ID) return null
2871
+ // An empty picker must explain itself: the boot screen says only "failed",
2872
+ // and a silent empty page is indistinguishable from a broken one.
2873
+ if (ids.length === 0) {
2874
+ return jsxs('div', {
2875
+ className: 'tg-page',
2876
+ children: [
2877
+ jsx('div', { className: 'tg-title', children: t('title') }),
2878
+ jsx('div', { className: 'tg-hint', children: status || t('empty') }),
2879
+ ],
2880
+ })
2881
+ }
2882
+ return jsxs('div', {
2883
+ className: 'tg-page',
2884
+ children: [
2885
+ jsxs('div', {
2886
+ className: 'tg-head',
2887
+ children: [
2888
+ jsx('span', { className: 'tg-title', children: t('title') }),
2889
+ jsx('span', { className: 'tg-hint', children: t('hint') }),
2890
+ jsx('span', { className: 'tg-hint', children: t('count', { count: ids.length }) }),
2891
+ ],
2892
+ }),
2893
+ debugEnabled() ? jsx('div', { className: 'tg-debug', children: themeDiagnostics(selected) }) : null,
2894
+ // Unconditional while the scenery is being brought up: when the active
2895
+ // skin's decorations are not on screen, the reason has to reach the person
2896
+ // looking at it. Shows nothing once the report says the layer is drawn and
2897
+ // inside the column.
2898
+ sceneryLine(selected) === null
2899
+ ? null
2900
+ : jsx('div', {
2901
+ className: `tg-debug${ambientWarning(selected) === null ? '' : ' tg-warn'}`,
2902
+ children: sceneryLine(selected),
2903
+ }),
2904
+ jsx('div', {
2905
+ className: 'tg-grid',
2906
+ children: ids.map((id) => jsx(ThemeCard, {
2907
+ id,
2908
+ label: labels[id],
2909
+ description: descriptions[id],
2910
+ swatches: swatches[id] || [],
2911
+ selected: id === selected,
2912
+ applied: t('applied'),
2913
+ onSelect: setTheme,
2914
+ t,
2915
+ }, id)),
2916
+ }),
2917
+ ],
2918
+ })
2919
+ }
2920
+
2921
+ /**
2922
+ * Render the sidebar panel entry.
2923
+ *
2924
+ * The sidebar owns the button and resolves the row label from the list
2925
+ * metadata; this renders only the glyph. Drawn inline rather than imported
2926
+ * from ui-primitives, whose payload is not part of the client's seeded module
2927
+ * table — the same reason the official bundle carries its own icons.
2928
+ * @param props - owner share (size, active).
2929
+ * @returns an inline SVG glyph.
2930
+ */
2931
+ function PanelGlyph({ size, active }) {
2932
+ const edge = typeof size === 'number' ? size : 16
2933
+ return jsxs('svg', {
2934
+ width: edge,
2935
+ height: edge,
2936
+ viewBox: '0 0 16 16',
2937
+ fill: 'none',
2938
+ 'aria-hidden': 'true',
2939
+ children: [
2940
+ jsx('circle', {
2941
+ cx: 8, cy: 8, r: 6,
2942
+ stroke: 'currentColor',
2943
+ 'stroke-width': active ? 1.8 : 1.4,
2944
+ }),
2945
+ jsx('path', {
2946
+ d: 'M8 2a6 6 0 0 0 0 12z',
2947
+ fill: 'currentColor',
2948
+ opacity: active ? 0.9 : 0.55,
2949
+ }),
2950
+ ],
2951
+ })
2952
+ }
2953
+
2954
+ /** Display name used in client diagnostics. */
2955
+ exports.name = 'theme-gallery'
2956
+
2957
+ /**
2958
+ * The HARD dependency list — deliberately ONE entry.
2959
+ *
2960
+ * Cordis' array form makes every entry REQUIRED: if an entry never becomes available the
2961
+ * fiber parks in `pending` forever, the loader's `await` never returns, and the desktop app
2962
+ * stops at "Loading plugins..." with nothing written to the crash log. This plugin has paid
2963
+ * for that lesson three times:
2964
+ *
2965
+ * - `settingsScope` — provided by the CLIENT half of ui-theme, so a HOST-side request for
2966
+ * it could never be satisfied: `pending (waiting for service: settingsScope)`.
2967
+ * - `theme` together with a reverse `modifies: [ui-theme]` in the bundle patch — the
2968
+ * loader was told to start this row both before AND after ui-theme, so both fibers
2969
+ * waited on each other: `dsh-theme-gallery: failed`.
2970
+ * - the same `theme` entry with no `dsh.client.inject` in the manifest, so nothing
2971
+ * guaranteed the module providing the service was loaded first — a hang with NO log line.
2972
+ *
2973
+ * ── WHY `theme` IS BACK, AND WHY THAT IS NOW SAFE ────────────────────────
2974
+ *
2975
+ * An attempt was made to avoid the hazard entirely by taking `theme` as a soft dependency
2976
+ * through `ctx.inject([], cb)`. That does not work, and the side effect was visible on the
2977
+ * page: the toolbar showed "主题服务不可用" and no panel appeared, because a context given an
2978
+ * EMPTY inject list cannot read the service at all. The probe in
2979
+ * `scripts/probe-soft-inject.mjs` records the two semantics that were considered.
2980
+ *
2981
+ * The deciding evidence is on this machine: the two third-party client plugins that work
2982
+ * both declare `theme` as a HARD dependency — `dsh-theme-firefly` uses exactly
2983
+ * `inject: ['theme']` — and both also declare the providing module in `dsh.client.inject`.
2984
+ * That is the pair that makes it safe, and this package now has both:
2985
+ *
2986
+ * package.json dsh.client.inject = ["@deepseek-ai/dsh-client-locale",
2987
+ * "@deepseek-ai/dsh-client-ui-theme"]
2988
+ * here exports.inject = ['slots', 'locale', 'theme']
2989
+ *
2990
+ * The two earlier failures were the `modifies` cycle and the missing module declaration.
2991
+ * Both are fixed, and both are asserted in `tests/check-boot-safety.mjs`.
2992
+ *
2993
+ * `locale` is declared for the same reason: the sidebar row resolves its label through it,
2994
+ * and the providing module is declared in the manifest.
2995
+ * @type {string[]}
2996
+ */
2997
+ exports.inject = ['slots', 'locale', 'theme']
2998
+
2999
+ /**
3000
+ * Marks this client module as a PLUGIN rather than a plain service module.
3001
+ *
3002
+ * This flag was missing, and the web-boot log recorded the entry as
3003
+ *
3004
+ * dsh-theme-gallery: failed
3005
+ *
3006
+ * which is distinct from `pending (waiting for service: settingsScope)` — the other half
3007
+ * of the same boot hang. `pending` means the loader knew about this entry and was waiting
3008
+ * on a dependency it was promised; `failed` is what an entry reports when it is evaluated
3009
+ * but never activates, which is the shape a missing plugin flag produces. Every working
3010
+ * third-party client plugin on this machine declares it (`dsh-theme-firefly`:
3011
+ * `exports.isPlugin = true`). `@deepseek-ai/dsh-client-ui-theme` does not, but it belongs
3012
+ * to the official composition and is mounted through a different path, so it is not the
3013
+ * pattern to copy here.
3014
+ * @type {boolean}
3015
+ */
3016
+ exports.isPlugin = true
3017
+
3018
+ /**
3019
+ * Client plugin body: register the sidebar page and track the reading state.
3020
+ *
3021
+ * WRAPPED SO A FAILURE HERE CANNOT HANG THE APPLICATION.
3022
+ *
3023
+ * The desktop shell waits for EVERY plugin's fiber to settle before it leaves the
3024
+ * "Loading plugins..." screen. A plugin that throws while mounting does not settle, so a
3025
+ * bug in a decoration plug-in becomes a boot hang — with the added cruelty that nothing
3026
+ * reaches the crash log, because no exception escapes. That is exactly how a theme skin
3027
+ * once stranded a real install.
3028
+ *
3029
+ * The body is therefore built in stages, and each stage is isolated: whatever fails is
3030
+ * reported and skipped, and the remaining stages still run. This plugin is an ENHANCEMENT
3031
+ * — a skin — so degrading it must never cost the user their application.
3032
+ *
3033
+ * @param ctx - the plugin context.
3034
+ */
3035
+ exports.apply = function apply(ctx) {
3036
+ // Nothing below is allowed to escape. A synchronous throw in `apply` is the one
3037
+ // failure mode that takes the whole boot down with it.
3038
+ try {
3039
+ applyGallery(ctx)
3040
+ } catch (error) {
3041
+ console.error('[theme-gallery] mount failed; the app continues without this plugin:', error)
3042
+ reportMountFailure(error)
3043
+ }
3044
+ }
3045
+
3046
+ /**
3047
+ * Record a mount failure where the user can see it.
3048
+ *
3049
+ * A console nobody opens is not a diagnosis, and this failure mode is invisible by
3050
+ * nature — the app simply never finishes loading. The message is written onto the document
3051
+ * so it survives the partial mount.
3052
+ * @param error - whatever was thrown.
3053
+ */
3054
+ function reportMountFailure(error) {
3055
+ try {
3056
+ if (typeof document === 'undefined') return
3057
+ const note = document.createElement('div')
3058
+ note.dataset.plugin = 'theme-gallery'
3059
+ note.dataset.pluginError = 'mount'
3060
+ note.setAttribute('style', 'position:fixed;left:0;bottom:0;z-index:2147483647;'
3061
+ + 'max-width:60ch;padding:6px 10px;font:12px/1.5 system-ui,sans-serif;'
3062
+ + 'background:#7f1d1d;color:#fff;pointer-events:none;white-space:pre-wrap')
3063
+ note.textContent = '主题皮肤插件挂载失败,已跳过(不影响使用):'
3064
+ + String(error && error.message ? error.message : error)
3065
+ document.body?.append?.(note)
3066
+ } catch {
3067
+ // Reporting must never be the thing that breaks the boot.
3068
+ }
3069
+ }
3070
+
3071
+ /**
3072
+ * Run a callback on the next frame, or as soon as possible when there is no frame clock.
3073
+ *
3074
+ * Used to coalesce DOM work: the scenery sync is triggered by a body-wide mutation observer
3075
+ * and by resize events, and streaming a reply mutates the transcript many times per frame.
3076
+ * Running the sync once per frame keeps its cost proportional to frames rather than to
3077
+ * mutations, which is the difference between "some work when the layout changes" and a
3078
+ * process that climbs to 11 GB.
3079
+ * @param callback - what to run.
3080
+ */
3081
+ function step(callback) {
3082
+ if (typeof requestAnimationFrame === 'function') {
3083
+ requestAnimationFrame(() => callback())
3084
+ return
3085
+ }
3086
+ // A documentless or animation-less environment (a test harness): a timeout still
3087
+ // coalesces, it just does not align to a paint.
3088
+ setTimeout(callback, 16)
3089
+ }
3090
+
3091
+ /**
3092
+ * The plugin body proper, run inside `apply`'s guard.
3093
+ *
3094
+ * The services this needs — `slots`, `locale`, `theme` — are DECLARED in `exports.inject`,
3095
+ * which is the same shape the two working third-party client plugins on this machine use
3096
+ * (`dsh-theme-firefly` declares `theme` and nothing else). By the time `apply` runs, the
3097
+ * framework has resolved all three, so they are used directly.
3098
+ *
3099
+ * An attempt was made to take `theme` softly instead, to remove every possible boot hazard.
3100
+ * It does not work: a context carrying an EMPTY inject list cannot read the service, so the
3101
+ * plugin reported "主题服务不可用" and never mounted. The safety that matters is not achieved
3102
+ * by weakening this list — it is achieved by the two fixes that actually address the hangs,
3103
+ * both asserted in `tests/check-boot-safety.mjs`: no `modifies` against the providing row,
3104
+ * and the providing module declared in `dsh.client.inject`.
3105
+ * @param ctx - the plugin context, with every declared service resolved.
3106
+ */
3107
+ function applyGallery(pluginCtx) {
3108
+ // Published for the factory-level helpers (`syncAccent`, `themeDiagnostics`). The parameter
3109
+ // is deliberately NOT named `ctx`: that would shadow the module-level binding this line
3110
+ // needs to assign, and the assignment would silently do nothing.
3111
+ ctx = pluginCtx
3112
+ mountGallery(pluginCtx)
3113
+ }
3114
+
3115
+ /**
3116
+ * Show, on the page, that a service this plugin needs never arrived.
3117
+ *
3118
+ * Kept even though nothing calls it now: a plugin that cannot load should be able to say so
3119
+ * where a person will see it, rather than disappearing silently. Silence is exactly how the
3120
+ * earlier boot failures stayed invisible for several rounds.
3121
+ * @param name - the service name.
3122
+ * @param message - what to display.
3123
+ */
3124
+ function reportMissingService(name, message) {
3125
+ console.error(`[theme-gallery] service "${name}" unavailable; skipping the skin`)
3126
+ try {
3127
+ if (typeof document === 'undefined' || document.body == null) return
3128
+ const note = document.createElement('div')
3129
+ note.dataset.plugin = 'theme-gallery'
3130
+ note.dataset.pluginMissing = name
3131
+ note.setAttribute('style', 'position:fixed;left:0;bottom:0;z-index:2147483647;'
3132
+ + 'max-width:60ch;padding:6px 10px;font:12px/1.5 system-ui,sans-serif;'
3133
+ + 'background:#78350f;color:#fff;pointer-events:none;white-space:pre-wrap')
3134
+ note.textContent = message
3135
+ document.body.append(note)
3136
+ } catch {
3137
+ // Reporting must never be what breaks the boot.
3138
+ }
3139
+ }
3140
+
3141
+ /**
3142
+ * The gallery itself.
3143
+ *
3144
+ * Every service it needs — `slots`, `locale`, `theme` — was declared in `exports.inject`, so
3145
+ * the framework resolved them before `apply` ran and they are used directly here.
3146
+ * @param ctx - the plugin context.
3147
+ */
3148
+ function mountGallery(ctx) {
3149
+ // Dictionaries first: the page's `locale` seat needs them installed.
3150
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'theme-gallery: dictionaries')
3151
+
3152
+ // The page stylesheet, owned for this plugin's lifetime.
3153
+ ctx.effect(() => {
3154
+ if (typeof document === 'undefined') return
3155
+ const tag = document.createElement('style')
3156
+ tag.dataset.plugin = 'theme-gallery'
3157
+ tag.dataset.pluginCss = 'theme-gallery/page'
3158
+ tag.textContent = PAGE_CSS
3159
+ document.head.append(tag)
3160
+ return () => { tag.remove() }
3161
+ }, 'theme-gallery: page stylesheet')
3162
+
3163
+ /* ---------------- gallery page ---------------- */
3164
+
3165
+ const storeHandle = createGalleryStore()
3166
+ /**
3167
+ * The one live store instance, shared by the publisher and the page.
3168
+ *
3169
+ * `handle.create()` returns a NEW instance on every call — verified by
3170
+ * `tests/check-store-contract.mjs` — so the render machinery's instance and
3171
+ * the one this code writes through would otherwise be different objects and
3172
+ * every publish would land in a throwaway. That is exactly why the shipped
3173
+ * panel plugins pin theirs: `{ ...handle, create: () => instance }`.
3174
+ */
3175
+ const storeInstance = storeHandle.create()
3176
+ const store = { ...storeHandle, create: () => storeInstance }
3177
+ /**
3178
+ * The shared instance's write surface.
3179
+ *
3180
+ * Publishing goes through THIS, not through the registration's `inject`
3181
+ * factory. `inject` supplies the component's business face, and the page
3182
+ * does not need one — it reads the store seat directly — so a page could
3183
+ * render perfectly while `inject` never ran, leaving every publish written
3184
+ * into `undefined` and the banner silent. Writing to the pinned instance
3185
+ * removes that whole class of silent failure.
3186
+ */
3187
+ const storeActions = storeInstance.actions
3188
+
3189
+ let revision = -1
3190
+ /** Whether the `main` slot gate has fired — i.e. the page really registered. */
3191
+ let gateRan = false
3192
+ /** Whether the registration's inject factory has run (diagnostics only). */
3193
+ let injectRan = false
3194
+
3195
+ /**
3196
+ * Reflect the ACTIVE theme's scenery and accent.
3197
+ *
3198
+ * Called from `publish()`, so it tracks whatever the theme service reports as
3199
+ * active — including themes selected through the official Appearance row, not
3200
+ * just through this plugin's picker.
3201
+ *
3202
+ * Both effects are driven by the same lookup, and both must be **withdrawn**
3203
+ * when the active theme is not one this package contributed: the scenery
3204
+ * belongs to the skin rather than to the app, and an accent layer would
3205
+ * otherwise colour some other theme's selection states. Active themes that
3206
+ * this package does not know resolve to `undefined`, which is what withdraws
3207
+ * both.
3208
+ * @param snapshot - the official theme snapshot.
3209
+ */
3210
+ function syncSkin(snapshot) {
3211
+ const activeId = snapshot?.active?.id
3212
+ const theme = typeof activeId === 'string' ? bundledTheme(activeId) : undefined
3213
+
3214
+ // ── THE PALETTE LAYER IS DRIVEN BY WHAT THE USER CHOSE, NOT BY WHAT IS ACTIVE ──
3215
+ //
3216
+ // Everything below follows `snapshot.active` — the theme the SERVICE currently reports.
3217
+ // That is right for the artwork and the accent marker, which belong to whichever skin is
3218
+ // active. It is NOT right for the palette: the shell reverts the active theme to a
3219
+ // built-in id whenever it adopts the persisted preference (`ui-theme`'s `adopt()`), so
3220
+ // following `active` would tear the skin's colours down moments after they appear —
3221
+ // exactly the "colours show, then vanish" that was reported.
3222
+ //
3223
+ // The layer therefore follows the REMEMBERED skin instead. It is withdrawn only when the
3224
+ // user has genuinely moved to a theme this package does not provide.
3225
+ try {
3226
+ stackSkinTokens(theme === undefined ? undefined : (rememberedSkin() ?? activeId))
3227
+ } catch (error) {
3228
+ console.error('[theme-gallery] could not stack the skin palette:', error)
3229
+ }
3230
+
3231
+ try {
3232
+ syncAmbient(theme?.ambient?.kind, theme?.ambient)
3233
+ } catch (error) {
3234
+ console.error('[theme-gallery] could not render sidebar scenery:', error)
3235
+ }
3236
+ try {
3237
+ syncAccent(theme?.accent)
3238
+ } catch (error) {
3239
+ console.error('[theme-gallery] could not stack the accent layer:', error)
3240
+ }
3241
+ }
3242
+
3243
+ /**
3244
+ * Remember the user's skin, and put it back after a restart.
3245
+ *
3246
+ * The composition layer does carry `ui-theme.config.preference`, but it is read while
3247
+ * the boot graph is still assembling — before this plugin has registered its themes —
3248
+ * so a preference naming one of our skins does not survive that moment. Every restart
3249
+ * therefore came up on the built-in white theme and the skin had to be picked by hand.
3250
+ *
3251
+ * The choice is kept where this plugin can reach it early instead: written whenever a
3252
+ * bundled skin becomes active, and re-applied once the registry holds that id again.
3253
+ *
3254
+ * ── THIS IS ALSO THE FALLBACK, AND IT IS WHY THE FALLBACK CANNOT FAIL ──────
3255
+ *
3256
+ * The skin id is deliberately NOT persisted through the preference at all. The official
3257
+ * service only stores built-in values —
3258
+ *
3259
+ * if (isThemePreference(id)) this.host.set(THEME_PREFERENCE_FIELD, id)
3260
+ * const THEME_PREFERENCES = ['light', 'dark', 'system']
3261
+ *
3262
+ * — so `preference` in the profile can only ever hold a built-in id. If this plugin is
3263
+ * disabled, uninstalled or simply fails to mount, the next boot reads a built-in
3264
+ * preference and starts normally on the built-in theme. The skin selection survives in
3265
+ * `localStorage`, which is private to this plugin and simply stops being consulted.
3266
+ *
3267
+ * That is the automatic fallback the user asked for, and it is stronger than anything
3268
+ * this plugin could do at runtime: a plugin that is not loaded cannot run a recovery
3269
+ * handler, so the safety has to live in the CONFIGURATION CONTRACT rather than in code.
3270
+ * The one way to break it is to hand-write a skin id into `preference` — which the
3271
+ * official service would then reject at boot. Never do that.
3272
+ */
3273
+ const SKIN_KEY = 'theme-gallery:last-skin'
3274
+
3275
+ /**
3276
+ * The token used to prove a skin's palette reached the document.
3277
+ *
3278
+ * `ui-layout`'s presenter writes every token with `body.style.setProperty`, so this can be
3279
+ * read straight back as an inline declaration. `--dsw-alias-bg-base` is chosen because every
3280
+ * bundled skin gives it a `linear-gradient(...)` literal while the built-in themes give it a
3281
+ * `var(...)` reference — two forms that cannot be mistaken for one another.
3282
+ */
3283
+ const PROBE_TOKEN = '--dsw-alias-bg-base'
3284
+
3285
+ /**
3286
+ * A built-in theme to bounce through when the presenter missed a change.
3287
+ *
3288
+ * `setTheme` short-circuits when the id is already active, so re-applying the same skin
3289
+ * emits nothing. Switching to a built-in value and back produces the two real changes that
3290
+ * force a repaint — the same thing the user was doing by hand.
3291
+ */
3292
+ const BUILT_IN_PROBE_THEME = 'light'
3293
+
3294
+ /** @returns the remembered skin id, or null. */
3295
+ function rememberedSkin() {
3296
+ try {
3297
+ const value = window.localStorage.getItem(SKIN_KEY)
3298
+ return typeof value === 'string' && value !== '' && bundledTheme(value) !== undefined
3299
+ ? value
3300
+ : null
3301
+ } catch {
3302
+ return null
3303
+ }
3304
+ }
3305
+
3306
+ /** The skin whose palette layer is currently stacked, and how to remove it. */
3307
+ let stackedSkin
3308
+ let stackedSkinDispose
3309
+
3310
+
3311
+
3312
+
3313
+ /** Set once a rate limit or the kill switch has stopped the restore. */
3314
+
3315
+ /**
3316
+ * Stack a skin's palette as an OVERRIDE LAYER, which the shell cannot undo.
3317
+ *
3318
+ * ── WHY SETTING THE THEME IS NOT ENOUGH ─────────────────────────────────
3319
+ *
3320
+ * `setTheme(id)` writes `this.preference` in memory, and that is all. The official service
3321
+ * ALSO adopts a persisted preference whenever the settings document changes:
3322
+ *
3323
+ * adopt() {
3324
+ * const section = this.host.getSnapshot().value
3325
+ * if (this.preference === section.preference && …) return
3326
+ * this.preference = section.preference // ← overwrites what we just set
3327
+ * this.publish()
3328
+ * }
3329
+ *
3330
+ * The persisted value can only ever be a BUILT-IN id (`light`/`dark`/`system`) — the service
3331
+ * stores no others — so that adoption always reverts the skin. On screen the colours appear
3332
+ * and then vanish a moment later, and clicking the skin once is not enough because the same
3333
+ * adoption can land straight after; only switching away and back produces changes late
3334
+ * enough to survive.
3335
+ *
3336
+ * An override layer does not have that problem. `overrideTokens` keeps layers in their own
3337
+ * map, keyed by source, and `buildSnapshot` composes them OVER whatever theme is active —
3338
+ * `adopt()` never touches them. The layer is owned by this plugin's fiber, so it also
3339
+ * disappears cleanly when the plugin unloads.
3340
+ *
3341
+ * Re-stacking is guarded by `stackedSkin`: `overrideTokens` emits `theme/change` itself, so
3342
+ * registering unconditionally would drive `publish` from inside `publish`.
3343
+ * @param id - the skin id, or undefined to withdraw the layer.
3344
+ */
3345
+ function stackSkinTokens(id) {
3346
+ if (!ambientEnabled()) return
3347
+ if (id === stackedSkin) return
3348
+ try {
3349
+ // The whole body runs under the self-emit guard.
3350
+ //
3351
+ // Both `stackedSkinDispose()` and `overrideTokens()` emit `theme/change`, and the
3352
+ // subscription that consumes that event would otherwise re-enter `syncSkin` →
3353
+ // `stackSkinTokens` at a moment when `stackedSkin` is deliberately undefined (it is
3354
+ // cleared below to release the previous layer). That re-entry used to stack another
3355
+ // layer, which emitted again — the spin that took the renderer to 11 GB.
3356
+ emitting(() => {
3357
+ if (stackedSkinDispose !== undefined) {
3358
+ stackedSkinDispose()
3359
+ stackedSkinDispose = undefined
3360
+ }
3361
+ stackedSkin = undefined
3362
+ if (typeof id !== 'string') return
3363
+ const definition = bundledTheme(id)
3364
+ if (definition === undefined) return
3365
+ const tokens = {}
3366
+ for (const [name, value] of Object.entries(flatten(definition).tokens)) {
3367
+ // The override format is a `{ light, dark }` pair. These palettes are a single
3368
+ // scheme by design, so both arms carry the same value rather than leaving one
3369
+ // undefined — an absent arm would render as a bare `undefined` inside the variable.
3370
+ tokens[name] = { light: value, dark: value }
3371
+ }
3372
+ stackedSkinDispose = ctx.theme.overrideTokens('theme-gallery: palette', tokens)
3373
+ // Handed to the plugin's fiber as well, so the layer is withdrawn even on the paths
3374
+ // that do not go through `stackSkinTokens` again (plugin unload).
3375
+ ctx.effect(() => stackedSkinDispose, 'theme-gallery: palette layer')
3376
+ stackedSkin = id
3377
+ noteAmbientEvent('叠加皮肤令牌层', id)
3378
+ })
3379
+ } catch (error) {
3380
+ noteAmbientEvent('令牌层抛错', String(error && error.message ? error.message : error))
3381
+ console.error('[theme-gallery] could not stack the skin palette layer:', error)
3382
+ }
3383
+ }
3384
+
3385
+ /**
3386
+ * Whether the shell has finished assembling, so a theme change will actually be painted.
3387
+ *
3388
+ * Set by the scenery effect once it has found a laid-out sidebar column — the same
3389
+ * readiness signal the artwork uses, and the earliest point at which the document is known
3390
+ * to have a real layout.
3391
+ *
3392
+ * Declared BEFORE `syncRememberedSkin` because `publish()` can run while this body is still
3393
+ * executing, and reading a `let` in its temporal dead zone would throw — producing a
3394
+ * misleading "could not restore" error on the very first publish.
3395
+ */
3396
+ let bootSettled = false
3397
+
3398
+ /**
3399
+ * Called by the scenery effect when the shell is ready.
3400
+ *
3401
+ * Re-reads the snapshot so the remembered skin gets a chance to be applied now that a
3402
+ * theme change is more likely to be observed.
3403
+ */
3404
+ function markBootSettled() {
3405
+ if (bootSettled) return
3406
+ bootSettled = true
3407
+ noteAmbientEvent('外壳就绪')
3408
+ try {
3409
+ syncRememberedSkin(ctx.theme.getTheme())
3410
+ } catch (error) {
3411
+ noteAmbientEvent('就绪复核抛错', String(error && error.message ? error.message : error))
3412
+ console.error('[theme-gallery] could not re-check the remembered skin:', error)
3413
+ }
3414
+ }
3415
+
3416
+ /**
3417
+ * Whether the document is actually showing the given skin's palette.
3418
+ *
3419
+ * ── WHY THIS DETECTOR IS NECESSARY ───────────────────────────────────────
3420
+ *
3421
+ * `ui-layout`'s presenter does this on mount:
3422
+ *
3423
+ * const presenter = new ThemePresenter()
3424
+ * presenter.apply(ctx.theme.getTheme()) // applies whatever is active NOW
3425
+ * const off = ctx.on('theme/change', ...) // and only then starts listening
3426
+ *
3427
+ * So it paints the theme that is active at mount time, and any `setTheme` call made before
3428
+ * that subscription is observed by nobody. The service still records the skin as active —
3429
+ * which is why the scenery switches but the colours do not — and because `setTheme`
3430
+ * short-circuits on `preference === id`, clicking the same skin afterwards emits NOTHING.
3431
+ * Only switching to another skin and back produces the two genuine changes that repaint.
3432
+ *
3433
+ * The presenter writes each token with `body.style.setProperty(name, value)`, so the tokens
3434
+ * are readable back as INLINE styles. That makes the effect verifiable instead of assumed:
3435
+ * an inline declaration carrying the skin's own value means the presenter has run for this
3436
+ * skin, and a missing one means the change was missed and must be retried.
3437
+ *
3438
+ * `--dsw-alias-bg-base` is the probe token because the skin gives it a gradient literal
3439
+ * while the built-in light theme gives it `var(--dsw-static-neutral-bluish-00)` — the two
3440
+ * forms cannot be confused.
3441
+ * @param id - the skin id to look for.
3442
+ * @returns whether the skin's palette is on the document.
3443
+ */
3444
+ function skinIsPainted(id) {
3445
+ try {
3446
+ if (typeof document === 'undefined' || document.body == null) return false
3447
+ const definition = bundledTheme(id)
3448
+ if (definition === undefined) return false
3449
+ const expected = flatten(definition).tokens[PROBE_TOKEN]
3450
+ if (typeof expected !== 'string' || expected === '') return false
3451
+ const actual = document.body.style.getPropertyValue(PROBE_TOKEN)
3452
+ return actual.trim() === expected.trim()
3453
+ } catch {
3454
+ return false
3455
+ }
3456
+ }
3457
+
3458
+ /**
3459
+ * How many times a bounce has been spent on a given skin.
3460
+ *
3461
+ * Declared BEFORE `ensureSkinPainted`, which reads it. This body runs top to bottom during
3462
+ * mount, so a `const` read above its own declaration throws a ReferenceError — and because
3463
+ * every caller here sits inside a `try`, that error is swallowed and the feature silently
3464
+ * does nothing. That is exactly what happened: the declaration sat below its reader, the
3465
+ * first `markBootSettled()` threw, `bootSettled` never became true, and the skin colours
3466
+ * were never applied.
3467
+ *
3468
+ * A bounce means switching to a built-in theme and straight back, which forces the
3469
+ * presenter to repaint — but it is two real repaints, so it is rationed to ONE per skin and
3470
+ * only used once the document has demonstrably failed to follow the service.
3471
+ */
3472
+ const paintAttempts = new Map()
3473
+ /**
3474
+ * Apply the remembered skin, and confirm it landed.
3475
+ *
3476
+ * Called on every publish while the app sits on a built-in theme, and also when the service
3477
+ * already reports a skin — the second case is the one that used to be unreachable, because
3478
+ * the id looked correct while the document had never been repainted.
3479
+ *
3480
+ * The retry is BOUNDED and cooled down. `publish()` fires often, and each attempt that has
3481
+ * to bounce through another theme causes two real repaints, so an unbounded retry would
3482
+ * flicker the whole interface while the presenter is still unavailable. A handful of spaced
3483
+ * attempts is enough to cover the mount window; after that the plugin stays quiet rather
3484
+ * than fighting the shell.
3485
+ * @param wanted - the skin id to put in effect.
3486
+ * @param activeId - what the service currently reports as active.
3487
+ */
3488
+ function ensureSkinPainted(wanted, activeId) {
3489
+ if (!ambientEnabled()) return
3490
+ if (restoreDisabledReason !== undefined) return
3491
+ if (!bootSettled) {
3492
+ // The gate that silently refused to act. It is the likeliest explanation for "the
3493
+ // colours never appear until something else happens", and it used to leave no trace.
3494
+ noteAmbientEvent('等待外壳', wanted)
3495
+ return
3496
+ }
3497
+ if (skinIsPainted(wanted)) {
3498
+ paintAttempts.clear()
3499
+ noteAmbientEvent('已上色', wanted)
3500
+ return
3501
+ }
3502
+
3503
+ // ── THE BOUNCE IS THE LAST RESORT, NOT THE FIRST MOVE ──────────────────
3504
+ //
3505
+ // The presenter writes tokens asynchronously: for a frame or two after a real theme
3506
+ // change the document still shows the old palette. Treating that window as "missed" made
3507
+ // this code bounce `built-in → skin` repeatedly, and every bounce is two genuine repaints
3508
+ // — which is what showed on screen as the interface flashing between coloured and plain.
3509
+ //
3510
+ // So a bounce happens only after the skin has failed to appear across a PAUSE, and only
3511
+ // once per skin. Telling the service first is always safe: when the id is already active
3512
+ // it is a documented no-op.
3513
+ if (wanted !== activeId) {
3514
+ // Budgeted and cooled down. This branch used to call `setTheme` unconditionally on every
3515
+ // publish, with no throttle at all, so the shell putting the active id back to a
3516
+ // built-in value made it a perpetual request ↔ publish ping-pong.
3517
+ if (requestTheme(wanted)) noteAmbientEvent('置为皮肤', wanted)
3518
+ // Give the presenter a chance to land before judging it.
3519
+ return
3520
+ }
3521
+
3522
+ // The service ALREADY reports this skin, yet the document does not show it: the change
3523
+ // was missed by a presenter that had not subscribed yet. Only now is a bounce justified.
3524
+ const bounced = paintAttempts.get(wanted) ?? 0
3525
+ if (bounced >= 1) {
3526
+ noteAmbientEvent('跳转已用尽', wanted)
3527
+ return
3528
+ }
3529
+ paintAttempts.set(wanted, bounced + 1)
3530
+ noteAmbientEvent('未上色·跳转一次', wanted)
3531
+ try {
3532
+ // Two writes, both under the global budget, and both marked as self-caused so the
3533
+ // subscription ignores the `theme/change` they produce.
3534
+ emitting(() => ctx.theme.setTheme(BUILT_IN_PROBE_THEME))
3535
+ emitting(() => ctx.theme.setTheme(wanted))
3536
+ } catch (error) {
3537
+ noteAmbientEvent('跳转抛错', String(error && error.message ? error.message : error))
3538
+ console.error('[theme-gallery] could not re-apply the remembered skin:', error)
3539
+ }
3540
+ }
3541
+
3542
+
3543
+ /**
3544
+ * Record the active skin, or restore the remembered one.
3545
+ *
3546
+ * ── TIMING IS THE WHOLE POINT HERE ───────────────────────────────────────
3547
+ *
3548
+ * The restore must not be a one-shot hope. `ctx.theme.setTheme(id)` updates the registry
3549
+ * and emits `theme/change`; the tokens reach the document only if the layout package's
3550
+ * presenter is already subscribed. When this plugin restored during mount, the call landed
3551
+ * before that subscription existed: the service recorded the skin as active while nothing
3552
+ * repainted — and because the service now believes the skin IS active, clicking it again
3553
+ * emits nothing at all. That is why the user had to switch to a different skin and back.
3554
+ *
3555
+ * Two things make the restore dependable:
3556
+ *
3557
+ * 1. it waits for the shell (`bootSettled`), so a change is far more likely to be seen;
3558
+ * 2. it CONFIRMS the effect by reading the tokens back off the document
3559
+ * (`skinIsPainted`), and re-applies while the confirmation is missing — so a change
3560
+ * that was missed is retried on the next publish instead of being lost for the session.
3561
+ *
3562
+ * When the service already reports the skin as active but the document disagrees, the plain
3563
+ * `setTheme` call is a documented no-op, so the retry bounces through a built-in theme and
3564
+ * back. That is exactly the manual toggle the user had to perform.
3565
+ * @param snapshot - the official theme snapshot.
3566
+ */
3567
+ function syncRememberedSkin(snapshot) {
3568
+ try {
3569
+ const activeId = snapshot?.active?.id
3570
+ // A contributed skin is active: remember it for the next boot, and make sure the document
3571
+ // is genuinely painted with it. The id alone is not proof — a change that arrived before
3572
+ // the presenter subscribed leaves the service reporting the skin while the page still
3573
+ // shows the built-in palette.
3574
+ if (typeof activeId === 'string' && bundledTheme(activeId) !== undefined) {
3575
+ if (window.localStorage.getItem(SKIN_KEY) !== activeId) {
3576
+ window.localStorage.setItem(SKIN_KEY, activeId)
3577
+ }
3578
+ ensureSkinPainted(activeId, activeId)
3579
+ return
3580
+ }
3581
+
3582
+ // The app is on a built-in theme even though a skin is remembered: the boot race lost
3583
+ // the preference. Re-checked on every publish rather than once per session, because a
3584
+ // single attempt can be swallowed by a presenter that is not listening yet.
3585
+ const wanted = rememberedSkin()
3586
+ if (wanted === null) return
3587
+ ensureSkinPainted(wanted, activeId)
3588
+ } catch (error) {
3589
+ console.error('[theme-gallery] could not restore the remembered skin:', error)
3590
+ }
3591
+ }
3592
+
3593
+
3594
+ /* ---------------- scenery stylesheet ----------------
3595
+ *
3596
+ * Installed before anything can create the seat, and installed defensively:
3597
+ * the sheet must be in the document before `#dsh-theme-ambient` exists, because
3598
+ * an UNSTYLED seat is not merely invisible — being a plain block, it joins the
3599
+ * sidebar's layout and spills over the main column, covering conversation text.
3600
+ * That happened for real, and it is why `syncAmbient` refuses to create a seat
3601
+ * while this sheet is absent.
3602
+ *
3603
+ * `head` can be missing if this runs before the parser has produced one, so the
3604
+ * fallback appends to the document element rather than dropping the sheet.
3605
+ *
3606
+ * Installation happens here for the earliest possible mount, but the sheet is
3607
+ * also re-checked and re-added by `ensureAmbientStylesheet()` on every sync —
3608
+ * this effect is the first attempt, not the only one.
3609
+ */
3610
+ ctx.effect(() => {
3611
+ if (typeof document === 'undefined') return
3612
+ const tag = ensureAmbientStylesheet()
3613
+ return () => {
3614
+ // Unloading is the one legitimate reason for the sheet to disappear, so the
3615
+ // cleanup removes it and the seat together.
3616
+ if (tag !== null) tag.remove()
3617
+ const column = sidebarColumn()
3618
+ if (column !== null) {
3619
+ const seat = column.querySelector(':scope > #dsh-theme-ambient')
3620
+ if (seat !== null) seat.remove()
3621
+ }
3622
+ }
3623
+ }, 'theme-gallery: sidebar scenery stylesheet')
3624
+
3625
+ /* ---------------- contribute themes, unconditionally ----------------
3626
+ *
3627
+ * Deliberately NOT inside the `main` gate. Contribution needs nothing but
3628
+ * the theme service — guaranteed present because `theme` is in `inject` —
3629
+ * and putting it behind the gate made the symptom ambiguous: when the panel
3630
+ * page came up empty there was no way to tell "the gate never ran" from "the
3631
+ * page never rendered".
3632
+ *
3633
+ * The call itself is placed AFTER `contribute` is defined, not here:
3634
+ * `ctx.effect` runs its callback synchronously, so an earlier call would hit
3635
+ * the temporal dead zone on the `contributed` set that `contribute` closes
3636
+ * over. That failure surfaced on the page as "Cannot access 'contributed'
3637
+ * before initialization".
3638
+ */
3639
+
3640
+ /**
3641
+ * Push the current registry into the page's store.
3642
+ *
3643
+ * Writes straight to the pinned instance, so it does not care whether the
3644
+ * registration's `inject` factory has run.
3645
+ *
3646
+ * Also records a status line when there is nothing to show, because an
3647
+ * empty picker is otherwise indistinguishable from a broken one and the
3648
+ * boot screen only ever says "failed". The line names which link is
3649
+ * missing: the slot gate, the contribution call, or the registry itself.
3650
+ * @param snapshot - the official theme snapshot.
3651
+ */
3652
+ function publish(snapshot) {
3653
+ revision += 1
3654
+ const themes = [...snapshot.themes].filter((theme) => !OMITTED_IDS.has(theme.id))
3655
+ storeActions.sync(themes, snapshot.preference, revision)
3656
+ syncSkin(snapshot)
3657
+ syncRememberedSkin(snapshot)
3658
+ // Expose what the theme SERVICE believes, so the page can compare it with
3659
+ // what is actually in effect on the document. The presenter consumes this
3660
+ // same snapshot (`snapshot.active.tokens`) and writes it to `body`, so a
3661
+ // mismatch between the two points at the presenter, and agreement points
3662
+ // at the colours themselves having no visible effect.
3663
+ const active = snapshot.active ?? {}
3664
+ window.__DSH_THEME_DEBUG__ = {
3665
+ activeId: active.id ?? '?',
3666
+ activeTokens: active.tokens === undefined ? '?' : Object.keys(active.tokens).length,
3667
+ themeIds: snapshot.themes.map((theme) => theme.id).join(','),
3668
+ }
3669
+ if (themes.length === 0) {
3670
+ storeActions.note(
3671
+ !gateRan
3672
+ ? 'main 插槽的 gate 尚未触发(页面外壳还没声明该插槽)'
3673
+ : !injectRan
3674
+ ? '页面已注册但尚未渲染;主题注册表为空'
3675
+ : '主题注册表为空:贡献调用已执行但没有皮肤进入注册表',
3676
+ )
3677
+ }
3678
+ }
3679
+
3680
+ /**
3681
+ * Resolve a bundled theme into the shape `register` actually consumes.
3682
+ *
3683
+ * `register()` stores the definition BY REFERENCE and `composeActive()`
3684
+ * passes it through untouched when no override layer exists — so the
3685
+ * `{ light, dark }` pair format belongs to `overrideTokens` layers, NOT
3686
+ * here. `ThemeDefinition.tokens` is `Record<string, string>`, one value for
3687
+ * the theme's own `colorScheme`. Feeding pairs to `register` writes
3688
+ * literally `[object Object]` into the CSS variables, which paints nothing
3689
+ * and reads on the page as a theme with no colours at all.
3690
+ * @param definition - the bundled theme.
3691
+ * @returns the registrable definition.
3692
+ */
3693
+ function flatten(definition) {
3694
+ const tokens = {}
3695
+ for (const [name, value] of Object.entries(definition.tokens ?? {})) {
3696
+ tokens[name] = typeof value === 'string' ? value : value[definition.colorScheme]
3697
+ }
3698
+ return { ...definition, tokens }
3699
+ }
3700
+
3701
+ /**
3702
+ * Put this package's themes into the registry, once each.
3703
+ *
3704
+ * `ctx.theme.register` THROWS on a duplicate id, and this runs again on
3705
+ * every `theme/change` — including the change its own first registration
3706
+ * causes. A local set of ids this plugin has already offered is what makes
3707
+ * it idempotent without depending on the snapshot being fresh one
3708
+ * microtask later, which is not guaranteed.
3709
+ *
3710
+ * Disposers land on the plugin fiber through `ctx.effect`, so unloading
3711
+ * withdraws this package's themes.
3712
+ * @param snapshot - the official theme snapshot.
3713
+ */
3714
+ const contributed = new Set()
3715
+ function contribute(snapshot) {
3716
+ const known = new Set(snapshot.themes.map((theme) => theme.id))
3717
+ for (const definition of BUNDLED_THEMES) {
3718
+ if (contributed.has(definition.id)) continue
3719
+ contributed.add(definition.id)
3720
+ // Another provider already registers this id: leave it to them.
3721
+ if (known.has(definition.id)) continue
3722
+ ctx.effect(
3723
+ () => ctx.theme.register(flatten(definition)),
3724
+ `theme-gallery: theme ${definition.id}`,
3725
+ )
3726
+ }
3727
+ }
3728
+
3729
+ // Placed after both declarations above, because `ctx.effect` calls its
3730
+ // callback synchronously.
3731
+ ctx.effect(() => {
3732
+ try {
3733
+ contribute(ctx.theme.getTheme())
3734
+ } catch (error) {
3735
+ console.error('[theme-gallery] could not contribute themes:', error)
3736
+ try { storeActions.note(`主题注册失败:${String(error && error.message ? error.message : error)}`) } catch { /* store unavailable */ }
3737
+ }
3738
+ }, 'theme-gallery: theme contribution')
3739
+
3740
+ /* ---------------- scenery follows the sidebar ----------------
3741
+ *
3742
+ * `publish()` can run before the shell has mounted the sidebar, and the seat
3743
+ * has to live inside that column. So the snapshot is remembered and the
3744
+ * scenery is re-synced when the column appears — and again if the shell ever
3745
+ * re-creates it, which is why this observes rather than checks once.
3746
+ */
3747
+ let lastSnapshot
3748
+ let ambientObserver
3749
+ let ambientResizeObserver
3750
+ let ambientResizeHandler
3751
+ let visibilityHandler
3752
+
3753
+ /**
3754
+ * A cheap fingerprint of the sidebar's geometry.
3755
+ *
3756
+ * Used to decide when the layout has stopped moving. Two consecutive equal samples mean
3757
+ * the shell has finished laying the sidebar out and a measurement can be trusted.
3758
+ * @returns a string, or null when the column does not exist.
3759
+ */
3760
+ function columnSignature() {
3761
+ const column = sidebarColumn()
3762
+ if (column === null) return null
3763
+ const r = column.getBoundingClientRect()
3764
+ const style = getComputedStyle(column)
3765
+ const band = bandBox(column)
3766
+ return [
3767
+ Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height),
3768
+ style.display, style.visibility,
3769
+ band === null ? 'no-band' : `${band.top}:${band.height}`,
3770
+ ].join('|')
3771
+ }
3772
+
3773
+ ctx.effect(() => {
3774
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
3775
+ lastSnapshot = ctx.theme.getTheme()
3776
+
3777
+ /**
3778
+ * The single action every trigger funnels into: put the scenery where the sidebar is
3779
+ * right now. Everything is recomputed, so a call that lands on unchanged geometry is
3780
+ * cheap and harmless — which is what makes it safe to call from many triggers.
3781
+ */
3782
+ /**
3783
+ * Coalesce every trigger into at most one sync per frame, and never let a sync's own
3784
+ * DOM writes queue the next one.
3785
+ *
3786
+ * Two separate hazards are handled here, and both of them were live:
3787
+ *
3788
+ * 1. FEEDBACK. The observer watches the whole body with `subtree: true`, and the sync
3789
+ * appends/removes nodes on the same body. A write therefore scheduled a sync, whose
3790
+ * write scheduled another. The idempotence guard inside `syncAmbient` stops the
3791
+ * cascade from doing work, but the round trip still costs a full-body mutation
3792
+ * delivery each time, so it is closed properly here as well: mutations queued while
3793
+ * a sync is running are discarded.
3794
+ *
3795
+ * 2. STAMPEDE. Streaming a reply mutates the transcript many times per frame. Calling
3796
+ * the sync per mutation multiplied the sidebar measurements by that factor, which is
3797
+ * what turned "a bit of work on layout change" into continuous CPU burn. Batching to
3798
+ * one call per frame makes the cost proportional to frames instead of mutations.
3799
+ */
3800
+ let syncInFlight = false
3801
+ let syncQueued = false
3802
+
3803
+ const runSync = () => {
3804
+ if (syncInFlight) {
3805
+ // Our own write. Do not re-enter: the geometry cannot have changed as a result.
3806
+ return
3807
+ }
3808
+ syncInFlight = true
3809
+ try {
3810
+ resync()
3811
+ } finally {
3812
+ syncInFlight = false
3813
+ }
3814
+ }
3815
+
3816
+ const requestSync = () => {
3817
+ if (syncQueued) return
3818
+ syncQueued = true
3819
+ step(() => {
3820
+ syncQueued = false
3821
+ runSync()
3822
+ })
3823
+ }
3824
+
3825
+ const resync = () => {
3826
+ // The snapshot is re-read rather than reused. If the theme changed while the shell
3827
+ // was still assembling, a cached `lastSnapshot` would describe the wrong skin.
3828
+ try {
3829
+ lastSnapshot = ctx.theme.getTheme()
3830
+ } catch {
3831
+ // Keep the previous snapshot if the registry is not ready yet.
3832
+ }
3833
+ const active = lastSnapshot?.active?.id
3834
+ const kind = typeof active === 'string' ? bundledTheme(active)?.ambient?.kind : undefined
3835
+ if (sidebarColumn() === null) {
3836
+ // Recorded even though there is nothing to draw. An attempt that finds no sidebar
3837
+ // is the likeliest explanation for boot-time silence, and the old report could not
3838
+ // show it because it only ever described the final, successful run.
3839
+ noteAmbientAttempt({ kind: kind ?? '(无)', column: false })
3840
+ return
3841
+ }
3842
+ syncSkin(lastSnapshot)
3843
+ }
3844
+
3845
+ ambientObserver = new MutationObserver(requestSync)
3846
+ if (document.body !== null) ambientObserver.observe(document.body, { childList: true, subtree: true })
3847
+
3848
+ // The geometry is measured, so it must be recomputed whenever it changes — including
3849
+ // when the shell REPLACES the sidebar node, which unregisters the observer with it.
3850
+ // Rebinding is therefore part of every sync rather than something done once at setup.
3851
+ let observed = null
3852
+ const bindResize = () => {
3853
+ if (typeof ResizeObserver === 'undefined') return
3854
+ const column = sidebarColumn()
3855
+ if (column === null || column === observed) return
3856
+ if (ambientResizeObserver !== undefined) ambientResizeObserver.disconnect()
3857
+ ambientResizeObserver = new ResizeObserver(requestSync)
3858
+ ambientResizeObserver.observe(column)
3859
+ observed = column
3860
+ }
3861
+
3862
+ ambientResizeHandler = () => {
3863
+ bindResize()
3864
+ requestSync()
3865
+ }
3866
+ window.addEventListener('resize', ambientResizeHandler)
3867
+
3868
+ // A backgrounded window does not lay out; on return the geometry may be stale.
3869
+ visibilityHandler = () => {
3870
+ if (document.visibilityState === 'visible') {
3871
+ bindResize()
3872
+ requestSync()
3873
+ }
3874
+ }
3875
+ document.addEventListener('visibilitychange', visibilityHandler)
3876
+
3877
+ /**
3878
+ * Keep asking for the remembered skin until the document actually shows it.
3879
+ *
3880
+ * ── WHY THIS IS A SEPARATE LOOP ──────────────────────────────────────────
3881
+ *
3882
+ * The skin check used to live only in two places, and both of them stop early:
3883
+ * `publish()` (which the shell may not call during startup) and the settling loop above
3884
+ * (which finishes as soon as the geometry holds still — about 100 ms in). Meanwhile the
3885
+ * LAYOUT presenter that actually paints tokens mounts later, so the boot-time request was
3886
+ * simply not observed, and nothing was watching any more by the time it could have been.
3887
+ *
3888
+ * On screen: the artwork appears (it is drawn directly and does not need the presenter)
3889
+ * while the colours do not, and opening any panel — which triggers the shell's first
3890
+ * `publish()` — finally applies them. That is exactly what was reported.
3891
+ *
3892
+ * So the check gets its own bounded window, driven by a plain interval, and stops the
3893
+ * moment the palette is confirmed OR the window closes. Confirmation is what keeps it
3894
+ * cheap: once the tokens are on the document this does nothing at all.
3895
+ */
3896
+ const paintWatch = (() => {
3897
+ const INTERVAL_MS = 500
3898
+ const MAX_MS = 20000
3899
+ const startedAt = Date.now()
3900
+ let handle
3901
+
3902
+ const tick = () => {
3903
+ handle = undefined
3904
+ let done = false
3905
+ try {
3906
+ const snapshot = ctx.theme.getTheme()
3907
+ const active = snapshot?.active?.id
3908
+ const wanted = rememberedSkin()
3909
+ if (wanted === null) {
3910
+ done = true
3911
+ } else if (skinIsPainted(wanted)) {
3912
+ paintAttempts.clear()
3913
+ done = true
3914
+ } else {
3915
+ if (active !== wanted) noteAmbientEvent('启动核对', `${active ?? '?'}→${wanted}`)
3916
+ ensureSkinPainted(wanted, active)
3917
+ }
3918
+ } catch (error) {
3919
+ noteAmbientEvent('启动核对抛错', String(error && error.message ? error.message : error))
3920
+ }
3921
+ const expired = Date.now() - startedAt > MAX_MS
3922
+ if (!done && !expired) handle = window.setTimeout(tick, INTERVAL_MS)
3923
+ else if (!done) noteAmbientEvent('启动核对超时')
3924
+ }
3925
+
3926
+ handle = window.setTimeout(tick, 0)
3927
+ return {
3928
+ stop() {
3929
+ if (handle !== undefined) window.clearTimeout(handle)
3930
+ handle = undefined
3931
+ },
3932
+ }
3933
+ })()
3934
+
3935
+ /**
3936
+ * Settle the scenery against a MOVING layout.
3937
+ *
3938
+ * The waiting itself lives in `repeatUntilStable`, which is driven by the observed
3939
+ * geometry rather than by elapsed time — see its documentation for why the earlier
3940
+ * fixed retry timing could not work. `stableTicks` and `maxMs` are hard bounds: the loop
3941
+ * always terminates, so it can never become the frame-by-frame loop that burned a core
3942
+ * and grew the process to 11 GB.
3943
+ */
3944
+ const settling = repeatUntilStable({
3945
+ sample: columnSignature,
3946
+ apply: () => {
3947
+ // The column having a layout is the readiness signal for the whole plugin: it means
3948
+ // the shell has mounted, so a `theme/change` now reaches ui-theme's presenter and is
3949
+ // actually painted. Restoring the remembered skin before this point was silently
3950
+ // swallowed — the scenery appeared (it is derived from the service) while the colours
3951
+ // never reached the document.
3952
+ if (sidebarColumn() !== null) markBootSettled()
3953
+ bindResize()
3954
+ runSync()
3955
+ },
3956
+ setTimer: (fn, delay) => window.setTimeout(fn, delay),
3957
+ clearTimer: (handle) => window.clearTimeout(handle),
3958
+ now: () => Date.now(),
3959
+ intervalMs: 100,
3960
+ maxMs: 15000,
3961
+ stableTicks: 2,
3962
+ })
3963
+
3964
+ // One immediate pass, so a sidebar that is already mounted shows the scenery on the
3965
+ // first paint rather than after the first interval.
3966
+ runSync()
3967
+
3968
+ return () => {
3969
+ settling.stop()
3970
+ paintWatch.stop()
3971
+ if (ambientObserver !== undefined) ambientObserver.disconnect()
3972
+ ambientObserver = undefined
3973
+ if (ambientResizeObserver !== undefined) ambientResizeObserver.disconnect()
3974
+ ambientResizeObserver = undefined
3975
+ if (ambientResizeHandler !== undefined) window.removeEventListener('resize', ambientResizeHandler)
3976
+ ambientResizeHandler = undefined
3977
+ if (visibilityHandler !== undefined) document.removeEventListener('visibilitychange', visibilityHandler)
3978
+ visibilityHandler = undefined
3979
+ }
3980
+ }, 'theme-gallery: scenery follows the sidebar')
3981
+
3982
+ /* ---------------- slot registrations ----------------
3983
+ *
3984
+ * Both registrations go through `ctx.slots.inject(key, callback)`, which is
3985
+ * what the shipped panel plugins do and what the contract requires: the
3986
+ * callback runs only AFTER the target slot is declared, and its returned
3987
+ * disposers are installed transactionally. Registering into an undeclared
3988
+ * slot creates a pending wait whose entry then vanishes when the shell
3989
+ * recomposes — the sidebar entry did exactly that before this change.
3990
+ */
3991
+
3992
+ // Main-column page, addressed by the same key as the sidebar entry.
3993
+ //
3994
+ // The callback returns a single disposer, which is the plainest shape the
3995
+ // contract documents ("callback effects are synchronous disposers"). An
3996
+ // earlier revision returned a generator to yield several disposers; both
3997
+ // shipped panel plugins use the plain form, so this does too — the extra
3998
+ // machinery was unverified, and a subscription that never installs is
3999
+ // indistinguishable from a panel that never registers.
4000
+ ctx.slots.inject('main', () => {
4001
+ const disposePage = ctx.slots.register({
4002
+ name: 'main',
4003
+ key: PANEL_ID,
4004
+ store,
4005
+ locale: NS,
4006
+ inject: () => {
4007
+ // The only thing this face exists for is switching a theme; all state
4008
+ // is published straight into the pinned store instance, so nothing
4009
+ // silences the page if this factory never runs. `injectRan` records
4010
+ // that it did, purely so the empty state can say which link is missing
4011
+ // — so it is set BEFORE the publish that reads it.
4012
+ injectRan = true
4013
+ publish(ctx.theme.getTheme())
4014
+ return { setTheme: (id) => { ctx.theme.setTheme(id) } }
4015
+ },
4016
+ }, ThemeGalleryPage)
4017
+
4018
+ // The page is now live, so record that the gate ran — an empty picker has
4019
+ // to be able to say whether the panel registered or the registry is bare.
4020
+ gateRan = true
4021
+ publish(ctx.theme.getTheme())
4022
+
4023
+ // ── THE ECHO GUARD ───────────────────────────────────────────────────────
4024
+ //
4025
+ // This plugin WRITES to the theme service (`setTheme`, `overrideTokens`) and also
4026
+ // SUBSCRIBES to the event those writes emit. Without a guard the two feed each other:
4027
+ //
4028
+ // publish → syncSkin → overrideTokens → theme/change → publish → …
4029
+ //
4030
+ // Nothing yields to the event loop along that path, so it is not a slow loop but a spin —
4031
+ // which is exactly what was measured: renderer RSS to 11 GB, ~2.7 cores of accumulated
4032
+ // CPU, main/host/GPU untouched, and no crash log because nothing throws.
4033
+ //
4034
+ // Comparing values cannot break it. `stackSkinTokens` clears `stackedSkin` before asking
4035
+ // for the new layer, so a re-entrant call always sees a different value; and `setTheme`
4036
+ // legitimately has to be called when the active id differs, which the shell's own
4037
+ // `adopt()` guarantees will keep happening. What CAN be distinguished is *who caused the
4038
+ // event*, and `emitting()` records exactly that.
4039
+ const disposeChange = ctx.on('theme/change', (snapshot) => {
4040
+ if (selfEmitDepth > 0) {
4041
+ // Our own write coming back. Applying our own change would re-enter the write.
4042
+ return
4043
+ }
4044
+ contribute(snapshot)
4045
+ publish(snapshot)
4046
+ })
4047
+
4048
+ return () => {
4049
+ disposeChange()
4050
+ disposePage()
4051
+ }
4052
+ })
4053
+
4054
+ // Sidebar entrance: the list id IS the main-panel key. `locale` is declared
4055
+ // the way the shipped panel entries declare it, so the row label resolves
4056
+ // through the dictionary rather than a raw string.
4057
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
4058
+ name: 'sidebar.panellist',
4059
+ id: PANEL_ID,
4060
+ order: 30,
4061
+ locale: NS,
4062
+ label: () => zh.title,
4063
+ }, PanelGlyph))
4064
+
4065
+ /* ---------------- reading state ---------------- */
4066
+
4067
+ /** The stylesheet element carrying the one reading rule, once installed. */
4068
+ let readingTag
4069
+ /** Disposer of the active reading token layer, when one is stacked. */
4070
+ let readingLayerDispose
4071
+
4072
+ /**
4073
+ * Install the reading rule, once per plugin lifetime.
4074
+ *
4075
+ * It constrains the reading measure only. Earlier revisions painted the
4076
+ * centre column here — first with a lightened card, then (while reading) with
4077
+ * `background: transparent`. The transparent form was the reason a selected
4078
+ * theme looked like a plain white app: the column stopped showing the theme's
4079
+ * own `--dsw-alias-bg-base`, and what showed through was the app's default
4080
+ * ground. A skin must never be painted over by its own plugin.
4081
+ *
4082
+ * Lightening is a TOKEN concern, so it goes through `overrideTokens` — the
4083
+ * service's own mechanism for stacking a layer over the active theme — and
4084
+ * not through this stylesheet.
4085
+ */
4086
+ function installReadingStyle() {
4087
+ if (typeof document === 'undefined' || readingTag !== undefined) return
4088
+ readingTag = document.createElement('style')
4089
+ readingTag.dataset.plugin = 'theme-gallery'
4090
+ readingTag.dataset.pluginCss = 'theme-gallery/reading'
4091
+ const sel = `[data-windows-titlebar] body[${READING_ATTRIBUTE}] .centerCol,body[${READING_ATTRIBUTE}] .centerCol`
4092
+ readingTag.textContent = `${sel}>*{max-width:var(--dsh-reading-width,640px);margin-inline:auto;width:100%;}`
4093
+ document.head.append(readingTag)
4094
+ }
4095
+
4096
+ /**
4097
+ * Find the centre column: the official stylesheet's own Windows anchor.
4098
+ * @returns the centre column element, or null before the shell mounts.
4099
+ */
4100
+ function centreColumn() {
4101
+ if (typeof document === 'undefined') return null
4102
+ return document.querySelector('[data-windows-titlebar] .centerCol')
4103
+ || document.querySelector('.centerCol')
4104
+ }
4105
+
4106
+ /**
4107
+ * Find the composer: the contenteditable inside the centre column.
4108
+ * @param centre - the centre column element.
4109
+ * @returns the composer element, or null.
4110
+ */
4111
+ function composerOf(centre) {
4112
+ if (centre === null) return null
4113
+ return centre.querySelector('[contenteditable="true"]') || centre.querySelector('textarea')
4114
+ }
4115
+
4116
+ /**
4117
+ * Decide whether the transcript holds messages.
4118
+ *
4119
+ * The composer's parent is its own seat; the transcript is a sibling that
4120
+ * holds element children. If the layout changes and this stops matching, the
4121
+ * plugin degrades to the idle look rather than breaking anything.
4122
+ * @param centre - the centre column element.
4123
+ * @returns true when a transcript sibling has content.
4124
+ */
4125
+ function transcriptHasContent(centre) {
4126
+ const composer = composerOf(centre)
4127
+ if (composer === null) return false
4128
+ const seat = composer.parentElement
4129
+ if (seat === null) return false
4130
+ const parent = seat.parentElement
4131
+ if (parent === null) return false
4132
+ for (const sibling of parent.children) {
4133
+ if (sibling === seat) continue
4134
+ if (sibling.childElementCount > 0) return true
4135
+ }
4136
+ return false
4137
+ }
4138
+
4139
+ /**
4140
+ * Apply the reading state for the current document.
4141
+ *
4142
+ * Lightening the transcript is a token concern, so it is expressed as an
4143
+ * `overrideTokens` layer (applied by `syncReadingLayer`) rather than a
4144
+ * stylesheet rule: a rule that paints over the centre column also paints
4145
+ * over the theme, which is how a selected skin came to look like a plain
4146
+ * white app.
4147
+ */
4148
+ function applyReading() {
4149
+ if (typeof document === 'undefined') return
4150
+ const centre = centreColumn()
4151
+ const body = document.body
4152
+ if (body === null) return
4153
+
4154
+ if (centre !== null) {
4155
+ centre.style.setProperty('--dsh-reading-width', `${READING.maxWidth}px`)
4156
+ }
4157
+
4158
+ if (transcriptHasContent(centre)) body.setAttribute(READING_ATTRIBUTE, 'card')
4159
+ else body.removeAttribute(READING_ATTRIBUTE)
4160
+ syncReadingLayer()
4161
+ }
4162
+
4163
+ /**
4164
+ * Stack or retract the reading token layer.
4165
+ *
4166
+ * `overrideTokens(source, tokens)` folds a `{ light, dark }`-shaped layer
4167
+ * over the ACTIVE theme, so the lightening follows whichever skin and
4168
+ * palette is in play and disappears cleanly when the transcript empties.
4169
+ * The source string is the layer's identity: re-calling it with the same
4170
+ * source replaces the layer, which is what makes this idempotent.
4171
+ */
4172
+ function syncReadingLayer() {
4173
+ if (typeof document === 'undefined') return
4174
+ const reading = document.body !== null && document.body.hasAttribute(READING_ATTRIBUTE)
4175
+ if (!reading) {
4176
+ if (readingLayerDispose !== undefined) {
4177
+ readingLayerDispose()
4178
+ readingLayerDispose = undefined
4179
+ }
4180
+ return
4181
+ }
4182
+ // Lightened ground for both palettes: a light theme wants a softer wash,
4183
+ // a dark one a slightly raised surface. Both keep the theme's hue.
4184
+ const lightened = lighten(READING.bg, READING.alpha)
4185
+ const darkLightened = lighten(READING.bg, Math.max(0, READING.alpha - 0.2))
4186
+ readingLayerDispose = ctx.theme.overrideTokens('theme-gallery: reading', {
4187
+ '--dsw-alias-bg-base': { light: lightened, dark: darkLightened },
4188
+ })
4189
+ }
4190
+
4191
+ // Debounced onto a microtask: streaming a reply mutates the transcript many
4192
+ // times per frame, and only the settled answer matters here.
4193
+ let pending
4194
+ const schedule = () => {
4195
+ if (pending !== undefined) return
4196
+ pending = Promise.resolve().then(() => {
4197
+ pending = undefined
4198
+ applyReading()
4199
+ })
4200
+ }
4201
+
4202
+ ctx.effect(() => {
4203
+ installReadingStyle()
4204
+ // The shell may not be mounted yet when apply runs.
4205
+ const observer = new MutationObserver(schedule)
4206
+ if (document.body !== null) observer.observe(document.body, { childList: true, subtree: true })
4207
+ else document.addEventListener('DOMContentLoaded', () => {
4208
+ if (document.body !== null) observer.observe(document.body, { childList: true, subtree: true })
4209
+ schedule()
4210
+ }, { once: true })
4211
+ schedule()
4212
+ return () => {
4213
+ observer.disconnect()
4214
+ if (readingTag !== undefined) readingTag.remove()
4215
+ readingTag = undefined
4216
+ if (readingLayerDispose !== undefined) {
4217
+ readingLayerDispose()
4218
+ readingLayerDispose = undefined
4219
+ }
4220
+ document.body.removeAttribute(READING_ATTRIBUTE)
4221
+ const centre = centreColumn()
4222
+ if (centre !== null) centre.style.removeProperty('--dsh-reading-width')
4223
+ }
4224
+ }, 'theme-gallery: reading state')
4225
+ }
4226
+
4227
+ return module.exports
4228
+ },
4229
+ })