dsh-custom-theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1512 @@
1
+ /**
2
+ * dsh-custom-theme — browser half.
3
+ *
4
+ * Hand-written in the client module system's lazy-CJS factory format, like the
5
+ * `fixture-input-extension` package: running this script only registers the
6
+ * package name and a factory, and the body runs at first materialization.
7
+ *
8
+ * Registers one Appearance row into the General settings section. The theme list
9
+ * and the stylesheet text come from the Host half's `/dsh-custom-theme/` route,
10
+ * which the Desktop shell forwards to its authenticated Web Host like any other
11
+ * local application request.
12
+ */
13
+
14
+ window.__ModuleLoader__.load({
15
+ id: 'dsh-custom-theme',
16
+ factory(require) {
17
+ const React = require('react')
18
+ const { IconChevronDownOutlineRegular } = require('@deepseek-ai/dsh-client-ui-primitives')
19
+ const h = React.createElement
20
+
21
+ const LIST_URL = '/dsh-custom-theme/themes'
22
+ const CSS_URL = (id) => `/dsh-custom-theme/theme/${encodeURIComponent(id)}.css`
23
+ const BACKGROUNDS_URL = '/dsh-custom-theme/backgrounds'
24
+ const BACKGROUND_URL = (name) => `/dsh-custom-theme/background/${encodeURIComponent(name)}`
25
+ const STORAGE_KEY = 'dsh-custom-theme.selected'
26
+ const STORAGE_KEY_BACKGROUNDS = 'dsh-custom-theme.backgrounds'
27
+ const LOCALE_NS = 'dshCustomTheme'
28
+
29
+ /**
30
+ * Zones a background image can be painted on, and the shell element each one
31
+ * targets.
32
+ *
33
+ * The shell's class names are CSS-module hashes (`BynINW_sidebarCol`), so these
34
+ * match the author-chosen suffix instead of a build-specific hash. `global`
35
+ * targets the app frame, which is the element that already paints the base
36
+ * surface. Verified against the shell at the time of writing; a rename in a
37
+ * later shell build leaves the zone inert rather than breaking the card.
38
+ */
39
+ const ZONES = [
40
+ { id: 'global', labelKey: 'zoneGlobal', selector: '[class*="_frame"]' },
41
+ { id: 'windowbar', labelKey: 'zoneWindowbar', selector: 'header' },
42
+ { id: 'sidebar', labelKey: 'zoneSidebar', selector: '[class*="_sidebarCol"]' },
43
+ { id: 'conversation', labelKey: 'zoneConversation', selector: '[class*="_centerCol"]' },
44
+ { id: 'composer', labelKey: 'zoneComposer', selector: '[data-composer-seat]' },
45
+ { id: 'dock', labelKey: 'zoneDock', selector: '[data-rightbar-col]' },
46
+ ]
47
+
48
+ /** `background-position` keywords offered per zone. */
49
+ const POSITIONS = ['center', 'top', 'bottom', 'left', 'right']
50
+
51
+ /** Locale key for one of {@link POSITIONS}. */
52
+ const positionKey = (position) => `pos${position.charAt(0).toUpperCase()}${position.slice(1)}`
53
+
54
+ /**
55
+ * Picture-alpha bounds.
56
+ *
57
+ * Deeptop caps this well below 1 so a picture can never obscure the shell's own
58
+ * surfaces; the same cap is kept here.
59
+ */
60
+ const OPACITY_MIN = 0.05
61
+ const OPACITY_MAX = 0.45
62
+
63
+ /**
64
+ * Picture-blur bounds, in px.
65
+ *
66
+ * Deeptop's range: 0 (the default) leaves the picture sharp, 16 is its ceiling.
67
+ */
68
+ const BLUR_MIN = 0
69
+ const BLUR_MAX = 16
70
+
71
+ /**
72
+ * How opaque each zone's own panel fill stays, as a percentage.
73
+ *
74
+ * Deeptop's per-zone defaults: only the whole-app frame is fully opaque, so the
75
+ * app backdrop shows faintly through the panels. Bounded to 0–100.
76
+ */
77
+ const PANEL_OPACITY = {
78
+ global: 100,
79
+ windowbar: 94,
80
+ sidebar: 92,
81
+ conversation: 91,
82
+ composer: 91,
83
+ dock: 92,
84
+ }
85
+
86
+ /** Defaults for one zone, matching the Deeptop background model. */
87
+ function defaultZoneConfig(zone) {
88
+ return {
89
+ name: '',
90
+ opacity: 0.18,
91
+ panelOpacity: PANEL_OPACITY[zone] ?? 100,
92
+ blur: 0,
93
+ size: 'cover',
94
+ position: 'center',
95
+ }
96
+ }
97
+
98
+ const PAGE_CSS = `
99
+ .dct-page { display: flex; flex-direction: column; max-width: 760px; }
100
+ .dct-heading { margin: 0 0 6px; font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, inherit); }
101
+ .dct-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; }
102
+ .dct-text { flex: 1 1 auto; min-width: 160px; }
103
+ .dct-title { font-size: 14px; color: var(--dsw-alias-label-primary, inherit); }
104
+ .dct-hint { margin-top: 2px; font-size: 12px; color: var(--dsw-alias-label-secondary, inherit); }
105
+ .dct-control { display: flex; flex: none; align-items: center; gap: 8px; }
106
+ .dct-select { max-width: 220px; padding: 4px 8px; font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; }
107
+ .dct-button { padding: 4px 10px; font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; cursor: pointer; }
108
+ .dct-button:disabled, .dct-select:disabled { opacity: 0.5; cursor: default; }
109
+ .dct-error { margin-top: 6px; font-size: 12px; color: var(--dsw-alias-state-error-primary, #d33); }
110
+ /* The background row carries six controls, so it wraps to the width it is given
111
+ rather than squeezing the label down to one character per line. */
112
+ .dct-wrap { flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: 62%; }
113
+ .dct-number { width: 58px; padding: 4px 6px; font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; }
114
+ .dct-sub { margin-top: 4px; align-items: flex-start; }
115
+ .dct-sub .dct-wrap .dct-select { max-width: 130px; }
116
+ .dct-area { width: 260px; min-height: 54px; padding: 4px 8px; font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; resize: vertical; }
117
+ .dct-area::placeholder { color: var(--dsw-alias-label-caption, currentColor); opacity: 0.85; }
118
+ .dct-sub .dct-wrap .dct-input { width: 130px; }
119
+ `
120
+
121
+ /** Read the persisted selection; an unreadable store means no selection. */
122
+ function readSaved() {
123
+ try {
124
+ const value = window.localStorage.getItem(STORAGE_KEY)
125
+ return typeof value === 'string' ? value : ''
126
+ } catch {
127
+ return ''
128
+ }
129
+ }
130
+
131
+ /** Persist the selection; an unwritable store leaves the in-memory choice only. */
132
+ function writeSaved(id) {
133
+ try {
134
+ if (id === '') window.localStorage.removeItem(STORAGE_KEY)
135
+ else window.localStorage.setItem(STORAGE_KEY, id)
136
+ } catch {
137
+ // A blocked storage backend is a supported state for this row.
138
+ }
139
+ }
140
+
141
+ return {
142
+ inject: ['slots', 'locale', 'theme'],
143
+ apply(ctx) {
144
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, {
145
+ zh: {
146
+ nav: '主题与背景',
147
+ convTitle: '对话流',
148
+ convHint: '正文字号、行距、正文字体、代码字体,右侧四项依次对应',
149
+ fontSize: '正文字号',
150
+ lineGap: '行距',
151
+ fontFamily: '正文字体',
152
+ codeFontFamily: '代码字体',
153
+ fontFollow: '跟随官方默认',
154
+ fontSystem: '系统默认',
155
+ fontYahei: '微软雅黑',
156
+ fontNoto: 'Noto Sans SC',
157
+ fontSerif: 'Georgia 衬线',
158
+ fontCustom: '自定义(沿用旧值)',
159
+ codeCascadia: 'Cascadia Mono',
160
+ codeJetbrains: 'JetBrains Mono',
161
+ codeSarasa: 'Sarasa Mono SC',
162
+ workTitle: '工作时文字',
163
+ workHint: '一行一条,运行期间轮播;留空则完全沿用官方文案',
164
+ workPlaceholder: '深度求索中',
165
+ workInterval: '轮播间隔',
166
+ title: '自定义主题',
167
+ scheme: '明暗模式',
168
+ schemeHint: '主题自带深浅两套时,跟随这里切换',
169
+ schemeLight: '浅色',
170
+ schemeDark: '深色',
171
+ schemeSystem: '跟随系统',
172
+ hint: '从主题目录读取 CSS;也可直接向该目录放入新的 .css 文件',
173
+ none: '跟随官方主题',
174
+ refresh: '重新扫描',
175
+ loading: '正在加载…',
176
+ failed: '主题加载失败',
177
+ bgTitle: '背景图片',
178
+ bgHint: '从背景目录选择图片;也可直接向该目录放入图片文件',
179
+ bgConfigured: '部分区域已设置背景',
180
+ bgZone: '区域',
181
+ bgImage: '图片',
182
+ bgNone: '无',
183
+ bgOpacity: '图片透明度',
184
+ bgBlur: '背景模糊',
185
+ bgFit: '填充',
186
+ bgCover: '覆盖',
187
+ bgContain: '完整显示',
188
+ bgPosition: '位置',
189
+ bgClear: '清除',
190
+ zoneGlobal: '整体',
191
+ zoneWindowbar: '标题栏',
192
+ zoneSidebar: '侧边栏',
193
+ zoneConversation: '会话区',
194
+ zoneComposer: '对话框',
195
+ zoneDock: '工具面板',
196
+ posCenter: '居中',
197
+ posTop: '顶部',
198
+ posBottom: '底部',
199
+ posLeft: '左侧',
200
+ posRight: '右侧',
201
+ },
202
+ en: {
203
+ nav: 'Theme & background',
204
+ convTitle: 'Conversation stream',
205
+ convHint: 'Text size, line spacing, text font and code font — the four controls on the right in that order.',
206
+ fontSize: 'Text size',
207
+ lineGap: 'Line spacing',
208
+ fontFamily: 'Text font',
209
+ codeFontFamily: 'Code font',
210
+ fontFollow: 'Follow the official default',
211
+ fontSystem: 'System default',
212
+ fontYahei: 'Microsoft YaHei',
213
+ fontNoto: 'Noto Sans SC',
214
+ fontSerif: 'Georgia (serif)',
215
+ fontCustom: 'Custom (kept value)',
216
+ codeCascadia: 'Cascadia Mono',
217
+ codeJetbrains: 'JetBrains Mono',
218
+ codeSarasa: 'Sarasa Mono SC',
219
+ workTitle: 'Working text',
220
+ workHint: 'One phrase per line, cycled while a turn runs. Leave it empty to keep the official wording.',
221
+ workPlaceholder: 'Deep diving...',
222
+ workInterval: 'Rotation',
223
+ title: 'Custom theme',
224
+ scheme: 'Light and dark',
225
+ schemeHint: 'A theme carrying both sets follows this',
226
+ schemeLight: 'Light',
227
+ schemeDark: 'Dark',
228
+ schemeSystem: 'Follow the system',
229
+ hint: 'Reads CSS from the theme directory; drop in another .css file to add one',
230
+ none: 'Follow the built-in theme',
231
+ refresh: 'Rescan',
232
+ loading: 'Loading…',
233
+ failed: 'Theme failed to load',
234
+ bgTitle: 'Background image',
235
+ bgHint: 'Pick an image from the background directory; drop image files in to add them',
236
+ bgConfigured: 'Some zones have a background',
237
+ bgZone: 'Zone',
238
+ bgImage: 'Image',
239
+ bgNone: 'None',
240
+ bgOpacity: 'Image opacity',
241
+ bgBlur: 'Background blur',
242
+ bgFit: 'Fit',
243
+ bgCover: 'Cover',
244
+ bgContain: 'Contain',
245
+ bgPosition: 'Position',
246
+ bgClear: 'Clear',
247
+ zoneGlobal: 'Whole app',
248
+ zoneWindowbar: 'Title bar',
249
+ zoneSidebar: 'Sidebar',
250
+ zoneConversation: 'Conversation',
251
+ zoneComposer: 'Composer',
252
+ zoneDock: 'Tool panel',
253
+ posCenter: 'Center',
254
+ posTop: 'Top',
255
+ posBottom: 'Bottom',
256
+ posLeft: 'Left',
257
+ posRight: 'Right',
258
+ },
259
+ }))
260
+
261
+ const controller = new AbortController()
262
+ const themeStyle = document.createElement('style')
263
+ themeStyle.dataset.plugin = 'dsh-custom-theme'
264
+ themeStyle.dataset.role = 'theme'
265
+ const pageStyle = document.createElement('style')
266
+ pageStyle.dataset.plugin = 'dsh-custom-theme'
267
+ pageStyle.dataset.role = 'page'
268
+ pageStyle.textContent = PAGE_CSS
269
+ document.head.append(themeStyle, pageStyle)
270
+ ctx.effect(() => () => {
271
+ controller.abort()
272
+ releaseOverrides()
273
+ clearZoneProperties()
274
+ themeStyle.remove()
275
+ pageStyle.remove()
276
+ })
277
+
278
+ /** Fetch the theme ids the Host half found in the theme directory. */
279
+ async function listThemes() {
280
+ const response = await fetch(LIST_URL, { signal: controller.signal, cache: 'no-store' })
281
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
282
+ const payload = await response.json()
283
+ return Array.isArray(payload?.themes)
284
+ ? payload.themes.map((theme) => theme.id).filter((id) => typeof id === 'string')
285
+ : []
286
+ }
287
+
288
+ /**
289
+ * Selectors that scope a rule to the dark palette.
290
+ *
291
+ * `data-ds-dark-theme` is what this shell sets on `body`; `data-theme="dark"`
292
+ * is the convention Deeptop's own theme files use, so a file written for it
293
+ * classifies correctly here even though the attribute name differs.
294
+ */
295
+ const DARK_SCOPE = /data-ds-dark-theme|data-theme\s*=\s*["']?dark|prefers-color-scheme\s*:\s*dark/iu
296
+
297
+ /** One custom-property declaration, quoted values kept intact. */
298
+ const TOKEN_DECLARATION = /(--[A-Za-z0-9_-]+)\s*:\s*((?:[^;{}"']|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')+)/gu
299
+
300
+ /**
301
+ * Call `visit` with the body of every rule in a stylesheet and whether that
302
+ * rule sits under a dark scope.
303
+ *
304
+ * Recurses through at-rules, so a dark set written as
305
+ * `@media (prefers-color-scheme: dark) { :root { … } }` is classified the same
306
+ * as one written behind `body[data-ds-dark-theme]`.
307
+ * @param source - Stylesheet text, comments already removed.
308
+ * @param dark - Whether an enclosing rule already scoped this region dark.
309
+ * @param visit - Receives each rule body and its dark scope.
310
+ */
311
+ function eachRuleBody(source, dark, visit) {
312
+ let cursor = 0
313
+ for (;;) {
314
+ const open = source.indexOf('{', cursor)
315
+ if (open === -1) return
316
+ const header = source.slice(cursor, open).trim()
317
+ let depth = 1
318
+ let position = open + 1
319
+ while (position < source.length && depth > 0) {
320
+ if (source[position] === '{') depth += 1
321
+ else if (source[position] === '}') depth -= 1
322
+ position += 1
323
+ }
324
+ const body = source.slice(open + 1, position - 1)
325
+ const scoped = dark || DARK_SCOPE.test(header)
326
+ if (header.startsWith('@')) eachRuleBody(body, scoped, visit)
327
+ else visit(body, scoped)
328
+ cursor = position
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Collect the custom-property declarations of a stylesheet, split by palette.
334
+ *
335
+ * Theme authors write `:root { --dsw-alias-bg-base: … }` because that is the
336
+ * CSS convention, but the declarations are re-applied through the official
337
+ * theme runtime (see `applyTheme`), so this reads them out of any selector
338
+ * block. A theme may carry one palette or a light/dark pair, the pair written
339
+ * the way the shell writes its own — see {@link DARK_SCOPE}.
340
+ * @param css - Stylesheet text.
341
+ * @returns `light` and `dark` token names to values, later declarations winning.
342
+ */
343
+ function parseTokenDeclarations(css) {
344
+ const source = css.replace(/\/\*[\s\S]*?\*\//gu, '')
345
+ const light = new Map()
346
+ const dark = new Map()
347
+ eachRuleBody(source, false, (body, isDark) => {
348
+ for (const match of body.matchAll(TOKEN_DECLARATION)) {
349
+ // A trailing `!important` would be part of the value once the runtime
350
+ // re-emits it as an inline custom property.
351
+ const value = match[2].trim().replace(/\s*!important\s*$/iu, '').trim()
352
+ if (value !== '') (isDark ? dark : light).set(match[1], value)
353
+ }
354
+ })
355
+ return { light, dark }
356
+ }
357
+
358
+ /** Override-layer source id; also names the layer's origin for inspection. */
359
+ const THEME_ID_PREFIX = 'dsh-custom-theme'
360
+
361
+ /**
362
+ * Rec. 709 luma of a colour, in whatever syntax its author wrote it.
363
+ *
364
+ * The stylesheet states the colour however it likes, so this hands the value
365
+ * to the browser instead of recognising syntaxes. A probe inside `body`
366
+ * resolves it — including a `var()` naming a token the shell declares, which
367
+ * a detached element could not see — and a canvas pixel then converts the
368
+ * result to sRGB: `getComputedStyle().color` alone is not enough, because it
369
+ * reports `oklch()` and friends back in the colour space they were written in.
370
+ * @param value - A colour as written in the theme.
371
+ * @returns Luma on 0–1, or `null` when the browser accepts no such colour.
372
+ */
373
+ function resolveLuma(value) {
374
+ if (typeof value !== 'string' || value.trim() === '') return null
375
+ const probe = document.createElement('span')
376
+ probe.style.color = value
377
+ if (probe.style.color === '') return null
378
+ probe.style.position = 'absolute'
379
+ probe.style.visibility = 'hidden'
380
+ document.body.append(probe)
381
+ const computed = getComputedStyle(probe).color
382
+ probe.remove()
383
+ if (computed === '' || !CSS.supports('color', computed)) return null
384
+ const canvas = document.createElement('canvas')
385
+ canvas.width = 1
386
+ canvas.height = 1
387
+ const context = canvas.getContext('2d')
388
+ if (context === null) return null
389
+ context.fillStyle = computed
390
+ context.fillRect(0, 0, 1, 1)
391
+ const [r, g, b] = context.getImageData(0, 0, 1, 1).data
392
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
393
+ }
394
+
395
+ /**
396
+ * Which base palette a theme builds on, or `null` when it does not say.
397
+ *
398
+ * The shell switches its entire base palette from `body[data-ds-dark-theme]`,
399
+ * so a theme that overrides alias tokens alone inherits the base of whichever
400
+ * scheme the user last picked, and every token it does not override keeps that
401
+ * scheme's colour — a light theme over a dark base leaves dark surfaces behind.
402
+ * `setTheme` is what switches that attribute, so asking for the scheme is what
403
+ * lets a partial palette come out coherent instead of half-applied.
404
+ *
405
+ * Only a theme with no dark set of its own needs this; one carrying a pair
406
+ * leaves the preference alone. An explicit `/* dsh:color-scheme light *\/`
407
+ * directive wins; otherwise the luma of `--dsw-alias-bg-base` decides. A colour
408
+ * this cannot read yields `null`, and the caller then leaves the preference
409
+ * where the user put it rather than guessing a scheme for it.
410
+ * @param css - Stylesheet text.
411
+ * @param tokens - The theme's light-palette token declarations.
412
+ * @returns `'light'`, `'dark'`, or `null`.
413
+ */
414
+ function themeColorScheme(css, tokens) {
415
+ const declared = /\/\*\s*dsh:color-scheme\s+(light|dark)\s*\*\//u.exec(css)
416
+ if (declared !== null) return declared[1]
417
+ const luma = resolveLuma(tokens.get('--dsw-alias-bg-base'))
418
+ if (luma === null) return null
419
+ return luma > 0.5 ? 'light' : 'dark'
420
+ }
421
+
422
+ /**
423
+ * Drop `!important` from the token declarations of an injected theme sheet.
424
+ *
425
+ * The official runtime applies the palette as inline custom properties, and an
426
+ * important stylesheet declaration outranks a normal inline one — so leaving
427
+ * the author's `!important` in place would let this sheet shadow the runtime it
428
+ * just handed the same values to. Non-token rules (`body { font-family }`)
429
+ * keep theirs.
430
+ * @param css - Stylesheet text.
431
+ * @returns The stylesheet with token declarations de-escalated.
432
+ */
433
+ function relaxTokenPriority(css) {
434
+ return css.replace(
435
+ /(--[A-Za-z0-9_-]+\s*:\s*(?:[^;{}"']|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')+?)\s*!important/gu,
436
+ '$1',
437
+ )
438
+ }
439
+
440
+ /**
441
+ * Disposer of the override layer currently stacked on the official runtime.
442
+ *
443
+ * A layer rather than a registered theme: a layer applies on top of whichever
444
+ * theme is active and survives a preference change, so a settings transport
445
+ * that lands after boot cannot silently undo it.
446
+ */
447
+ let releaseOverrides = () => {}
448
+
449
+ /**
450
+ * Serial number of the newest apply request.
451
+ *
452
+ * The layer is identified by one source, so a boot-time apply that is still
453
+ * fetching must lose to a selection made while it was in flight.
454
+ */
455
+ let applySeq = 0
456
+
457
+ /** Scheme a single-palette theme forced, or `null` when the theme adapts. */
458
+ let appliedScheme = null
459
+
460
+ /** Notified when the applied theme is dropped outside this card's own select. */
461
+ const themeChangeListeners = new Set()
462
+
463
+ /**
464
+ * Drop a single-palette theme once the base palette moves to the other scheme.
465
+ *
466
+ * A pair theme adapts, so the appearance preference keeps deciding and this
467
+ * leaves it alone. A single-palette theme cannot adapt: its layer carries one
468
+ * set of values for both modes, so switching scheme keeps every token it
469
+ * declares while the base palette flips, leaving a window split across the two.
470
+ * Unloading it returns the whole window to the built-in palette instead.
471
+ */
472
+ function syncSelection() {
473
+ if (appliedScheme === null) return
474
+ if (ctx.theme.getTheme().active.colorScheme === appliedScheme) return
475
+ // Deferred: releasing the layer publishes, and a nested publish lets the
476
+ // remaining listeners of the outer emit apply its now-stale snapshot, which
477
+ // still carries the theme's values. Let the current emit finish first.
478
+ const scheme = appliedScheme
479
+ queueMicrotask(() => {
480
+ if (appliedScheme !== scheme) return
481
+ releaseOverrides()
482
+ appliedScheme = null
483
+ writeSaved('')
484
+ for (const listener of themeChangeListeners) listener('')
485
+ })
486
+ }
487
+ ctx.on('theme/change', syncSelection)
488
+
489
+ /**
490
+ * Apply one theme, or clear back to the built-in palette.
491
+ *
492
+ * The tokens go to the official theme runtime as an override layer rather
493
+ * than into a stylesheet, so the presenter applies them exactly as it applies
494
+ * its own. `setTheme` still selects the base palette the theme asks for: the
495
+ * override layer covers only the tokens the theme declares, and every token it
496
+ * leaves alone would otherwise keep the colour of whichever scheme the user
497
+ * last picked. Only the theme's non-token rules reach a stylesheet.
498
+ * @param id - Theme id, or an empty string to clear.
499
+ * @returns Whether the theme was applied.
500
+ */
501
+ async function applyTheme(id) {
502
+ const seq = ++applySeq
503
+ releaseOverrides()
504
+ appliedScheme = null
505
+ themeStyle.textContent = ''
506
+ if (id === '') return true
507
+ const response = await fetch(CSS_URL(id), { signal: controller.signal, cache: 'no-store' })
508
+ if (!response.ok) return false
509
+ const css = await response.text()
510
+ if (seq !== applySeq) return false
511
+ const { light, dark } = parseTokenDeclarations(css)
512
+ // A token declared in one palette only still has to reach both, or it would
513
+ // be undefined in the other — the API takes a pair for every token.
514
+ const modes = {}
515
+ for (const name of new Set([...light.keys(), ...dark.keys()])) {
516
+ modes[name] = {
517
+ light: light.get(name) ?? dark.get(name),
518
+ dark: dark.get(name) ?? light.get(name),
519
+ }
520
+ }
521
+ releaseOverrides = ctx.theme.overrideTokens(THEME_ID_PREFIX, modes)
522
+ // A theme with a dark set of its own adapts, and the appearance preference
523
+ // keeps deciding which set applies. A theme stating one palette states one
524
+ // look, so the base palette has to follow it: every token it leaves alone
525
+ // would otherwise keep the other scheme's colour. Assigned after `setTheme`,
526
+ // whose `theme/change` must not see this as a pending drop. A theme whose
527
+ // base colour cannot be read gets no scheme of its own, so the user's
528
+ // preference stands rather than being moved on a guess.
529
+ if (dark.size === 0 && light.size > 0) {
530
+ const scheme = themeColorScheme(css, light)
531
+ if (scheme !== null) {
532
+ ctx.theme.setTheme(scheme)
533
+ appliedScheme = scheme
534
+ }
535
+ }
536
+ themeStyle.textContent = relaxTokenPriority(css)
537
+ return true
538
+ }
539
+
540
+ /**
541
+ * Parse the `rgb()`/`rgba()` form `getComputedStyle` returns, including its
542
+ * alpha, so a fully transparent surface can be told from a painted one.
543
+ */
544
+ function parseColor(value) {
545
+ const match = /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/u
546
+ .exec(typeof value === 'string' ? value : '')
547
+ if (match === null) return null
548
+ return {
549
+ r: Number(match[1]),
550
+ g: Number(match[2]),
551
+ b: Number(match[3]),
552
+ a: match[4] === undefined ? 1 : Number(match[4]),
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Re-emit a parsed colour at a new alpha; opaque black when unparseable.
558
+ *
559
+ * The alpha is rounded because it is usually computed as `1 - opacity`, and
560
+ * that subtraction leaves values like `0.19999999999999996` in the DOM.
561
+ */
562
+ function withAlpha(color, alpha) {
563
+ const rounded = Math.round(alpha * 1000) / 1000
564
+ return color === null ? `rgba(0, 0, 0, ${rounded})` : `rgba(${color.r}, ${color.g}, ${color.b}, ${rounded})`
565
+ }
566
+
567
+ /** Read the per-zone background settings, dropping anything out of range. */
568
+ function readSavedBackgrounds() {
569
+ const settings = {}
570
+ for (const zone of ZONES) settings[zone.id] = defaultZoneConfig(zone.id)
571
+ try {
572
+ const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY_BACKGROUNDS) ?? 'null')
573
+ if (parsed === null || typeof parsed !== 'object') return settings
574
+ for (const zone of ZONES) {
575
+ const stored = parsed[zone.id]
576
+ if (stored === null || typeof stored !== 'object') continue
577
+ const config = settings[zone.id]
578
+ if (typeof stored.name === 'string') config.name = stored.name
579
+ const opacity = Number(stored.opacity)
580
+ if (Number.isFinite(opacity)) config.opacity = Math.min(OPACITY_MAX, Math.max(OPACITY_MIN, opacity))
581
+ const panelOpacity = Number(stored.panelOpacity)
582
+ if (Number.isFinite(panelOpacity)) {
583
+ config.panelOpacity = Math.min(100, Math.max(0, panelOpacity))
584
+ }
585
+ const blur = Number(stored.blur)
586
+ if (Number.isFinite(blur)) config.blur = Math.min(BLUR_MAX, Math.max(BLUR_MIN, Math.round(blur)))
587
+ if (stored.size === 'cover' || stored.size === 'contain') config.size = stored.size
588
+ if (POSITIONS.includes(stored.position)) config.position = stored.position
589
+ }
590
+ } catch {
591
+ // A blocked or corrupt store means the defaults.
592
+ }
593
+ return settings
594
+ }
595
+
596
+ /** Persist the per-zone settings; an all-empty set removes the entry. */
597
+ function writeSavedBackgrounds(settings) {
598
+ try {
599
+ if (ZONES.every((zone) => settings[zone.id].name === '')) {
600
+ window.localStorage.removeItem(STORAGE_KEY_BACKGROUNDS)
601
+ } else {
602
+ window.localStorage.setItem(STORAGE_KEY_BACKGROUNDS, JSON.stringify(settings))
603
+ }
604
+ } catch {
605
+ // A blocked storage backend is a supported state for this row.
606
+ }
607
+ }
608
+
609
+ /** Inline properties this plugin set on shell elements, for exact removal. */
610
+ let zoneProperties = []
611
+
612
+ /**
613
+ * The colour each painted surface's panel fill is based on, for the current pass.
614
+ *
615
+ * Two zones can resolve to the same surface — the composer seat and the tool
616
+ * column both sit inside the conversation column — and the second paint would
617
+ * otherwise read the translucent fill the first one wrote as its basis and
618
+ * compound the transparency. Reset per pass, alongside the removed overrides.
619
+ */
620
+ let zoneBases = new Map()
621
+
622
+ /** Attributes this plugin set on shell elements, for exact removal. */
623
+ let zoneAttributes = []
624
+
625
+ /** Timer for the bounded retry that waits for the shell's zones to mount. */
626
+ let zoneTimer = null
627
+
628
+ /**
629
+ * The picture-layer stylesheet.
630
+ *
631
+ * A `::before` layer cannot be styled inline, so its declarations live here. Each
632
+ * rule names the surface through the generated `data-dct-layer` attribute the
633
+ * paint pass sets, which keeps two zones that land on one surface from having to
634
+ * share a selector. The sheet is rebuilt on every pass and emptied on every
635
+ * clear, so a zone with no picture leaves nothing behind.
636
+ */
637
+ const layerStyle = document.createElement('style')
638
+ layerStyle.dataset.plugin = 'dsh-custom-theme'
639
+ layerStyle.dataset.role = 'background-layer'
640
+ document.head.append(layerStyle)
641
+
642
+ /** The layer rules of the current pass, in zone order. */
643
+ let layerRules = []
644
+
645
+ /**
646
+ * Serial number behind each layer's own `data-dct-layer` attribute.
647
+ *
648
+ * The rule cannot be keyed on `data-dct-zone`: two zones can resolve to the same
649
+ * surface element (the composer seat sits inside the conversation column), and an
650
+ * element carries one value of each attribute, so the first zone's rule would
651
+ * stop matching as soon as the second zone overwrote the tag. A per-pass serial
652
+ * gives every painted layer a selector that is unique by construction.
653
+ */
654
+ let layerSerial = 0
655
+
656
+ /** Drop every layer rule; called at the start of each pass. */
657
+ function clearLayerRules() {
658
+ layerRules = []
659
+ layerSerial = 0
660
+ layerStyle.textContent = ''
661
+ }
662
+
663
+ /**
664
+ * Record one zone's picture layer.
665
+ *
666
+ * The declaration block is what makes the picture its own layer rather than the
667
+ * surface's `background-image`: only a separate box can carry the picture's own
668
+ * alpha and a blur without fading or smearing the shell's text, which shares the
669
+ * surface element. `z-index: -1` puts it under the shell's content, and
670
+ * `inset: 0` sizes it to the surface, so the stack is panel fill → picture →
671
+ * content.
672
+ * @param theLayer - The selector's unique identifier.
673
+ * @param config - `name`, `opacity`, `size`, `position` and `blur` for the zone.
674
+ */
675
+ function addLayerRule(theLayer, config) {
676
+ layerRules.push(`[data-dct-layer="${theLayer}"]::before {
677
+ content: "";
678
+ position: absolute;
679
+ inset: 0;
680
+ z-index: -1;
681
+ pointer-events: none;
682
+ background-image: url("${BACKGROUND_URL(config.name)}");
683
+ background-repeat: no-repeat;
684
+ background-size: ${config.size};
685
+ background-position: ${config.position};
686
+ ${config.blur > 0 ? `filter: blur(${config.blur}px);` : ''}
687
+ opacity: ${Math.round(config.opacity * 1000) / 1000};
688
+ }`)
689
+ }
690
+
691
+ /** Publish the pass's layer rules as one sheet. */
692
+ function flushLayerRules() {
693
+ layerStyle.textContent = layerRules.join('\n')
694
+ }
695
+
696
+ /** Remove every inline property and attribute the previous application set. */
697
+ function clearZoneProperties() {
698
+ for (const { element, property } of zoneProperties) element.style.removeProperty(property)
699
+ zoneProperties = []
700
+ zoneBases = new Map()
701
+ for (const { element, name } of zoneAttributes) element.removeAttribute(name)
702
+ zoneAttributes = []
703
+ clearLayerRules()
704
+ if (zoneTimer !== null) {
705
+ window.clearTimeout(zoneTimer)
706
+ zoneTimer = null
707
+ }
708
+ }
709
+
710
+ /** Set one inline property and remember it for {@link clearZoneProperties}. */
711
+ function setZoneProperty(element, property, value) {
712
+ element.style.setProperty(property, value, 'important')
713
+ zoneProperties.push({ element, property })
714
+ }
715
+
716
+ /**
717
+ * Find the element that actually paints a zone's visible surface.
718
+ *
719
+ * A zone is an outer layout box, and the shell paints the surface the user
720
+ * sees from a descendant component root that covers that box completely. The
721
+ * deepest such opaque descendant is the one to paint on; painting the outer
722
+ * box would be hidden behind it.
723
+ * @param anchor - The zone's outer element.
724
+ * @returns The deepest opaque descendant covering `anchor`, else `anchor`.
725
+ */
726
+ function surfaceOf(anchor) {
727
+ const anchorRect = anchor.getBoundingClientRect()
728
+ const covers = (rect) => rect.width >= anchorRect.width - 1 && rect.height >= anchorRect.height - 1
729
+ && rect.left <= anchorRect.left + 1 && rect.top <= anchorRect.top + 1
730
+ && rect.right >= anchorRect.right - 1 && rect.bottom >= anchorRect.bottom - 1
731
+ let best = { element: anchor, depth: -1 }
732
+ let budget = 20000
733
+ const walk = (element, depth) => {
734
+ for (const child of element.children) {
735
+ if (budget-- <= 0) return
736
+ // The shell wraps its painted roots in zero-size or transparent boxes,
737
+ // so the search descends through elements that do not cover the zone
738
+ // themselves. Only a covering, opaque element can become the surface.
739
+ if (covers(child.getBoundingClientRect())) {
740
+ const color = parseColor(getComputedStyle(child).backgroundColor)
741
+ if (color !== null && color.a > 0 && depth > best.depth) best = { element: child, depth }
742
+ }
743
+ walk(child, depth + 1)
744
+ }
745
+ }
746
+ walk(anchor, 0)
747
+ return best.element
748
+ }
749
+
750
+ /**
751
+ * The colour a zone's panel fill is built from.
752
+ *
753
+ * Some anchors — the header, the composer seat, the dock column — have a
754
+ * transparent background of their own and take their colour from an ancestor.
755
+ * Building a fill from such a surface would leave the picture at full strength
756
+ * whatever the fill is set to, so the nearest opaque ancestor supplies the
757
+ * colour instead.
758
+ * @param surface - The painted element.
759
+ * @returns A colour string.
760
+ */
761
+ function tintBasis(surface) {
762
+ const known = zoneBases.get(surface)
763
+ if (known !== undefined) return known
764
+ let fallback = null
765
+ for (let node = surface; node !== null; node = node.parentElement) {
766
+ const color = getComputedStyle(node).backgroundColor
767
+ if (color === '' || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') continue
768
+ // A translucent colour is skipped rather than used: it is either the shell's
769
+ // own panel fill or this plugin's own override from an earlier paint, and
770
+ // neither is a stable basis for a fill. `rgb(...)` is opaque by definition.
771
+ if (color.startsWith('rgb(') || color.endsWith(', 1)')) {
772
+ zoneBases.set(surface, color)
773
+ return color
774
+ }
775
+ if (fallback === null) fallback = color
776
+ }
777
+ const basis = fallback ?? 'rgb(0, 0, 0)'
778
+ zoneBases.set(surface, basis)
779
+ return basis
780
+ }
781
+
782
+ /**
783
+ * Paint one zone's picture.
784
+ *
785
+ * The surface element only keeps the two things the layer needs from it: the
786
+ * panel fill, and a stacking context for the layer to sit in. The picture itself
787
+ * goes on a `::before` layer, so the stack reads panel fill → picture → the
788
+ * shell's own content.
789
+ *
790
+ * `isolation: isolate` is what makes that order hold. Without it the layer's
791
+ * `z-index: -1` escapes to the nearest ancestor stacking context and can be
792
+ * hidden behind a background that is painted there; with it, the layer is
793
+ * confined to this surface, above its own background and below its content, and
794
+ * no `z-index` is put on the surface itself, so the shell's own layering is left
795
+ * alone. A `static` surface also needs `position: relative` for the layer to be
796
+ * constrained by it.
797
+ * @param anchor - The zone's outer element.
798
+ * @param config - `name`, `opacity`, `panelOpacity`, `size`, `position` and
799
+ * `blur` for the zone.
800
+ */
801
+ function paintZone(anchor, config) {
802
+ const surface = surfaceOf(anchor)
803
+ const basis = tintBasis(surface)
804
+ const color = parseColor(basis)
805
+ // The panel fill sits under the picture, so its own alpha is what decides how
806
+ // much of the app backdrop shows through. Only an incomplete fill is written:
807
+ // leaving the shell's own colour alone at 100% keeps whatever alpha it had.
808
+ if (config.panelOpacity < 100) {
809
+ setZoneProperty(surface, 'background-color', withAlpha(color, config.panelOpacity / 100))
810
+ }
811
+ // The layer escapes to the nearest ancestor stacking context unless this
812
+ // surface becomes one, which would let an ancestor's background cover the
813
+ // picture. `isolation` creates that context without adding a `z-index`, so the
814
+ // shell's own layering is left exactly as it was.
815
+ setZoneProperty(surface, 'isolation', 'isolate')
816
+ // An absolutely positioned layer is laid out against its nearest positioned
817
+ // ancestor, so a static surface would let the picture escape the element it is
818
+ // meant to fill. This only ever runs on a static surface: one that is already
819
+ // positioned keeps the containing block its own descendants already use.
820
+ if (getComputedStyle(surface).position === 'static') setZoneProperty(surface, 'position', 'relative')
821
+ // The colour the fill was built from, recorded so tooling and tests can read the
822
+ // basis. `basis` was captured before the fill above overrode the element's own
823
+ // computed colour, so what is recorded is the surface's real colour.
824
+ surface.setAttribute('data-dct-tint', basis)
825
+ zoneAttributes.push({ element: surface, name: 'data-dct-tint' })
826
+ surface.setAttribute('data-dct-zone', config.zone)
827
+ zoneAttributes.push({ element: surface, name: 'data-dct-zone' })
828
+ const theLayer = String(++layerSerial)
829
+ surface.setAttribute('data-dct-layer', theLayer)
830
+ zoneAttributes.push({ element: surface, name: 'data-dct-layer' })
831
+ addLayerRule(theLayer, config)
832
+ }
833
+
834
+ /**
835
+ * Whether a set of boxes together covers `anchor`.
836
+ *
837
+ * Sampled on a grid rather than compared edge by edge, because the shell's
838
+ * boxes overlap arbitrarily.
839
+ * @param anchor - The element to cover.
840
+ * @param others - Candidate covering elements; `undefined` entries are skipped.
841
+ * @returns True when every sampled point lies inside some box.
842
+ */
843
+ function fullyCovered(anchor, others) {
844
+ const rect = anchor.getBoundingClientRect()
845
+ const boxes = others.filter((element) => element !== undefined).map((element) => element.getBoundingClientRect())
846
+ const steps = 8
847
+ for (let column = 0; column <= steps; column++) {
848
+ for (let row = 0; row <= steps; row++) {
849
+ const x = rect.left + (rect.width * column) / steps
850
+ const y = rect.top + (rect.height * row) / steps
851
+ if (!boxes.some((box) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom)) return false
852
+ }
853
+ }
854
+ return true
855
+ }
856
+
857
+ /**
858
+ * Paint every configured zone.
859
+ *
860
+ * `global` paints the other zones as well, because the shell's columns cover
861
+ * the frame completely and only their own surfaces are visible. A zone with
862
+ * its own image is painted after the global one, so it wins.
863
+ * @param settings - Per-zone settings from {@link readSavedBackgrounds}.
864
+ * @returns Whether the shell's zone elements were found.
865
+ */
866
+ function applyBackgrounds(settings) {
867
+ clearZoneProperties()
868
+ const targets = new Map()
869
+ for (const zone of ZONES) {
870
+ const element = document.querySelector(zone.selector)
871
+ if (element !== null) targets.set(zone.id, element)
872
+ }
873
+ const ordered = ZONES.filter((zone) => zone.id !== 'global')
874
+ if (settings.global.name !== '') {
875
+ const others = ordered.map((zone) => targets.get(zone.id))
876
+ for (const zone of ZONES) {
877
+ const anchor = targets.get(zone.id)
878
+ if (anchor === undefined) continue
879
+ // A zone with an image of its own is painted below, over the global one.
880
+ if (zone.id !== 'global' && settings[zone.id].name !== '') continue
881
+ // The column fills are translucent, so painting a fully covered frame as
882
+ // well would show the same picture twice and read stronger than configured.
883
+ if (zone.id === 'global' && fullyCovered(anchor, others)) continue
884
+ // The global entry supplies the picture, its alpha and its blur; how opaque
885
+ // the panel fill stays is a property of the zone itself, so it is taken from
886
+ // that zone's own entry even when the picture comes from the global one.
887
+ paintZone(anchor, { ...settings.global, panelOpacity: settings[zone.id].panelOpacity, zone: zone.id })
888
+ }
889
+ }
890
+ for (const zone of ordered) {
891
+ const anchor = targets.get(zone.id)
892
+ if (anchor === undefined || settings[zone.id].name === '') continue
893
+ paintZone(anchor, { ...settings[zone.id], zone: zone.id })
894
+ }
895
+ // Only a zone the user actually configured can be reported missing; a warn
896
+ // on every repaint would fire for everyone who never sets a background.
897
+ for (const zone of ZONES) {
898
+ if (settings[zone.id].name !== '' && !targets.has(zone.id)) {
899
+ ctx.logger.warn('dsh-custom-theme: zone %s has an image but matched no element (%s)', zone.id, zone.selector)
900
+ }
901
+ }
902
+ flushLayerRules()
903
+ return targets.size > 0
904
+ }
905
+
906
+ /**
907
+ * Apply the backgrounds once the shell has rendered its zones.
908
+ *
909
+ * At boot the plugin can run before the shell mounts, so a bounded retry waits
910
+ * for the zone elements instead of dropping the settings silently.
911
+ * @param settings - Per-zone settings.
912
+ * @param attempt - Retry counter; the wait gives up after roughly five seconds.
913
+ */
914
+ function applyBackgroundsWhenReady(settings, attempt = 0) {
915
+ if (applyBackgrounds(settings)) return
916
+ if (attempt >= 20) return
917
+ zoneTimer = window.setTimeout(() => {
918
+ zoneTimer = null
919
+ applyBackgroundsWhenReady(settings, attempt + 1)
920
+ }, 250)
921
+ }
922
+
923
+ /** localStorage key holding the conversation-stream choices. */
924
+ const APPEARANCE_KEY = 'dsh-custom-theme.appearance'
925
+ /** Extra line spacing the user may add, in px, on top of the shell's line height. */
926
+ const LINE_GAP_MIN = -4
927
+ const LINE_GAP_MAX = 8
928
+ const LINE_GAPS = Array.from({ length: LINE_GAP_MAX - LINE_GAP_MIN + 1 }, (_, index) => LINE_GAP_MIN + index)
929
+ /** Content font sizes the official runtime accepts (`FONT_SIZE_MIN`..`FONT_SIZE_MAX`). */
930
+ const FONT_SIZES = [12, 13, 14, 15, 16, 17]
931
+
932
+ /** Text stacks offered for the stream. The empty value leaves the shell's own. */
933
+ const TEXT_FONT_PRESETS = [
934
+ { value: '', labelKey: 'fontFollow' },
935
+ { value: '"Segoe UI Variable", "Segoe UI", "Microsoft YaHei UI", sans-serif', labelKey: 'fontSystem' },
936
+ { value: '"Microsoft YaHei UI", "Microsoft YaHei", sans-serif', labelKey: 'fontYahei' },
937
+ { value: '"Noto Sans SC", "Noto Sans CJK SC", sans-serif', labelKey: 'fontNoto' },
938
+ { value: 'Georgia, "Times New Roman", serif', labelKey: 'fontSerif' },
939
+ ]
940
+
941
+ /** Code stacks offered for fenced blocks. */
942
+ const CODE_FONT_PRESETS = [
943
+ { value: '', labelKey: 'fontFollow' },
944
+ { value: '"Cascadia Mono", Consolas, monospace', labelKey: 'codeCascadia' },
945
+ { value: '"JetBrains Mono", "Cascadia Mono", Consolas, monospace', labelKey: 'codeJetbrains' },
946
+ { value: '"Sarasa Mono SC", "Cascadia Mono", Consolas, monospace', labelKey: 'codeSarasa' },
947
+ ]
948
+
949
+ /**
950
+ * Preset options, plus the stored value itself when it is not one of them, so a
951
+ * stack an earlier version or a hand-edited entry left behind stays selectable
952
+ * instead of silently resetting.
953
+ * @param presets - Offered stacks.
954
+ * @param current - The stored stack.
955
+ * @param t - The page's translate seat.
956
+ * @returns Option elements.
957
+ */
958
+ function fontOptions(presets, current, t) {
959
+ const options = presets.map((preset) => h('option', { key: preset.value, value: preset.value }, t(preset.labelKey)))
960
+ if (current !== '' && !presets.some((preset) => preset.value === current)) {
961
+ options.push(h('option', { key: 'custom', value: current }, t('fontCustom')))
962
+ }
963
+ return options
964
+ }
965
+
966
+ /** Strip the characters that would end a declaration early. */
967
+ function cleanFont(value) {
968
+ return typeof value === 'string' ? value.replace(/[;{}]/gu, '').trim() : ''
969
+ }
970
+
971
+ /** Read the saved conversation-stream choices, clamped to what the runtime accepts. */
972
+ function readSavedAppearance() {
973
+ let raw = {}
974
+ try {
975
+ const parsed = JSON.parse(localStorage.getItem(APPEARANCE_KEY) ?? '{}')
976
+ if (parsed !== null && typeof parsed === 'object') raw = parsed
977
+ } catch {
978
+ // A corrupt entry falls back to the shell's own rendering.
979
+ }
980
+ const gap = Number(raw.lineGap)
981
+ return {
982
+ lineGap: Number.isFinite(gap) ? Math.min(LINE_GAP_MAX, Math.max(LINE_GAP_MIN, Math.round(gap))) : 0,
983
+ fontFamily: cleanFont(raw.fontFamily),
984
+ codeFontFamily: cleanFont(raw.codeFontFamily),
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Stylesheet carrying the conversation-stream choices.
990
+ *
991
+ * The shell declares the font families on `:root` and the line-height delta on
992
+ * `body`, so each override has to be declared on the same element the shell
993
+ * uses: a value inherited from `:root` loses to the shell's own `body` rule.
994
+ */
995
+ const fontStyle = document.createElement('style')
996
+ fontStyle.dataset.plugin = 'dsh-custom-theme'
997
+ fontStyle.dataset.role = 'appearance'
998
+ document.head.append(fontStyle)
999
+
1000
+ /**
1001
+ * Declare the conversation-stream overrides, or withdraw a field by leaving it
1002
+ * out once it is back at the shell's default.
1003
+ * @param settings - Choices from {@link readSavedAppearance}.
1004
+ */
1005
+ function applyAppearance(settings) {
1006
+ const root = []
1007
+ const body = []
1008
+ if (settings.fontFamily !== '') root.push(`--dsw-font-family: ${settings.fontFamily};`)
1009
+ if (settings.codeFontFamily !== '') root.push(`--ds-font-family-code: ${settings.codeFontFamily};`)
1010
+ // The shell derives every content line height from this delta, so adding to
1011
+ // it keeps the whole stream in step instead of pinning one absolute height.
1012
+ if (settings.lineGap !== 0) {
1013
+ body.push(`--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px + ${settings.lineGap}px);`)
1014
+ }
1015
+ // Raised specificity, not source order: the shell installs its own palette
1016
+ // styles at boot and may do so after this plugin runs, so an equal-specificity
1017
+ // `:root`/`body` rule would lose to it depending on who ran last.
1018
+ fontStyle.textContent = [
1019
+ root.length > 0 ? `html:root {\n ${root.join('\n ')}\n}` : '',
1020
+ body.length > 0 ? `html body {\n ${body.join('\n ')}\n}` : '',
1021
+ ].filter(Boolean).join('\n')
1022
+ }
1023
+
1024
+ /** localStorage key holding the working-indicator choices. */
1025
+ const WORKING_KEY = 'dsh-custom-theme.working'
1026
+ /** Caps the official indicator accepts: at most 12 phrases of 120 characters. */
1027
+ const WORKING_MAX_TEXTS = 12
1028
+ const WORKING_MAX_LENGTH = 120
1029
+ /** Rotation intervals offered, in ms. */
1030
+ const WORKING_INTERVALS = [1200, 1800, 2400, 3000, 4000, 6000, 8000, 10000]
1031
+ const WORKING_INTERVAL_DEFAULT = 2400
1032
+
1033
+ /** Read the saved working-indicator choices. */
1034
+ function readSavedWorking() {
1035
+ let raw = {}
1036
+ try {
1037
+ const parsed = JSON.parse(localStorage.getItem(WORKING_KEY) ?? '{}')
1038
+ if (parsed !== null && typeof parsed === 'object') raw = parsed
1039
+ } catch {
1040
+ // A corrupt entry falls back to the official label.
1041
+ }
1042
+ const texts = (Array.isArray(raw.texts) ? raw.texts : [])
1043
+ .filter((item) => typeof item === 'string')
1044
+ .map((item) => item.trim().slice(0, WORKING_MAX_LENGTH))
1045
+ .filter(Boolean)
1046
+ .slice(0, WORKING_MAX_TEXTS)
1047
+ const interval = Number(raw.interval)
1048
+ return {
1049
+ texts,
1050
+ interval: WORKING_INTERVALS.includes(interval) ? interval : WORKING_INTERVAL_DEFAULT,
1051
+ }
1052
+ }
1053
+
1054
+ let workingSettings = readSavedWorking()
1055
+ /** Rows subscribe here, because the indicator lives outside the settings page. */
1056
+ const workingListeners = new Set()
1057
+ /** Set by `apply` to mount or unmount the replacement row as phrases come and go. */
1058
+ let turnRowSync = null
1059
+
1060
+ /** Persist the working-indicator choices and repaint every subscriber. */
1061
+ function setWorkingSettings(next) {
1062
+ localStorage.setItem(WORKING_KEY, JSON.stringify({ texts: next.texts, interval: next.interval }))
1063
+ // Re-read rather than trust the caller, so the stored and live values share
1064
+ // one normalization path and cannot drift apart.
1065
+ workingSettings = readSavedWorking()
1066
+ for (const listener of workingListeners) listener()
1067
+ if (turnRowSync !== null) turnRowSync()
1068
+ }
1069
+
1070
+ /**
1071
+ * Whether whole-turn collapse is unavailable.
1072
+ *
1073
+ * Restated from the shell's `turnProcessAlwaysOpen`: live, stopped and failed
1074
+ * Turns stay open.
1075
+ * @param node - Node carrying the owning Turn.
1076
+ * @returns Whether the row must not be collapsible.
1077
+ */
1078
+ function turnProcessAlwaysOpen(node) {
1079
+ const location = node?.location
1080
+ if (location?.kind !== 'turn' && location?.kind !== 'step') return false
1081
+ const reason = location.turn.end?.data.reason.kind
1082
+ return location.turn.status === 'open' || reason === 'aborted' || reason === 'error'
1083
+ }
1084
+
1085
+ /**
1086
+ * The official turn-process row under this plugin's class names.
1087
+ *
1088
+ * Every declaration is copied from `ui-chat/chat/TurnProcessNodeView.module.css`
1089
+ * and `accessibility.module.css` so a replaced row keeps the shipped look.
1090
+ */
1091
+ const TURN_ROW_CSS = [
1092
+ '.dct-turn-row { box-sizing: border-box; display: flex; align-items: center; width: 100%; min-width: 0;',
1093
+ ' height: calc(33px + var(--dsh-content-font-delta, 0px)); padding: 0 0 8px; border: none;',
1094
+ ' border-bottom: 0.5px solid var(--dsw-alias-border-l2); background: none; color: var(--dsw-alias-label-tertiary);',
1095
+ ' cursor: pointer; text-align: left; transition: color 100ms ease; }',
1096
+ '.dct-turn-row:disabled { cursor: default; }',
1097
+ '.dct-turn-row:not(:disabled):hover { color: var(--dsw-alias-label-primary); }',
1098
+ '.dct-turn-row:not([data-open]) { margin-bottom: 8px; }',
1099
+ '.dct-turn-chevron { flex: none; width: 14px; height: 14px; margin-left: 4px;',
1100
+ ' color: var(--dsw-alias-label-caption); transition: transform 100ms ease; }',
1101
+ '.dct-turn-row[data-open] .dct-turn-chevron { transform: rotate(180deg); }',
1102
+ '.dct-turn-label { min-width: 0; overflow: hidden; font-size: var(--dsh-content-font-size, 14px);',
1103
+ ' line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; }',
1104
+ '.dct-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden;',
1105
+ ' clip: rect(0 0 0 0); white-space: nowrap; }',
1106
+ '@media (prefers-reduced-motion: reduce) { .dct-turn-row, .dct-turn-chevron { transition: none; } }',
1107
+ ].join('\n')
1108
+
1109
+ const turnRowStyle = document.createElement('style')
1110
+ turnRowStyle.dataset.plugin = 'dsh-custom-theme'
1111
+ turnRowStyle.dataset.role = 'turn-row'
1112
+ turnRowStyle.textContent = TURN_ROW_CSS
1113
+ document.head.append(turnRowStyle)
1114
+
1115
+ /**
1116
+ * Replacement for the shell's `turn-process` row.
1117
+ *
1118
+ * Reproduces the official component's markup, styling, icon, locale strings,
1119
+ * duration formatting and collapse rule. The one intended difference is that a
1120
+ * configured phrase replaces the running label; with none configured the label
1121
+ * resolves through the same official keys, so the row reads as shipped.
1122
+ * @param props - The owner props the official row receives.
1123
+ * @returns The disclosure row, or null while the Turn carries no start.
1124
+ */
1125
+ function TurnProcessRow({ node, turnProcess, t }) {
1126
+ if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state')
1127
+ const open = !turnProcess.foldable || turnProcess.open
1128
+ const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined
1129
+ const [spoken, setSpoken] = React.useState(0)
1130
+ const [, setRevision] = React.useState(0)
1131
+ const ticking = turn?.status === 'open'
1132
+ const texts = workingSettings.texts
1133
+ // The phrase clock restarts with each live Turn.
1134
+ React.useEffect(() => {
1135
+ if (!ticking) return undefined
1136
+ setSpoken(0)
1137
+ if (texts.length < 2) return undefined
1138
+ const timer = setInterval(() => { setSpoken((value) => value + 1) }, workingSettings.interval)
1139
+ return () => { clearInterval(timer) }
1140
+ }, [ticking, texts.length, workingSettings.interval])
1141
+ // The row lives in the transcript, so it has to hear about settings changes.
1142
+ React.useEffect(() => {
1143
+ const listener = () => setRevision((value) => value + 1)
1144
+ workingListeners.add(listener)
1145
+ return () => { workingListeners.delete(listener) }
1146
+ }, [])
1147
+ if (turn?.start === undefined && turn?.status !== 'closed') return null
1148
+ const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node)
1149
+ const running = turn.status === 'open'
1150
+ const reason = turn.end?.data.reason.kind
1151
+ // Only parameter-free keys: the seat a replacement row receives does not
1152
+ // interpolate parameters, so a template carrying a placeholder renders with the
1153
+ // slot empty. The shipped row shows an elapsed time, which is part of why it is
1154
+ // left in place until a phrase makes the replacement worthwhile.
1155
+ const phrase = texts[spoken % texts.length]
1156
+ const label = running ? phrase
1157
+ : reason === 'aborted' ? t('message.stopped')
1158
+ : reason === 'error' ? t('message.turnProcess.failed')
1159
+ : t('message.turnProcess.worked')
1160
+ const announcement = label
1161
+ return h(React.Fragment, null,
1162
+ h('span', {
1163
+ className: 'dct-visually-hidden',
1164
+ role: 'status',
1165
+ 'aria-live': 'polite',
1166
+ 'aria-atomic': 'true',
1167
+ }, announcement),
1168
+ h('button', {
1169
+ type: 'button',
1170
+ className: 'dct-turn-row',
1171
+ 'data-open': open || undefined,
1172
+ 'data-turn-process': node.data.turn,
1173
+ 'data-turn-process-messages': node.data.messageCount,
1174
+ 'data-turn-process-tool-calls': node.data.toolCallCount,
1175
+ 'data-turn-process-subagents': node.data.subagentCount,
1176
+ disabled: !canCollapse,
1177
+ 'aria-expanded': turnProcess.hasContent ? open : undefined,
1178
+ onClick: (event) => {
1179
+ event.currentTarget.focus()
1180
+ turnProcess.setOpen(!open)
1181
+ },
1182
+ },
1183
+ h('span', { className: 'dct-turn-label' }, label),
1184
+ canCollapse ? h(IconChevronDownOutlineRegular, { className: 'dct-turn-chevron' }) : null))
1185
+ }
1186
+
1187
+ const saved = readSaved()
1188
+ if (saved !== '') {
1189
+ applyTheme(saved).then((applied) => {
1190
+ if (!applied) {
1191
+ ctx.logger.warn('dsh-custom-theme: saved theme %s is missing', saved)
1192
+ writeSaved('')
1193
+ }
1194
+ }).catch((error) => {
1195
+ ctx.logger.warn('dsh-custom-theme: applying %s failed: %s', saved, error.message)
1196
+ })
1197
+ }
1198
+ // Backgrounds apply on boot whether or not the settings page is ever opened.
1199
+ applyBackgroundsWhenReady(readSavedBackgrounds())
1200
+ // So do the font and line-spacing choices, which need no shell element to exist.
1201
+ applyAppearance(readSavedAppearance())
1202
+
1203
+ /**
1204
+ * Names of the images the Host lists.
1205
+ * @returns Image file names in the background directory.
1206
+ */
1207
+ async function listBackgrounds() {
1208
+ const response = await fetch(BACKGROUNDS_URL, { signal: controller.signal, cache: 'no-store' })
1209
+ if (!response.ok) throw new Error(`backgrounds listing failed: ${response.status}`)
1210
+ const payload = await response.json()
1211
+ return Array.isArray(payload.backgrounds) ? payload.backgrounds.map((entry) => entry.name) : []
1212
+ }
1213
+
1214
+ function ThemeRow({ t }) {
1215
+ const [themes, setThemes] = React.useState([])
1216
+ const [selected, setSelected] = React.useState(readSaved)
1217
+ const [status, setStatus] = React.useState('idle')
1218
+ const [images, setImages] = React.useState([])
1219
+ const [backgrounds, setBackgrounds] = React.useState(readSavedBackgrounds)
1220
+ const [zone, setZone] = React.useState('global')
1221
+ const [preference, setPreference] = React.useState(() => ctx.theme.getTheme().preference)
1222
+ const [appearance, setAppearance] = React.useState(readSavedAppearance)
1223
+ const [fontSize, setFontSize] = React.useState(() => ctx.theme.getTheme().fontSize)
1224
+ const [working, setWorking] = React.useState(readSavedWorking)
1225
+
1226
+ const rescan = React.useCallback(async () => {
1227
+ setStatus('loading')
1228
+ try {
1229
+ const [ids, names] = await Promise.all([listThemes(), listBackgrounds()])
1230
+ setThemes(ids)
1231
+ setImages(names)
1232
+ setSelected((current) => (current !== '' && !ids.includes(current) ? '' : current))
1233
+ setStatus('idle')
1234
+ } catch (error) {
1235
+ setStatus(error.name === 'AbortError' ? 'idle' : 'failed')
1236
+ }
1237
+ }, [])
1238
+
1239
+ React.useEffect(() => { rescan() }, [rescan])
1240
+
1241
+ // The official Appearance row can drop a single-palette theme; follow it so
1242
+ // the select never shows a theme the window is not painting.
1243
+ React.useEffect(() => {
1244
+ themeChangeListeners.add(setSelected)
1245
+ return () => { themeChangeListeners.delete(setSelected) }
1246
+ }, [])
1247
+
1248
+ // The shell renders one settings section at a time, so the official
1249
+ // appearance row is off-screen whenever this page is open. This page carries
1250
+ // its own switch for the same preference: both write through `setTheme` and
1251
+ // this follows `theme/change`, so the two can never disagree.
1252
+ React.useEffect(() => ctx.on('theme/change', (snapshot) => {
1253
+ setPreference(snapshot.preference)
1254
+ setFontSize(snapshot.fontSize)
1255
+ }), [])
1256
+
1257
+ const onSelect = React.useCallback(async (event) => {
1258
+ const id = event.target.value
1259
+ setSelected(id)
1260
+ setStatus('loading')
1261
+ try {
1262
+ const applied = await applyTheme(id)
1263
+ if (applied) writeSaved(id)
1264
+ // A panel fill is built from the live surface colour, so the zones have to
1265
+ // be repainted whenever the palette under them changes.
1266
+ applyBackgroundsWhenReady(backgrounds)
1267
+ setStatus(applied ? 'idle' : 'failed')
1268
+ } catch (error) {
1269
+ setStatus(error.name === 'AbortError' ? 'idle' : 'failed')
1270
+ }
1271
+ }, [backgrounds])
1272
+
1273
+ /** Patch one field of the selected zone, then persist and repaint. */
1274
+ const updateZone = React.useCallback((patch) => {
1275
+ setBackgrounds((current) => {
1276
+ const next = { ...current, [zone]: { ...current[zone], ...patch } }
1277
+ writeSavedBackgrounds(next)
1278
+ applyBackgroundsWhenReady(next)
1279
+ return next
1280
+ })
1281
+ }, [zone])
1282
+
1283
+ /** Persist one conversation-stream choice, then redeclare the overrides. */
1284
+ const updateAppearance = React.useCallback((patch) => {
1285
+ setAppearance((current) => {
1286
+ const next = { ...current, ...patch }
1287
+ localStorage.setItem(APPEARANCE_KEY, JSON.stringify(next))
1288
+ applyAppearance(next)
1289
+ return next
1290
+ })
1291
+ }, [])
1292
+
1293
+ /** Persist one working-indicator choice; the live transcript row repaints itself. */
1294
+ const updateWorking = React.useCallback((patch) => {
1295
+ setWorking((current) => {
1296
+ setWorkingSettings({ ...current, ...patch })
1297
+ return readSavedWorking()
1298
+ })
1299
+ }, [])
1300
+
1301
+ const config = backgrounds[zone]
1302
+ const configured = ZONES.filter((item) => backgrounds[item.id].name !== '').length
1303
+ const busy = status === 'loading'
1304
+ const noImage = config.name === ''
1305
+
1306
+ return h('div', { className: 'dct-page' },
1307
+ h('h2', { className: 'dct-heading' }, t('nav')),
1308
+ h('div', { className: 'dct-row' },
1309
+ h('div', { className: 'dct-text' },
1310
+ h('div', { className: 'dct-title' }, t('title')),
1311
+ h('div', { className: 'dct-hint' }, busy ? t('loading') : t('hint'))),
1312
+ h('div', { className: 'dct-control' },
1313
+ h('select', {
1314
+ className: 'dct-select dct-theme',
1315
+ value: selected,
1316
+ disabled: busy,
1317
+ 'aria-label': t('title'),
1318
+ onChange: onSelect,
1319
+ },
1320
+ h('option', { value: '' }, t('none')),
1321
+ themes.map((id) => h('option', { key: id, value: id }, id))),
1322
+ h('button', {
1323
+ type: 'button',
1324
+ className: 'dct-button dct-rescan',
1325
+ disabled: busy,
1326
+ onClick: rescan,
1327
+ }, t('refresh')))),
1328
+ h('div', { className: 'dct-row' },
1329
+ h('div', { className: 'dct-text' },
1330
+ h('div', { className: 'dct-title' }, t('scheme')),
1331
+ h('div', { className: 'dct-hint' }, t('schemeHint'))),
1332
+ h('div', { className: 'dct-control' },
1333
+ h('select', {
1334
+ className: 'dct-select dct-scheme',
1335
+ value: preference,
1336
+ 'aria-label': t('scheme'),
1337
+ onChange: (event) => { ctx.theme.setTheme(event.target.value) },
1338
+ },
1339
+ h('option', { value: 'light' }, t('schemeLight')),
1340
+ h('option', { value: 'dark' }, t('schemeDark')),
1341
+ h('option', { value: 'system' }, t('schemeSystem'))))),
1342
+ h('div', { className: 'dct-row dct-sub' },
1343
+ h('div', { className: 'dct-text' },
1344
+ h('div', { className: 'dct-title' }, t('convTitle')),
1345
+ h('div', { className: 'dct-hint' }, t('convHint'))),
1346
+ h('div', { className: 'dct-control dct-wrap' },
1347
+ h('select', {
1348
+ className: 'dct-select dct-fontsize',
1349
+ value: String(fontSize),
1350
+ 'aria-label': t('fontSize'),
1351
+ onChange: (event) => {
1352
+ const px = Number(event.target.value)
1353
+ ctx.theme.setFontSize(px)
1354
+ setFontSize(px)
1355
+ },
1356
+ }, FONT_SIZES.map((px) => h('option', { key: px, value: String(px) }, `${px}px`))),
1357
+ h('select', {
1358
+ className: 'dct-select dct-linegap',
1359
+ value: String(appearance.lineGap),
1360
+ 'aria-label': t('lineGap'),
1361
+ onChange: (event) => updateAppearance({ lineGap: Number(event.target.value) }),
1362
+ }, LINE_GAPS.map((gap) => h('option', { key: gap, value: String(gap) }, gap > 0 ? `+${gap}px` : `${gap}px`))),
1363
+ h('select', {
1364
+ className: 'dct-select dct-font',
1365
+ value: appearance.fontFamily,
1366
+ 'aria-label': t('fontFamily'),
1367
+ onChange: (event) => updateAppearance({ fontFamily: event.target.value }),
1368
+ }, fontOptions(TEXT_FONT_PRESETS, appearance.fontFamily, t)),
1369
+ h('select', {
1370
+ className: 'dct-select dct-codefont',
1371
+ value: appearance.codeFontFamily,
1372
+ 'aria-label': t('codeFontFamily'),
1373
+ onChange: (event) => updateAppearance({ codeFontFamily: event.target.value }),
1374
+ }, fontOptions(CODE_FONT_PRESETS, appearance.codeFontFamily, t)))),
1375
+ h('div', { className: 'dct-row dct-sub' },
1376
+ h('div', { className: 'dct-text' },
1377
+ h('div', { className: 'dct-title' }, t('workTitle')),
1378
+ h('div', { className: 'dct-hint' }, t('workHint'))),
1379
+ h('div', { className: 'dct-control dct-wrap' },
1380
+ h('textarea', {
1381
+ className: 'dct-area dct-working',
1382
+ value: working.texts.join('\n'),
1383
+ placeholder: t('workPlaceholder'),
1384
+ 'aria-label': t('workTitle'),
1385
+ onChange: (event) => updateWorking({ texts: event.target.value.split('\n') }),
1386
+ }),
1387
+ h('select', {
1388
+ className: 'dct-select dct-interval',
1389
+ value: String(working.interval),
1390
+ 'aria-label': t('workInterval'),
1391
+ onChange: (event) => updateWorking({ interval: Number(event.target.value) }),
1392
+ }, WORKING_INTERVALS.map((ms) => h('option', { key: ms, value: String(ms) }, `${ms / 1000}s`))))),
1393
+ h('div', { className: 'dct-row dct-sub' },
1394
+ h('div', { className: 'dct-text' },
1395
+ h('div', { className: 'dct-title' }, t('bgTitle')),
1396
+ h('div', { className: 'dct-hint' }, configured > 0 ? t('bgConfigured') : t('bgHint'))),
1397
+ h('div', { className: 'dct-control dct-wrap' },
1398
+ h('select', {
1399
+ className: 'dct-select dct-zone',
1400
+ value: zone,
1401
+ disabled: busy,
1402
+ 'aria-label': t('bgZone'),
1403
+ onChange: (event) => setZone(event.target.value),
1404
+ }, ZONES.map((item) => h('option', { key: item.id, value: item.id }, t(item.labelKey)))),
1405
+ h('select', {
1406
+ className: 'dct-select dct-image',
1407
+ value: config.name,
1408
+ disabled: busy,
1409
+ 'aria-label': t('bgImage'),
1410
+ onChange: (event) => updateZone({ name: event.target.value }),
1411
+ },
1412
+ h('option', { value: '' }, t('bgNone')),
1413
+ images.map((name) => h('option', { key: name, value: name }, name))),
1414
+ h('input', {
1415
+ className: 'dct-number dct-opacity',
1416
+ type: 'number',
1417
+ min: OPACITY_MIN * 100,
1418
+ max: OPACITY_MAX * 100,
1419
+ step: 5,
1420
+ value: Math.round(config.opacity * 100),
1421
+ disabled: busy || noImage,
1422
+ 'aria-label': t('bgOpacity'),
1423
+ onChange: (event) => {
1424
+ const percent = Number(event.target.value)
1425
+ if (Number.isFinite(percent)) {
1426
+ updateZone({ opacity: Math.min(OPACITY_MAX * 100, Math.max(OPACITY_MIN * 100, percent)) / 100 })
1427
+ }
1428
+ },
1429
+ }),
1430
+ h('input', {
1431
+ className: 'dct-number dct-blur',
1432
+ type: 'number',
1433
+ min: BLUR_MIN,
1434
+ max: BLUR_MAX,
1435
+ step: 1,
1436
+ value: config.blur,
1437
+ disabled: busy || noImage,
1438
+ 'aria-label': t('bgBlur'),
1439
+ onChange: (event) => {
1440
+ const pixels = Number(event.target.value)
1441
+ if (Number.isFinite(pixels)) {
1442
+ updateZone({ blur: Math.min(BLUR_MAX, Math.max(BLUR_MIN, Math.round(pixels))) })
1443
+ }
1444
+ },
1445
+ }),
1446
+ h('select', {
1447
+ className: 'dct-select dct-fit',
1448
+ value: config.size,
1449
+ disabled: busy || noImage,
1450
+ 'aria-label': t('bgFit'),
1451
+ onChange: (event) => updateZone({ size: event.target.value }),
1452
+ },
1453
+ h('option', { value: 'cover' }, t('bgCover')),
1454
+ h('option', { value: 'contain' }, t('bgContain'))),
1455
+ h('select', {
1456
+ className: 'dct-select dct-position',
1457
+ value: config.position,
1458
+ disabled: busy || noImage,
1459
+ 'aria-label': t('bgPosition'),
1460
+ onChange: (event) => updateZone({ position: event.target.value }),
1461
+ }, POSITIONS.map((position) => h('option', { key: position, value: position }, t(positionKey(position))))),
1462
+ h('button', {
1463
+ type: 'button',
1464
+ className: 'dct-button dct-clear',
1465
+ disabled: noImage,
1466
+ onClick: () => updateZone({ name: '' }),
1467
+ }, t('bgClear')))),
1468
+ status === 'failed' ? h('div', { className: 'dct-error', role: 'status' }, t('failed')) : null)
1469
+ }
1470
+
1471
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1472
+ name: 'settings.section',
1473
+ id: 'dsh-custom-theme',
1474
+ order: 30,
1475
+ // The shell re-reads the label on every projection, so binding here keeps
1476
+ // the nav row following a locale change without re-registering.
1477
+ label: () => ctx.locale.bind(LOCALE_NS)('nav'),
1478
+ locale: LOCALE_NS,
1479
+ }, ThemeRow))
1480
+
1481
+ /*
1482
+ * The running label is `chat.deepDiving`, owned by the shell's `chat`
1483
+ * namespace. `ctx.locale.register` throws for a namespace and locale that
1484
+ * already exist, and no slot carries that label on its own, so replacing the
1485
+ * row is the only way to reword it.
1486
+ *
1487
+ * The replacement is opt-in. The official component cannot be rendered from
1488
+ * here, so replacing it by default would mean reproducing a shell build this
1489
+ * plugin cannot read — and the installed build already differs from the
1490
+ * published sources in both wording and copy, so a copy would silently miss.
1491
+ * With no phrase configured, the shipped row stays exactly in place.
1492
+ */
1493
+ let turnRowInject = null
1494
+ function syncTurnRow() {
1495
+ if (turnRowInject !== null) {
1496
+ turnRowInject()
1497
+ turnRowInject = null
1498
+ }
1499
+ if (workingSettings.texts.length === 0) return
1500
+ turnRowInject = ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
1501
+ name: 'conversation.chat.node',
1502
+ key: 'turn-process',
1503
+ priority: -1,
1504
+ locale: 'chat',
1505
+ }, TurnProcessRow))
1506
+ }
1507
+ turnRowSync = syncTurnRow
1508
+ syncTurnRow()
1509
+ },
1510
+ }
1511
+ },
1512
+ })