dsh-custom-theme 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +1512 -0
- package/package.json +50 -0
- package/src/index.mjs +289 -0
- package/src/themes.mjs +121 -0
- package/themes/gov.css +53 -0
- package/themes/monokai-pro.css +39 -0
- package/themes/one-dark.css +39 -0
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
|
+
})
|