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/README.md +657 -0
- package/cordis.patch.yml +48 -0
- package/lib/client.d.ts +29 -0
- package/lib/client.js +4229 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +178 -0
- package/lib/themes/meng-hai-you-yu.json +292 -0
- package/lib/themes/shan-qing-ting-cai.json +290 -0
- package/package.json +118 -0
- package/schema/theme.schema.json +159 -0
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, '"')}"`
|
|
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
|
+
})
|