dsh-bloomglass 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,3643 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-bloomglass",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region src/client/constants.ts
10
+ /** Package id — also the theme override layer source and the loader entry id. */
11
+ const PACKAGE_ID = "dsh-bloomglass";
12
+ /** Body attribute that scopes every injected style. */
13
+ const BODY_ATTR = "data-dsh-bloomglass";
14
+ /**
15
+ * Body attribute that exists only while a wallpaper is actually painted.
16
+ *
17
+ * Kept separate from {@link BODY_ATTR} on purpose: retracting the frost must
18
+ * not retract the palette theme. Its value is the resolved colour scheme.
19
+ */
20
+ const FROST_ATTR = "data-bloomglass-frost";
21
+ /**
22
+ * Body attribute carrying the active palette key.
23
+ *
24
+ * Bloom's stylesheets select on this attribute, so the name is load-bearing:
25
+ * renaming it means rewriting every selector in `css/bloom-*.ts`.
26
+ */
27
+ const VARIANT_ATTR = "data-bloomglass-variant";
28
+ /** Settings locale namespace. */
29
+ const LOCALE_NS = "settings.bloomglass";
30
+ /** localStorage key for knobs (never the image bytes). */
31
+ const KNOBS_KEY = "dsh-bloomglass:knobs";
32
+ /** IndexedDB database that holds the uploaded wallpaper blob. */
33
+ const IMAGE_DB = "dsh-bloomglass";
34
+ const IMAGE_STORE = "files";
35
+ const IMAGE_KEY = "wallpaper";
36
+ /** Allowed image MIME types at the upload boundary. */
37
+ const ALLOWED_TYPES = [
38
+ "image/jpeg",
39
+ "image/png",
40
+ "image/webp",
41
+ "image/gif"
42
+ ];
43
+ /** dsh-bloom-theme's palette key. */
44
+ const LEGACY_VARIANT_KEY = "dsh-bloom-variant";
45
+ /** dsh-frosted-window's knob record. */
46
+ const LEGACY_KNOBS_KEY = "dsh-frosted-window:knobs";
47
+ /** dsh-frosted-window's wallpaper database. */
48
+ const LEGACY_IMAGE_DB = "dsh-frosted-window";
49
+ //#endregion
50
+ //#region src/client/glass-css.ts
51
+ /**
52
+ * Two independently gated stylesheets.
53
+ *
54
+ * GLASS_CSS is the frosted chrome: the wallpaper plate, the dim veil, and the
55
+ * per-column glass. It is gated on FROST_ATTR, which exists only while a
56
+ * wallpaper is actually painted — so retracting the frost leaves the palette
57
+ * theme itself untouched.
58
+ *
59
+ * SETTINGS_CSS styles the settings panel and is injected once per activate.
60
+ */
61
+ /**
62
+ * Scoped glass stylesheet. Every rule hangs off the plugin body attribute so
63
+ * dispose is one attribute removal + one style-tag removal. Selectors use
64
+ * official `data-slot` names, never hashed CSS-module class names.
65
+ */
66
+ const GLASS_CSS$1 = `
67
+ [${FROST_ATTR}-wallpaper] {
68
+ position: fixed;
69
+ inset: 0;
70
+ z-index: -3;
71
+ pointer-events: none;
72
+ background-repeat: no-repeat;
73
+ background-position: center;
74
+ background-size: cover;
75
+ }
76
+
77
+ [${FROST_ATTR}-dim] {
78
+ position: fixed;
79
+ inset: 0;
80
+ z-index: -1;
81
+ pointer-events: none;
82
+ background: rgba(12, 16, 24, var(--fw-dim, 0.28));
83
+ }
84
+
85
+ body[${FROST_ATTR}] {
86
+ --fw-blur: 28px;
87
+ --fw-saturate: 155%;
88
+ --fw-dim: 0.28;
89
+ --fw-highlight: rgba(255, 255, 255, 0.55);
90
+ --fw-edge: rgba(255, 255, 255, 0.28);
91
+ background-color: transparent;
92
+ }
93
+
94
+ body[${FROST_ATTR}='dark'] [${FROST_ATTR}-dim] {
95
+ background: rgba(6, 8, 12, var(--fw-dim, 0.28));
96
+ }
97
+
98
+ body[${FROST_ATTR}='dark'] {
99
+ --fw-highlight: rgba(255, 255, 255, 0.14);
100
+ --fw-edge: rgba(255, 255, 255, 0.12);
101
+ }
102
+
103
+ /* AppFrame + slot roots: drop opaque fills so the wallpaper shows through. */
104
+ body[${FROST_ATTR}] *:has(> [data-slot='sidebar']):has(> [data-slot='conversation']),
105
+ body[${FROST_ATTR}] [data-slot='sidebar'],
106
+ body[${FROST_ATTR}] [data-slot='sidebar'] > :first-child,
107
+ body[${FROST_ATTR}] [data-slot='conversation'],
108
+ body[${FROST_ATTR}] [data-slot='details'] {
109
+ background-color: transparent !important;
110
+ }
111
+
112
+ /*
113
+ * One frosted plate per column, painted on ::before.
114
+ * backdrop-filter must stay on the pseudo — never on the column itself —
115
+ * or position:fixed settings (a sidebar descendant) lock to the rail width.
116
+ */
117
+ body[${FROST_ATTR}] *:has(> [data-slot='sidebar']),
118
+ body[${FROST_ATTR}] *:has(> [data-slot='conversation']),
119
+ body[${FROST_ATTR}] *:has(> [data-slot='details']) {
120
+ position: relative;
121
+ }
122
+ body[${FROST_ATTR}] *:has(> [data-slot='sidebar']) {
123
+ border-right: none !important;
124
+ }
125
+ body[${FROST_ATTR}] *:has(> [data-slot='details']) {
126
+ border-left: none !important;
127
+ }
128
+ body[${FROST_ATTR}] *:has(> [data-slot='sidebar'])::before,
129
+ body[${FROST_ATTR}] *:has(> [data-slot='conversation'])::before,
130
+ body[${FROST_ATTR}] *:has(> [data-slot='details'])::before {
131
+ content: '';
132
+ position: absolute;
133
+ z-index: -1;
134
+ pointer-events: none;
135
+ background: var(--dsw-alias-bg-layer-1);
136
+ -webkit-backdrop-filter: blur(var(--bloom-glass-blur)) saturate(var(--fw-saturate));
137
+ backdrop-filter: blur(var(--bloom-glass-blur)) saturate(var(--fw-saturate));
138
+ }
139
+ body[${FROST_ATTR}] *:has(> [data-slot='sidebar'])::before {
140
+ inset: 0 -2px 0 0;
141
+ background: var(--dsw-specific-sidebar-fill);
142
+ }
143
+ body[${FROST_ATTR}] *:has(> [data-slot='conversation'])::before {
144
+ inset: 0 0 0 -2px;
145
+ }
146
+ body[${FROST_ATTR}] *:has(> [data-slot='details'])::before {
147
+ inset: 0 0 0 -2px;
148
+ }
149
+
150
+ @media (prefers-reduced-transparency: reduce) {
151
+ body[${FROST_ATTR}] *:has(> [data-slot='sidebar'])::before,
152
+ body[${FROST_ATTR}] *:has(> [data-slot='conversation'])::before,
153
+ body[${FROST_ATTR}] *:has(> [data-slot='details'])::before {
154
+ -webkit-backdrop-filter: none;
155
+ backdrop-filter: none;
156
+ }
157
+ }`.trim();
158
+ /** Settings-panel styles. Class names are fw- prefixed and used only by SettingsSection. */
159
+ const SETTINGS_CSS = `/* Settings page */
160
+ .fw-section {
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: 16px;
164
+ padding: 4px 0 28px;
165
+ min-width: 0;
166
+ max-width: 100%;
167
+ color: var(--dsw-alias-label-primary);
168
+ }
169
+ .fw-panel {
170
+ display: flex;
171
+ flex-direction: column;
172
+ gap: 18px;
173
+ padding: 18px;
174
+ border-radius: 20px;
175
+ border: 1px solid var(--dsw-alias-border-l2);
176
+ background:
177
+ linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.02)),
178
+ var(--dsw-alias-bg-layer-1);
179
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.28);
180
+ }
181
+ body[data-ds-dark-theme] .fw-panel {
182
+ background:
183
+ linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.01)),
184
+ var(--dsw-alias-bg-layer-1);
185
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
186
+ }
187
+ .fw-head {
188
+ display: flex;
189
+ align-items: flex-start;
190
+ justify-content: space-between;
191
+ gap: 12px;
192
+ }
193
+ .fw-lead { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
194
+ .fw-kicker {
195
+ font-size: 11px;
196
+ letter-spacing: 0.08em;
197
+ text-transform: uppercase;
198
+ color: var(--dsw-alias-label-tertiary);
199
+ }
200
+ .fw-title { font-size: 18px; line-height: 26px; font-weight: 600; }
201
+ .fw-desc { font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
202
+ .fw-chip {
203
+ flex: 0 0 auto;
204
+ margin-top: 4px;
205
+ padding: 3px 8px;
206
+ border-radius: 999px;
207
+ font-size: 11px;
208
+ line-height: 16px;
209
+ background: var(--dsw-alias-interactive-bg-hover);
210
+ color: var(--dsw-alias-label-secondary);
211
+ }
212
+ .fw-chip[data-tone='warn'] {
213
+ background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 18%, transparent);
214
+ color: var(--dsw-alias-state-warn-label, var(--dsw-alias-state-warn-primary));
215
+ }
216
+ .fw-hero {
217
+ position: relative;
218
+ isolation: isolate;
219
+ overflow: hidden;
220
+ width: 100%;
221
+ max-width: 100%;
222
+ min-height: 196px;
223
+ border: 0;
224
+ border-radius: 16px;
225
+ padding: 0;
226
+ background:
227
+ radial-gradient(circle at 20% 20%, rgba(255,255,255,0.18), transparent 42%),
228
+ linear-gradient(135deg, #8aa4c8 0%, #3d4f6b 52%, #1b2330 100%);
229
+ color: inherit;
230
+ font: inherit;
231
+ cursor: pointer;
232
+ }
233
+ .fw-hero[data-over='true'] { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
234
+ .fw-hero:disabled { cursor: default; }
235
+ .fw-hero img {
236
+ position: absolute;
237
+ inset: 0;
238
+ width: 100%;
239
+ height: 100%;
240
+ object-fit: cover;
241
+ }
242
+ .fw-hero-glass {
243
+ position: absolute;
244
+ inset: auto 18px 18px auto;
245
+ width: 42%;
246
+ min-width: 120px;
247
+ height: 46%;
248
+ border-radius: 14px;
249
+ border: 1px solid rgba(255,255,255,0.35);
250
+ background: rgba(255,255,255, var(--fw-ui-glass, 0.46));
251
+ -webkit-backdrop-filter: blur(var(--fw-ui-blur, 28px)) saturate(var(--fw-ui-sat, 155%));
252
+ backdrop-filter: blur(var(--fw-ui-blur, 28px)) saturate(var(--fw-ui-sat, 155%));
253
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
254
+ pointer-events: none;
255
+ }
256
+ .fw-hero-copy {
257
+ position: relative;
258
+ z-index: 1;
259
+ display: flex;
260
+ flex-direction: column;
261
+ align-items: flex-start;
262
+ gap: 6px;
263
+ padding: 20px;
264
+ text-align: left;
265
+ }
266
+ .fw-hero-copy strong { font-size: 14px; line-height: 20px; font-weight: 600; }
267
+ .fw-hero-copy span {
268
+ font-size: 12px;
269
+ line-height: 18px;
270
+ color: var(--dsw-alias-label-secondary);
271
+ }
272
+ .fw-hero:not([data-has='true']) .fw-hero-copy strong,
273
+ .fw-hero:not([data-has='true']) .fw-hero-copy span { color: #f4f7fb; }
274
+ .fw-switch {
275
+ display: flex;
276
+ align-items: center;
277
+ justify-content: space-between;
278
+ gap: 12px;
279
+ font-size: 14px;
280
+ line-height: 22px;
281
+ }
282
+ .fw-switch input {
283
+ appearance: none;
284
+ -webkit-appearance: none;
285
+ width: 44px;
286
+ height: 26px;
287
+ margin: 0;
288
+ border: 0;
289
+ border-radius: 999px;
290
+ background: #6b7178;
291
+ position: relative;
292
+ cursor: pointer;
293
+ transition: background 160ms ease;
294
+ }
295
+ .fw-switch input::after {
296
+ content: '';
297
+ position: absolute;
298
+ top: 3px;
299
+ left: 3px;
300
+ width: 20px;
301
+ height: 20px;
302
+ border-radius: 50%;
303
+ background: #fff;
304
+ box-shadow: 0 1px 3px rgba(0,0,0,0.28);
305
+ transition: transform 160ms ease;
306
+ }
307
+ .fw-switch input:checked {
308
+ background: #34c759;
309
+ }
310
+ .fw-switch input:checked::after { transform: translateX(18px); }
311
+ .fw-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ gap: 14px 16px;
315
+ }
316
+ @media (max-width: 640px) { .fw-grid { grid-template-columns: 1fr; } }
317
+ .fw-row { display: flex; flex-direction: column; gap: 8px; }
318
+ .fw-row-head {
319
+ display: flex;
320
+ justify-content: space-between;
321
+ font-size: 12px;
322
+ line-height: 18px;
323
+ }
324
+ .fw-row-head span:last-child { color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
325
+ .fw-row input[type='range'] {
326
+ width: 100%;
327
+ height: 4px;
328
+ accent-color: var(--dsw-alias-brand-primary);
329
+ cursor: pointer;
330
+ }
331
+ .fw-bar {
332
+ display: flex;
333
+ justify-content: flex-end;
334
+ gap: 8px;
335
+ padding-top: 4px;
336
+ }
337
+ .fw-btn {
338
+ appearance: none;
339
+ border: 1px solid var(--dsw-alias-border-l2);
340
+ border-radius: 12px;
341
+ background: transparent;
342
+ color: var(--dsw-alias-label-primary);
343
+ font: inherit;
344
+ font-size: 13px;
345
+ line-height: 20px;
346
+ padding: 8px 14px;
347
+ cursor: pointer;
348
+ }
349
+ .fw-btn:hover { background: var(--dsw-alias-interactive-bg-hover); }
350
+ .fw-btn[data-kind='primary'] {
351
+ background: #34c759;
352
+ color: #fff;
353
+ border-color: transparent;
354
+ }
355
+ .fw-btn[data-kind='primary']:hover { background: #2fb350; }
356
+ .fw-btn[data-kind='primary']:disabled {
357
+ background: transparent;
358
+ color: var(--dsw-alias-label-primary);
359
+ border-color: var(--dsw-alias-border-l2);
360
+ opacity: 0.5;
361
+ }
362
+ .fw-btn[data-kind='danger'] { color: var(--dsw-alias-state-error-primary); }
363
+ .fw-btn:disabled { opacity: 0.5; cursor: default; }
364
+ .fw-error {
365
+ font-size: 13px;
366
+ line-height: 20px;
367
+ color: var(--dsw-alias-state-error-primary);
368
+ }
369
+ .fw-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
370
+ `.trim();
371
+ //#endregion
372
+ //#region src/client/css/bloom-component.ts
373
+ /**
374
+ * 质感层 —— 这是「Bloom 好看」的真正来源,不是色值。
375
+ *
376
+ * 原版 typora-Bloom-theme 的 base-light/base-dark.css 共 2968 行,做的就是这件事:
377
+ * 14 处氛围渐变 + 20 处长距柔影 + 35 处圆角 + 渐变装饰线。只搬色板(root-*.css,89 行)
378
+ * 得到的是「换了色的原界面」,不是 Bloom。
379
+ *
380
+ * 全部用 var(--bloom-*)(见 bloomTokens),所以这份 CSS 只写一遍,4 个变体 × 明暗自动适配。
381
+ *
382
+ * ⚠️ 选择器脆弱性:DSH 用 CSS Modules,类名形如 `wSkVaW_root`(<hash>_<语义名>)。
383
+ * hash 随 DSH 构建变化,语义名稳定,所以这里一律用 [class*="_语义名"] 后缀匹配。
384
+ * DSH 改版导致失配时,效果只会「退回纯色」——不会错位或不可用,属安全降级。
385
+ */
386
+ const COMPONENT_CSS = `
387
+ /* ═══ 1. 氛围层 ═══════════════════════════════════════════════════
388
+ 原版 body 的三层叠加:大尺度径向光晕 + 斜向淡染 + 顶部柔光。
389
+ 全部用气质轨(morandi)的极低透明度,这是莫兰迪「灰调通透」的来源。
390
+
391
+ v0.10.x(owner 反馈「没光感」):把 veil 换成 aurora-stream 并提高透明度。
392
+ aurora-stream 来自 motion 谱,是每个变体的三色光谱(鼠尾草=草绿系、青金=蓝系),
393
+ 透明度从原来的 40% 提高到 55-65%,让"四角色斑"真正能看出来 —— 这是
394
+ "光感"的主要来源,不再靠硬边光带。 */
395
+ body {
396
+ /* 抽成变量:输入区那块不透明底板要原样复刻同一份氛围(见 §1.5),
397
+ 两处必须逐字一致,否则底部会出现一道色阶。 */
398
+ --bloom-ambience:
399
+ /* 顶左:主色光晕(accent,给画面主调) */
400
+ radial-gradient(1400px circle at 6% -4%, color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 80%), transparent 50%),
401
+ /* 顶右:aurora stream 2 —— 异色相,跟主色形成对比 */
402
+ radial-gradient(1100px circle at 100% 8%, color-mix(in oklch, var(--bloom-aurora-stream-2), transparent 50%), transparent 60%),
403
+ /* 底右:aurora stream 3 —— 再一个异色相 */
404
+ radial-gradient(900px circle at 96% 100%, color-mix(in oklch, var(--bloom-aurora-stream-3), transparent 50%), transparent 62%),
405
+ /* 底左:aurora stream 1 —— 四角各一团 */
406
+ radial-gradient(1000px circle at 2% 96%, color-mix(in oklch, var(--bloom-aurora-stream-1), transparent 50%), transparent 60%);
407
+ background-attachment: fixed;
408
+ background-image: var(--bloom-ambience);
409
+ }
410
+
411
+ /* ═══ 1.5 输入区底板:让氛围一路铺到窗口底边 ═══════════════════════
412
+ *
413
+ * owner 反馈「底部有种戛然而止的感觉」。实测原因:DSH 给 _composerSeat 铺了
414
+ * linear-gradient(transparent 0, oklch(0.28 0.02 25) 36px)
415
+ * ——36px 内从透明冲到**不透明**,之后 90px 全是死板一块。氛围的左右色差
416
+ * 在 y=721 还有 13 级,到 y=757 直接归零,窗口最下面 1/5 是块死色。
417
+ *
418
+ * 修法不是把它改透明(它要盖住滚到输入框背后的正文),而是**在这块不透明
419
+ * 底板上原样重画一遍氛围**:同一份 --bloom-ambience + background-attachment:
420
+ * fixed,光斑相对视口定位,于是和 body 那层严丝合缝地接上。
421
+ *
422
+ * 为什么要走 ::before + mask 而不是直接给 seat 叠背景:直接叠的话,渐变
423
+ * 顶部那段半透明区域会**同时**透出 body 的氛围和自己画的氛围,双份叠加,
424
+ * 实测顶端亮了 20 级。改成 ::before 承载「不透明底 + 氛围」整体,再用
425
+ * mask 做同一条 56px 淡入 —— 淡入区里 ::before 本身是半透的,透出来的
426
+ * 只有 body 那一份,不会重复。实测纵向逐行色差 0-1 级,无缝。
427
+ *
428
+ * ⚠️ 只用 position:absolute + z-index:-1,不引入 backdrop-filter / transform /
429
+ * isolation —— seat 的子树里有 conversation.input.overlay 注入的 fixed 元素,
430
+ * 判据同 glass.ts 侧栏那段。seat 自身是 position:sticky,已经是定位元素,
431
+ * 不需要也不要去改它的 position。 */
432
+ /* 宿主那条是 .wSkVaW_root[data-phase="active"] .wSkVaW_composerSeat,特异度
433
+ (0,3,0);这里必须写满三段才压得过((0,3,1)),少一段就还是它赢。 */
434
+ body[data-bloomglass-variant] [class*="_root"] [class*="_composerSeat"] {
435
+ background-image: none;
436
+ }
437
+ body[data-bloomglass-variant] [class*="_root"] [class*="_composerSeat"]::before {
438
+ content: '';
439
+ position: absolute;
440
+ inset: 0;
441
+ z-index: -1;
442
+ pointer-events: none;
443
+ background-color: var(--dsw-alias-bg-layer-1, #101010);
444
+ background-image: var(--bloom-ambience);
445
+ background-attachment: fixed;
446
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 56px);
447
+ mask-image: linear-gradient(to bottom, transparent 0, #000 56px);
448
+ }
449
+
450
+ /* 让 body 的氛围层透出来:DSH 这几个全屏容器自带不透明底色会盖住它。
451
+ 侧栏与卡片保留自己的 surface 色(原版同样保留),只做描边和光。 */
452
+ body[data-bloomglass-variant] [class*="_frame"],
453
+ body[data-bloomglass-variant] [class*="_centerCol"],
454
+ /* :has() 里不能加子组合器(\`> \`):实测 DSH 的结构是 _root > _body > _scrollBody,
455
+ scrollBody 是孙子。写成子选择器这条一直没命中 —— 会话区一直盖着
456
+ oklch(0.28 0.02 240) 不透明底,body 的流线在中区完全看不见(这就是
457
+ owner 说「没光感」的真正原因)。 */
458
+ body[data-bloomglass-variant] [class*="_root"]:has([class*="_scrollBody"]),
459
+ body[data-bloomglass-variant] [class*="_scrollBody"] {
460
+ background-color: transparent;
461
+ background-image: none;
462
+ }
463
+
464
+ /* ═══ 2. 冷光线条 ═════════════════════════════════════════════════
465
+ 细、冷、低透明度的莫兰迪描边 + 极弱外辉。深色下最出效果。 */
466
+
467
+ /* ═══ 侧栏 ════════════════════════════════════════════════════════
468
+ 之前只给了右侧一道竖线,内部还是一整块死板的纯色。补足:顶部氛围淡染、
469
+ 会话项冷光态、分组标题层级。 */
470
+ [class*="_sidebarCol"] {
471
+ position: relative;
472
+ box-shadow: 1px 0 0 var(--bloom-hairline), 8px 0 32px -10px var(--bloom-glow);
473
+ /* 顶部莫兰迪淡染,跟主区氛围同源,消除"两块拼起来"的割裂感 */
474
+ background-image:
475
+ linear-gradient(180deg, rgba(var(--bloom-morandi), 0.1), transparent 260px),
476
+ radial-gradient(600px circle at 0% 0%, rgba(var(--bloom-morandi), 0.08), transparent 60%);
477
+ }
478
+
479
+ /* 会话条目:圆角 + 过渡。hover / 选中的着色统一在 §6「会话行」那一处,
480
+ 这里不再重复一遍(曾经两处各写一遍 6% 和 7%,靠 !important 决定谁赢,
481
+ 等于用 !important 压自己人)。 */
482
+ [class*="_sidebarCol"] [role="treeitem"],
483
+ [class*="_sidebarCol"] [class*="_sessionRow"] {
484
+ border-radius: 8px;
485
+ transition: background 0.15s ease;
486
+ }
487
+ [class*="_sidebarCol"] [role="treeitem"][aria-selected="true"] [class*="_title"],
488
+ [class*="_sidebarCol"] [class*="_active"] [class*="_title"],
489
+ [class*="_sidebarCol"] [class*="_selected"] [class*="_title"] {
490
+ font-weight: 500;
491
+ color: var(--dsw-alias-label-primary);
492
+ }
493
+ /* v0.10.x(owner 反馈「脏」):左侧 3px 蓝竖条是脏点 —— 莫兰迪克制不应有这种色块。
494
+ 删掉 ::before。选中感靠 bg 的 8% 染色传达。 */
495
+
496
+ /* 新建会话按钮:极淡底色,无 border,文字与图标都用主文字色(owner 反馈「脏」——
497
+ 之前 accent 色的 svg + accent 染色 bg + hairline border 三层叠,跟莫兰迪"统一克制"冲突)。
498
+ 现在的按钮只靠 5% accent 染色跟会话行区分,没有色块、没有边框、没有彩色文字。 */
499
+ [class*="_sidebarCol"] button[class*="_newSession"],
500
+ [class*="_sidebarCol"] button[class*="_newChat"] {
501
+ background: color-mix(in oklch, var(--bloom-accent) 5%, transparent);
502
+ border: 0;
503
+ border-radius: 10px;
504
+ transition: background 180ms ease;
505
+ }
506
+ [class*="_sidebarCol"] button[class*="_newSession"]:hover,
507
+ [class*="_sidebarCol"] button[class*="_newChat"]:hover {
508
+ background: color-mix(in oklch, var(--bloom-accent) 12%, transparent);
509
+ }
510
+ /* 图标和文字都用主色 —— 不让 accent 出现在按钮上,跟莫兰迪统一 */
511
+ [class*="_sidebarCol"] button[class*="_newSession"] svg,
512
+ [class*="_sidebarCol"] button[class*="_newChat"] svg {
513
+ color: var(--dsw-alias-label-primary);
514
+ }
515
+
516
+ /* 分组标题(工作区 / 未分组):拉开与条目的层级。
517
+ _sectionLabel 是 DSH 实际用的类名片段(qDHVXG_sectionLabel),
518
+ 12px + 字距让它明显是「标签」而不是一行内容。 */
519
+ [class*="_sidebarCol"] [class*="_groupLabel"],
520
+ [class*="_sidebarCol"] [class*="_sectionTitle"],
521
+ [class*="_sidebarCol"] [class*="_sectionLabel"] {
522
+ font-size: 12px;
523
+ letter-spacing: 0.06em;
524
+ color: var(--dsw-alias-label-caption);
525
+ }
526
+
527
+ /* 分区头右侧的搜索/动作图标:默认 60% 亮度降权,hover 才全亮 ——
528
+ 和「工作区」标题同排且全亮时,四个元素抢注意力,列表反而不突出。 */
529
+ [class*="_sidebarCol"] [class*="_sectionHeader"] [class*="_searchSlot"],
530
+ [class*="_sidebarCol"] [class*="_sectionHeader"] [class*="_headerActions"] {
531
+ opacity: 0.6;
532
+ transition: opacity 150ms ease;
533
+ }
534
+ [class*="_sidebarCol"] [class*="_sectionHeader"] [class*="_searchSlot"]:hover,
535
+ [class*="_sidebarCol"] [class*="_sectionHeader"] [class*="_headerActions"]:hover {
536
+ opacity: 1;
537
+ }
538
+
539
+ /* 会话行元信息(时间戳/来源等 _slot):DSH 默认 0.55 透明度,
540
+ 暗色氛围渐变上偏糊,统一提到主题的 secondary 档(明暗自适应)。 */
541
+ [class*="_sidebarCol"] [class*="_sessionRow"] [class*="_slot"] {
542
+ color: var(--dsw-alias-label-secondary);
543
+ }
544
+
545
+ /* 会话列表细滚动条:4px、冷光 thumb、hover 才加强。
546
+ 系统默认滚动条在 280px 的窄栏里非常抢;DSH 自己只有全局 alias 变量,
547
+ 没有给侧栏列表单独收窄。 */
548
+ [class*="_sidebarCol"] [class*="_list"]::-webkit-scrollbar { width: 4px; }
549
+ [class*="_sidebarCol"] [class*="_list"]::-webkit-scrollbar-track { background: transparent; }
550
+ [class*="_sidebarCol"] [class*="_list"]::-webkit-scrollbar-thumb {
551
+ background: color-mix(in oklch, var(--bloom-accent) 30%, transparent);
552
+ border-radius: 999px;
553
+ }
554
+ [class*="_sidebarCol"] [class*="_list"]::-webkit-scrollbar-thumb:hover {
555
+ background: color-mix(in oklch, var(--bloom-accent) 55%, transparent);
556
+ }
557
+
558
+ /* 侧边栏成「面」:右侧一道冷光分界 + 向内的极淡渐变。
559
+ 光靠 sidebar-fill 的底色差不足以让它跟消息区分开 —— 消息区有氛围渐变和气泡,
560
+ 侧边栏只有一列文字,不给边界就显得平。分界线用 hairline(比实色 border 轻),
561
+ 渐变只在顶部 120px 内,避免整列发灰。
562
+
563
+ v0.10.x(owner 反馈「没层次感」):之前光带 1px + 18% alpha 在截图里几乎
564
+ 看不见,侧栏和背景完全融成一片。这里两层:① 内部底色 4% accent 给容器
565
+ 自己的"色",与 body 区分;② 顶部 120px 氛围染。右侧冷光线交给下面的
566
+ ::after / ::before 双层(线 + 光晕),因为 box-shadow inset 会被 glass.ts
567
+ 的 _sidebarCol 规则覆盖掉(cascade 顺序),改用伪元素才不会被吃掉。 */
568
+ body[data-bloomglass-variant] [class*="_sidebarCol"] {
569
+ /* 宿主那条 0.5px 实线优先级更高,不加 !important 关不掉(实测覆盖后仍是 0.5px) */
570
+ border-right: 0;
571
+ background-color: color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 96%);
572
+ background-image: linear-gradient(
573
+ 180deg,
574
+ color-mix(in oklch, var(--bloom-accent) 6%, transparent),
575
+ transparent 120px
576
+ );
577
+ background-repeat: no-repeat;
578
+ background-position: left top;
579
+ background-size: 100% 100%;
580
+ }
581
+ /* 侧栏右缘的冷光交给 glass.ts 的 box-shadow(向外投)——
582
+ 这里曾用 ::after 画一条 18px 渐变,但侧栏 overflow:hidden,伪元素只能落在
583
+ 容器内侧,看上去是「向内发光」,owner 反馈很奇怪。box-shadow 的外阴影不受
584
+ 自身 overflow 裁剪,是唯一能真正往外散的做法。 */
585
+
586
+ /* 底部设置区与会话列表之间补一道分隔,让「设置」不像是最后一条会话 */
587
+ [class*="_sidebarCol"] [class*="_footer"],
588
+ [class*="_sidebarCol"] [class*="_bottom"] {
589
+ border-top: 1px solid var(--bloom-hairline);
590
+ }
591
+
592
+ /* 顶栏下沿冷光线 —— 标题区与消息流之间的分界,比实色 border 轻。
593
+ 只给 tabs 这一条真正的分界线:_header 会同时命中 headerActions /
594
+ headerUtilities 等右上角子容器,给它们加投影会凭空多出几个浮块。 */
595
+ div[class*="_tabs"] {
596
+ box-shadow: 0 1px 0 var(--bloom-hairline), 0 6px 20px -12px var(--bloom-glow);
597
+ }
598
+
599
+ /* 排队消息条(输入框上方那条待发送预览)。
600
+ DSH 原样式是「完全透明容器 + 70% 不透明白字」—— 在它自己的纯色底上尚可读,
601
+ 但本主题给 body 铺了氛围渐变,背景不再均匀,这条就糊进背景里了。
602
+ 这是氛围层带来的副作用,必须由本主题自己补一个实体容器兜住。
603
+
604
+ ⚠️ 必须用 :has(> [class*="_preview"]) 收窄,不能裸用 [class*="_dock"]:
605
+ GoalBar(dsh-client-ui-goal)的外层类名也叫「*_dock」,但它是**全宽布局容器**
606
+ (原生 width: calc(100% - 各种 inset)),真正该被看见的条是里面那个限宽居中的
607
+ 「*_bar」(max-width: var(--dsh-composer-card-max-width))。
608
+ 裸选择器会把边框+毛玻璃画到那个全宽容器上——实测 dock 1810px 而 bar 只有 748px,
609
+ 于是「进行中的目标」左右各露出一大截空壳边框,跟下方输入框完全不对齐。
610
+ QueueDock 一定有「_preview」子元素(就是下一条规则要上色的那个),拿它当判据最稳,
611
+ 比写死 QueueDock 的哈希类名(会随 DSH 构建变)可靠。 */
612
+ div[class*="_dock"]:has(> [class*="_preview"]) {
613
+ background: color-mix(in oklch, var(--dsw-alias-bg-layer-2, #fff), transparent 22%);
614
+ backdrop-filter: blur(12px);
615
+ -webkit-backdrop-filter: blur(12px);
616
+ border: 1px solid var(--bloom-hairline);
617
+ border-radius: 12px;
618
+ }
619
+ div[class*="_dock"] [class*="_preview"] {
620
+ color: var(--dsw-alias-label-secondary);
621
+ }
622
+
623
+ /* 消息卡 / 工具调用行:只给轻描边,不给阴影 —— 数量多(一次会话 30+ 个),
624
+ 加长距阴影会把整个消息流糊成一片。全部限定 div,否则会命中 SVG 元素。 */
625
+ /* 工具调用行:常态不描边,hover 才亮起。
626
+ 常驻边框有两个毛病:① 同为工具行,可展开的 Bash 有框、Read/Edit 没框,视觉不一致;
627
+ ② 折叠行实测 748×26,画成圆角框又扁又长,且边框贴着内容区边缘而行内图标有内缩,
628
+ 看起来像个输入框。保留 transparent 占位是为了 hover 时不发生布局跳动。 */
629
+ div[class*="_card"],
630
+ div[class*="_toolRow"],
631
+ div[class*="_panel"] {
632
+ border-radius: 10px;
633
+ border: 1px solid transparent;
634
+ transition: border-color 0.16s ease, background 0.16s ease;
635
+ }
636
+ div[class*="_card"]:hover,
637
+ div[class*="_toolRow"]:hover {
638
+ border-color: var(--bloom-hairline);
639
+ background: rgba(var(--bloom-morandi), 0.05);
640
+ }
641
+ /* 展开态:DSH 的终端区(_terminal)自带 1px 边框 + 12px 圆角,
642
+ 外层 card 再描一圈就是两个几乎同尺寸的圆角框相套(实测 439px 套 377px)=框中框。
643
+ 展开后连 hover 边框也不要。 */
644
+ div[class*="_card"]:has([class*="_terminal"]),
645
+ div[class*="_card"]:has(pre),
646
+ div[class*="_card"]:has([class*="_terminal"]):hover,
647
+ div[class*="_card"]:has(pre):hover {
648
+ border-color: transparent;
649
+ background: transparent;
650
+ }
651
+
652
+ /* 输入卡片:整个界面的视觉主角,给足纸感。
653
+ 必须限定在 composer 内 —— 裸的 [class*="_card"] 会命中 30+ 个消息卡片,
654
+ 把长距阴影糊得到处都是。 */
655
+ div[class*="_composer"] div[class*="_card"] {
656
+ border: 1px solid var(--bloom-hairline);
657
+ border-radius: 16px;
658
+ /* 三层:顶部内高光(模拟光从上方打来)+ 长距柔影 + 外侧冷辉 */
659
+ box-shadow:
660
+ inset 0 1px 0 rgba(var(--bloom-morandi), 0.14),
661
+ var(--bloom-shadow),
662
+ 0 0 24px -14px var(--bloom-glow);
663
+ transition: border-color 0.22s ease, box-shadow 0.22s ease;
664
+ }
665
+ div[class*="_composer"] div[class*="_card"]:focus-within {
666
+ border-color: var(--bloom-hairline-strong);
667
+ box-shadow:
668
+ inset 0 1px 0 rgba(var(--bloom-morandi), 0.2),
669
+ var(--bloom-shadow),
670
+ 0 0 0 2px var(--bloom-glow);
671
+ }
672
+
673
+ /* 消息气泡:柔和圆角 + 近距阴影,脱离"贴在背景上"的平面感。
674
+ 限定 div:DSH 的全局 Tooltip 是 span[class*="_bubble"](见 glass.ts 同名注释),
675
+ 不能吃到气泡的圆角/阴影/入场动画 —— 尤其动画:带 !important 的 bloom-fade-up
676
+ 会在 420ms 里盖掉 tooltip 的定位 transform([data-side=right] 的 translateY(-50%)),
677
+ 气泡先偏移再跳回,也是"提示看不清"的一部分。 */
678
+ div[class*="_bubble"] {
679
+ border-radius: 14px;
680
+ box-shadow: var(--bloom-shadow-sm);
681
+ }
682
+
683
+ /* ═══ 3. Markdown 排版质感 ═════════════════════════════════════════
684
+ 照搬原版手法:标题渐变短横、hr 两端消隐、引用块左侧主色条。 */
685
+ [class*="_markdown"] h1,
686
+ [class*="_markdown"] h2,
687
+ [class*="_markdown"] h3 {
688
+ position: relative;
689
+ }
690
+ [class*="_markdown"] h1::after,
691
+ [class*="_markdown"] h2::after,
692
+ [class*="_markdown"] h3::after {
693
+ content: "";
694
+ position: absolute;
695
+ left: 0;
696
+ bottom: -0.34em;
697
+ height: 2px;
698
+ border-radius: 999px;
699
+ background: linear-gradient(90deg, rgba(var(--bloom-morandi), 0.45), transparent);
700
+ }
701
+ [class*="_markdown"] h1::after { width: 76px; height: 3px; }
702
+ [class*="_markdown"] h2::after { width: 56px; }
703
+ [class*="_markdown"] h3::after { width: 40px; background: linear-gradient(90deg, rgba(var(--bloom-morandi), 0.28), transparent); }
704
+
705
+ hr {
706
+ border: 0;
707
+ height: 1px;
708
+ background: linear-gradient(90deg, transparent, var(--bloom-hairline-strong), transparent);
709
+ }
710
+
711
+ blockquote {
712
+ border-left: 3px solid rgba(var(--bloom-morandi), 0.55);
713
+ border-radius: 12px;
714
+ background: rgba(var(--bloom-morandi), 0.07);
715
+ box-shadow: var(--bloom-shadow-sm);
716
+ }
717
+
718
+ /* 代码:内联块用莫兰迪淡底(变量已在 alias 层修正为背景色),
719
+ 块级代码加冷光描边 + 圆角,消除方角感 */
720
+ code {
721
+ border-radius: 6px;
722
+ }
723
+
724
+ /* 代码块:描边/阴影只能给「有实色背景的那一层」——DSH 的结构是
725
+ div.md-code-block(实色底 + 圆角)> div > pre.shiki(背景透明)
726
+ 曾经把 border/box-shadow 加在 pre 上:两层各画一个 12px 圆角框 = 框中框,
727
+ 而且 pre 背景透明,阴影直接投进容器内部形成一圈脏边。
728
+ md-code-block 是 DSH 的全局类名(非 CSS Modules hash),可以稳定引用。 */
729
+ .md-code-block {
730
+ border: 1px solid var(--bloom-hairline);
731
+ box-shadow: var(--bloom-shadow-sm);
732
+ overflow: hidden;
733
+ }
734
+ .md-code-block pre,
735
+ pre.shiki {
736
+ border: 0;
737
+ border-radius: 0;
738
+ box-shadow: none;
739
+ }
740
+ /* 兜底:不在 md-code-block 里的裸 pre 才自己描边 */
741
+ pre:not(.shiki) {
742
+ border-radius: 12px;
743
+ }
744
+ pre code {
745
+ border-radius: 0;
746
+ background: transparent;
747
+ }
748
+
749
+ /* 表格:严格限定在 [_tableScroll] 容器内,只动 markdown 渲染的聊天表格,
750
+ 不影响 DSH 会话列表(Y0dWHa_table)这类其他用途的 <table>。
751
+ DSH 原生规则:th/td 各有一条 border-bottom,th:first-child 与 td:first-child
752
+ 的 padding-left:0(让首列对齐正文),空 cell 仍撑出 100px min-width。
753
+ 三个肉眼短板:① 表头只有加粗无底色,与主体混淆;② 无列分隔,像列表不像表;
754
+ ③ 空 cell 100px 留白让残缺行(如 P1 状态列空)看着"半残废"。 */
755
+ [class*="_tableScroll"] {
756
+ border: 1px solid var(--bloom-hairline);
757
+ border-radius: 10px;
758
+ background: rgba(var(--bloom-morandi), 0.02);
759
+ }
760
+ /* min-width:100% 而不是 width:100%:
761
+ DSH 原生表格按内容定宽,而 _tableScroll 容器是块级全宽。上面刚给容器加了边框+圆角,
762
+ 两边宽度不一致立刻就看得见——实测容器 748px、表格只有 469px,右侧露出 279px 空框,
763
+ 表头底色和行分隔线都在半路截断(截图里那种「表格缩在左边」)。
764
+ 用 min-width 而非 width 是因为 _tableScroll 本来就是横向滚动容器:
765
+ 窄表格撑满对齐边框,宽表格仍能超出并滚动;写 width:100% 会把宽表格压缩换行,
766
+ 把这个容器的滚动能力废掉。 */
767
+ [class*="_tableScroll"] table {
768
+ border-collapse: separate;
769
+ border-spacing: 0;
770
+ min-width: 100%;
771
+ }
772
+ body[data-bloomglass-variant] [class*="_tableScroll"] th,
773
+ body[data-bloomglass-variant] [class*="_tableScroll"] td {
774
+ border-color: var(--bloom-hairline);
775
+ border-right-width: 1px;
776
+ border-right-style: solid;
777
+ border-bottom-width: 1px;
778
+ }
779
+ [class*="_tableScroll"] th:last-child,
780
+ [class*="_tableScroll"] td:last-child { border-right: 0; }
781
+ [class*="_tableScroll"] tr:last-child th,
782
+ [class*="_tableScroll"] tr:last-child td { border-bottom: 0; }
783
+ [class*="_tableScroll"] th:first-child,
784
+ [class*="_tableScroll"] td:first-child { padding-left: 16px; }
785
+ [class*="_tableScroll"] thead th {
786
+ background: rgba(var(--bloom-morandi), 0.10);
787
+ font-weight: 600;
788
+ border-bottom-width: 1px;
789
+ border-bottom-color: var(--bloom-hairline-strong);
790
+ color: var(--dsw-alias-label-secondary, currentColor);
791
+ }
792
+ [class*="_tableScroll"] tbody tr:hover { background: rgba(var(--bloom-morandi), 0.06); }
793
+ /* 空 cell 视觉降权:透明度 0.35,省得缺值列(P1 状态空)显得行"断了一截" */
794
+ [class*="_tableScroll"] td:empty { opacity: 0.35; }
795
+
796
+ /* 推理中的 “Deep diving…”:DSH 原生 shimmer 直接使用 DeepSeek 静态蓝。
797
+ Bloom 为每个变体提供主色及两种邻近色,做成克制的三色光谱流动:有 Gemini
798
+ 式的生命力,但色相始终属于当前主题。以语义后缀而非 CSS Module hash 匹配。 */
799
+ body[data-bloomglass-variant] [class*="_turnStatus"]:not([class*="_turnStatusClock"]) {
800
+ background-image: linear-gradient(
801
+ 110deg,
802
+ var(--bloom-motion-1) 0%,
803
+ var(--bloom-motion-2) 24%,
804
+ var(--bloom-motion-3) 43%,
805
+ var(--bloom-motion-1) 60%,
806
+ var(--bloom-motion-2) 78%,
807
+ var(--bloom-motion-3) 100%
808
+ );
809
+ background-size: 260% 100% !important;
810
+ animation: bloom-deep-dive-spectrum 3.6s ease-in-out infinite alternate !important;
811
+ }
812
+ [class*="_turnStatusClock"] {
813
+ color: color-mix(in oklch, var(--bloom-motion-2) 58%, var(--dsw-alias-label-caption)) !important;
814
+ -webkit-text-fill-color: color-mix(in oklch, var(--bloom-motion-2) 58%, var(--dsw-alias-label-caption)) !important;
815
+ }
816
+ @keyframes bloom-deep-dive-spectrum {
817
+ from { background-position: 100% 0; }
818
+ to { background-position: 0 0; }
819
+ }
820
+ @media (prefers-reduced-motion: reduce) {
821
+ [class*="_turnStatus"]:not([class*="_turnStatusClock"]) {
822
+ animation: none !important;
823
+ background-position: 50% 0 !important;
824
+ }
825
+ }
826
+
827
+ /* 选中文本:原版用 accent 混 80%,比 DSH 默认的 85% 更实,能看清 */
828
+ ::selection {
829
+ background: color-mix(in oklch, var(--bloom-accent), transparent 78%);
830
+ }
831
+
832
+ /* ═══ 4. 裸露的 <think> 标签 ═══════════════════════════════════════
833
+ 由 markThinkTags() 打标(见该函数注释:这是 workaround,根因在 LLM 适配层)。
834
+ 标签行整行隐藏 —— 实测它们各自独占段落,隐藏不丢内容;
835
+ 标签之间的思考内容只降权、不隐藏,用冷光竖线标出,信息仍可读。 */
836
+ [data-bloom-think] {
837
+ display: none !important;
838
+ }
839
+ /* 左侧冷光竖线 + 降低透明度。
840
+ ⚠️ 竖线错位的坑:思考块是一串平铺的兄弟元素(p / ul / ol / h3 / div),各标签
841
+ 默认 margin-left 不同(实测 p 被置为 0、ul 为 2px),不强制归零就会让竖线
842
+ 错开 2px、连不成一条直线。所以 margin-left 必须 !important 压平。
843
+ 另:用背景色块代替竖线会让每段变成一张「卡片」,且色块边缘直接贴住文字,
844
+ 观感更差 —— 竖线 + 内边距才是对的做法。 */
845
+ [data-bloom-think-body] {
846
+ opacity: 0.62;
847
+ border-left: 2px solid var(--bloom-hairline);
848
+ margin-left: 0 !important;
849
+ padding-left: 14px;
850
+ transition: opacity 0.2s ease, border-color 0.2s ease;
851
+ }
852
+ [data-bloom-think-body]:hover {
853
+ opacity: 0.92;
854
+ border-left-color: var(--bloom-hairline-strong);
855
+ }
856
+ /* 列表要保留编号/圆点的缩进,否则 padding 被覆盖后编号会贴到竖线上 */
857
+ ul[data-bloom-think-body],
858
+ ol[data-bloom-think-body] {
859
+ padding-left: 38px;
860
+ }
861
+
862
+ /* ═══ 6. 微交互动效系统(v0.5.0)══════════════════════════════════
863
+ 原则(导师批注):
864
+ ① 只为「状态反馈」动:hover=可点、active=按下、选中=切换;
865
+ ② 统一时长/缓动走 token(反馈 130ms、常规 200ms、大过渡 280ms;
866
+ ease-out 入场、通用 ease),全站一致,否则就是「东一块西一块」;
867
+ ③ 只动 transform/opacity(GPU 合成不动布局),backdrop-filter 很贵不放进 transition;
868
+ ④ prefers-reduced-motion 一律降级成瞬间(无动画)。
869
+ 别加「为了炫而炫」的常驻动画——那个会回到「脏/四不像」。 */
870
+ body {
871
+ --bloom-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
872
+ --bloom-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
873
+ --bloom-dur-fast: 130ms;
874
+ --bloom-dur: 200ms;
875
+ --bloom-dur-slow: 280ms;
876
+ }
877
+
878
+ /* 侧栏会话行:hover 轻微右移 + 淡染(可点暗示),active 按下回弹 */
879
+ [class*="_sidebarCol"] [role="treeitem"],
880
+ [class*="_sidebarCol"] [class*="_sessionRow"] {
881
+ transition:
882
+ background var(--bloom-dur-fast) var(--bloom-ease),
883
+ transform var(--bloom-dur-fast) var(--bloom-ease);
884
+ }
885
+ [class*="_sidebarCol"] [role="treeitem"]:hover {
886
+ transform: translateX(2px);
887
+ }
888
+ [class*="_sidebarCol"] [role="treeitem"]:active { transform: translateX(2px) scale(0.996); }
889
+
890
+ /* ── 侧栏:去分割线,靠间距 + 极淡光感分组(owner 2026-09-10)──
891
+ *
892
+ * 反馈原话:「基本取消分割线,用行间距和浅色交替分组」「取消网格,增加当前行
893
+ * 左侧粉色短竖条」「现在已经不流行通过 border 去做切割了,要不然就是很淡的
894
+ * 边框线加光感流动或者是阴影,比硬的边框好看得多」。
895
+ *
896
+ * 所以这里一条实线都不画。曾经退而求其次画过"极淡的渐隐光带",
897
+ * 结果 18 个分组 = 18 条横线,等于换了个材质的网格 —— 也删了,见下。
898
+ */
899
+ [class*="_sidebarCol"] [class*="_groupSection"] + [class*="_groupSection"] {
900
+ /* 分组之间只靠留白。10px 时还要配一条组标题下的光带才分得开,
901
+ 16px 之后光带就多余了 —— 见下面那段。 */
902
+ margin-top: 16px;
903
+ }
904
+
905
+ /* 组标题下曾有一道渐隐光带(左浓右透)。删掉了:侧栏里有 18 个分组,
906
+ * 一组一条 = 18 条横线,正是 owner 说的「取消网格」要取消的东西
907
+ * (2026-09-10 实拍反馈"细节不满意")。浅色下线的左端是 28% accent,
908
+ * 在莫兰迪里更是一条明显的彩色规线。
909
+ *
910
+ * 分组识别现在靠三件不画线的东西:组标题自带文件夹图标、条目相对缩进、
911
+ * 组间 16px 留白。实测这三样够了 —— 加线只是把"够了"变成"吵"。 */
912
+
913
+ /* 会话行:圆角 + hover 淡染,靠留白区分,不描边不画网格 */
914
+ [class*="_sidebarCol"] [class*="_sessionRow"] {
915
+ position: relative;
916
+ border-radius: 8px;
917
+ }
918
+ [class*="_sidebarCol"] [class*="_sessionRow"]:hover {
919
+ background: color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 93%);
920
+ }
921
+
922
+ /* 当前行:只染色,不画条不画框(owner 反馈「脏」)
923
+ 之前有左侧 3px 蓝竖条 + inset 1px 边框 + 25% accent 实色底,三个都是脏点。
924
+ 现在只留一个 8% 的极淡 accent 染色,跟 hover 区分用细微浓淡差。
925
+
926
+ 这里不需要 !important(2026-09-10 实测推翻了原注释):DSH 那条是
927
+ .YDXeBa_sessionRow.YDXeBa_selected { background: var(--dsw-alias-interactive-bg-hover) },
928
+ 特异度 (0,2,0) 且不带 !important,而本条 (0,3,0) 本来就赢;更何况
929
+ --dsw-alias-interactive-bg-hover 早已被 tokens.ts 覆写成本主题的 accent,
930
+ 压根不是"固定蓝色"。 */
931
+ [class*="_sidebarCol"] [class*="_sessionRow"][class*="_selected"],
932
+ [class*="_sidebarCol"] [class*="_sessionRow"][class*="_active"],
933
+ [class*="_sidebarCol"] [class*="_sessionRow"][aria-selected="true"],
934
+ [class*="_sidebarCol"] [role="treeitem"][aria-selected="true"] {
935
+ background: color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 92%);
936
+ }
937
+
938
+ /* v0.10.x:左色条 + bloom-bar-in 动画整体删除(owner 反馈「脏」)。
939
+ 没有 ::before 了,bloom-bar-in keyframe 也跟着删。 */
940
+
941
+ /* 玻璃面板 / 输入卡片 hover:微抬升(只在已有 transition 的元素上加,避免泛化抖动) */
942
+ div[class*="_composer"] div[class*="_card"] {
943
+ transition:
944
+ border-color var(--bloom-dur) var(--bloom-ease),
945
+ box-shadow var(--bloom-dur) var(--bloom-ease);
946
+ }
947
+ div[class*="_composer"] div[class*="_card"]:hover {
948
+ border-color: var(--bloom-hairline-strong);
949
+ }
950
+
951
+ /* 主题自己的按钮:按下微缩(明确「点到了」) */
952
+ .dsh-bloom-trigger:active,
953
+ .dsh-bloom-option:active,
954
+ [class*="_sidebarCol"] button:active { transform: scale(0.985); }
955
+
956
+ /* ═══ 焦点环兜底:统一成主题色 ═══
957
+ DSH 有些控件(例如设置面板右上角的关闭按钮)没定义自己的 focus 样式,
958
+ 于是露出**浏览器默认**的 focus ring —— Chrome 暗色下是一圈亮蓝
959
+ rgb(153,200,255),跟 8 套莫兰迪配色全都打架(用户实拍反馈里那个蓝圈)。
960
+ 注意它不是 DSH 的 token,改 --dsw-* 改不到,只能靠这条兜底。
961
+
962
+ 用 :focus-visible 而不是 :focus —— 只在键盘导航时出现,鼠标点击不显示,
963
+ 这也是 WCAG 2.4.7「焦点可见」的正确做法:焦点依然清晰,只是跟着主题走。 */
964
+ :focus-visible {
965
+ outline: 2px solid var(--bloom-accent) !important;
966
+ outline-offset: 2px;
967
+ }
968
+
969
+ /* 重新截图/使用观察时可平滑淡入的低频面板入场(默认不绑到常驻元素上) */
970
+ @keyframes bloom-panel-in {
971
+ from { opacity: 0; transform: translateY(6px); }
972
+ to { opacity: 1; transform: none; }
973
+ }
974
+
975
+ /* ═══ 6.5 空状态视觉锚点 (v0.10.x) ═══════════════════════════════════
976
+ *
977
+ * owner 反馈「没层次感」:空状态下「探索未至之境」一行字飘在苍白画布上,
978
+ * 眼睛找不到着陆位置。这里在 scrollBody 没有气泡时(即空状态),在中央
979
+ * 投一团主题色光晕 + 慢呼吸,给视觉一个焦点。光晕跟 message 流重叠的
980
+ * 概率低(消息堆在顶部),不会污染正常态。
981
+ *
982
+ * selector 限制条件:\`:not(:has(div[class*="_bubble"]))\` 确保只在没消息时出现;
983
+ * 有消息时这条规则不匹配,::before 不渲染。\`:has()\` Chrome 105+/Safari 15.4+。
984
+ * 限定 div 同样是为了不把 tooltip(span)算进"有消息"。 */
985
+ [class*="_scrollBody"] { position: relative; }
986
+ [class*="_scrollBody"]:not(:has(div[class*="_bubble"]))::before {
987
+ content: '';
988
+ position: absolute;
989
+ inset: 0;
990
+ pointer-events: none;
991
+ z-index: 0;
992
+ background: radial-gradient(
993
+ 42% 32% at 50% 48%,
994
+ color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 86%) 0%,
995
+ color-mix(in oklch, var(--bloom-aurora-stream-2), transparent 92%) 35%,
996
+ transparent 68%
997
+ );
998
+ animation: bloom-anchor-breathe 4.2s ease-in-out infinite;
999
+ }
1000
+ @keyframes bloom-anchor-breathe {
1001
+ 0%, 100% { opacity: 0.85; transform: scale(1); }
1002
+ 50% { opacity: 1; transform: scale(1.05); }
1003
+ }
1004
+
1005
+ /* ═══ 7. 全站统一入场系统 (v0.10.x) ═════════════════════════════════
1006
+ *
1007
+ * owner 反馈:「除了深度求索中的文字有动效外,别的几乎是没有任何吸引到我的动画」
1008
+ * ——所以这一节专门把页面"活起来":消息气泡/工具行/侧栏/顶栏首次入场都走
1009
+ * 同一条 keyframe,长度/缓动全走 §6 的 token;只动 opacity + transform,
1010
+ * prefers-reduced-motion 下由下方媒体查询清零。
1011
+ *
1012
+ * 为什么用 \`animation\` 而不是 transition + \`:not([hidden])\`:
1013
+ * 后者在 React 里反复 toggle hidden 时 transition 不会重新计算;
1014
+ * 且「元素插入」这件事 transition 表达不了。\`animation\` 在元素 mount 那一刻
1015
+ * 唯一一次触发,正好对应「第一次出现在视野中」的语义;React 重渲染复用
1016
+ * DOM 节点时也不会重放,不会污染正常状态切换。
1017
+ */
1018
+ @keyframes bloom-fade-up {
1019
+ from { opacity: 0; transform: translateY(16px); }
1020
+ to { opacity: 1; transform: none; }
1021
+ }
1022
+ @keyframes bloom-fade-down {
1023
+ from { opacity: 0; transform: translateY(-12px); }
1024
+ to { opacity: 1; transform: none; }
1025
+ }
1026
+
1027
+ /* 消息气泡 / 工具调用行 / 滚动体内的卡片 —— 整页停留时间最长的地方。
1028
+ 气泡限定 div(span 的 _bubble 是全局 Tooltip,动画会盖掉它的定位 transform,
1029
+ 见 §3 气泡圆角处的注释)。
1030
+ \`:not(:has(...))\` 排除含终端 / 代码块的卡:它们是「展开后才有」的元素,
1031
+ 跟着外层一起进,单独动会让代码块在父卡里飘。
1032
+ !important 必要:DSH 在这些元素上有自己的 \`animation\`(比如呼吸 loader),
1033
+ 后注入的会赢;用 !important 让 Bloom 的入场动画压过它。 */
1034
+ div[class*="_bubble"],
1035
+ div[class*="_toolRow"],
1036
+ div[class*="_scrollBody"] > div[class*="_card"]:not(:has([class*="_terminal"])):not(:has(pre)) {
1037
+ animation: bloom-fade-up 420ms var(--bloom-ease-out) backwards !important;
1038
+ }
1039
+ /* 侧栏会话行:页面打开时整组淡入(一次性,不是循环) */
1040
+ [class*="_sidebarCol"] [role="treeitem"] {
1041
+ animation: bloom-fade-up 320ms var(--bloom-ease-out) backwards !important;
1042
+ }
1043
+ /* 顶栏 —— 从顶部滑下比从下方滑上更贴「页面打开」的语义。
1044
+ :has(> _tabs) 是"真顶栏"的唯一判据,见下方 §去硬边框 里的说明。 */
1045
+ [class*="_header"]:has(> [class*="_tabs"]) {
1046
+ animation: bloom-fade-down 420ms var(--bloom-ease-out) backwards !important;
1047
+ }
1048
+ /* 顶栏三按钮入场(newSession / sessionLog / Bloom trigger):
1049
+ transform / box-shadow 加到原 transition 里 —— 原来 WIP 只 transition 了
1050
+ background,加 lift 不需要新 transition 字段,hover 时一并平滑 */
1051
+ [class*="_newSession"],
1052
+ [class*="_sessionLogButton"],
1053
+ .dsh-bloom-trigger {
1054
+ animation: bloom-fade-down 320ms var(--bloom-ease-out) backwards !important;
1055
+ }
1056
+ [class*="_newSession"]:hover,
1057
+ [class*="_sessionLogButton"]:hover,
1058
+ .dsh-bloom-trigger:hover {
1059
+ transform: translateY(-1px);
1060
+ box-shadow: 0 4px 14px -6px var(--bloom-glow);
1061
+ }
1062
+
1063
+ /* ═══ 输入卡三态 ═══════════════════════════════════════════════════
1064
+ * owner 要的是"输入框会说话"(2026-09-10):
1065
+ * 静置 —— 光晕边框慢呼吸(还活着,在等你)
1066
+ * 聚焦 —— 同一条呼吸,节奏收紧一倍(跟手)
1067
+ * 执行中 —— 流光沿边框流转(它在跑)
1068
+ *
1069
+ * 两个 @property 是必需的:普通自定义属性在 keyframes 里只会**离散跳变**,
1070
+ * 声明了 syntax 浏览器才真正插值。--bloom-halo 推 glass.ts 里那圈主题色
1071
+ * 外晕的浓度(阴影本体写在那边,这里只给数值);--bloom-spin 推 conic
1072
+ * 渐变的起始角。
1073
+ *
1074
+ * 「执行中」的判据是 :has([class*="_pending"]):uV2eYG_pending 是 DSH 自己的
1075
+ * 待处理指示点(8px 圆点 + 1s 脉冲),只在跑的时候渲染进输入卡 —— idle 态
1076
+ * 实测 DOM 里查不到。比按 aria-label="停止" 判稳(那个会随界面语言变)。 */
1077
+ @property --bloom-halo { syntax: '<number>'; initial-value: 0.5; inherits: false; }
1078
+ @property --bloom-spin { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
1079
+
1080
+ @keyframes bloom-halo-breathe {
1081
+ 0%, 100% { --bloom-halo: 0.3; }
1082
+ 50% { --bloom-halo: 0.85; }
1083
+ }
1084
+ @keyframes bloom-ring-spin {
1085
+ to { --bloom-spin: 360deg; }
1086
+ }
1087
+
1088
+ body[data-bloomglass-variant] div[class*="_composer"] div[class*="_card"] {
1089
+ animation: bloom-halo-breathe 5.2s ease-in-out infinite;
1090
+ }
1091
+ body[data-bloomglass-variant] div[class*="_composer"] div[class*="_card"]:focus-within {
1092
+ animation-duration: 2.6s;
1093
+ }
1094
+
1095
+ /* 执行中:1.5px 的一圈环,conic 起始角在转,看上去像一束光绕着卡边跑。
1096
+ mask 两层 + exclude 把实心圆角矩形挖成环,padding 就是环的粗细。
1097
+ 角度停留在 52%~90% 这一段,所以任一时刻只有约三分之一圈是亮的 ——
1098
+ 亮满一整圈就成了跑马灯,不是"流光"。 */
1099
+ body[data-bloomglass-variant] div[class*="_composer"] div[class*="_card"]:has([class*="_pending"])::after {
1100
+ content: '';
1101
+ position: absolute;
1102
+ inset: -1.5px;
1103
+ border-radius: 18px;
1104
+ padding: 1.5px;
1105
+ pointer-events: none;
1106
+ background: conic-gradient(
1107
+ from var(--bloom-spin),
1108
+ transparent 0 52%,
1109
+ color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 20%) 70%,
1110
+ color-mix(in oklch, var(--bloom-motion-2, #6b8f71), transparent 45%) 80%,
1111
+ transparent 90% 100%
1112
+ );
1113
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
1114
+ -webkit-mask-composite: xor;
1115
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
1116
+ mask-composite: exclude;
1117
+ animation: bloom-ring-spin 2.6s linear infinite;
1118
+ }
1119
+ /* 跑起来时呼吸让位给流光 —— 两套动效同时在同一块边上跑会互相打架 */
1120
+ body[data-bloomglass-variant] div[class*="_composer"] div[class*="_card"]:has([class*="_pending"]) {
1121
+ --bloom-halo: 0.7;
1122
+ animation: none;
1123
+ }
1124
+
1125
+ /* View Transition:主题切换时整页 cross-fade,让颜色"流过去"而不是瞬切。
1126
+ 只有浏览器支持 \`document.startViewTransition\` 时才触发(switcher.ts 里
1127
+ 调),这里只控动画时长 / 缓动;不支持和 reduced-motion 一起由下方兜底
1128
+ 清零。 */
1129
+ ::view-transition-old(root),
1130
+ ::view-transition-new(root) {
1131
+ animation-duration: 320ms;
1132
+ animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
1133
+ }
1134
+
1135
+ /* ④ 无障碍:动效全部降级为瞬间 */
1136
+ @media (prefers-reduced-motion: reduce) {
1137
+ [class*="_sidebarCol"] [role="treeitem"],
1138
+ [class*="_sidebarCol"] [class*="_active"]::before,
1139
+ .dsh-bloom-switcher, .dsh-bloom-trigger, .dsh-bloom-option, .dsh-bloom-menu,
1140
+ [class*="_composer"] div[class*="_card"],
1141
+ [class*="_composer"] div[class*="_card"]::after,
1142
+ [class*="_composer"] div[class*="_card"]:has([class*="_pending"])::after,
1143
+ div[class*="_bubble"],
1144
+ div[class*="_toolRow"],
1145
+ div[class*="_scrollBody"] > div[class*="_card"],
1146
+ [class*="_newSession"], [class*="_sessionLogButton"],
1147
+ [class*="_scrollBody"]:not(:has(div[class*="_bubble"]))::before,
1148
+ ::view-transition-old(root), ::view-transition-new(root) {
1149
+ animation: none !important;
1150
+ transition: none !important;
1151
+ }
1152
+ }
1153
+
1154
+ /* ══ 去硬边框:全站统一换成光带 / 浅底 / 阴影(owner 2026-09-10)══
1155
+ *
1156
+ * 原话:「现在已经不流行通过 border 去做切割了,要不然就是很淡的边框线加光感
1157
+ * 流动或者是阴影,比硬的边框好看得多」;以及「不能光靠我一个一个说」。
1158
+ *
1159
+ * 所以全页扫了一遍 border-width > 0 的元素(39 处 / 9 类),按类别统一处理。
1160
+ * 消息气泡与输入卡不在此列 —— 它们的边由 glass.ts 的玻璃体系管,动了会破相。
1161
+ */
1162
+
1163
+ /* 顶部标题栏下沿:跟侧栏右缘同一套做法 —— box-shadow 往外投的辉光,
1164
+ * 不是伪元素画的带。
1165
+ *
1166
+ * 为什么不用 ::after:伪元素被自身盒子裁在里面,渐变最浓的一端就是一条
1167
+ * 硬边(实拍 y=58 处 207→189 一跳)。box-shadow 的外阴影画在 border-box
1168
+ * 之外、由 blur 自然收尾,两头都没有边。offset 与 spread 同量(24/-24)
1169
+ * 保证光只往下走,不糊到左右和顶上。
1170
+ *
1171
+ * ⚠️ 选择器必须是 :has(> [class*="_tabs"]),不能只写 [class*="_header"]。
1172
+ * 后者在实际 DOM 里匹配 5 个元素,其中 4 个是**小按钮组**
1173
+ * (wSkVaW_headerActions 68px、wSkVaW_headerUtilities 208px、
1174
+ * bhn1Oq_headerActions 60px、_2ctAZa_header 24px),二三十像素高的盒子
1175
+ * 挂上 18px 光带就是一小段悬空横带 —— owner 反馈"像被玻璃劈开"
1176
+ * (2026-09-10 实拍:药丸组下沿 y=43 一条硬边,色差 191→209)。
1177
+ * 而真顶栏 wSkVaW_header 反而没吃到:它自带 ::after 是 0.5px 线,
1178
+ * 跟我们 (0,1,0) 平手、后注入者赢。:has() 把特异度提到 (0,2,0),
1179
+ * 既只命中真顶栏,又压得过宿主那条硬线。 */
1180
+ body[data-bloomglass-variant] [class*="_header"]:has(> [class*="_tabs"]) {
1181
+ position: relative;
1182
+ border-bottom: 0;
1183
+ /* 强度实测定档:45% 时边界跳变 32 级、仍读成一条线;78% 跳变 10 级,
1184
+ 只剩"上面比下面亮一点"的错觉,这才是分隔而不是分割。 */
1185
+ box-shadow: 0 28px 48px -28px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 78%);
1186
+ }
1187
+ body[data-bloomglass-variant] [class*="_header"]:has(> [class*="_tabs"])::after {
1188
+ /* 宿主那条 0.5px 硬线关掉,交给上面的辉光 */
1189
+ display: none;
1190
+ }
1191
+
1192
+ /* 描边按钮 → 浅底。新会话 / Session 日志 / 本主题切换器三个长得一样,
1193
+ 一起处理,免得只改自己的显得突兀 */
1194
+ body[data-bloomglass-variant] [class*="_newSession"],
1195
+ body[data-bloomglass-variant] [class*="_sessionLogButton"],
1196
+ body[data-bloomglass-variant] .dsh-bloom-trigger {
1197
+ border-color: transparent;
1198
+ background: color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 92%);
1199
+ /* transform + box-shadow 也进 transition:§7 hover 抬升要平滑,不能 snap。
1200
+ §7 那边只改属性,不重声明 transition —— 避免重复定义打架 */
1201
+ transition:
1202
+ background var(--bloom-dur-fast, .16s) var(--bloom-ease, ease),
1203
+ transform var(--bloom-dur-fast, .16s) var(--bloom-ease, ease),
1204
+ box-shadow var(--bloom-dur-fast, .16s) var(--bloom-ease, ease);
1205
+ }
1206
+ body[data-bloomglass-variant] [class*="_newSession"]:hover,
1207
+ body[data-bloomglass-variant] [class*="_sessionLogButton"]:hover,
1208
+ body[data-bloomglass-variant] .dsh-bloom-trigger:hover {
1209
+ background: color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 86%);
1210
+ }
1211
+
1212
+ /* 侧栏底部操作区:上边框 → 向上扩散的极淡阴影 */
1213
+ body[data-bloomglass-variant] [class*="_footerActions"] {
1214
+ border-top: 0;
1215
+ box-shadow: 0 -6px 10px -8px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 78%);
1216
+ }
1217
+
1218
+ /* ═══ 禁用主按钮的图标可读性(2026-09-14 用户实拍「浅色下看不清」)══
1219
+ * DSH 禁用主按钮(空输入时的发送键等)的原生做法:fill 压到 10% + 整体再叠
1220
+ * opacity .4,但文字/图标仍用「fill 上的前景」label-primary-foreground ——
1221
+ * 那个 token 亮色语义就是白(static neutral-bluish-00)。白箭头浮在洗白的
1222
+ * 淡染底上,禁用态形同消失(禁用 bloom 样式实测原生同样如此,只是莫兰迪的
1223
+ * 淡底把它衬得更明显)。README 挂着 WCAG 徽章,这里修正:禁用态的底已经是
1224
+ * 近白的淡染,图标用正文字色(label-primary)才读得清,明暗自适应 ——
1225
+ * 暗色下两者同为亮色,行为不变,只有亮色被纠正。 */
1226
+ body[data-bloomglass-variant] button[class*="_primary"]:disabled {
1227
+ color: var(--dsw-alias-label-primary);
1228
+ }
1229
+ `;
1230
+ //#endregion
1231
+ //#region src/client/css/bloom-glass.ts
1232
+ /**
1233
+ * 氛围层 CSS(v0.4.0)—— 壁纸 + 磨砂玻璃,全部由 body 的 data-* 属性驱动,
1234
+ * 默认不生效(data 属性不写就不渲染),关掉即完全回到 v0.3 的纯 Bloom。
1235
+ *
1236
+ * 壁纸:两个 fixed 层(图 + 压暗纱),z-index:-1 画在 body 背景之上、
1237
+ * 应用内容之下;同时必须把 body 自己的氛围渐变和底色清掉,否则会盖住壁纸。
1238
+ * 玻璃:只接管「面」级容器(侧栏/气泡/输入卡/菜单/顶栏)——这些容器
1239
+ * 已经在 COMPONENT_CSS 里被透明化过背景,这里补半透明底 + backdrop-filter。
1240
+ */
1241
+ /**
1242
+ * 玻璃层(v0.5.0,主视觉)—— 不再有壁纸/氛围层。
1243
+ *
1244
+ * 背景即 body 的莫兰迪氛围渐变(见 COMPONENT_CSS 第 1 节,已增强)。
1245
+ * 玻璃由三件事读出来,缺一不可:
1246
+ * 1. 半透底(透明 60~82%)让氛围渐变的色相透过来;
1247
+ * 2. backdrop blur + saturate:面板与背景/内容交界产生霜化;
1248
+ * 3. 玻璃边缘——顶部亮高光(inset) + 半透描边 + 柔和深色外辉。
1249
+ *
1250
+ * 明暗两档透明度:暗色面板更实一点(保亮字可读),亮色更透(玻璃感更足)。
1251
+ * 全部用 color-mix(theme token, transparent) 而不是死白/死黑,色相跟着变体走。
1252
+ */
1253
+ const GLASS_CSS = `
1254
+ /* ═══ 落霞流线(v0.9.0,仅 aurora 变体)═══════════════════════════
1255
+ 用户要的「流线」—— body 背后挂两条斜向渐变丝带,transform 缓慢漂移 +
1256
+ blur 软化,做出光帘的视差感。
1257
+ 用 ::before 而非改 body background 的原因:动 body 的 background-image
1258
+ 每帧重绘整个 viewport;伪元素独立合成层,transform 走 GPU,便宜得多。
1259
+ z-index:-1 + position:fixed —— body 的背景透传规则下,负 z-index 落在根
1260
+ 堆叠上下文的负层,画在 body 背景之上、应用内容之下。
1261
+ 颜色走 --bloom-aurora-stream-1/2/3(在 tokens.ts 由 motion 谱混透明得到),
1262
+ v0.12.x 起 aurora 换橙黄系,切到该变体时呈现金→橙→珊瑚的晚霞流光,
1263
+ 其它变体保持原样不显示。 */
1264
+ body[data-bloomglass-variant="aurora"]::before {
1265
+ content: '';
1266
+ position: fixed;
1267
+ inset: -40%;
1268
+ z-index: -1;
1269
+ pointer-events: none;
1270
+ background-image:
1271
+ linear-gradient(115deg,
1272
+ transparent 38%, var(--bloom-aurora-stream-1) 50%, transparent 62%),
1273
+ linear-gradient(70deg,
1274
+ transparent 32%, var(--bloom-aurora-stream-2) 46%, transparent 60%),
1275
+ radial-gradient(60% 40% at 50% 60%,
1276
+ var(--bloom-aurora-stream-3), transparent 70%);
1277
+ background-size: 220% 220%, 260% 240%, 100% 100%;
1278
+ background-position: 25% 30%, 80% 15%, 50% 50%;
1279
+ background-repeat: no-repeat;
1280
+ /* v0.12.1:这层是「脏」的真正来源(owner 2026-09-15)。原来 opacity .6 +
1281
+ saturate(1.2) + blur 60px 铺满整个视口 —— 三条金橙渐变糊成一片盖在界面上,
1282
+ 底色再干净也被这层罩住。saturate 尤其致命:它在已经糊开的大色块上继续加彩度,
1283
+ 等于把浑浊放大。
1284
+ 氛围层的作用是「若隐若现的呼吸感」,不是给界面上色:不透明度砍到三分之一,
1285
+ 去掉饱和增益,blur 加大让边界更软。 */
1286
+ filter: blur(76px);
1287
+ opacity: 0.2;
1288
+ transform: translate3d(0, 0, 0);
1289
+ animation: bloom-aurora-drift 28s ease-in-out infinite alternate;
1290
+ will-change: transform, background-position;
1291
+ }
1292
+ @keyframes bloom-aurora-drift {
1293
+ from { transform: translate3d(-2.5%, -1.5%, 0); background-position: 25% 30%, 80% 15%, 50% 50%; }
1294
+ to { transform: translate3d(3%, 2%, 0); background-position: 65% 55%, 35% 45%, 55% 45%; }
1295
+ }
1296
+ @media (prefers-reduced-motion: reduce) {
1297
+ body[data-bloomglass-variant="aurora"]::before {
1298
+ animation: none !important;
1299
+ transform: none !important;
1300
+ opacity: 0.4 !important;
1301
+ }
1302
+ }
1303
+
1304
+ /* ═══ 顶栏 tab 条:不做玻璃,只留一条发丝底边 ═══════════════════
1305
+ 这里曾和侧栏/排队条共用「面级玻璃」档位(半透底 + backdrop blur +
1306
+ inset 白描边)。但 tab 条只有 27px 高、1400px 宽 —— 那套玻璃在这个尺寸上
1307
+ 读不出「一块玻璃」,只会变成一条自带底色和白边框的横带,跟下方内容区
1308
+ 撞出一道突兀的色块边界(用户实拍反馈:「对话和轨迹这里」)。
1309
+
1310
+ 玻璃需要面积才成立。窄条带该做的是「分界」而不是「面」,所以只留一条
1311
+ morandi 发丝底边,底色完全交给 body 的氛围渐变。 */
1312
+ body[data-bloomglass-variant] div[class*="_tabs"] {
1313
+ background-color: transparent;
1314
+ backdrop-filter: none;
1315
+ -webkit-backdrop-filter: none;
1316
+ box-shadow: inset 0 -1px 0 var(--bloom-hairline, rgba(146,168,179,0.3));
1317
+ }
1318
+
1319
+ /* ═══ 面级面板(排队条 / 预览 dock)═══════════════════════════════
1320
+ 面积大,档位「略实」;顶部亮高光 + 深色外辉让它像一块立起来的玻璃。 */
1321
+ body[data-bloomglass-variant] div[class*="_dock"]:has(> [class*="_preview"]) {
1322
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #fff), transparent 82%);
1323
+ backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.3);
1324
+ -webkit-backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.3);
1325
+ box-shadow:
1326
+ inset 0 1px 0 rgba(255,255,255,0.22),
1327
+ inset 0 0 0 1px rgba(255,255,255,0.10),
1328
+ 0 14px 44px -16px rgba(0,0,0,0.22);
1329
+ }
1330
+ body[data-ds-dark-theme] div[class*="_dock"]:has(> [class*="_preview"]) {
1331
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #101010), transparent 64%);
1332
+ box-shadow:
1333
+ inset 0 1px 0 rgba(255,255,255,0.10),
1334
+ inset 0 0 0 1px rgba(255,255,255,0.05),
1335
+ 0 16px 48px -18px rgba(0,0,0,0.5);
1336
+ }
1337
+
1338
+ /* ═══ 侧栏玻璃:backdrop-filter 必须走 ::before,绝不能加在 _sidebarCol 自身 ═══
1339
+ ⚠️ 这是本项目踩过的最贵的坑,改这块前先读完。
1340
+
1341
+ backdrop-filter(和 transform / filter / perspective / contain / will-change 一样)
1342
+ 会让元素成为**其 position:fixed 后代的 containing block**。而 DSH 的**设置面板
1343
+ 挂在侧栏子树里**(_sidebarCol > … > _footArea > _settingsArea > _overlay),
1344
+ 它的 overlay 是 fixed + inset:0,本来相对视口铺满、panel 800px 居中。
1345
+
1346
+ 一旦 _sidebarCol 自己带 backdrop-filter,那个 fixed 就改为相对 280px 宽的侧栏定位:
1347
+ 遮罩缩到侧栏那一条,panel 被挤成 279px,detail 区 flex 收缩到 18px ——
1348
+ 于是中文变成逐字竖排。这个「DSH 的 layout bug」从来不是 DSH 的,是我们自己造的,
1349
+ 而且为它写了 60 行 modal 改造 CSS、来回改了三轮(详见 DEV_NOTES 2026-08-24)。
1350
+
1351
+ 伪元素的 backdrop-filter 只作用于伪元素自己,不改变父元素的 containing block
1352
+ 资格,所以玻璃观感一致、fixed 后代不受影响。
1353
+
1354
+ ⚠️ 第二个坑(修第一个坑时当场踩的):**不要给侧栏加 isolation: isolate。**
1355
+ 它确实不创建 containing block,但会创建 **stacking context** —— overlay 的
1356
+ z-index:1000 会被困在侧栏内部,而侧栏自身是 z-index:auto,于是设置面板被
1357
+ 主聊天区的 composer 画在了上面。两个属性伤的是两件不同的事:
1358
+ backdrop-filter → containing block(伤 fixed 的**定位基准**)
1359
+ isolation → stacking context(伤 fixed 的**层叠顺序**)
1360
+ 所以这里只用 position:relative + z-index:-1:伪元素落在 root 层叠上下文里、
1361
+ body 氛围渐变之上、所有正常流内容之下,玻璃该模糊的背景一点没变。
1362
+
1363
+ 判据(以后加玻璃时对每个目标问一遍):
1364
+ 「这个元素的子树里有 position:fixed 的东西吗?」有 → 玻璃必须走 ::before,
1365
+ 且不得引入 isolation / transform / filter / contain / will-change。
1366
+ 输入卡的 conversation.input.overlay 槽也会注入 fixed 元素(如 dsh-convmap),
1367
+ 必须遵守同一判据,不能把滤镜放回卡片本体(issue #15)。 */
1368
+ body[data-bloomglass-variant] [class*="_sidebarCol"] {
1369
+ position: relative;
1370
+ /* v0.10.x:之前 transparent !important 让侧栏和 body 完全同色,没有容器感。
1371
+ 改成带 accent tint 的底色 —— 6% accent 混进 bg-layer-1,肉眼能看出
1372
+ "这块区域有自己的颜色",但又不至于抢内容。 */
1373
+ background-color: color-mix(in oklch, var(--bloom-accent, #6b8f71), var(--dsw-alias-bg-layer-1, #fff) 94%);
1374
+ box-shadow:
1375
+ inset 0 1px 0 rgba(255,255,255,0.22),
1376
+ inset 0 0 0 1px rgba(255,255,255,0.10),
1377
+ /* 右缘冷光必须走 box-shadow:侧栏 overflow:hidden,伪元素画的光带只能落在
1378
+ 容器内侧,看上去是「向内发光」(owner 2026-09-10 反馈);外阴影不受自身
1379
+ overflow 裁剪,是唯一真正往外散的做法。offset 与 spread 同量(24/-24),
1380
+ 让光只出现在右侧,不糊到上下边。 */
1381
+ 24px 0 40px -24px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 45%),
1382
+ 0 14px 44px -16px rgba(0,0,0,0.22);
1383
+ }
1384
+ body[data-bloomglass-variant] [class*="_sidebarCol"]::before {
1385
+ content: '';
1386
+ position: absolute;
1387
+ inset: 0;
1388
+ z-index: -1;
1389
+ pointer-events: none;
1390
+ /* 玻璃层也带一点 accent tint —— 和上面的底色呼应 */
1391
+ background-color: color-mix(in oklch, var(--bloom-accent, #6b8f71), var(--dsw-alias-bg-layer-1, #fff) 88%);
1392
+ backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.3);
1393
+ -webkit-backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.3);
1394
+ }
1395
+ body[data-ds-dark-theme] [class*="_sidebarCol"] {
1396
+ background-color: color-mix(in oklch, var(--bloom-accent, #6b8f71), var(--dsw-alias-bg-layer-1, #101010) 92%);
1397
+ box-shadow:
1398
+ inset 0 1px 0 rgba(255,255,255,0.10),
1399
+ inset 0 0 0 1px rgba(255,255,255,0.05),
1400
+ 24px 0 40px -24px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 35%),
1401
+ 0 16px 48px -18px rgba(0,0,0,0.5);
1402
+ }
1403
+ body[data-ds-dark-theme] [class*="_sidebarCol"]::before {
1404
+ background-color: color-mix(in oklch, var(--bloom-accent, #6b8f71), var(--dsw-alias-bg-layer-1, #101010) 82%);
1405
+ }
1406
+
1407
+ /* v0.6.2: 「对话 / 轨迹」tab 字号上限保护 —— 用户在窄屏 / 浏览器 zoom>100% 下
1408
+ 反馈 tab 视觉上被放大、撑得过宽。DSH 原生 tab 是 wSkVaW_tab(默认 13px),
1409
+ 这里兜底 clamp 到 14px,避免任何状态下字号异常撑开。 */
1410
+ div[class*="_tabs"] button[class*="_tab"],
1411
+ div[class*="_tabs"] [class*="_tab"] {
1412
+ font-size: clamp(13px, 0.9vw, 14px) !important;
1413
+ font-weight: 500 !important;
1414
+ letter-spacing: normal !important;
1415
+ white-space: nowrap !important;
1416
+ }
1417
+
1418
+ /* ═══ 输入卡片(主角)—— 最清晰的一块玻璃,focus 时玻璃边缘点亮 ═══
1419
+ v0.9.0: 用户反馈边框「粗粗的」——
1420
+ 原因不是 1px hairline 本身粗,而是 COMPONENT_CSS 给的 border: 1px solid var(--bloom-hairline)
1421
+ 又叠了 GLASS_CSS 的 inset 0 0 0 1px rgba(255,255,255,...) 内白圈,
1422
+ 1px 外框 + 1px 内圈 = 视觉上等于 2px 的厚边框;focus-within 再加 3px 的
1423
+ --bloom-glow 光环就更肥。
1424
+ 修复:去掉 inset 0 0 0 1px 那圈内白线,只保留顶部 1px 高光(玻璃边沿)
1425
+ 和外阴影;focus 环 3px -> 2px,颜色用 accent x 25% 收敛到主题色相,
1426
+ 远看像一根细线而不是一圈光晕。border 本身仍走 COMPONENT_CSS 的 hairline。 */
1427
+ body[data-bloomglass-variant] div[class*="_composer"] div[class*="_card"] {
1428
+ position: relative;
1429
+ background-color: transparent;
1430
+ /* v0.10.x(owner 反馈「没层次感」):阴影只完成"功能"没完成"戏剧"。
1431
+ 单层远影 + 顶部内高光只是让卡片不和背景撞色,不让卡片"提起来"。
1432
+ 三层叠:① 顶部内高光(光从上方来);② 紧贴的硬短影(贴着卡边的
1433
+ 0.5px 影,像把卡片按下去一点弹回来的感觉);③ 远散的长距柔影
1434
+ (把卡片安放在画布上);④ 主题色 tint 外晕("这张卡属于这里"——
1435
+ 莫兰迪主题自己的颜色,不是死的灰黑阴影)。 */
1436
+ box-shadow:
1437
+ inset 0 1px 0 rgba(255,255,255,0.28),
1438
+ 0 1px 2px -1px rgba(0, 0, 0, 0.18),
1439
+ 0 18px 52px -20px rgba(0,0,0,0.26),
1440
+ /* 外晕浓度由 --bloom-halo 驱动(component.ts §7 的呼吸动画在推它):
1441
+ halo=0.73 时等于原来的 78%,0.3 时淡到 91%,0.85 时浓到 74.5%。 */
1442
+ 0 0 48px -16px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent calc(100% - var(--bloom-halo, 0.73) * 30%));
1443
+ }
1444
+ body[data-bloomglass-variant] div[class*="_composer"] div[class*="_card"]::before {
1445
+ content: '';
1446
+ position: absolute;
1447
+ inset: 0;
1448
+ z-index: -1;
1449
+ pointer-events: none;
1450
+ border-radius: inherit;
1451
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #fff), transparent 84%);
1452
+ backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.35);
1453
+ -webkit-backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.35);
1454
+ }
1455
+ body[data-ds-dark-theme] div[class*="_composer"] div[class*="_card"] {
1456
+ box-shadow:
1457
+ inset 0 1px 0 rgba(255,255,255,0.12),
1458
+ 0 1px 2px -1px rgba(0, 0, 0, 0.5),
1459
+ 0 20px 56px -22px rgba(0,0,0,0.55),
1460
+ 0 0 64px -20px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent calc(100% - var(--bloom-halo, 0.7) * 40%));
1461
+ }
1462
+ body[data-ds-dark-theme] div[class*="_composer"] div[class*="_card"]::before {
1463
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #101010), transparent 66%);
1464
+ }
1465
+ body[data-bloomglass-variant] div[class*="_composer"] div[class*="_card"]:focus-within {
1466
+ border-color: var(--bloom-hairline-strong);
1467
+ box-shadow:
1468
+ inset 0 1px 0 rgba(255,255,255,0.3),
1469
+ 0 1px 2px -1px rgba(0, 0, 0, 0.18),
1470
+ 0 0 0 2px color-mix(in oklch, var(--bloom-accent) 25%, transparent),
1471
+ 0 18px 52px -20px rgba(0,0,0,0.26),
1472
+ 0 0 56px -16px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 74%);
1473
+ }
1474
+ body[data-ds-dark-theme] div[class*="_composer"] div[class*="_card"]:focus-within {
1475
+ box-shadow:
1476
+ inset 0 1px 0 rgba(255,255,255,0.14),
1477
+ 0 1px 2px -1px rgba(0, 0, 0, 0.5),
1478
+ 0 0 0 2px color-mix(in oklch, var(--bloom-accent) 28%, transparent),
1479
+ 0 20px 56px -22px rgba(0,0,0,0.55),
1480
+ 0 0 72px -20px color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 68%);
1481
+ }
1482
+
1483
+ /* ═══ 消息气泡 —— 柔和玻璃,近距淡影,不压内容 ═══
1484
+ ⚠️ 必须限定 div(2026-09-14 用户实拍「浅色下提示看不清」):
1485
+ DSH 的全局 Tooltip 与消息气泡共用「_bubble」语义类名(_bubble_1nw3t_1,span)。
1486
+ 裸 [class*="_bubble"] 会把 tooltip 的深底 var(--dsw-alias-tooltip-bg) 盖成
1487
+ 22% 透明度的近白玻璃,而 tooltip 文字是 static 白(--dsw-static-neutral-bluish-00)
1488
+ —— 白字白底,实测对比度 1.0。消息气泡是 div、tooltip 是 span,限定标签即分开;
1489
+ tooltip 的底色由 tokens.ts 接管的 --dsw-alias-tooltip-bg(深底)自动跟随主题。 */
1490
+ body[data-bloomglass-variant] div[class*="_bubble"] {
1491
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #fff), transparent 78%);
1492
+ backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.25);
1493
+ -webkit-backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.25);
1494
+ box-shadow:
1495
+ inset 0 1px 0 rgba(255,255,255,0.16),
1496
+ inset 0 0 0 1px rgba(255,255,255,0.08),
1497
+ 0 6px 24px -10px rgba(0,0,0,0.14);
1498
+ }
1499
+ body[data-ds-dark-theme] div[class*="_bubble"] {
1500
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #101010), transparent 62%);
1501
+ box-shadow:
1502
+ inset 0 1px 0 rgba(255,255,255,0.08),
1503
+ inset 0 0 0 1px rgba(255,255,255,0.04),
1504
+ 0 8px 28px -12px rgba(0,0,0,0.4);
1505
+ }
1506
+
1507
+ /* ═══ 下拉/选择器(覆盖型)—— blur 在这里真正可见 ═══ */
1508
+ /* v0.6.0 patch: 暗色版从 transparent 52% → 12%(48% → 88% 不透明)。
1509
+ v0.6.0 早期设到 22%(78% 不透明)已被 verify 证伪:青金/冷色调 + 亮色聊天内容
1510
+ 透字仍明显(用户截图「字竖排的 layout bug」整段透出)。现在跟 Bloom 自己的
1511
+ 下拉(transparent 12%)一致。*/
1512
+ body[data-bloomglass-variant] [class*="_menu"],
1513
+ body[data-bloomglass-variant] [class*="_selector"] {
1514
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-2, #fff), transparent 20%);
1515
+ backdrop-filter: blur(28px) saturate(1.4);
1516
+ -webkit-backdrop-filter: blur(28px) saturate(1.4);
1517
+ box-shadow:
1518
+ inset 0 1px 0 rgba(255,255,255,0.22),
1519
+ inset 0 0 0 1px rgba(255,255,255,0.12),
1520
+ 0 20px 56px -18px rgba(0,0,0,0.3);
1521
+ }
1522
+ body[data-ds-dark-theme] [class*="_menu"],
1523
+ body[data-ds-dark-theme] [class*="_selector"] {
1524
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-2, #101010), transparent 12%);
1525
+ box-shadow:
1526
+ inset 0 1px 0 rgba(255,255,255,0.1),
1527
+ inset 0 0 0 1px rgba(255,255,255,0.05),
1528
+ 0 24px 64px -20px rgba(0,0,0,0.6);
1529
+ }
1530
+
1531
+ /* ═══ 设置面板:无需任何覆盖 ═══
1532
+ 这里曾经有 60 行把「279px 窄 drawer」改造成居中 modal 的 CSS,前后改了三轮
1533
+ (v0.6.0 修 → v0.6.1 以「不覆盖 DSH 原生 layout」回滚并记为「去官方提 issue」
1534
+ → 又改回来)。三轮都白做,因为**前提是错的**:
1535
+
1536
+ 实测(2026-08-24,摘掉 Bloom 样式后量的)DSH 原生设置面板本来就是
1537
+ 800×800 的居中 modal,x=464=(1728-800)/2 精确居中,中文描述 383~398px 正常横排。
1538
+ **DSH 没有这个 bug。** 那个「窄 drawer + 中文逐字竖排」是 Bloom 自己造成的回归 ——
1539
+ 见上方 _sidebarCol 的 ::before 注释:侧栏的 backdrop-filter 把设置面板 overlay
1540
+ 的 fixed containing block 从视口换成了 280px 的侧栏。
1541
+
1542
+ 修根因(玻璃移到伪元素)之后,**布局上**这里一行 CSS 都不需要。
1543
+
1544
+ ── 唯一的例外是下面这条,它改的是颜色而不是布局 ── */
1545
+
1546
+ /* DSH 拿**前景色 token 当边框色**用的地方(第二例)。设置面板 Agent 预设的
1547
+ 选中卡片写的是 border-color: var(--dsw-alias-label-primary) —— 那是正文文字色,
1548
+ Bloom 在暗色下给它 oklch(0.96 …) 近白,于是选中卡围了一圈刺眼白边。
1549
+ 选中态本该是主题色。hover 态同样拿 label-dimmed 当边框,一并换成发丝线。 */
1550
+ body[data-bloomglass-variant] [class*="_cardActive"] {
1551
+ border-color: var(--bloom-accent);
1552
+ }
1553
+ body[data-bloomglass-variant] [class*="_card"]:hover:not([class*="_cardActive"]) {
1554
+ border-color: var(--bloom-hairline-strong);
1555
+ }
1556
+
1557
+ /* 「浅色 / 深色 / 跟随系统」选中态的边框:DSH 用 --dsw-static-neutral-bluish-400
1558
+ (#adb2b8)画它。那是 static 层的中性灰阶 —— 绕过了 alias 层,主题的
1559
+ --dsw-alias-border-* 改不到它,于是在莫兰迪暗底上留下一圈刺眼的灰白边
1560
+ (用户实拍反馈「白色边框很突兀」,而且只有选中那一个特别亮)。
1561
+
1562
+ 选中态本该是主题色,这里按 accent 接管。并且**不**整体覆盖
1563
+ --dsw-static-neutral-bluish-400 —— static 是 DSH 的基础色阶,全局改会波及
1564
+ 大量无关组件;只在这个具体组件上纠正,影响面可控。 */
1565
+ body[data-bloomglass-variant] button[class*="_themeCube"][class*="_selected"] {
1566
+ border-color: var(--bloom-accent);
1567
+ }
1568
+
1569
+ body[data-bloomglass-variant] .md-code-block,
1570
+ body[data-bloomglass-variant] [class*="_tableScroll"] {
1571
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #fff), transparent 70%);
1572
+ backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.2);
1573
+ -webkit-backdrop-filter: blur(var(--bloom-glass-blur, 24px)) saturate(1.2);
1574
+ }
1575
+ body[data-ds-dark-theme] .md-code-block,
1576
+ body[data-ds-dark-theme] [class*="_tableScroll"] {
1577
+ background-color: color-mix(in oklch, var(--dsw-alias-bg-layer-1, #101010), transparent 56%);
1578
+ }
1579
+ body[data-bloomglass-variant] .md-code-block pre,
1580
+ body[data-bloomglass-variant] .md-code-block code { background: transparent; }
1581
+
1582
+ /* ═══ 表格内部分隔线加强(v0.9.0)用户截图反馈列线几乎不可见 ═══
1583
+ 原 CSS 用 var(--bloom-hairline)(莫兰迪 30% alpha)做列分隔,
1584
+ 在深色氛围渐变上几乎消失,看起来像没线的「列表」。提到
1585
+ hairline-strong(55% alpha)并给 thead 加一档淡底,列与行都立起来。 */
1586
+ body[data-bloomglass-variant] [class*="_tableScroll"] th,
1587
+ body[data-bloomglass-variant] [class*="_tableScroll"] td {
1588
+ border-color: var(--bloom-hairline-strong);
1589
+ }
1590
+ [class*="_tableScroll"] thead th {
1591
+ background: rgba(var(--bloom-morandi), 0.10);
1592
+ }
1593
+ body[data-ds-dark-theme] [class*="_tableScroll"] thead th {
1594
+ background: rgba(var(--bloom-morandi), 0.08);
1595
+ }
1596
+ [class*="_tableScroll"] tbody tr:nth-child(even) td {
1597
+ background: rgba(var(--bloom-morandi), 0.03);
1598
+ }
1599
+ body[data-ds-dark-theme] [class*="_tableScroll"] tbody tr:nth-child(even) td {
1600
+ background: rgba(var(--bloom-morandi), 0.04);
1601
+ }
1602
+ `;
1603
+ //#endregion
1604
+ //#region src/client/css/palette-css.ts
1605
+ /**
1606
+ * Styles for the palette picker — the one part of the settings panel that is
1607
+ * new in the merge, so it lives apart from the ported `SETTINGS_CSS`.
1608
+ *
1609
+ * Every colour is read from the live theme, so the picker re-tints itself as
1610
+ * soon as a palette is chosen.
1611
+ */
1612
+ const PALETTE_CSS = `
1613
+ .fw-palette {
1614
+ display: grid;
1615
+ grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
1616
+ gap: 8px;
1617
+ min-width: 0;
1618
+ }
1619
+ .fw-palette-item {
1620
+ display: flex;
1621
+ align-items: center;
1622
+ gap: 9px;
1623
+ padding: 8px 10px;
1624
+ border-radius: 10px;
1625
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
1626
+ background: transparent;
1627
+ cursor: pointer;
1628
+ text-align: left;
1629
+ font: inherit;
1630
+ transition: border-color 0.16s ease, background-color 0.16s ease;
1631
+ }
1632
+ .fw-palette-item:hover {
1633
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
1634
+ }
1635
+ .fw-palette-item[aria-selected='true'] {
1636
+ border-color: var(--bloom-accent, #6b8f71);
1637
+ background: color-mix(in oklch, var(--bloom-accent, #6b8f71), transparent 91%);
1638
+ }
1639
+ .fw-palette-item:focus-visible {
1640
+ outline: 2px solid var(--bloom-accent, #6b8f71);
1641
+ outline-offset: 2px;
1642
+ }
1643
+ .fw-palette-dot {
1644
+ width: 16px;
1645
+ height: 16px;
1646
+ border-radius: 50%;
1647
+ flex: none;
1648
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.14);
1649
+ }
1650
+ .fw-palette-text {
1651
+ display: flex;
1652
+ flex-direction: column;
1653
+ gap: 1px;
1654
+ min-width: 0;
1655
+ }
1656
+ .fw-palette-name {
1657
+ font-size: 13px;
1658
+ line-height: 1.3;
1659
+ color: var(--dsw-alias-label-primary);
1660
+ }
1661
+ .fw-palette-poem {
1662
+ font-size: 11px;
1663
+ line-height: 1.3;
1664
+ color: var(--dsw-alias-label-tertiary);
1665
+ white-space: nowrap;
1666
+ overflow: hidden;
1667
+ text-overflow: ellipsis;
1668
+ }
1669
+ .fw-subhead {
1670
+ font-size: 12px;
1671
+ font-weight: 600;
1672
+ color: var(--dsw-alias-label-secondary);
1673
+ margin: 2px 0 -4px;
1674
+ }
1675
+ `.trim();
1676
+ //#endregion
1677
+ //#region src/client/image.ts
1678
+ /** Build a Blob for object URLs, keeping the original encoded type. */
1679
+ function wallpaperBlob(record) {
1680
+ const type = ALLOWED_TYPES.includes(record.mime) ? record.mime : "image/jpeg";
1681
+ return new Blob([Uint8Array.from(record.bytes)], { type });
1682
+ }
1683
+ var ImageValidationError = class extends Error {
1684
+ name = "ImageValidationError";
1685
+ };
1686
+ const MAGIC = [
1687
+ {
1688
+ mime: "image/jpeg",
1689
+ test: (b) => b[0] === 255 && b[1] === 216 && b[2] === 255
1690
+ },
1691
+ {
1692
+ mime: "image/png",
1693
+ test: (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71
1694
+ },
1695
+ {
1696
+ mime: "image/gif",
1697
+ test: (b) => b[0] === 71 && b[1] === 73 && b[2] === 70 && b[3] === 56
1698
+ },
1699
+ {
1700
+ mime: "image/webp",
1701
+ test: (b) => b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80
1702
+ }
1703
+ ];
1704
+ function normalizeImageType(type) {
1705
+ if (type === "image/jpg") return "image/jpeg";
1706
+ return ALLOWED_TYPES.find((allowed) => allowed === type);
1707
+ }
1708
+ /**
1709
+ * Reject files that are not a supported image. Size is intentionally unbounded.
1710
+ * @param file - browser File from an <input> or drop.
1711
+ */
1712
+ function assertImageFile(file) {
1713
+ if (file.size <= 0) throw new ImageValidationError("empty image");
1714
+ if (file.type !== "" && normalizeImageType(file.type) === void 0) throw new ImageValidationError(`unsupported type: ${file.type}`);
1715
+ }
1716
+ /** Confirm the file header matches a declared or inferred image type. */
1717
+ async function assertImageMagic(file) {
1718
+ const header = new Uint8Array(await readFileBytes(file.slice(0, 16)));
1719
+ const declared = normalizeImageType(file.type);
1720
+ const match = MAGIC.find((entry) => entry.test(header));
1721
+ if (match === void 0) throw new ImageValidationError(`unsupported type: ${file.type || "unknown"}`);
1722
+ if (declared !== void 0 && declared !== match.mime) throw new ImageValidationError(`unsupported type: ${file.type}`);
1723
+ return match.mime;
1724
+ }
1725
+ /** Read width/height from the container when the header is well-formed. */
1726
+ function readImageSize(bytes) {
1727
+ if (bytes.length >= 24 && MAGIC[1].test(bytes)) return {
1728
+ width: readU32(bytes, 16),
1729
+ height: readU32(bytes, 20)
1730
+ };
1731
+ if (bytes.length >= 10 && MAGIC[2].test(bytes)) return {
1732
+ width: bytes[6] | bytes[7] << 8,
1733
+ height: bytes[8] | bytes[9] << 8
1734
+ };
1735
+ if (bytes.length >= 30 && MAGIC[3].test(bytes)) return readWebpSize(bytes);
1736
+ if (bytes.length >= 4 && MAGIC[0].test(bytes)) return readJpegSize(bytes);
1737
+ }
1738
+ /** Accept a record that came back from IndexedDB. */
1739
+ function sanitizeWallpaperRecord(raw) {
1740
+ if (raw === null || typeof raw !== "object") return void 0;
1741
+ const value = raw;
1742
+ if (!Array.isArray(value.bytes) || value.bytes.length < 1) return void 0;
1743
+ const mime = normalizeImageType(String(value.mime ?? "")) ?? "image/jpeg";
1744
+ const width = Number(value.width);
1745
+ const height = Number(value.height);
1746
+ return {
1747
+ bytes: value.bytes.map((item) => Number(item) & 255),
1748
+ mime,
1749
+ name: typeof value.name === "string" ? value.name : "wallpaper",
1750
+ width: Number.isFinite(width) && width > 0 ? width : 0,
1751
+ height: Number.isFinite(height) && height > 0 ? height : 0,
1752
+ updatedAt: Number(value.updatedAt) || 0
1753
+ };
1754
+ }
1755
+ /**
1756
+ * Keep the original encoded image. No resize and no byte cap.
1757
+ */
1758
+ async function prepareWallpaper(file) {
1759
+ assertImageFile(file);
1760
+ const mime = await assertImageMagic(file);
1761
+ const source = new Uint8Array(await readFileBytes(file));
1762
+ const declared = readImageSize(source);
1763
+ return {
1764
+ bytes: Array.from(source),
1765
+ mime,
1766
+ name: file.name,
1767
+ width: declared?.width ?? 0,
1768
+ height: declared?.height ?? 0,
1769
+ updatedAt: Date.now()
1770
+ };
1771
+ }
1772
+ function readU32(bytes, offset) {
1773
+ return (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
1774
+ }
1775
+ function readWebpSize(bytes) {
1776
+ const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
1777
+ if (fourcc === "VP8X" && bytes.length >= 30) return {
1778
+ width: 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16),
1779
+ height: 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16)
1780
+ };
1781
+ if (fourcc === "VP8 " && bytes.length >= 30 && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return {
1782
+ width: (bytes[26] | bytes[27] << 8) & 16383,
1783
+ height: (bytes[28] | bytes[29] << 8) & 16383
1784
+ };
1785
+ if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
1786
+ const bits = bytes[21] | bytes[22] << 8 | bytes[23] << 16 | bytes[24] << 24;
1787
+ return {
1788
+ width: (bits & 16383) + 1,
1789
+ height: (bits >> 14 & 16383) + 1
1790
+ };
1791
+ }
1792
+ }
1793
+ function readJpegSize(bytes) {
1794
+ let offset = 2;
1795
+ while (offset + 8 < bytes.length) {
1796
+ if (bytes[offset] !== 255) return void 0;
1797
+ const marker = bytes[offset + 1];
1798
+ offset += 2;
1799
+ if (marker === 216 || marker === 217 || marker >= 208 && marker <= 215) continue;
1800
+ const length = bytes[offset] << 8 | bytes[offset + 1];
1801
+ if (length < 2) return void 0;
1802
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
1803
+ height: bytes[offset + 3] << 8 | bytes[offset + 4],
1804
+ width: bytes[offset + 5] << 8 | bytes[offset + 6]
1805
+ };
1806
+ offset += length;
1807
+ }
1808
+ }
1809
+ function readFileBytes(file) {
1810
+ if (typeof file.arrayBuffer === "function") return file.arrayBuffer();
1811
+ return new Promise((resolve, reject) => {
1812
+ const reader = new FileReader();
1813
+ reader.onload = () => {
1814
+ resolve(reader.result);
1815
+ };
1816
+ reader.onerror = () => {
1817
+ reject(reader.error ?? /* @__PURE__ */ new Error("failed to read image"));
1818
+ };
1819
+ reader.readAsArrayBuffer(file);
1820
+ });
1821
+ }
1822
+ //#endregion
1823
+ //#region src/client/image-store.ts
1824
+ /**
1825
+ * Open the IndexedDB-backed store. The database is created on first use.
1826
+ *
1827
+ * A wallpaper saved by dsh-frosted-window is adopted on first read, so moving
1828
+ * to the merged plugin does not lose the user's image. The legacy database is
1829
+ * never written to and never created: we only look inside it if IndexedDB
1830
+ * reports that it already exists.
1831
+ */
1832
+ function openImageStore() {
1833
+ return {
1834
+ get: async () => {
1835
+ const stored = await withStore(IMAGE_DB, "readonly", (store) => requestToPromise(store.get(IMAGE_KEY)));
1836
+ if (stored !== void 0) return hydrate(stored);
1837
+ return hydrate(await readLegacy$1());
1838
+ },
1839
+ put: (record) => withStore(IMAGE_DB, "readwrite", (store) => requestToPromise(store.put({
1840
+ bytes: record.bytes,
1841
+ mime: record.mime,
1842
+ name: record.name,
1843
+ width: record.width,
1844
+ height: record.height,
1845
+ updatedAt: record.updatedAt
1846
+ }, IMAGE_KEY)).then(() => void 0)),
1847
+ clear: () => withStore(IMAGE_DB, "readwrite", (store) => requestToPromise(store.delete(IMAGE_KEY)).then(() => void 0))
1848
+ };
1849
+ }
1850
+ /** Read the predecessor plugin's wallpaper, if that database exists. */
1851
+ async function readLegacy$1() {
1852
+ if (typeof indexedDB.databases !== "function") return void 0;
1853
+ try {
1854
+ if (!(await indexedDB.databases()).some((entry) => entry.name === "dsh-frosted-window")) return void 0;
1855
+ return await withStore(LEGACY_IMAGE_DB, "readonly", (store) => requestToPromise(store.get(IMAGE_KEY)));
1856
+ } catch {
1857
+ return;
1858
+ }
1859
+ }
1860
+ function hydrate(stored) {
1861
+ return sanitizeWallpaperRecord(stored);
1862
+ }
1863
+ async function withStore(database, mode, use) {
1864
+ const db = await openDb(database);
1865
+ try {
1866
+ const tx = db.transaction(IMAGE_STORE, mode);
1867
+ const result = await use(tx.objectStore(IMAGE_STORE));
1868
+ await txDone(tx);
1869
+ return result;
1870
+ } finally {
1871
+ db.close();
1872
+ }
1873
+ }
1874
+ function openDb(database) {
1875
+ return new Promise((resolve, reject) => {
1876
+ const req = indexedDB.open(database, 1);
1877
+ req.onupgradeneeded = () => {
1878
+ if (!req.result.objectStoreNames.contains("files")) req.result.createObjectStore(IMAGE_STORE);
1879
+ };
1880
+ req.onsuccess = () => {
1881
+ resolve(req.result);
1882
+ };
1883
+ req.onerror = () => {
1884
+ reject(req.error ?? /* @__PURE__ */ new Error("indexedDB open failed"));
1885
+ };
1886
+ });
1887
+ }
1888
+ function requestToPromise(req) {
1889
+ return new Promise((resolve, reject) => {
1890
+ req.onsuccess = () => {
1891
+ resolve(req.result);
1892
+ };
1893
+ req.onerror = () => {
1894
+ reject(req.error ?? /* @__PURE__ */ new Error("indexedDB request failed"));
1895
+ };
1896
+ });
1897
+ }
1898
+ function txDone(tx) {
1899
+ return new Promise((resolve, reject) => {
1900
+ tx.oncomplete = () => {
1901
+ resolve();
1902
+ };
1903
+ tx.onerror = () => {
1904
+ reject(tx.error ?? /* @__PURE__ */ new Error("indexedDB transaction failed"));
1905
+ };
1906
+ tx.onabort = () => {
1907
+ reject(tx.error ?? /* @__PURE__ */ new Error("indexedDB transaction aborted"));
1908
+ };
1909
+ });
1910
+ }
1911
+ //#endregion
1912
+ //#region src/client/palette.ts
1913
+ /**
1914
+ * 10 套莫兰迪配色的色板与标签 —— 纯数据 + 一个色彩工具函数,不 import 任何东西。
1915
+ *
1916
+ * **双轨制不能退回单轨**(见 CONTRIBUTING「改配色时注意」):
1917
+ * accentL / accentD 可读轨 —— 文字、按钮填充、边框,必须过 WCAG AA
1918
+ * morandi 气质轨 —— 只用于 rgba(morandi, 0.05~0.2) 的大面积氛围渐变、
1919
+ * 冷光细线、边框阶梯(暗色档)
1920
+ * 拿可读轨铺大面积、或拿气质轨做文字色,都会失去莫兰迪质感。
1921
+ */
1922
+ const VARIANTS = [
1923
+ "mist",
1924
+ "cinnabar",
1925
+ "petal",
1926
+ "ripple",
1927
+ "sage",
1928
+ "stone",
1929
+ "lapis",
1930
+ "amber",
1931
+ "aurora",
1932
+ "lavender"
1933
+ ];
1934
+ /** Narrow an unknown value to a palette key, for values read back from storage. */
1935
+ function isVariant(value) {
1936
+ return typeof value === "string" && VARIANTS.includes(value);
1937
+ }
1938
+ const PALETTE = {
1939
+ mist: {
1940
+ accentL: "oklch(50% 0.08 240)",
1941
+ accentD: "oklch(72% 0.12 240)",
1942
+ morandi: "146, 168, 179",
1943
+ bgL: "oklch(96% 0.01 240)",
1944
+ bgD: "oklch(28% 0.02 240)",
1945
+ txL: "oklch(25% 0.02 240)",
1946
+ txD: "oklch(96% 0.01 240)",
1947
+ sfL: "oklch(94% 0.01 240)",
1948
+ sfD: "oklch(34% 0.02 240)",
1949
+ sf2L: "oklch(91% 0.01 240)",
1950
+ sf2D: "oklch(40% 0.02 240)",
1951
+ motionL: [
1952
+ "oklch(50% 0.08 240)",
1953
+ "oklch(50% 0.10 210)",
1954
+ "oklch(50% 0.09 275)"
1955
+ ],
1956
+ motionD: [
1957
+ "oklch(72% 0.12 240)",
1958
+ "oklch(74% 0.13 210)",
1959
+ "oklch(74% 0.11 275)"
1960
+ ]
1961
+ },
1962
+ cinnabar: {
1963
+ accentL: "oklch(55% 0.18 25)",
1964
+ accentD: "oklch(72% 0.12 25)",
1965
+ morandi: "215, 75, 75",
1966
+ bgL: "oklch(97% 0.005 25)",
1967
+ bgD: "oklch(28% 0.02 25)",
1968
+ txL: "oklch(25% 0.02 25)",
1969
+ txD: "oklch(96% 0.01 25)",
1970
+ sfL: "oklch(95% 0.005 25)",
1971
+ sfD: "oklch(34% 0.02 25)",
1972
+ sf2L: "oklch(92% 0.005 25)",
1973
+ sf2D: "oklch(40% 0.02 25)",
1974
+ motionL: [
1975
+ "oklch(55% 0.18 25)",
1976
+ "oklch(55% 0.16 65)",
1977
+ "oklch(55% 0.15 350)"
1978
+ ],
1979
+ motionD: [
1980
+ "oklch(72% 0.12 25)",
1981
+ "oklch(74% 0.14 65)",
1982
+ "oklch(73% 0.13 350)"
1983
+ ]
1984
+ },
1985
+ petal: {
1986
+ accentL: "oklch(58% 0.22 350)",
1987
+ accentD: "oklch(75% 0.18 350)",
1988
+ morandi: "232, 133, 155",
1989
+ bgL: "oklch(98% 0.01 350)",
1990
+ bgD: "oklch(28% 0.02 350)",
1991
+ txL: "oklch(25% 0.02 354)",
1992
+ txD: "oklch(98% 0.01 350)",
1993
+ sfL: "oklch(96% 0.015 350)",
1994
+ sfD: "oklch(34% 0.02 350)",
1995
+ sf2L: "oklch(94% 0.015 350)",
1996
+ sf2D: "oklch(40% 0.02 350)",
1997
+ motionL: [
1998
+ "oklch(58% 0.22 350)",
1999
+ "oklch(58% 0.17 310)",
2000
+ "oklch(58% 0.17 20)"
2001
+ ],
2002
+ motionD: [
2003
+ "oklch(75% 0.18 350)",
2004
+ "oklch(75% 0.14 310)",
2005
+ "oklch(76% 0.14 20)"
2006
+ ]
2007
+ },
2008
+ ripple: {
2009
+ accentL: "oklch(51% 0.12 195)",
2010
+ accentD: "oklch(75% 0.12 195)",
2011
+ morandi: "95, 168, 178",
2012
+ bgL: "oklch(96% 0.01 195)",
2013
+ bgD: "oklch(20% 0.02 195)",
2014
+ txL: "oklch(25% 0.02 195)",
2015
+ txD: "oklch(96% 0.01 195)",
2016
+ sfL: "oklch(94% 0.01 195)",
2017
+ sfD: "oklch(28% 0.02 195)",
2018
+ sf2L: "oklch(92% 0.01 195)",
2019
+ sf2D: "oklch(38% 0.02 195)",
2020
+ motionL: [
2021
+ "oklch(51% 0.12 195)",
2022
+ "oklch(51% 0.13 225)",
2023
+ "oklch(51% 0.11 165)"
2024
+ ],
2025
+ motionD: [
2026
+ "oklch(75% 0.12 195)",
2027
+ "oklch(76% 0.14 225)",
2028
+ "oklch(77% 0.12 165)"
2029
+ ]
2030
+ },
2031
+ sage: {
2032
+ accentL: "oklch(54.1% 0.111 115)",
2033
+ accentD: "oklch(71.9% 0.120 115)",
2034
+ morandi: "138, 154, 91",
2035
+ bgL: "oklch(97% 0.011 112)",
2036
+ bgD: "oklch(20% 0.019 113)",
2037
+ txL: "oklch(25% 0.02 116)",
2038
+ txD: "oklch(96% 0.011 118)",
2039
+ sfL: "oklch(94.9% 0.009 113)",
2040
+ sfD: "oklch(27.9% 0.02 116)",
2041
+ sf2L: "oklch(91.9% 0.009 113)",
2042
+ sf2D: "oklch(34% 0.03 116)",
2043
+ motionL: [
2044
+ "oklch(54.1% 0.111 115)",
2045
+ "oklch(54.1% 0.12 83)",
2046
+ "oklch(54.1% 0.10 152)"
2047
+ ],
2048
+ motionD: [
2049
+ "oklch(71.9% 0.120 115)",
2050
+ "oklch(71.9% 0.13 83)",
2051
+ "oklch(71.9% 0.11 152)"
2052
+ ]
2053
+ },
2054
+ stone: {
2055
+ accentL: "oklch(49.9% 0.06 29)",
2056
+ accentD: "oklch(75% 0.12 30)",
2057
+ morandi: "180, 160, 155",
2058
+ bgL: "oklch(95.9% 0.01 25)",
2059
+ bgD: "oklch(20.1% 0.019 30)",
2060
+ txL: "oklch(25% 0.02 29)",
2061
+ txD: "oklch(95.9% 0.01 25)",
2062
+ sfL: "oklch(94.1% 0.01 33)",
2063
+ sfD: "oklch(27.9% 0.02 28)",
2064
+ sf2L: "oklch(91% 0.01 33)",
2065
+ sf2D: "oklch(33.9% 0.03 28)",
2066
+ motionL: [
2067
+ "oklch(49.9% 0.06 29)",
2068
+ "oklch(49.9% 0.075 0)",
2069
+ "oklch(49.9% 0.07 60)"
2070
+ ],
2071
+ motionD: [
2072
+ "oklch(75% 0.12 30)",
2073
+ "oklch(75% 0.13 0)",
2074
+ "oklch(75% 0.11 62)"
2075
+ ]
2076
+ },
2077
+ lapis: {
2078
+ accentL: "oklch(50% 0.13 258)",
2079
+ accentD: "oklch(74% 0.10 255)",
2080
+ morandi: "47, 98, 172",
2081
+ bgL: "oklch(97.4% 0.006 240)",
2082
+ bgD: "oklch(23.1% 0.019 249)",
2083
+ txL: "oklch(23% 0.02 249)",
2084
+ txD: "oklch(96.1% 0.008 237)",
2085
+ sfL: "oklch(95.6% 0.008 242)",
2086
+ sfD: "oklch(30.1% 0.022 251)",
2087
+ sf2L: "oklch(92.4% 0.013 244)",
2088
+ sf2D: "oklch(35.9% 0.025 251)",
2089
+ motionL: [
2090
+ "oklch(50% 0.13 258)",
2091
+ "oklch(50% 0.12 226)",
2092
+ "oklch(50% 0.11 295)"
2093
+ ],
2094
+ motionD: [
2095
+ "oklch(74% 0.10 255)",
2096
+ "oklch(74% 0.11 225)",
2097
+ "oklch(74% 0.10 292)"
2098
+ ]
2099
+ },
2100
+ amber: {
2101
+ accentL: "oklch(55.5% 0.12 70)",
2102
+ accentD: "oklch(78% 0.11 70)",
2103
+ morandi: "159, 100, 1",
2104
+ bgL: "oklch(97.5% 0.008 74)",
2105
+ bgD: "oklch(26% 0.018 60)",
2106
+ txL: "oklch(24% 0.02 74)",
2107
+ txD: "oklch(95% 0.008 70)",
2108
+ sfL: "oklch(95.6% 0.01 82)",
2109
+ sfD: "oklch(32% 0.020 60)",
2110
+ sf2L: "oklch(93% 0.014 78)",
2111
+ sf2D: "oklch(38% 0.022 60)",
2112
+ motionL: [
2113
+ "oklch(55.5% 0.12 70)",
2114
+ "oklch(55.5% 0.11 38)",
2115
+ "oklch(55.5% 0.12 100)"
2116
+ ],
2117
+ motionD: [
2118
+ "oklch(78% 0.11 70)",
2119
+ "oklch(78% 0.12 38)",
2120
+ "oklch(78% 0.11 100)"
2121
+ ]
2122
+ },
2123
+ aurora: {
2124
+ accentL: "oklch(55% 0.145 72)",
2125
+ accentD: "oklch(80% 0.13 72)",
2126
+ morandi: "218, 158, 72",
2127
+ bgL: "oklch(98% 0.004 72)",
2128
+ bgD: "oklch(24.5% 0.008 70)",
2129
+ txL: "oklch(24% 0.012 72)",
2130
+ txD: "oklch(95% 0.005 72)",
2131
+ sfL: "oklch(96.2% 0.005 72)",
2132
+ sfD: "oklch(30.5% 0.01 70)",
2133
+ sf2L: "oklch(93.4% 0.007 72)",
2134
+ sf2D: "oklch(36.5% 0.012 70)",
2135
+ motionL: [
2136
+ "oklch(55% 0.145 72)",
2137
+ "oklch(56% 0.15 50)",
2138
+ "oklch(56% 0.13 32)"
2139
+ ],
2140
+ motionD: [
2141
+ "oklch(80% 0.13 72)",
2142
+ "oklch(80% 0.14 50)",
2143
+ "oklch(80% 0.12 32)"
2144
+ ]
2145
+ },
2146
+ lavender: {
2147
+ accentL: "oklch(52% 0.11 295)",
2148
+ accentD: "oklch(75% 0.11 295)",
2149
+ morandi: "164, 148, 190",
2150
+ bgL: "oklch(97% 0.012 295)",
2151
+ bgD: "oklch(26% 0.02 295)",
2152
+ txL: "oklch(25% 0.02 295)",
2153
+ txD: "oklch(96% 0.012 295)",
2154
+ sfL: "oklch(95% 0.014 295)",
2155
+ sfD: "oklch(32% 0.022 295)",
2156
+ sf2L: "oklch(92.5% 0.016 295)",
2157
+ sf2D: "oklch(38% 0.024 295)",
2158
+ motionL: [
2159
+ "oklch(52% 0.11 295)",
2160
+ "oklch(52% 0.10 265)",
2161
+ "oklch(53% 0.12 325)"
2162
+ ],
2163
+ motionD: [
2164
+ "oklch(75% 0.11 295)",
2165
+ "oklch(75% 0.11 265)",
2166
+ "oklch(76% 0.12 325)"
2167
+ ]
2168
+ }
2169
+ };
2170
+ const VARIANT_LABELS = {
2171
+ mist: {
2172
+ zh: "黛蓝",
2173
+ en: "Mist",
2174
+ poem: "山色有无中"
2175
+ },
2176
+ cinnabar: {
2177
+ zh: "朱砂",
2178
+ en: "Cinnabar",
2179
+ poem: "日出江花红胜火"
2180
+ },
2181
+ petal: {
2182
+ zh: "桃夭",
2183
+ en: "Petal",
2184
+ poem: "灼灼其华"
2185
+ },
2186
+ ripple: {
2187
+ zh: "天青",
2188
+ en: "Ripple",
2189
+ poem: "雨过天青云破处"
2190
+ },
2191
+ sage: {
2192
+ zh: "竹青",
2193
+ en: "Sage",
2194
+ poem: "绿竹猗猗"
2195
+ },
2196
+ stone: {
2197
+ zh: "赭石",
2198
+ en: "Stone",
2199
+ poem: "秋山敛余照"
2200
+ },
2201
+ lapis: {
2202
+ zh: "青金",
2203
+ en: "Lapis",
2204
+ poem: "碧海青天夜夜心"
2205
+ },
2206
+ amber: {
2207
+ zh: "琥珀",
2208
+ en: "Amber",
2209
+ poem: "玉碗盛来琥珀光"
2210
+ },
2211
+ aurora: {
2212
+ zh: "落霞",
2213
+ en: "Afterglow",
2214
+ poem: "落霞与孤鹜齐飞"
2215
+ },
2216
+ lavender: {
2217
+ zh: "青莲",
2218
+ en: "Lavender",
2219
+ poem: "清水出芙蓉"
2220
+ }
2221
+ };
2222
+ /** oklch 混透明度的简写(在 oklch 空间里混合,色相/彩度不漂移) */
2223
+ const mix = (c, p) => `color-mix(in oklch, ${c}, transparent ${p}%)`;
2224
+ //#endregion
2225
+ //#region src/client/knobs.ts
2226
+ /**
2227
+ * User-tunable knobs — one record now covers both halves of the merge.
2228
+ *
2229
+ * The palette key (Bloom) and the glass / wallpaper numbers (Frosted) live
2230
+ * together because they are saved together: a theme is the pair of them.
2231
+ * Image bytes never live here — those go to IndexedDB.
2232
+ */
2233
+ const DEFAULT_KNOBS = {
2234
+ enabled: true,
2235
+ variant: "mist",
2236
+ glassOpacity: .46,
2237
+ blurPx: 28,
2238
+ saturate: 1.55,
2239
+ dim: .28
2240
+ };
2241
+ /** Numeric knob bounds, shared by the clamps and the settings sliders. */
2242
+ const KNOB_RANGES = {
2243
+ glassOpacity: [.18, .82],
2244
+ blurPx: [8, 64],
2245
+ saturate: [1, 2],
2246
+ dim: [0, .65]
2247
+ };
2248
+ /**
2249
+ * Clamp one numeric knob into its published range.
2250
+ * @param key - numeric knob name.
2251
+ * @param value - raw number.
2252
+ */
2253
+ function clampKnob(key, value) {
2254
+ const [min, max] = KNOB_RANGES[key];
2255
+ if (!Number.isFinite(value)) return DEFAULT_KNOBS[key];
2256
+ return Math.min(max, Math.max(min, value));
2257
+ }
2258
+ /**
2259
+ * Normalize a partial / unknown record into a complete knob set.
2260
+ * @param raw - persisted JSON or UI draft.
2261
+ */
2262
+ function normalizeKnobs(raw) {
2263
+ const input = raw !== null && typeof raw === "object" ? raw : {};
2264
+ return {
2265
+ enabled: input.enabled !== false,
2266
+ variant: isVariant(input.variant) ? input.variant : DEFAULT_KNOBS.variant,
2267
+ glassOpacity: clampKnob("glassOpacity", Number(input.glassOpacity)),
2268
+ blurPx: clampKnob("blurPx", Number(input.blurPx)),
2269
+ saturate: clampKnob("saturate", Number(input.saturate)),
2270
+ dim: clampKnob("dim", Number(input.dim))
2271
+ };
2272
+ }
2273
+ /**
2274
+ * Read the palette and glass numbers the two predecessor plugins persisted,
2275
+ * so moving to the merged plugin does not reset a tuned theme.
2276
+ *
2277
+ * Best-effort: any failure just yields nothing and defaults take over.
2278
+ */
2279
+ function readLegacy() {
2280
+ const legacy = {};
2281
+ try {
2282
+ const variant = localStorage.getItem(LEGACY_VARIANT_KEY);
2283
+ if (isVariant(variant)) legacy.variant = variant;
2284
+ } catch {}
2285
+ try {
2286
+ const raw = localStorage.getItem(LEGACY_KNOBS_KEY);
2287
+ if (raw !== null) Object.assign(legacy, JSON.parse(raw));
2288
+ } catch {}
2289
+ return legacy;
2290
+ }
2291
+ /** Read knobs from localStorage; missing or corrupt values become defaults. */
2292
+ function loadKnobs() {
2293
+ try {
2294
+ const raw = localStorage.getItem(KNOBS_KEY);
2295
+ if (raw !== null) return normalizeKnobs(JSON.parse(raw));
2296
+ } catch {}
2297
+ return normalizeKnobs(readLegacy());
2298
+ }
2299
+ /** Persist a complete knob set. Failures stay local (private mode / quota). */
2300
+ function saveKnobs(knobs) {
2301
+ try {
2302
+ localStorage.setItem(KNOBS_KEY, JSON.stringify(normalizeKnobs(knobs)));
2303
+ } catch {}
2304
+ }
2305
+ //#endregion
2306
+ //#region src/client/locales.ts
2307
+ /**
2308
+ * Settings copy. Chinese is the key-set source of truth.
2309
+ *
2310
+ * Palette names are not here — they come from `palette.ts`, which carries the
2311
+ * Chinese name, the English name, and the source poem for all ten palettes.
2312
+ */
2313
+ const zh = {
2314
+ nav: "Bloom Glass",
2315
+ title: "Bloom Glass 主题",
2316
+ description: "十套莫兰迪配色打底,可选一张壁纸铺满窗口、界面以磨砂玻璃叠上去。浅色 / 深色仍跟随官方外观。",
2317
+ palette: "配色",
2318
+ frost: "壁纸与玻璃",
2319
+ enable: "启用壁纸与磨砂",
2320
+ drop: "把图片拖到这里,或点击选择",
2321
+ dropReplace: "更换图片",
2322
+ choose: "选择图片",
2323
+ save: "保存",
2324
+ saved: "已保存",
2325
+ unsaved: "未保存",
2326
+ remove: "删除",
2327
+ glass: "玻璃浓度",
2328
+ blur: "磨砂模糊",
2329
+ saturate: "色彩饱和",
2330
+ dim: "壁纸压暗",
2331
+ busy: "正在读取图片…",
2332
+ empty: "还没有壁纸,当前为纯配色模式",
2333
+ errorType: "只支持 JPEG、PNG、WebP 或 GIF。",
2334
+ errorGeneric: "无法读取这张图片,请换一张再试。"
2335
+ };
2336
+ const en = {
2337
+ nav: "Bloom Glass",
2338
+ title: "Bloom Glass theme",
2339
+ description: "Ten Morandi palettes underneath, and optionally one wallpaper filling the window with the chrome frosted on top. Light / Dark still follow official appearance.",
2340
+ palette: "Palette",
2341
+ frost: "Wallpaper & glass",
2342
+ enable: "Enable wallpaper and frost",
2343
+ drop: "Drop an image here, or click to choose",
2344
+ dropReplace: "Replace image",
2345
+ choose: "Choose image",
2346
+ save: "Save",
2347
+ saved: "Saved",
2348
+ unsaved: "Unsaved",
2349
+ remove: "Delete",
2350
+ glass: "Glass",
2351
+ blur: "Blur",
2352
+ saturate: "Saturation",
2353
+ dim: "Dim",
2354
+ busy: "Reading image…",
2355
+ empty: "No wallpaper yet — palette only",
2356
+ errorType: "JPEG, PNG, WebP, or GIF only.",
2357
+ errorGeneric: "Could not read that image. Try another file."
2358
+ };
2359
+ //#endregion
2360
+ //#region src/client/SettingsSection.tsx
2361
+ /**
2362
+ * The single settings page for the merged theme: palette picker on top,
2363
+ * wallpaper and glass controls underneath. Replaces Bloom's top-bar switcher
2364
+ * and Frosted's panel with one surface.
2365
+ * @param props - inject face from apply().
2366
+ */
2367
+ function SettingsSection({ store, t, setVariant, setEnabled, setKnob, upload, save, remove }) {
2368
+ const state = (0, react.useSyncExternalStore)(store.subscribe, store.get, store.get);
2369
+ const inputRef = (0, react.useRef)(null);
2370
+ const [over, setOver] = (0, react.useState)(false);
2371
+ const onFiles = (files) => {
2372
+ if (state.busy) return;
2373
+ const file = files?.[0];
2374
+ if (file === void 0) return;
2375
+ upload(file);
2376
+ };
2377
+ const pick = () => {
2378
+ if (!state.busy) inputRef.current?.click();
2379
+ };
2380
+ const previewStyle = {
2381
+ "--fw-ui-glass": String(state.glassOpacity),
2382
+ "--fw-ui-blur": `${state.blurPx}px`,
2383
+ "--fw-ui-sat": `${Math.round(state.saturate * 100)}%`
2384
+ };
2385
+ const meta = [state.fileName, state.width > 0 && state.height > 0 ? `${state.width}×${state.height}` : null].filter(Boolean).join(" · ");
2386
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2387
+ className: "fw-section",
2388
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2389
+ className: "fw-panel",
2390
+ children: [
2391
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2392
+ className: "fw-head",
2393
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2394
+ className: "fw-lead",
2395
+ children: [
2396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2397
+ className: "fw-kicker",
2398
+ children: "Theme"
2399
+ }),
2400
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2401
+ className: "fw-title",
2402
+ children: t("title")
2403
+ }),
2404
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2405
+ className: "fw-desc",
2406
+ children: t("description")
2407
+ })
2408
+ ]
2409
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2410
+ className: "fw-chip",
2411
+ "data-tone": state.dirty ? "warn" : void 0,
2412
+ children: state.dirty ? t("unsaved") : t("saved")
2413
+ })]
2414
+ }),
2415
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2416
+ className: "fw-subhead",
2417
+ children: t("palette")
2418
+ }),
2419
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2420
+ className: "fw-palette",
2421
+ role: "radiogroup",
2422
+ "aria-label": t("palette"),
2423
+ children: VARIANTS.map((variant) => {
2424
+ const label = VARIANT_LABELS[variant];
2425
+ const selected = state.variant === variant;
2426
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2427
+ type: "button",
2428
+ role: "radio",
2429
+ "aria-checked": selected,
2430
+ "aria-selected": selected,
2431
+ className: "fw-palette-item",
2432
+ "data-variant": variant,
2433
+ onClick: () => {
2434
+ setVariant(variant);
2435
+ },
2436
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2437
+ className: "fw-palette-dot",
2438
+ style: { background: PALETTE[variant].accentL }
2439
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2440
+ className: "fw-palette-text",
2441
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2442
+ className: "fw-palette-name",
2443
+ children: label.zh
2444
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2445
+ className: "fw-palette-poem",
2446
+ children: label.poem
2447
+ })]
2448
+ })]
2449
+ }, variant);
2450
+ })
2451
+ }),
2452
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2453
+ className: "fw-subhead",
2454
+ children: t("frost")
2455
+ }),
2456
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2457
+ className: "fw-switch",
2458
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("enable") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2459
+ type: "checkbox",
2460
+ checked: state.enabled,
2461
+ onChange: (event) => {
2462
+ setEnabled(event.target.checked);
2463
+ }
2464
+ })]
2465
+ }),
2466
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2467
+ type: "button",
2468
+ className: "fw-hero",
2469
+ style: previewStyle,
2470
+ "data-over": over ? "true" : "false",
2471
+ "data-has": state.hasImage ? "true" : "false",
2472
+ disabled: state.busy,
2473
+ onClick: pick,
2474
+ onDragOver: (event) => {
2475
+ event.preventDefault();
2476
+ setOver(true);
2477
+ },
2478
+ onDragLeave: () => {
2479
+ setOver(false);
2480
+ },
2481
+ onDrop: (event) => {
2482
+ event.preventDefault();
2483
+ setOver(false);
2484
+ onFiles(event.dataTransfer.files);
2485
+ },
2486
+ children: [
2487
+ state.previewUrl !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
2488
+ src: state.previewUrl,
2489
+ alt: ""
2490
+ }) : null,
2491
+ state.hasImage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fw-hero-glass" }) : null,
2492
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2493
+ className: "fw-hero-copy",
2494
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: state.busy ? t("busy") : state.hasImage ? t("dropReplace") : t("drop") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: state.hasImage ? meta || t("dropReplace") : t("empty") })]
2495
+ })
2496
+ ]
2497
+ }),
2498
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2499
+ ref: inputRef,
2500
+ className: "fw-hidden",
2501
+ type: "file",
2502
+ accept: "image/jpeg,image/jpg,image/png,image/webp,image/gif,image/*",
2503
+ onChange: (event) => {
2504
+ onFiles(event.target.files);
2505
+ event.target.value = "";
2506
+ }
2507
+ }),
2508
+ state.error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2509
+ className: "fw-error",
2510
+ role: "alert",
2511
+ children: state.error
2512
+ }) : null,
2513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2514
+ className: "fw-grid",
2515
+ children: [
2516
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Slider, {
2517
+ label: t("glass"),
2518
+ value: state.glassOpacity,
2519
+ range: KNOB_RANGES.glassOpacity,
2520
+ step: .01,
2521
+ display: `${Math.round(state.glassOpacity * 100)}%`,
2522
+ onChange: (value) => {
2523
+ setKnob("glassOpacity", value);
2524
+ }
2525
+ }),
2526
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Slider, {
2527
+ label: t("blur"),
2528
+ value: state.blurPx,
2529
+ range: KNOB_RANGES.blurPx,
2530
+ step: 1,
2531
+ display: `${Math.round(state.blurPx)}px`,
2532
+ onChange: (value) => {
2533
+ setKnob("blurPx", value);
2534
+ }
2535
+ }),
2536
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Slider, {
2537
+ label: t("saturate"),
2538
+ value: state.saturate,
2539
+ range: KNOB_RANGES.saturate,
2540
+ step: .01,
2541
+ display: `${Math.round(state.saturate * 100)}%`,
2542
+ onChange: (value) => {
2543
+ setKnob("saturate", value);
2544
+ }
2545
+ }),
2546
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Slider, {
2547
+ label: t("dim"),
2548
+ value: state.dim,
2549
+ range: KNOB_RANGES.dim,
2550
+ step: .01,
2551
+ display: `${Math.round(state.dim * 100)}%`,
2552
+ onChange: (value) => {
2553
+ setKnob("dim", value);
2554
+ }
2555
+ })
2556
+ ]
2557
+ }),
2558
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2559
+ className: "fw-bar",
2560
+ children: [
2561
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2562
+ type: "button",
2563
+ className: "fw-btn",
2564
+ "data-kind": "danger",
2565
+ disabled: !state.hasImage || state.busy,
2566
+ onClick: () => {
2567
+ remove();
2568
+ },
2569
+ children: t("remove")
2570
+ }),
2571
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2572
+ type: "button",
2573
+ className: "fw-btn",
2574
+ onClick: pick,
2575
+ disabled: state.busy,
2576
+ children: t("choose")
2577
+ }),
2578
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2579
+ type: "button",
2580
+ className: "fw-btn",
2581
+ "data-kind": "primary",
2582
+ disabled: !state.dirty || state.busy,
2583
+ onClick: () => {
2584
+ save();
2585
+ },
2586
+ children: t("save")
2587
+ })
2588
+ ]
2589
+ })
2590
+ ]
2591
+ })
2592
+ });
2593
+ }
2594
+ function Slider(props) {
2595
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2596
+ className: "fw-row",
2597
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2598
+ className: "fw-row-head",
2599
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: props.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: props.display })]
2600
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2601
+ type: "range",
2602
+ min: props.range[0],
2603
+ max: props.range[1],
2604
+ step: props.step,
2605
+ value: props.value,
2606
+ onChange: (event) => {
2607
+ props.onChange(Number(event.target.value));
2608
+ }
2609
+ })]
2610
+ });
2611
+ }
2612
+ //#endregion
2613
+ //#region src/client/store.ts
2614
+ const INITIAL_STATE = {
2615
+ ...DEFAULT_KNOBS,
2616
+ hasImage: false,
2617
+ previewUrl: null,
2618
+ fileName: null,
2619
+ width: 0,
2620
+ height: 0,
2621
+ dirty: false,
2622
+ busy: false,
2623
+ error: null,
2624
+ revision: -1
2625
+ };
2626
+ /** Create an in-memory store for the settings section. */
2627
+ function createBloomglassStore(init = INITIAL_STATE) {
2628
+ let state = init;
2629
+ const listeners = /* @__PURE__ */ new Set();
2630
+ return {
2631
+ get: () => state,
2632
+ set: (next) => {
2633
+ state = next;
2634
+ for (const listener of listeners) listener();
2635
+ },
2636
+ subscribe: (listener) => {
2637
+ listeners.add(listener);
2638
+ return () => {
2639
+ listeners.delete(listener);
2640
+ };
2641
+ }
2642
+ };
2643
+ }
2644
+ //#endregion
2645
+ //#region src/client/bloom-tokens.ts
2646
+ /**
2647
+ * 把色板编译成 CSS 变量 —— 主题的「颜色大脑」。
2648
+ *
2649
+ * 结构:
2650
+ * bloomTokens 每变体的 --bloom-* 自有 token(质感层的唯一色源 SSOT)
2651
+ * borderStack border 四档阶梯(暗色必须走 morandi,不能用前景色 mix)
2652
+ * labelStack 次级文字四档(单调递减,两档都过 WCAG AA)
2653
+ * sharedDswTokens 状态色 / 反色 / toast / tooltip(三处共用,防变体漂移)
2654
+ * mistLight/Dark mist 完整骨架
2655
+ * variantBlock 其余 7 个变体(只覆盖主色 + 背景调,骨架继承 mist)
2656
+ * buildBloomCSS 把上面全部拼成一张样式表
2657
+ *
2658
+ * 这个文件里的三个 *Stack / sharedDswTokens 函数是**刻意抽出来的**:它们覆盖的
2659
+ * token 原本在 mistLight / mistDark / variantBlock 三处各写一遍,漂移过多次
2660
+ * (ripple/petal 继承过 mist 的错误值;22 个 token 只在 mist 定义,导致其余
2661
+ * 7 个变体回落 DSH 原生值)。详见 DEV_NOTES 2026-08-24。
2662
+ */
2663
+ /**
2664
+ * Bloom 自有 token —— 三个变体块共用,是所有「质感」CSS 的唯一色源(SSOT)。
2665
+ * 有了这层,COMPONENT_CSS 只需写一份、全部引用 var(--bloom-*),
2666
+ * 不必为 4 个变体 × 明暗各抄一遍。
2667
+ *
2668
+ * --bloom-morandi 气质轨 RGB 三元组,供 rgba() 做低透明度氛围渐变与冷光
2669
+ * --bloom-shadow-* 长距柔和阴影三档(原版 typora 的 --shadow-sm/md/lg)
2670
+ * --bloom-hairline 冷光细线:极低透明度的莫兰迪色,用于分割线/描边
2671
+ * --bloom-glow 冷光辉:描边外侧的微弱扩散,深色下尤其出高级感
2672
+ */
2673
+ function bloomTokens(p, dark) {
2674
+ const tx = dark ? p.txD : p.txL;
2675
+ const m = p.morandi;
2676
+ const motion = dark ? p.motionD : p.motionL;
2677
+ return `
2678
+ --bloom-morandi: ${m};
2679
+ --bloom-accent: ${dark ? p.accentD : p.accentL};
2680
+ --bloom-motion-1: ${motion[0]};
2681
+ --bloom-motion-2: ${motion[1]};
2682
+ --bloom-motion-3: ${motion[2]};
2683
+ /* v0.9.0:落霞流线光带色(v0.12.x 前叫「极光」) —— 从 motion 谱取色,混透明度降饱和后做丝带渐变。
2684
+ 透明度低是故意的:流线是大尺度、低饱和的氛围效果,不是主色块。
2685
+ aurora 变体 v0.12.x 换色后 motion 谱是 78/55/35 → 金→橙→珊瑚,
2686
+ 「流线」随之变成晚霞色(owner 2026-09-14 反馈原青绿系不好看)。 */
2687
+ --bloom-aurora-stream-1: color-mix(in oklch, ${motion[0]}, transparent ${dark ? 55 : 72}%);
2688
+ --bloom-aurora-stream-2: color-mix(in oklch, ${motion[1]}, transparent ${dark ? 55 : 72}%);
2689
+ --bloom-aurora-stream-3: color-mix(in oklch, ${motion[2]}, transparent ${dark ? 55 : 72}%);
2690
+ /* ⚠️ 阴影在暗色下必须用纯黑,不能用前景色混透明度 ——
2691
+ 暗色的 --text 是近白,mix 出来的"阴影"会变成一团白雾贴在深色背景上。
2692
+ 原版 root-*-dark.css 同样是写死 rgba(0,0,0,.4~.6),只有亮色才用 text 混。 */
2693
+ --bloom-shadow-sm: 0 2px 8px ${dark ? "rgba(0,0,0,0.4)" : mix(tx, 96)};
2694
+ --bloom-shadow: 0 10px 30px ${dark ? "rgba(0,0,0,0.5)" : mix(tx, 94)};
2695
+ --bloom-shadow-lg: 0 24px 60px ${dark ? "rgba(0,0,0,0.6)" : mix(tx, 90)};
2696
+ /* DSH 的品牌蓝 #679efe(--dsw-alias-state-business-primary)用在顶栏
2697
+ 「对话 / 轨迹」tab 的选中文字与下划线等处。主题必须接管它,否则 8 套配色
2698
+ 切来切去,那条选中下划线永远是蓝的、跟主色打架(用户实拍反馈)。
2699
+ 注意只接管 alias 层 —— --dsw-static-deepseek-400 是 DSH 的品牌标识色,
2700
+ 不属于主题可覆盖范围。 */
2701
+ --dsw-alias-state-business-primary: ${dark ? p.accentD : p.accentL};
2702
+ /* 玻璃模糊半径 —— glass.ts 里 10 处 backdrop-filter 都读它。
2703
+ 之前只写了 var(--bloom-glass-blur, 24px) 却从未定义,fallback 一直生效所以
2704
+ 视觉没问题,但这个变量是**死的**:想统一调玻璃质感根本改不到。定义出来之后
2705
+ 它才真的是一个可调参数(暗色略薄一点,深色面板本就更"实"、不需要那么重的糊)。 */
2706
+ --bloom-glass-blur: ${dark ? "22px" : "24px"};
2707
+ --bloom-hairline: rgba(${m}, ${dark ? .3 : .3});
2708
+ --bloom-hairline-strong: rgba(${m}, ${dark ? .55 : .5});
2709
+ --bloom-glow: rgba(${m}, ${dark ? .34 : .2});
2710
+ --bloom-code-bg: rgba(${m}, ${dark ? .16 : .13});
2711
+ --bloom-code-fg: ${dark ? p.txD : p.txL};`;
2712
+ }
2713
+ /**
2714
+ * mist 亮色:完整接管 alias 语义 + specific 组件。
2715
+ * 这是骨架 —— 其它变体只覆盖「主色 + 背景调」相关的行。
2716
+ */
2717
+ /**
2718
+ * border 阶梯(l1 最淡 → l4 最强)—— **暗色必须走 morandi 气质轨**。
2719
+ *
2720
+ * 这是「前景色 token 当背景/边框用」这个反模式的第三次复发(前两次是
2721
+ * markdown-inline-code 和 --bloom-shadow,见 DEV_NOTES 2026-08-18):
2722
+ * 暗色的 tx 是近白 oklch(96%),`mix(tx, 58%)` 得到 42% 不透明的近白 ——
2723
+ * 在莫兰迪暗底上就是一圈扎眼的白框(用户实拍反馈:「白色边框很突兀」)。
2724
+ *
2725
+ * 暗色改用 rgba(morandi, α):morandi 是中等亮度的低饱和主题色(如 sage
2726
+ * 是 138,154,91 灰绿),同样的可见度但不刺眼,且边框跟着变体走色相 ——
2727
+ * 这正是 --bloom-hairline 一直在用的思路,只是 border 阶梯漏了。
2728
+ * 亮色仍用 mix(tx):亮色的 tx 是深色,当边框是对的。
2729
+ *
2730
+ * 抽成一个函数是因为这四行原本在 mistLight / mistDark / variantBlock
2731
+ * **三处**各写一遍 —— 本项目已经因为「三处只改了两处」让 ripple/petal
2732
+ * 继承过 mist 的错误值(DEV_NOTES 2026-08-18)。现在只有一个真源。
2733
+ */
2734
+ function borderStack(p, dark) {
2735
+ const m = p.morandi;
2736
+ const tx = dark ? p.txD : p.txL;
2737
+ return dark ? ` --dsw-alias-border-l1: rgba(${m}, 0.10);
2738
+ --dsw-alias-border-l2: rgba(${m}, 0.18);
2739
+ --dsw-alias-border-l3: rgba(${m}, 0.28);
2740
+ --dsw-alias-border-l4: rgba(${m}, 0.40);` : ` --dsw-alias-border-l1: ${mix(tx, 90)};
2741
+ --dsw-alias-border-l2: ${mix(tx, 80)};
2742
+ --dsw-alias-border-l3: ${mix(tx, 70)};
2743
+ --dsw-alias-border-l4: ${mix(tx, 60)};`;
2744
+ }
2745
+ /**
2746
+ * 次级文字层级(secondary → tertiary → caption → dimmed,依次更弱)。
2747
+ *
2748
+ * 和 borderStack 一样,这四行原本在 mistLight / mistDark / variantBlock **三处**
2749
+ * 各写一遍,而且亮色档的顺序是乱的:caption 给了 40%、比 tertiary 的 45% 更**不**
2750
+ * 透明 —— 说明三处分别改过、没人对齐。
2751
+ *
2752
+ * 亮色档实测(canvas 取真实 sRGB 值算 WCAG,sage 亮色):
2753
+ * mix(tx, 45) → 3.46:1 mix(tx, 40) → 4.16:1 都够不上 AA 的 4.5:1
2754
+ * README 挂着 WCAG AA 徽章,这里必须达标,所以亮色重新分档到 28/32/35/37
2755
+ * (反推自 ratio ≥ 4.5 所需的 alpha ≈ 0.63),层级仍单调递减,只是整体压缩。
2756
+ *
2757
+ * 暗色档原为 35/45/48/58。前三档没问题(≥ 5:1),但 dimmed 的 58%(= 42% 不透明
2758
+ * 近白)在 8 个变体的暗底上只有 3.53~3.78 —— 8/8 全部不及格。dimmed 用在时间戳、
2759
+ * 「展开其余 N 个会话」这类**有意义**的信息上,不是纯装饰,所以必须达标。
2760
+ * 反推 ratio ≥ 4.5 需要 alpha ≈ 0.57,于是整条阶梯上移到 30/35/40/43。
2761
+ *
2762
+ * 注意保持单调递减 —— 这个档位修过一次「暗色三档塌成一档」
2763
+ * (曾是 35/45/30/35,caption 比 secondary 还亮,见 DEV_NOTES 2026-08-18)。
2764
+ * 上移后跨度从 23 收窄到 13,层级仍在(alpha 0.70/0.65/0.60/0.57),
2765
+ * 但已经没有再压缩的余地了:再靠近就会重演塌成一档。
2766
+ */
2767
+ function labelStack(p, dark) {
2768
+ const tx = dark ? p.txD : p.txL;
2769
+ return dark ? ` --dsw-alias-label-secondary: ${mix(tx, 30)};
2770
+ --dsw-alias-label-tertiary: ${mix(tx, 35)};
2771
+ --dsw-alias-label-caption: ${mix(tx, 40)};
2772
+ --dsw-alias-label-dimmed: ${mix(tx, 43)};` : ` --dsw-alias-label-secondary: ${mix(tx, 28)};
2773
+ --dsw-alias-label-tertiary: ${mix(tx, 32)};
2774
+ --dsw-alias-label-caption: ${mix(tx, 35)};
2775
+ --dsw-alias-label-dimmed: ${mix(tx, 37)};`;
2776
+ }
2777
+ /**
2778
+ * 三处共用的 --dsw-* token(状态色 / 反色 / toast / tooltip / 遮罩)。
2779
+ *
2780
+ * 为什么要抽出来:这 22 个 token 原本只在 mistLight / mistDark 定义,
2781
+ * **variantBlock 一个都没有** —— 于是除 mist 之外的 7 个变体
2782
+ * (cinnabar/petal/ripple/sage/stone/lapis/amber)全部回落到 DSH 原生值:
2783
+ * 错误红实测是 DSH 的 #ec1313(4.13:1,够不上 AA),tooltip 是蓝灰
2784
+ * oklch(30% 0.02 240),跟莫兰迪色相完全不搭。
2785
+ *
2786
+ * 更隐蔽的是 --dsw-alias-state-business-primary:bloomTokens 已把它接管成
2787
+ * accent,但 mist 块里还留着旧的蓝色定义 oklch(62% 0.1 250),同一选择器内
2788
+ * 后写的赢 —— 结果「顶栏 tab 选中色跟主题」这个修复在 7 个变体生效、
2789
+ * 偏偏 mist(默认变体!)不生效。这类「三处定义漂移」是本仓反复出血的地方,
2790
+ * 现在 borderStack / labelStack / sharedDswTokens 三个函数把它收干净了。
2791
+ *
2792
+ * 状态色顺手修了亮色档对比度(canvas 取真实 sRGB 值实测):
2793
+ * success oklch(70%) → 2.0:1 warn oklch(75%) → 1.7:1 error oklch(60%) → 2.9:1
2794
+ * 三个在亮底上全部不及格,亮色统一压到 45% 左右(≈4.5:1 起)。
2795
+ * 暗色档大多本来就有 5~9:1,唯一例外是 error:oklch(62%) 在几个偏亮的暗底变体上
2796
+ * 只有 3.74~4.46(mist/cinnabar/petal/lapis/amber 实测),提到 72% 后 16 组全过。
2797
+ * 状态色保持红/绿/黄语义色相,不跟 accent 走 —— 「成功」不该因为切了个配色
2798
+ * 就变成绿色以外的东西。
2799
+ */
2800
+ function sharedDswTokens(p, dark) {
2801
+ const bg = dark ? p.bgD : p.bgL;
2802
+ const tx = dark ? p.txD : p.txL;
2803
+ const ok = dark ? "oklch(72% 0.12 150)" : "oklch(45% 0.14 150)";
2804
+ const err = dark ? "oklch(72% 0.16 25)" : "oklch(45% 0.16 25)";
2805
+ const warn = dark ? "oklch(80% 0.12 75)" : "oklch(45% 0.11 75)";
2806
+ /**
2807
+ * ⚠️ secondary 与 tertiary 的语义**完全不同**,别按名字想当然。
2808
+ *
2809
+ * 实测 DSH 原生值(禁用 Bloom 样式后读 computed):
2810
+ * state-error-primary #f25a5a
2811
+ * state-error-secondary #f25a5a ← 和 primary **一模一样**,全饱和
2812
+ * state-success-tertiary rgb(35,60,44) ← 这才是暗淡背景色
2813
+ *
2814
+ * 也就是说 `-secondary` 是**全饱和状态色本身**(DSH 拿它当 color-mix 的色源),
2815
+ * `-tertiary` 才是背景档。Bloom 起初把两档都当成「淡背景」,写了
2816
+ * color-mix(c, transparent 82%),于是:
2817
+ *
2818
+ * .assistantVioletBright { color: color-mix(in srgb,
2819
+ * var(--dsw-alias-brand-primary-…) 60%, var(--dsw-alias-state-error-secondary)) }
2820
+ *
2821
+ * 轨迹视图的 ASSISTANT 标签被混成半透明,对比度 3.39 → 2.89,**比 DSH 原生还差**。
2822
+ * 改成拿 bg 预混后 alpha 恢复 1,但色值被暗背景拖黑,2.89 → 2.70,更差 ——
2823
+ * 两次都错在把 secondary 当背景。现在 secondary 直接给全饱和值,对齐 DSH 语义。
2824
+ *
2825
+ * tertiary 保持 bg 预混(它确实是背景),但比例从 90% 降到 78%:原生
2826
+ * success-tertiary 有明显绿调,混 90% bg 只剩 rgb(33,36,20) 几乎无色相。
2827
+ *
2828
+ * Bloom 自己不消费这几个 token(只定义),所以改它们只影响 DSH 组件的渲染。
2829
+ */
2830
+ const t = (c, pct) => `color-mix(in oklch, ${c}, ${bg} ${pct}%)`;
2831
+ return `
2832
+ /* 状态色(语义色相固定,明暗分档,两档都过 WCAG AA) */
2833
+ --dsw-alias-state-success-primary: ${ok};
2834
+ --dsw-alias-state-success-secondary: ${ok};
2835
+ --dsw-alias-state-success-tertiary: ${t(ok, dark ? 78 : 88)};
2836
+ --dsw-alias-state-error-primary: ${err};
2837
+ --dsw-alias-state-error-secondary: ${err};
2838
+ --dsw-alias-state-warn-primary: ${warn};
2839
+ --dsw-alias-state-warn-secondary: ${warn};
2840
+ --dsw-alias-state-warn-tertiary: ${t(warn, dark ? 78 : 88)};
2841
+ --dsw-alias-state-warn-label: ${dark ? "oklch(88% 0.1 75)" : "oklch(40% 0.09 75)"};
2842
+ /* interactive-bg-* 是明确的**背景**语义,保持 transparent 混 —— 它要叠在各种
2843
+ 底色上(行 hover、按钮 hover),预混 bg 反而会在非 bg 底色上露出色块。 */
2844
+ --dsw-alias-interactive-bg-hover-danger: ${mix(err, dark ? 86 : 90)};
2845
+ /* business = 主题 accent(bloomTokens 里已接管,这里给 tertiary 配套) */
2846
+ --dsw-alias-state-business-tertiary: ${t(dark ? p.accentD : p.accentL, dark ? 78 : 88)};
2847
+ /* 反色 / 浮层 —— 跟着变体的 bg / tx 走,不再是硬编码蓝灰 */
2848
+ --dsw-alias-border-inverted: ${mix(bg, dark ? 85 : 85)};
2849
+ --dsw-alias-border-inverted2: ${mix(bg, dark ? 70 : 70)};
2850
+ --dsw-alias-label-primary-inverted: ${bg};
2851
+ --dsw-alias-button-contrast-fill: ${tx};
2852
+ --dsw-alias-button-elevated-fill: ${bg};
2853
+ --dsw-alias-button-tool-bar-fill-invisible: transparent;
2854
+ --dsw-alias-markdown-code-block-banner: ${mix(tx, 96)};
2855
+ /* tooltip / toast 两档都要是「深底浅字」:亮色拿 tx 压深,暗色拿 bg 压更深 */
2856
+ --dsw-alias-tooltip-bg: color-mix(in oklch, ${dark ? bg : tx}, black ${dark ? 22 : 8}%);
2857
+ --dsw-alias-toast-bg: color-mix(in oklch, ${dark ? bg : tx}, black ${dark ? 22 : 8}%);
2858
+ --dsw-alias-bg-mask-photo: color-mix(in oklch, ${bg}, black ${dark ? 45 : 30}%);`;
2859
+ }
2860
+ function mistLight(p) {
2861
+ const { accentL: aL, bgL, txL, sfL, sf2L } = p;
2862
+ return `
2863
+ /* ─── Bloom · mist 雾蓝 亮色(默认 + body[data-bloom-variant=mist])─────────── */
2864
+ body, body[data-bloom-variant="mist"] {${bloomTokens(p, false)}${sharedDswTokens(p, false)}
2865
+ --dsw-alias-bg-base: ${bgL};
2866
+ --dsw-alias-bg-layer-1: ${bgL};
2867
+ --dsw-alias-bg-layer-2: ${sfL};
2868
+ --dsw-alias-bg-layer-3: ${sf2L};
2869
+ --dsw-alias-bg-overlay: ${sfL};
2870
+ --dsw-alias-bg-module-platform: ${sfL};
2871
+ --dsw-alias-bg-multi-select: ${sf2L};
2872
+ --dsw-alias-bg-skeleton: ${mix(txL, 96)};
2873
+ --dsw-alias-bg-mask-1: ${mix(txL, 97)};
2874
+ --dsw-alias-bg-mask-2: ${mix(txL, 95)};
2875
+ --dsw-alias-bg-mask-3: ${mix(txL, 93)};
2876
+ --dsw-alias-bg-mask-drop: ${mix(txL, 88)};
2877
+ --dsw-alias-label-primary: ${txL};
2878
+ --dsw-alias-label-primary-bluish: ${txL};
2879
+ --dsw-alias-label-primary-dimmed: ${mix(txL, 25)};
2880
+ --dsw-alias-label-primary-foreground: ${bgL};
2881
+ ${labelStack(p, false)}
2882
+ --dsw-alias-brand-primary: ${aL};
2883
+ --dsw-alias-brand-primary-invert: ${bgL};
2884
+ --dsw-alias-brand-text: ${bgL};
2885
+ ${borderStack(p, false)}
2886
+ --dsw-alias-button-primary-fill: ${aL};
2887
+ --dsw-alias-button-primary-hover: color-mix(in oklch, ${aL}, black 8%);
2888
+ /* dimmed 的官方语义是「主按钮填充上的文字色」,与 fill 反向——
2889
+ 官方亮色 fill=neutral-bluish-1000(#0f1115) / dimmed=neutral-bluish-100(#ebeef2),
2890
+ 官方暗色 fill=neutral-bluish-50(#f9fafb) / dimmed=neutral-bluish-750(#43454a)。
2891
+ 曾误当作「accent 淡化」写成 mix(accent, 85),于是任何按官方语义把它用作
2892
+ 文字色的插件(如 @opendsh/dsh-plugin-scheduled-tasks 的 .dshst-btn-primary
2893
+ 与 .dshst-tab-active)都得到「accent 文字叠在 accent 填充上」→ 对比度趋 0,
2894
+ 按钮字不可见(#16)。
2895
+ 这里取 bg 而非 label 类 token 是刻意的:fill 是 accent,而 accent 与 bg 在
2896
+ 明暗之间反向翻转(亮色 accent 中深 / bg 浅,暗色 accent 亮 / bg 深),
2897
+ 两个主题都成立;bloom 内 label-primary-foreground 与 brand-primary-invert
2898
+ 早已用同一个值表达「品牌色块上的前景」,此处复用,不新造颜色。 */
2899
+ --dsw-alias-button-primary-dimmed: ${bgL};
2900
+ --dsw-alias-button-tool-bar-fill: ${sfL};
2901
+ --dsw-alias-button-tool-bar-hover: ${mix(txL, 95)};
2902
+ --dsw-alias-button-floating-fill: ${sfL};
2903
+ --dsw-alias-button-floating-hover: ${sf2L};
2904
+ --dsw-alias-button-info-fill: ${mix(aL, 90)};
2905
+ --dsw-alias-button-info-hover: ${mix(aL, 84)};
2906
+ --dsw-alias-button-ghost-active-fill: ${mix(aL, 92)};
2907
+ --dsw-alias-button-ghost-active-hover: ${mix(aL, 88)};
2908
+ --dsw-alias-button-ghost-active-border: ${mix(aL, 78)};
2909
+ --dsw-alias-interactive-bg-hover: ${mix(aL, 92)};
2910
+ --dsw-alias-interactive-bg-hover-accent: ${mix(aL, 85)};
2911
+ --dsw-alias-interactive-bg-hover-solid: ${mix(aL, 88)};
2912
+ --dsw-alias-interactive-bg-active: ${mix(aL, 88)};
2913
+ /* ⚠️ inline-code 是「背景色」不是文字色 —— DSH 把它 set 到 code 元素的 background。
2914
+ 曾经按文字色给(亮色 L30% 深色 / 暗色 L88% 浅色),结果亮色深底深字、
2915
+ 暗色浅底白字(实测 1.2:1,一块刺眼亮斑)。必须给背景值。 */
2916
+ --dsw-alias-markdown-inline-code: var(--bloom-code-bg);
2917
+ --dsw-alias-markdown-code-block: ${sfL};
2918
+ --dsw-alias-markdown-tag: ${mix(aL, 88)};
2919
+ --dsw-alias-markdown-placeholder: ${mix(txL, 50)};
2920
+ --dsw-alias-markdown-citation: ${mix(txL, 55)};
2921
+ --dsw-alias-scrollbar-bg-l1: ${mix(txL, 90)};
2922
+ --dsw-alias-scrollbar-bg-l2: ${mix(txL, 80)};
2923
+ --dsw-alias-scrollbar-hover-l1: ${mix(txL, 82)};
2924
+ --dsw-alias-scrollbar-hover-l2: ${mix(txL, 72)};
2925
+ /* specific:消息气泡 / 侧栏 / 输入区 / 菜单 —— 不接管就会回落到 DSH 蓝灰调 */
2926
+ --dsw-specific-bubble: color-mix(in oklch, ${bgL}, ${txL} 3%);
2927
+ --dsw-specific-bubble-highlight: ${mix(aL, 88)};
2928
+ --dsw-specific-input-major: ${bgL};
2929
+ --dsw-specific-login-input: ${sfL};
2930
+ --dsw-specific-menu: ${sf2L};
2931
+ --dsw-specific-selector: ${sfL};
2932
+ /* 5% 而不是 2%:2% 的色差在浅色模式下肉眼几乎分不出侧边栏和主区,
2933
+ 侧边栏就显得没有存在感(配合下方 _sidebarCol 的右侧分界线一起看) */
2934
+ --dsw-specific-sidebar-fill: color-mix(in oklch, ${bgL}, ${txL} 5%);
2935
+ --dsw-specific-sidebar-nav-item-active-accent: ${mix(aL, 86)};
2936
+ --dsw-specific-sidebar-nav-item-active: ${mix(aL, 93)};
2937
+ --dsw-specific-sidebar-nav-item-hover: ${mix(txL, 95)};
2938
+ --dsw-specific-tip: ${mix(aL, 90)};
2939
+ }
2940
+ `;
2941
+ }
2942
+ /** mist 暗色:完整接管(暗色下文字是亮灰、表面是深灰、主色提亮) */
2943
+ function mistDark(p) {
2944
+ const { accentD: aD, bgD, txD, sfD, sf2D } = p;
2945
+ return `
2946
+ /* ─── Bloom · mist 暗色 ─────────────────────────────────────────── */
2947
+ body[data-ds-dark-theme], body[data-ds-dark-theme][data-bloom-variant="mist"] {${bloomTokens(p, true)}${sharedDswTokens(p, true)}
2948
+ --dsw-alias-bg-base: ${bgD};
2949
+ --dsw-alias-bg-layer-1: ${bgD};
2950
+ --dsw-alias-bg-layer-2: ${sfD};
2951
+ --dsw-alias-bg-layer-3: ${sf2D};
2952
+ --dsw-alias-bg-overlay: ${sfD};
2953
+ --dsw-alias-bg-module-platform: ${sfD};
2954
+ --dsw-alias-bg-multi-select: ${sf2D};
2955
+ --dsw-alias-bg-skeleton: ${mix(txD, 94)};
2956
+ --dsw-alias-bg-mask-1: ${mix(txD, 96)};
2957
+ --dsw-alias-bg-mask-2: ${mix(txD, 93)};
2958
+ --dsw-alias-bg-mask-3: ${mix(txD, 90)};
2959
+ --dsw-alias-bg-mask-drop: ${mix(txD, 84)};
2960
+ --dsw-alias-label-primary: ${txD};
2961
+ --dsw-alias-label-primary-bluish: ${txD};
2962
+ --dsw-alias-label-primary-dimmed: ${mix(txD, 30)};
2963
+ --dsw-alias-label-primary-foreground: ${bgD};
2964
+ /* 四档由 labelStack() 统一给(层级必须单调递减:
2965
+ secondary > tertiary > caption > dimmed)。曾经是 35/45/30/35 ——
2966
+ caption 比 secondary 还亮、dimmed 跟 secondary 相同,三档在暗色下塌成一档。 */
2967
+ ${labelStack(p, true)}
2968
+ --dsw-alias-brand-primary: ${aD};
2969
+ --dsw-alias-brand-primary-invert: ${bgD};
2970
+ --dsw-alias-brand-text: ${bgD};
2971
+ ${borderStack(p, true)}
2972
+ --dsw-alias-button-primary-fill: ${aD};
2973
+ --dsw-alias-button-primary-hover: color-mix(in oklch, ${aD}, white 8%);
2974
+ /* 见亮色段说明:fill 上的文字色,与 fill 反向(#16) */
2975
+ --dsw-alias-button-primary-dimmed: ${bgD};
2976
+ --dsw-alias-button-tool-bar-fill: ${sfD};
2977
+ --dsw-alias-button-tool-bar-hover: ${mix(txD, 92)};
2978
+ --dsw-alias-button-floating-fill: ${sfD};
2979
+ --dsw-alias-button-floating-hover: ${sf2D};
2980
+ --dsw-alias-button-info-fill: ${mix(aD, 86)};
2981
+ --dsw-alias-button-info-hover: ${mix(aD, 78)};
2982
+ --dsw-alias-button-ghost-active-fill: ${mix(aD, 88)};
2983
+ --dsw-alias-button-ghost-active-hover: ${mix(aD, 82)};
2984
+ --dsw-alias-button-ghost-active-border: ${mix(aD, 70)};
2985
+ --dsw-alias-interactive-bg-hover: ${mix(aD, 90)};
2986
+ --dsw-alias-interactive-bg-hover-accent: ${mix(aD, 84)};
2987
+ --dsw-alias-interactive-bg-hover-solid: ${mix(aD, 82)};
2988
+ --dsw-alias-interactive-bg-active: ${mix(aD, 86)};
2989
+ /* 见亮色块同名变量的说明:这是背景色。 */
2990
+ --dsw-alias-markdown-inline-code: var(--bloom-code-bg);
2991
+ --dsw-alias-markdown-code-block: oklch(24% 0.02 240);
2992
+ --dsw-alias-markdown-tag: ${mix(aD, 85)};
2993
+ --dsw-alias-markdown-placeholder: ${mix(txD, 40)};
2994
+ --dsw-alias-markdown-citation: ${mix(txD, 35)};
2995
+ --dsw-alias-scrollbar-bg-l1: ${mix(txD, 86)};
2996
+ --dsw-alias-scrollbar-bg-l2: ${mix(txD, 76)};
2997
+ --dsw-alias-scrollbar-hover-l1: ${mix(txD, 75)};
2998
+ --dsw-alias-scrollbar-hover-l2: ${mix(txD, 65)};
2999
+ --dsw-specific-bubble: color-mix(in oklch, ${bgD}, white 3%);
3000
+ --dsw-specific-bubble-highlight: ${mix(aD, 82)};
3001
+ --dsw-specific-input-major: ${bgD};
3002
+ --dsw-specific-login-input: ${sfD};
3003
+ --dsw-specific-menu: ${sf2D};
3004
+ --dsw-specific-selector: ${sfD};
3005
+ --dsw-specific-sidebar-fill: color-mix(in oklch, ${bgD}, black 7%);
3006
+ --dsw-specific-sidebar-nav-item-active-accent: ${mix(aD, 80)};
3007
+ --dsw-specific-sidebar-nav-item-active: ${mix(aD, 88)};
3008
+ --dsw-specific-sidebar-nav-item-hover: ${mix(txD, 95)};
3009
+ --dsw-specific-tip: ${mix(aD, 84)};
3010
+ }
3011
+ `;
3012
+ }
3013
+ /**
3014
+ * 非 mist 变体:只覆盖「主色 + 背景调」相关的行(含 specific 组件),
3015
+ * 灰阶骨架、状态色、markdown 色继承 mist —— 变体之间保持同构,只换气质。
3016
+ */
3017
+ function variantBlock(v, dark) {
3018
+ const p = PALETTE[v];
3019
+ const a = dark ? p.accentD : p.accentL;
3020
+ const bg = dark ? p.bgD : p.bgL;
3021
+ const tx = dark ? p.txD : p.txL;
3022
+ const sf = dark ? p.sfD : p.sfL;
3023
+ const sf2 = dark ? p.sf2D : p.sf2L;
3024
+ const sel = dark ? `body[data-ds-dark-theme][data-bloom-variant="${v}"]` : `body[data-bloom-variant="${v}"]`;
3025
+ return `
3026
+ /* ─── Bloom · ${v}${dark ? " 暗色" : ""}(仅覆盖主色 + 背景调,骨架继承 mist)─────────── */
3027
+ ${sel} {${bloomTokens(p, dark)}${sharedDswTokens(p, dark)}
3028
+ --dsw-alias-bg-base: ${bg};
3029
+ --dsw-alias-bg-layer-1: ${bg};
3030
+ --dsw-alias-bg-layer-2: ${sf};
3031
+ --dsw-alias-bg-layer-3: ${sf2};
3032
+ --dsw-alias-bg-overlay: ${sf};
3033
+ --dsw-alias-bg-module-platform: ${sf};
3034
+ --dsw-alias-bg-multi-select: ${sf2};
3035
+ --dsw-alias-bg-skeleton: ${mix(tx, 95)};
3036
+ --dsw-alias-bg-mask-1: ${mix(tx, 97)};
3037
+ --dsw-alias-bg-mask-2: ${mix(tx, 95)};
3038
+ --dsw-alias-bg-mask-3: ${mix(tx, 93)};
3039
+ --dsw-alias-bg-mask-drop: ${mix(tx, 88)};
3040
+ --dsw-alias-brand-primary: ${a};
3041
+ --dsw-alias-brand-primary-invert: ${bg};
3042
+ --dsw-alias-brand-text: ${bg};
3043
+ --dsw-alias-button-primary-fill: ${a};
3044
+ --dsw-alias-button-primary-hover: color-mix(in oklch, ${a}, ${dark ? "white" : "black"} 8%);
3045
+ /* 见亮色段说明:fill 上的文字色,与 fill 反向(#16) */
3046
+ --dsw-alias-button-primary-dimmed: ${bg};
3047
+ --dsw-alias-button-info-fill: ${mix(a, dark ? 86 : 90)};
3048
+ --dsw-alias-button-info-hover: ${mix(a, dark ? 78 : 84)};
3049
+ --dsw-alias-button-ghost-active-fill: ${mix(a, dark ? 88 : 92)};
3050
+ --dsw-alias-button-ghost-active-hover: ${mix(a, dark ? 82 : 88)};
3051
+ --dsw-alias-button-ghost-active-border: ${mix(a, dark ? 70 : 78)};
3052
+ --dsw-alias-button-tool-bar-fill: ${sf};
3053
+ --dsw-alias-button-tool-bar-hover: ${mix(tx, dark ? 92 : 95)};
3054
+ --dsw-alias-button-floating-fill: ${sf};
3055
+ --dsw-alias-button-floating-hover: ${sf2};
3056
+ --dsw-alias-interactive-bg-hover: ${mix(a, dark ? 90 : 92)};
3057
+ --dsw-alias-interactive-bg-hover-accent: ${mix(a, dark ? 84 : 85)};
3058
+ --dsw-alias-interactive-bg-hover-solid: ${mix(a, dark ? 82 : 88)};
3059
+ --dsw-alias-interactive-bg-active: ${mix(a, dark ? 86 : 88)};
3060
+ --dsw-alias-markdown-tag: ${mix(a, dark ? 85 : 88)};
3061
+ /* 必须逐变体覆盖:否则继承 mist 的蓝灰 hue,ripple/petal 下代码块会跟主色打架 */
3062
+ --dsw-alias-markdown-inline-code: var(--bloom-code-bg);
3063
+ --dsw-alias-markdown-code-block: ${dark ? `color-mix(in oklch, ${bg}, black 12%)` : sf};
3064
+ --dsw-alias-scrollbar-bg-l1: ${mix(tx, dark ? 86 : 90)};
3065
+ --dsw-alias-scrollbar-bg-l2: ${mix(tx, dark ? 76 : 80)};
3066
+ --dsw-alias-scrollbar-hover-l1: ${mix(tx, dark ? 75 : 82)};
3067
+ --dsw-alias-scrollbar-hover-l2: ${mix(tx, dark ? 65 : 72)};
3068
+ --dsw-alias-label-primary: ${tx};
3069
+ --dsw-alias-label-primary-bluish: ${tx};
3070
+ --dsw-alias-label-primary-dimmed: ${mix(tx, dark ? 30 : 25)};
3071
+ --dsw-alias-label-primary-foreground: ${bg};
3072
+ ${labelStack(p, dark)}
3073
+ ${borderStack(p, dark)}
3074
+ --dsw-alias-markdown-placeholder: ${mix(tx, dark ? 40 : 50)};
3075
+ --dsw-alias-markdown-citation: ${mix(tx, dark ? 35 : 55)};
3076
+ --dsw-specific-bubble: color-mix(in oklch, ${bg}, ${tx} ${dark ? 4 : 3}%);
3077
+ --dsw-specific-bubble-highlight: ${mix(a, dark ? 82 : 88)};
3078
+ --dsw-specific-input-major: ${bg};
3079
+ --dsw-specific-login-input: ${sf};
3080
+ --dsw-specific-menu: ${sf2};
3081
+ --dsw-specific-selector: ${sf};
3082
+ /* 4/2% → 7/5%:原值色差太小,侧边栏和主区几乎同一片底色,缺少「面」的区分 */
3083
+ --dsw-specific-sidebar-fill: color-mix(in oklch, ${bg}, ${dark ? "black" : tx} ${dark ? 7 : 5}%);
3084
+ --dsw-specific-sidebar-nav-item-active-accent: ${mix(a, dark ? 80 : 86)};
3085
+ --dsw-specific-sidebar-nav-item-active: ${mix(a, dark ? 88 : 93)};
3086
+ --dsw-specific-sidebar-nav-item-hover: ${mix(tx, dark ? 95 : 95)};
3087
+ --dsw-specific-tip: ${mix(a, dark ? 84 : 90)};
3088
+ }
3089
+ `;
3090
+ }
3091
+ //#endregion
3092
+ //#region src/client/tokens.ts
3093
+ /**
3094
+ * The unified token pipeline — where Bloom and Frosted actually merge.
3095
+ *
3096
+ * Bloom owns the *colour*: ten OKLCH palettes compiled by `bloom-tokens.ts`
3097
+ * into ~90 `--dsw-*` declarations per light/dark mode.
3098
+ * Frosted owns the *transparency*: a wallpaper sits behind the UI, so every
3099
+ * surface token is re-expressed at the glass opacity the user dialled in.
3100
+ *
3101
+ * Two separate channels come out of this module:
3102
+ *
3103
+ * 1. `bloomTokenOverrides()` → `--dsw-*` only, fed to the official
3104
+ * `ThemeRuntime.overrideTokens` layer (reversible, stacks correctly).
3105
+ * 2. `bloomOwnVarsCss()` → the `--bloom-*` properties our stylesheets read.
3106
+ * They are plugin-internal, so they ride our own scoped <style> tag
3107
+ * instead of the theme registry.
3108
+ *
3109
+ * Values are never re-typed by hand: they are parsed out of Bloom's own
3110
+ * builders, so the palette cannot drift from upstream during a merge.
3111
+ */
3112
+ /**
3113
+ * Pull `--name: value;` pairs out of a CSS rule emitted by Bloom's builders.
3114
+ * Nested builders are already interpolated by the time we see the text, so the
3115
+ * body holds flat declarations and no nested braces.
3116
+ * @param css - a rule such as `body[data-bloom-variant="mist"] { --a: b; }`.
3117
+ */
3118
+ function declarations(css) {
3119
+ const open = css.indexOf("{");
3120
+ const close = css.lastIndexOf("}");
3121
+ const body = open >= 0 && close > open ? css.slice(open + 1, close) : css;
3122
+ const out = {};
3123
+ const pattern = /(--[a-z0-9-]+)\s*:\s*([^;]+);/gi;
3124
+ let match;
3125
+ while ((match = pattern.exec(body)) !== null) out[match[1]] = match[2].trim();
3126
+ return out;
3127
+ }
3128
+ /** Split a declaration map into the DSH token layer and Bloom's own variables. */
3129
+ function partition(map) {
3130
+ const dsw = {};
3131
+ const own = {};
3132
+ for (const [name, value] of Object.entries(map)) if (name.startsWith("--dsw-")) dsw[name] = value;
3133
+ else own[name] = value;
3134
+ return {
3135
+ dsw,
3136
+ own
3137
+ };
3138
+ }
3139
+ /** Compose light + dark declaration maps into per-mode token pairs. */
3140
+ function pair(light, dark) {
3141
+ const out = {};
3142
+ for (const name of /* @__PURE__ */ new Set([...Object.keys(light), ...Object.keys(dark)])) out[name] = {
3143
+ light: light[name] ?? dark[name],
3144
+ dark: dark[name] ?? light[name]
3145
+ };
3146
+ return out;
3147
+ }
3148
+ /**
3149
+ * Bloom's complete DSH token layer for one palette, both modes.
3150
+ * `mist` is the skeleton every other variant inherits; a variant only
3151
+ * overrides its accent and background tonality.
3152
+ * @param variant - palette key; unknown values fall back to `mist`.
3153
+ */
3154
+ function bloomTokenOverrides(variant) {
3155
+ const key = isVariant(variant) ? variant : "mist";
3156
+ const light = partition(declarations(mistLight(PALETTE.mist))).dsw;
3157
+ const dark = partition(declarations(mistDark(PALETTE.mist))).dsw;
3158
+ if (key !== "mist") {
3159
+ Object.assign(light, partition(declarations(variantBlock(key, false))).dsw);
3160
+ Object.assign(dark, partition(declarations(variantBlock(key, true))).dsw);
3161
+ }
3162
+ return pair(light, dark);
3163
+ }
3164
+ /**
3165
+ * Bloom's own `--bloom-*` properties as a scoped stylesheet.
3166
+ * Gated on the plugin body attribute and on the active palette, mirroring the
3167
+ * cascade Bloom's own builders rely on (dark block wins by specificity).
3168
+ * @param variant - palette key; unknown values fall back to `mist`.
3169
+ */
3170
+ function bloomOwnVarsCss(variant, attr) {
3171
+ const key = isVariant(variant) ? variant : "mist";
3172
+ const light = partition(declarations(bloomTokens(PALETTE[key], false))).own;
3173
+ const dark = partition(declarations(bloomTokens(PALETTE[key], true))).own;
3174
+ const render = (map) => Object.entries(map).map(([name, value]) => `${name}: ${value};`).join("");
3175
+ return [`body[${attr}="${key}"]{${render(light)}}`, `body[${attr}="${key}"][data-ds-dark-theme]{${render(dark)}}`].join("\n");
3176
+ }
3177
+ /**
3178
+ * Frosted's published opacity recipe, preserved token-for-token so the merged
3179
+ * plugin keeps the slider range users already tuned.
3180
+ */
3181
+ const GLASS_ROLE = {
3182
+ "--dsw-alias-bg-base": "base",
3183
+ "--dsw-alias-bg-layer-1": "plate",
3184
+ "--dsw-alias-bg-layer-2": "raised",
3185
+ "--dsw-alias-bg-layer-3": "raised",
3186
+ "--dsw-alias-bg-overlay": "overlay",
3187
+ "--dsw-alias-bg-module-platform": "raised",
3188
+ "--dsw-alias-bg-mask-drop": "drop",
3189
+ "--dsw-specific-sidebar-fill": "plate",
3190
+ "--dsw-specific-input-major": "input",
3191
+ "--dsw-specific-menu": "menu",
3192
+ "--dsw-specific-bubble": "bubble",
3193
+ "--dsw-specific-selector": "raised",
3194
+ "--dsw-specific-sidebar-nav-item-active": "raised",
3195
+ "--dsw-specific-sidebar-nav-item-hover": "hover",
3196
+ "--dsw-alias-button-elevated-fill": "input",
3197
+ "--dsw-alias-button-floating-fill": "input",
3198
+ "--dsw-alias-markdown-code-block": "raised",
3199
+ "--dsw-alias-markdown-inline-code": "raised"
3200
+ };
3201
+ /** Expand one glass-opacity knob into the per-role opacity table. */
3202
+ function glassAlphas(glassOpacity) {
3203
+ const a = glassOpacity;
3204
+ return {
3205
+ base: Math.max(.08, a * .42),
3206
+ plate: a,
3207
+ raised: Math.min(.92, a + .1),
3208
+ overlay: Math.min(.94, a + .22),
3209
+ input: Math.min(.9, a + .12),
3210
+ menu: Math.min(.9, a + .16),
3211
+ bubble: Math.min(.88, a + .08),
3212
+ hover: Math.min(.55, a * .7),
3213
+ drop: .45
3214
+ };
3215
+ }
3216
+ /**
3217
+ * Re-express a palette colour at a given opacity without shifting its hue.
3218
+ * `color-mix` in oklch keeps the Morandi character that converting to rgba
3219
+ * would flatten.
3220
+ * @param color - any CSS colour, typically one of Bloom's oklch values.
3221
+ * @param alpha - target opacity in [0, 1].
3222
+ */
3223
+ function veil(color, alpha) {
3224
+ return `color-mix(in oklch, ${color}, transparent ${Math.round((1 - Math.min(1, Math.max(0, alpha))) * 1e3) / 10}%)`;
3225
+ }
3226
+ /**
3227
+ * Fold the frosted layer into Bloom's colours: surface tokens become
3228
+ * translucent plates, text and border tokens stay exactly as Bloom set them
3229
+ * (transparency there would only cost contrast).
3230
+ * @param base - Bloom's opaque token layer.
3231
+ * @param glassOpacity - the user's glass-density knob.
3232
+ */
3233
+ function applyGlass(base, glassOpacity) {
3234
+ const alpha = glassAlphas(glassOpacity);
3235
+ const out = {};
3236
+ for (const [name, modes] of Object.entries(base)) {
3237
+ const role = GLASS_ROLE[name];
3238
+ if (role === void 0) {
3239
+ out[name] = modes;
3240
+ continue;
3241
+ }
3242
+ out[name] = {
3243
+ light: veil(modes.light, alpha[role]),
3244
+ dark: veil(modes.dark, alpha[role])
3245
+ };
3246
+ }
3247
+ return out;
3248
+ }
3249
+ /**
3250
+ * The single entry point the plugin calls: Bloom's palette, optionally
3251
+ * re-expressed as frosted glass over a wallpaper.
3252
+ * @param inputs - palette, glass density, and whether a wallpaper is active.
3253
+ */
3254
+ function bloomglassTokens(inputs) {
3255
+ const base = bloomTokenOverrides(inputs.variant);
3256
+ return inputs.frosted ? applyGlass(base, inputs.glassOpacity) : base;
3257
+ }
3258
+ //#endregion
3259
+ //#region src/client/wallpaper.ts
3260
+ /**
3261
+ * Owns the wallpaper plate, the dim veil, the scoped frost stylesheet, and the
3262
+ * `--fw-*` custom properties. Retracts exactly what it wrote.
3263
+ *
3264
+ * Deliberately never touches {@link BODY_ATTR}: that attribute means "the
3265
+ * palette theme is installed", and the frost is only one optional layer of it.
3266
+ */
3267
+ /**
3268
+ * Custom properties the frost layer publishes. `--fw-*` drive Frosted's own
3269
+ * selectors; `--bloom-glass-blur` is the single blur knob Bloom's stylesheets
3270
+ * read, so both halves of the merge blur by the same amount.
3271
+ */
3272
+ const BLUR_VAR = "--bloom-glass-blur";
3273
+ const SATURATE_VAR = "--fw-saturate";
3274
+ const DIM_VAR = "--fw-dim";
3275
+ /** Bloom paints its own ambience wash; a wallpaper must displace it. */
3276
+ const AMBIENCE_VAR = "--bloom-ambience";
3277
+ const FROST_VARS = [
3278
+ BLUR_VAR,
3279
+ SATURATE_VAR,
3280
+ DIM_VAR,
3281
+ AMBIENCE_VAR
3282
+ ];
3283
+ /** Presenter over the document: one wallpaper, one dim veil, one stylesheet. */
3284
+ var FrostedPresenter = class {
3285
+ styleEl;
3286
+ wallpaperEl;
3287
+ dimEl;
3288
+ objectUrl;
3289
+ /** Project one surface onto the document. Passing a disabled/empty surface retracts. */
3290
+ apply(surface) {
3291
+ if (!(surface.knobs.enabled && surface.objectUrl !== null)) {
3292
+ this.retractChrome();
3293
+ return;
3294
+ }
3295
+ this.ensureChrome();
3296
+ const wallpaper = this.wallpaperEl;
3297
+ const dim = this.dimEl;
3298
+ if (wallpaper === void 0 || dim === void 0) return;
3299
+ const body = document.body;
3300
+ wallpaper.style.backgroundImage = `url(${JSON.stringify(surface.objectUrl)})`;
3301
+ body.setAttribute(FROST_ATTR, surface.scheme);
3302
+ body.style.setProperty(BLUR_VAR, `${surface.knobs.blurPx}px`);
3303
+ body.style.setProperty(SATURATE_VAR, `${Math.round(surface.knobs.saturate * 100)}%`);
3304
+ body.style.setProperty(DIM_VAR, String(surface.knobs.dim));
3305
+ body.style.setProperty(AMBIENCE_VAR, "none");
3306
+ }
3307
+ /** Remember a blob URL so dispose can revoke it. Callers revoke the previous URL after React paints. */
3308
+ adoptObjectUrl(url) {
3309
+ this.objectUrl = url;
3310
+ }
3311
+ /** Current adopted object URL, if any. */
3312
+ currentObjectUrl() {
3313
+ return this.objectUrl;
3314
+ }
3315
+ /** Retract every node, attribute, custom property, and object URL. */
3316
+ dispose() {
3317
+ this.retractChrome();
3318
+ if (this.objectUrl !== void 0) {
3319
+ URL.revokeObjectURL(this.objectUrl);
3320
+ this.objectUrl = void 0;
3321
+ }
3322
+ }
3323
+ ensureChrome() {
3324
+ if (this.styleEl === void 0 || !this.styleEl.isConnected) {
3325
+ const style = document.createElement("style");
3326
+ style.dataset.plugin = PACKAGE_ID;
3327
+ style.dataset.pluginCss = `${PACKAGE_ID}/glass.css`;
3328
+ style.textContent = GLASS_CSS$1;
3329
+ document.head.append(style);
3330
+ this.styleEl = style;
3331
+ }
3332
+ if (this.wallpaperEl === void 0 || !this.wallpaperEl.isConnected) {
3333
+ const plate = document.createElement("div");
3334
+ plate.setAttribute(`${FROST_ATTR}-wallpaper`, "");
3335
+ document.body.prepend(plate);
3336
+ this.wallpaperEl = plate;
3337
+ }
3338
+ if (this.dimEl === void 0 || !this.dimEl.isConnected) {
3339
+ const veil = document.createElement("div");
3340
+ veil.setAttribute(`${FROST_ATTR}-dim`, "");
3341
+ this.wallpaperEl.after(veil);
3342
+ this.dimEl = veil;
3343
+ }
3344
+ }
3345
+ retractChrome() {
3346
+ this.styleEl?.remove();
3347
+ this.styleEl = void 0;
3348
+ this.wallpaperEl?.remove();
3349
+ this.wallpaperEl = void 0;
3350
+ this.dimEl?.remove();
3351
+ this.dimEl = void 0;
3352
+ const body = document.body;
3353
+ body.removeAttribute(FROST_ATTR);
3354
+ for (const name of FROST_VARS) body.style.removeProperty(name);
3355
+ }
3356
+ };
3357
+ //#endregion
3358
+ //#region src/client/index.ts
3359
+ const name = PACKAGE_ID;
3360
+ const inject = [
3361
+ "slots",
3362
+ "locale",
3363
+ "theme"
3364
+ ];
3365
+ /** Client plugin body. */
3366
+ function apply(ctx) {
3367
+ const images = openImageStore();
3368
+ const presenter = new FrostedPresenter();
3369
+ const store = createBloomglassStore({
3370
+ ...loadKnobs(),
3371
+ hasImage: false,
3372
+ previewUrl: null,
3373
+ fileName: null,
3374
+ width: 0,
3375
+ height: 0,
3376
+ dirty: false,
3377
+ busy: false,
3378
+ error: null,
3379
+ revision: 0
3380
+ });
3381
+ let knobs = loadKnobs();
3382
+ let draft;
3383
+ let disposeTokens;
3384
+ let styleEl;
3385
+ let mutation = 0;
3386
+ let disposed = false;
3387
+ let projecting = false;
3388
+ const t = (key) => {
3389
+ try {
3390
+ return ctx.locale.bind(LOCALE_NS)(key);
3391
+ } catch {
3392
+ return zh[key];
3393
+ }
3394
+ };
3395
+ const publish = (patch) => {
3396
+ const current = store.get();
3397
+ store.set({
3398
+ ...current,
3399
+ ...patch,
3400
+ revision: current.revision + 1
3401
+ });
3402
+ };
3403
+ const schemeOf = () => {
3404
+ try {
3405
+ return ctx.theme.getTheme?.()?.active?.colorScheme === "dark" ? "dark" : "light";
3406
+ } catch {
3407
+ return "light";
3408
+ }
3409
+ };
3410
+ /** True while a wallpaper is actually painted behind the UI. */
3411
+ const frostLive = () => knobs.enabled && store.get().previewUrl !== null;
3412
+ const ownCss = (variant) => [
3413
+ bloomOwnVarsCss(variant, VARIANT_ATTR),
3414
+ COMPONENT_CSS,
3415
+ GLASS_CSS,
3416
+ SETTINGS_CSS,
3417
+ PALETTE_CSS
3418
+ ].join("\n");
3419
+ const mountChrome = () => {
3420
+ if (styleEl !== void 0 && styleEl.isConnected) return;
3421
+ const style = document.createElement("style");
3422
+ style.dataset.plugin = PACKAGE_ID;
3423
+ style.dataset.pluginCss = `${PACKAGE_ID}/theme.css`;
3424
+ style.textContent = ownCss(knobs.variant);
3425
+ document.head.append(style);
3426
+ styleEl = style;
3427
+ document.body.setAttribute(BODY_ATTR, "");
3428
+ document.body.setAttribute(VARIANT_ATTR, knobs.variant);
3429
+ };
3430
+ const retractChrome = () => {
3431
+ styleEl?.remove();
3432
+ styleEl = void 0;
3433
+ document.body.removeAttribute(BODY_ATTR);
3434
+ document.body.removeAttribute(VARIANT_ATTR);
3435
+ };
3436
+ const stackTokens = () => {
3437
+ if (typeof disposeTokens === "function") disposeTokens();
3438
+ disposeTokens = void 0;
3439
+ if (disposed || typeof ctx.theme.overrideTokens !== "function") return;
3440
+ const retract = ctx.theme.overrideTokens(PACKAGE_ID, bloomglassTokens({
3441
+ variant: knobs.variant,
3442
+ glassOpacity: knobs.glassOpacity,
3443
+ frosted: frostLive()
3444
+ }));
3445
+ disposeTokens = typeof retract === "function" ? retract : void 0;
3446
+ };
3447
+ const projectChrome = () => {
3448
+ if (disposed || projecting) return;
3449
+ projecting = true;
3450
+ try {
3451
+ presenter.apply({
3452
+ knobs,
3453
+ objectUrl: store.get().previewUrl,
3454
+ scheme: schemeOf()
3455
+ });
3456
+ } finally {
3457
+ projecting = false;
3458
+ }
3459
+ };
3460
+ const project = (restack) => {
3461
+ if (disposed) return;
3462
+ projectChrome();
3463
+ if (restack) stackTokens();
3464
+ };
3465
+ /** Palette + slider changes apply live; Save only makes them durable. */
3466
+ const persistKnobs = (next) => {
3467
+ const variantChanged = normalizeKnobs(next).variant !== knobs.variant;
3468
+ knobs = normalizeKnobs(next);
3469
+ publish({
3470
+ ...knobs,
3471
+ dirty: true
3472
+ });
3473
+ if (variantChanged) {
3474
+ document.body.setAttribute(VARIANT_ATTR, knobs.variant);
3475
+ if (styleEl !== void 0) styleEl.textContent = ownCss(knobs.variant);
3476
+ }
3477
+ project(true);
3478
+ };
3479
+ const adoptRecord = (record, dirty) => {
3480
+ if (disposed) return;
3481
+ const previous = presenter.currentObjectUrl();
3482
+ draft = record;
3483
+ if (record === void 0) {
3484
+ publish({
3485
+ hasImage: false,
3486
+ previewUrl: null,
3487
+ fileName: null,
3488
+ width: 0,
3489
+ height: 0,
3490
+ dirty,
3491
+ error: null
3492
+ });
3493
+ presenter.adoptObjectUrl(void 0);
3494
+ if (previous !== void 0) requestAnimationFrame(() => {
3495
+ URL.revokeObjectURL(previous);
3496
+ });
3497
+ project(true);
3498
+ return;
3499
+ }
3500
+ const url = URL.createObjectURL(wallpaperBlob(record));
3501
+ publish({
3502
+ hasImage: true,
3503
+ previewUrl: url,
3504
+ fileName: record.name,
3505
+ width: record.width,
3506
+ height: record.height,
3507
+ dirty,
3508
+ error: null
3509
+ });
3510
+ presenter.adoptObjectUrl(url);
3511
+ if (previous !== void 0 && previous !== url) requestAnimationFrame(() => {
3512
+ URL.revokeObjectURL(previous);
3513
+ });
3514
+ project(true);
3515
+ };
3516
+ const upload = async (file) => {
3517
+ const generation = ++mutation;
3518
+ publish({
3519
+ busy: true,
3520
+ error: null
3521
+ });
3522
+ try {
3523
+ const record = await prepareWallpaper(file);
3524
+ if (generation !== mutation || disposed) return;
3525
+ adoptRecord(record, true);
3526
+ } catch (error) {
3527
+ if (generation !== mutation || disposed) return;
3528
+ publish({ error: messageFor(error, t) });
3529
+ } finally {
3530
+ if (generation === mutation && !disposed) publish({ busy: false });
3531
+ }
3532
+ };
3533
+ const save = async () => {
3534
+ const generation = ++mutation;
3535
+ publish({
3536
+ busy: true,
3537
+ error: null
3538
+ });
3539
+ try {
3540
+ saveKnobs(knobs);
3541
+ if (draft === void 0) await images.clear();
3542
+ else await images.put(draft);
3543
+ if (generation !== mutation || disposed) return;
3544
+ publish({ dirty: false });
3545
+ } catch (error) {
3546
+ if (generation !== mutation || disposed) return;
3547
+ publish({ error: messageFor(error, t) });
3548
+ } finally {
3549
+ if (generation === mutation && !disposed) publish({ busy: false });
3550
+ }
3551
+ };
3552
+ const remove = async () => {
3553
+ const generation = ++mutation;
3554
+ publish({
3555
+ busy: true,
3556
+ error: null
3557
+ });
3558
+ try {
3559
+ await images.clear();
3560
+ saveKnobs(knobs);
3561
+ if (generation !== mutation || disposed) return;
3562
+ adoptRecord(void 0, false);
3563
+ } catch (error) {
3564
+ if (generation !== mutation || disposed) return;
3565
+ publish({ error: messageFor(error, t) });
3566
+ } finally {
3567
+ if (generation === mutation && !disposed) publish({ busy: false });
3568
+ }
3569
+ };
3570
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, {
3571
+ zh,
3572
+ en
3573
+ }), `${PACKAGE_ID}: locale`);
3574
+ const injected = () => ({
3575
+ store,
3576
+ t,
3577
+ setVariant: (variant) => {
3578
+ persistKnobs({
3579
+ ...knobs,
3580
+ variant
3581
+ });
3582
+ },
3583
+ setEnabled: (enabled) => {
3584
+ persistKnobs({
3585
+ ...knobs,
3586
+ enabled
3587
+ });
3588
+ },
3589
+ setKnob: (key, value) => {
3590
+ persistKnobs({
3591
+ ...knobs,
3592
+ [key]: value
3593
+ });
3594
+ },
3595
+ upload,
3596
+ save,
3597
+ remove
3598
+ });
3599
+ ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
3600
+ name: "settings.section",
3601
+ id: PACKAGE_ID,
3602
+ order: 36,
3603
+ label: () => t("nav"),
3604
+ locale: LOCALE_NS,
3605
+ inject: injected
3606
+ }, SettingsSection)), `${PACKAGE_ID}: settings`);
3607
+ ctx.effect(() => {
3608
+ const boot = mutation;
3609
+ mountChrome();
3610
+ project(true);
3611
+ const off = ctx.on("theme/change", () => {
3612
+ projectChrome();
3613
+ });
3614
+ images.get().then((record) => {
3615
+ if (disposed || mutation !== boot) return;
3616
+ if (record !== void 0) adoptRecord(record, false);
3617
+ }).catch((error) => {
3618
+ if (!disposed && mutation === boot) publish({ error: messageFor(error, t) });
3619
+ });
3620
+ return () => {
3621
+ disposed = true;
3622
+ mutation += 1;
3623
+ off();
3624
+ if (typeof disposeTokens === "function") disposeTokens();
3625
+ disposeTokens = void 0;
3626
+ retractChrome();
3627
+ presenter.dispose();
3628
+ };
3629
+ }, `${PACKAGE_ID}: surface`);
3630
+ }
3631
+ function messageFor(error, t) {
3632
+ if (error instanceof ImageValidationError && error.message.includes("unsupported")) return t("errorType");
3633
+ return t("errorGeneric");
3634
+ }
3635
+ //#endregion
3636
+ exports.apply = apply;
3637
+ exports.inject = inject;
3638
+ exports.name = name;
3639
+ return module.exports;
3640
+ }
3641
+ });
3642
+
3643
+ //# sourceMappingURL=client.js.map